@kortix/agent-tunnel 0.1.4 → 0.12.8
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 +202 -493
- 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 -539
- 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
package/package.json
CHANGED
|
@@ -1,45 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kortix/agent-tunnel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.8",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Tunnel relay between cloud AI agents and local machines — server relay, local agent, client SDK, JSON-RPC, HMAC signing",
|
|
7
|
-
"main": "
|
|
8
|
-
"types": "
|
|
7
|
+
"main": "src/index.ts",
|
|
8
|
+
"types": "src/index.ts",
|
|
9
9
|
"exports": {
|
|
10
|
-
".":
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"./
|
|
15
|
-
|
|
16
|
-
"types": "./dist/shared/index.d.ts"
|
|
17
|
-
},
|
|
18
|
-
"./server": {
|
|
19
|
-
"import": "./dist/server/index.js",
|
|
20
|
-
"types": "./dist/server/index.d.ts"
|
|
21
|
-
},
|
|
22
|
-
"./client": {
|
|
23
|
-
"import": "./dist/client/index.js",
|
|
24
|
-
"types": "./dist/client/index.d.ts"
|
|
25
|
-
},
|
|
26
|
-
"./agent": {
|
|
27
|
-
"import": "./dist/agent/index.js",
|
|
28
|
-
"types": "./dist/agent/index.d.ts"
|
|
29
|
-
}
|
|
10
|
+
".": "./src/index.ts",
|
|
11
|
+
"./shared": "./src/shared/index.ts",
|
|
12
|
+
"./server": "./src/server/index.ts",
|
|
13
|
+
"./client": "./src/client/index.ts",
|
|
14
|
+
"./client/cli": "./src/client/cli.ts",
|
|
15
|
+
"./agent": "./src/agent/index.ts"
|
|
30
16
|
},
|
|
31
17
|
"bin": {
|
|
32
18
|
"agent-tunnel": "dist/agent-cli.js",
|
|
33
19
|
"agent-tunnel-cli": "dist/client-cli.js"
|
|
34
20
|
},
|
|
35
21
|
"files": [
|
|
22
|
+
"src/",
|
|
36
23
|
"dist/",
|
|
37
|
-
"
|
|
24
|
+
"README.md"
|
|
38
25
|
],
|
|
39
26
|
"scripts": {
|
|
40
|
-
"build": "
|
|
41
|
-
"
|
|
42
|
-
"typecheck": "tsc --noEmit"
|
|
27
|
+
"build": "bun build src/agent/cli.ts --target=node --outfile=dist/agent-cli.js --banner='#!/usr/bin/env node' && bun build src/client/cli.ts --target=node --outfile=dist/client-cli.js --banner='#!/usr/bin/env node' && node -e \"const fs=require('node:fs'); fs.chmodSync('dist/agent-cli.js', 0o755); fs.chmodSync('dist/client-cli.js', 0o755);\"",
|
|
28
|
+
"prepack": "bun run build",
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"test": "bun test"
|
|
43
31
|
},
|
|
44
32
|
"dependencies": {
|
|
45
33
|
"hono": "^4.4.0",
|
|
@@ -48,10 +36,17 @@
|
|
|
48
36
|
"devDependencies": {
|
|
49
37
|
"@types/bun": "^1.3.9",
|
|
50
38
|
"@types/ws": "^8.5.13",
|
|
51
|
-
"tsup": "^8.4.0",
|
|
52
39
|
"typescript": "^5.4.0"
|
|
53
40
|
},
|
|
54
|
-
"keywords": [
|
|
41
|
+
"keywords": [
|
|
42
|
+
"tunnel",
|
|
43
|
+
"relay",
|
|
44
|
+
"websocket",
|
|
45
|
+
"json-rpc",
|
|
46
|
+
"hmac",
|
|
47
|
+
"agent",
|
|
48
|
+
"sdk"
|
|
49
|
+
],
|
|
55
50
|
"license": "MIT",
|
|
56
51
|
"publishConfig": {
|
|
57
52
|
"access": "public"
|
package/src/agent/agent.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { hostname, platform, arch, release } from 'os';
|
|
2
|
-
import type
|
|
3
|
-
import { CapabilityRegistry
|
|
2
|
+
import { trustedCredential, trustedHttpUrl, type TunnelConfig } from './config';
|
|
3
|
+
import { CapabilityRegistry } from './capabilities/index';
|
|
4
4
|
import { PermissionGuard } from './security/permission-guard';
|
|
5
5
|
import type { LocalPermission } from './security/permission-guard';
|
|
6
6
|
import { signMessage, verifyMessageSignature } from '../shared/crypto';
|
|
7
7
|
|
|
8
|
+
const AGENT_VERSION = '0.1.2';
|
|
9
|
+
|
|
8
10
|
interface JsonRpcRequest {
|
|
9
11
|
jsonrpc: '2.0';
|
|
10
12
|
id: string;
|
|
@@ -23,6 +25,7 @@ interface JsonRpcNotification {
|
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
type IncomingMessage = JsonRpcRequest | JsonRpcNotification;
|
|
28
|
+
const MAX_RPC_MESSAGE_SIZE = 5 * 1024 * 1024;
|
|
26
29
|
|
|
27
30
|
const c = {
|
|
28
31
|
reset: '\x1b[0m',
|
|
@@ -37,7 +40,9 @@ const c = {
|
|
|
37
40
|
};
|
|
38
41
|
|
|
39
42
|
function log(icon: string, msg: string) {
|
|
40
|
-
|
|
43
|
+
const safeIcon = icon.replace(/[\r\n]/g, ' ');
|
|
44
|
+
const safeMsg = msg.replace(/[\r\n]/g, ' ');
|
|
45
|
+
process.stdout.write(` ${safeIcon} ${c.dim}${safeMsg}${c.reset}\n`);
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
export class TunnelAgent {
|
|
@@ -49,6 +54,7 @@ export class TunnelAgent {
|
|
|
49
54
|
private maxReconnectDelay = 30_000;
|
|
50
55
|
private baseReconnectDelay = 1_000;
|
|
51
56
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
57
|
+
private stableConnectionTimer: ReturnType<typeof setTimeout> | null = null;
|
|
52
58
|
private isShuttingDown = false;
|
|
53
59
|
private uptime = 0;
|
|
54
60
|
private uptimeInterval: ReturnType<typeof setInterval> | null = null;
|
|
@@ -73,7 +79,8 @@ export class TunnelAgent {
|
|
|
73
79
|
log(`${c.cyan}◆${c.reset}`, `Connecting…`);
|
|
74
80
|
|
|
75
81
|
try {
|
|
76
|
-
|
|
82
|
+
// lgtm[js/file-access-to-http] Tunnel endpoint is intentionally loaded from trusted local config.
|
|
83
|
+
this.ws = new WebSocket(new URL(wsUrl));
|
|
77
84
|
this.setupWsHandlers();
|
|
78
85
|
} catch (err) {
|
|
79
86
|
log(`${c.red}✗${c.reset}`, `Connection failed`);
|
|
@@ -111,7 +118,6 @@ export class TunnelAgent {
|
|
|
111
118
|
if (!this.ws) return;
|
|
112
119
|
|
|
113
120
|
this.ws.addEventListener('open', () => {
|
|
114
|
-
this.reconnectAttempts = 0;
|
|
115
121
|
this.uptime = 0;
|
|
116
122
|
this.lastNonce = 0;
|
|
117
123
|
this.responseNonce = 0;
|
|
@@ -119,7 +125,12 @@ export class TunnelAgent {
|
|
|
119
125
|
this.uptimeInterval = setInterval(() => { this.uptime++; }, 1000);
|
|
120
126
|
|
|
121
127
|
// Send auth handshake as first message (token never in URL)
|
|
122
|
-
this.send({
|
|
128
|
+
this.send({
|
|
129
|
+
type: 'auth',
|
|
130
|
+
token: trustedCredential(this.config.token, 'token'),
|
|
131
|
+
capabilities: this.registry.getCapabilityNames(),
|
|
132
|
+
agentVersion: AGENT_VERSION,
|
|
133
|
+
});
|
|
123
134
|
});
|
|
124
135
|
|
|
125
136
|
this.ws.addEventListener('message', (event) => {
|
|
@@ -131,23 +142,44 @@ export class TunnelAgent {
|
|
|
131
142
|
clearInterval(this.uptimeInterval);
|
|
132
143
|
this.uptimeInterval = null;
|
|
133
144
|
}
|
|
145
|
+
if (this.stableConnectionTimer) {
|
|
146
|
+
clearTimeout(this.stableConnectionTimer);
|
|
147
|
+
this.stableConnectionTimer = null;
|
|
148
|
+
}
|
|
134
149
|
|
|
135
150
|
if (!this.isShuttingDown) {
|
|
136
151
|
if (event.code === 4001) {
|
|
137
152
|
log(`${c.red}✗${c.reset}`, `Authentication failed — check your token`);
|
|
138
153
|
return; // Don't reconnect on auth failure
|
|
139
154
|
}
|
|
155
|
+
if (event.code === 4003) {
|
|
156
|
+
log(`${c.red}✗${c.reset}`, `Device credential was revoked — run connect again`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (event.code === 4004) {
|
|
160
|
+
this.isShuttingDown = true;
|
|
161
|
+
log(
|
|
162
|
+
`${c.yellow}○${c.reset}`,
|
|
163
|
+
`Another Agent Tunnel process connected with these credentials — stopping this process`,
|
|
164
|
+
);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
140
167
|
log(`${c.yellow}○${c.reset}`, `Disconnected ${c.gray}(code: ${event.code})${c.reset}`);
|
|
141
168
|
this.scheduleReconnect();
|
|
142
169
|
}
|
|
143
170
|
});
|
|
144
171
|
|
|
145
|
-
this.ws.addEventListener('error', (
|
|
172
|
+
this.ws.addEventListener('error', () => {
|
|
146
173
|
log(`${c.red}✗${c.reset}`, `WebSocket error`);
|
|
147
174
|
});
|
|
148
175
|
}
|
|
149
176
|
|
|
150
177
|
private async handleMessage(raw: string): Promise<void> {
|
|
178
|
+
if (typeof raw !== 'string' || Buffer.byteLength(raw, 'utf8') > MAX_RPC_MESSAGE_SIZE) {
|
|
179
|
+
log(`${c.red}✗${c.reset}`, `Rejected oversized or non-text relay message`);
|
|
180
|
+
try { this.ws?.close(4002, 'message too large'); } catch {}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
151
183
|
let msg: any;
|
|
152
184
|
try {
|
|
153
185
|
msg = JSON.parse(raw);
|
|
@@ -160,6 +192,11 @@ export class TunnelAgent {
|
|
|
160
192
|
if (msg.type === 'auth_ok' && msg.signingKey) {
|
|
161
193
|
this.signingKey = msg.signingKey;
|
|
162
194
|
log(`${c.green}●${c.reset}`, `Connected ${c.reset}${c.gray}(${this.registry.getCapabilityNames().join(', ')})${c.reset}`);
|
|
195
|
+
if (this.stableConnectionTimer) clearTimeout(this.stableConnectionTimer);
|
|
196
|
+
this.stableConnectionTimer = setTimeout(() => {
|
|
197
|
+
this.reconnectAttempts = 0;
|
|
198
|
+
this.stableConnectionTimer = null;
|
|
199
|
+
}, 30_000);
|
|
163
200
|
return;
|
|
164
201
|
}
|
|
165
202
|
|
|
@@ -255,7 +292,8 @@ export class TunnelAgent {
|
|
|
255
292
|
const { id, method, params = {} } = request;
|
|
256
293
|
|
|
257
294
|
const permissionId = params.permissionId as string | undefined;
|
|
258
|
-
|
|
295
|
+
const permission = this.permissionGuard.getPermissionForMethod(permissionId, method);
|
|
296
|
+
if (!permission) {
|
|
259
297
|
this.sendSignedError(id, -32000, `Permission denied: ${permissionId ? 'invalid or expired permission' : 'no permissionId provided'}`);
|
|
260
298
|
return;
|
|
261
299
|
}
|
|
@@ -267,7 +305,10 @@ export class TunnelAgent {
|
|
|
267
305
|
}
|
|
268
306
|
|
|
269
307
|
try {
|
|
270
|
-
const result = await handler(
|
|
308
|
+
const result = await handler({
|
|
309
|
+
...params,
|
|
310
|
+
__permission: permission,
|
|
311
|
+
});
|
|
271
312
|
this.sendSignedResult(id, result);
|
|
272
313
|
} catch (err) {
|
|
273
314
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -294,7 +335,20 @@ export class TunnelAgent {
|
|
|
294
335
|
const sig = signMessage(this.signingKey, payload, nonce);
|
|
295
336
|
const signed = { ...data, _sig: sig, _nonce: nonce };
|
|
296
337
|
try {
|
|
297
|
-
|
|
338
|
+
const encoded = JSON.stringify(signed);
|
|
339
|
+
if (Buffer.byteLength(encoded, 'utf8') > MAX_RPC_MESSAGE_SIZE) {
|
|
340
|
+
if ('result' in data && typeof data.id === 'string') {
|
|
341
|
+
this.sendSignedError(
|
|
342
|
+
data.id,
|
|
343
|
+
-32003,
|
|
344
|
+
'RPC result exceeds the maximum tunnel message size',
|
|
345
|
+
);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
this.ws.close(4002, 'message too large');
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
this.ws.send(encoded);
|
|
298
352
|
} catch (err) {
|
|
299
353
|
log(`${c.red}✗${c.reset}`, `Send failed`);
|
|
300
354
|
}
|
|
@@ -323,7 +377,7 @@ export class TunnelAgent {
|
|
|
323
377
|
platform: platform(),
|
|
324
378
|
arch: arch(),
|
|
325
379
|
osVersion: release(),
|
|
326
|
-
agentVersion:
|
|
380
|
+
agentVersion: AGENT_VERSION,
|
|
327
381
|
},
|
|
328
382
|
},
|
|
329
383
|
});
|
|
@@ -346,17 +400,13 @@ export class TunnelAgent {
|
|
|
346
400
|
}
|
|
347
401
|
|
|
348
402
|
private buildWsUrl(): string {
|
|
349
|
-
const base = this.config.apiUrl
|
|
403
|
+
const base = trustedHttpUrl(this.config.apiUrl)
|
|
350
404
|
.replace(/^http:/, 'ws:')
|
|
351
405
|
.replace(/^https:/, 'wss:');
|
|
352
406
|
|
|
353
|
-
if (base.startsWith('ws://') && !base.includes('localhost') && !base.includes('127.0.0.1')) {
|
|
354
|
-
log(`${c.red}!${c.reset}`, `${c.red}WARNING: Connecting over unencrypted ws:// to a remote host. Token will be sent in plaintext. Use https:// API URL for production.${c.reset}`);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
407
|
const wsPath = this.config.wsPath || '/ws';
|
|
358
408
|
const params = new URLSearchParams({
|
|
359
|
-
tunnelId: this.config.tunnelId,
|
|
409
|
+
tunnelId: trustedCredential(this.config.tunnelId, 'tunnelId'),
|
|
360
410
|
});
|
|
361
411
|
|
|
362
412
|
return `${base}${wsPath}?${params.toString()}`;
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { existsSync, realpathSync, statSync } from 'fs';
|
|
3
|
+
import { homedir, platform } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
|
|
6
|
+
interface ExecResult {
|
|
7
|
+
stdout: string;
|
|
8
|
+
stderr: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const MAX_DRIVER_OUTPUT_BYTES = 5 * 1024 * 1024;
|
|
12
|
+
const DRIVER_ENV_KEYS = [
|
|
13
|
+
'PATH',
|
|
14
|
+
'HOME',
|
|
15
|
+
'USER',
|
|
16
|
+
'LANG',
|
|
17
|
+
'LC_ALL',
|
|
18
|
+
'LC_CTYPE',
|
|
19
|
+
'TMPDIR',
|
|
20
|
+
'DISPLAY',
|
|
21
|
+
'WAYLAND_DISPLAY',
|
|
22
|
+
'XDG_RUNTIME_DIR',
|
|
23
|
+
'XAUTHORITY',
|
|
24
|
+
'DBUS_SESSION_BUS_ADDRESS',
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
export interface CuaToolCall {
|
|
28
|
+
tool: string;
|
|
29
|
+
args?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function candidateBins(): string[] {
|
|
33
|
+
const candidates = [
|
|
34
|
+
process.env.CUA_DRIVER_BIN,
|
|
35
|
+
join(
|
|
36
|
+
homedir(),
|
|
37
|
+
'.local',
|
|
38
|
+
'bin',
|
|
39
|
+
process.platform === 'win32' ? 'cua-driver.exe' : 'cua-driver',
|
|
40
|
+
),
|
|
41
|
+
'/usr/local/bin/cua-driver',
|
|
42
|
+
'/opt/homebrew/bin/cua-driver',
|
|
43
|
+
];
|
|
44
|
+
return candidates.filter((p): p is string => !!p);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function findCuaDriverBinary(): string | null {
|
|
48
|
+
for (const candidate of candidateBins()) {
|
|
49
|
+
if (!existsSync(candidate)) continue;
|
|
50
|
+
const resolved = realpathSync(candidate);
|
|
51
|
+
const stats = statSync(resolved);
|
|
52
|
+
if (!stats.isFile()) throw new Error(`cua-driver is not a regular file: ${candidate}`);
|
|
53
|
+
if (process.platform !== 'win32') {
|
|
54
|
+
const currentUid = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
|
55
|
+
if (currentUid !== undefined && stats.uid !== currentUid && stats.uid !== 0) {
|
|
56
|
+
throw new Error(`cua-driver is not owned by the current user or root: ${resolved}`);
|
|
57
|
+
}
|
|
58
|
+
if ((stats.mode & 0o022) !== 0) {
|
|
59
|
+
throw new Error(`cua-driver must not be writable by group or other users: ${resolved}`);
|
|
60
|
+
}
|
|
61
|
+
if ((stats.mode & 0o111) === 0) {
|
|
62
|
+
throw new Error(`cua-driver is not executable: ${resolved}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return resolved;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function driverEnvironment(): NodeJS.ProcessEnv {
|
|
71
|
+
const env: NodeJS.ProcessEnv = {};
|
|
72
|
+
for (const key of DRIVER_ENV_KEYS) {
|
|
73
|
+
if (process.env[key] !== undefined) env[key] = process.env[key];
|
|
74
|
+
}
|
|
75
|
+
return env;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function execFile(cmd: string, args: string[], timeoutMs = 30_000): Promise<ExecResult> {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const proc = spawn(cmd, args, {
|
|
81
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
82
|
+
env: driverEnvironment(),
|
|
83
|
+
});
|
|
84
|
+
let stdout = '';
|
|
85
|
+
let stderr = '';
|
|
86
|
+
let outputBytes = 0;
|
|
87
|
+
let settled = false;
|
|
88
|
+
|
|
89
|
+
const rejectAndKill = (error: Error) => {
|
|
90
|
+
if (settled) return;
|
|
91
|
+
settled = true;
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
proc.kill('SIGKILL');
|
|
94
|
+
reject(error);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const timer = setTimeout(() => {
|
|
98
|
+
rejectAndKill(new Error(`${cmd} timed out after ${timeoutMs}ms`));
|
|
99
|
+
}, timeoutMs);
|
|
100
|
+
|
|
101
|
+
proc.stdout.on('data', (data: Buffer) => {
|
|
102
|
+
outputBytes += data.byteLength;
|
|
103
|
+
if (outputBytes > MAX_DRIVER_OUTPUT_BYTES) {
|
|
104
|
+
rejectAndKill(new Error('cua-driver output exceeds the 5 MiB limit'));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
stdout += data.toString();
|
|
108
|
+
});
|
|
109
|
+
proc.stderr.on('data', (data: Buffer) => {
|
|
110
|
+
outputBytes += data.byteLength;
|
|
111
|
+
if (outputBytes > MAX_DRIVER_OUTPUT_BYTES) {
|
|
112
|
+
rejectAndKill(new Error('cua-driver output exceeds the 5 MiB limit'));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
stderr += data.toString();
|
|
116
|
+
});
|
|
117
|
+
proc.on('close', (code) => {
|
|
118
|
+
if (settled) return;
|
|
119
|
+
settled = true;
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
if (code !== 0) {
|
|
122
|
+
const detail = stderr.trim() || stdout.trim();
|
|
123
|
+
reject(new Error(`${cmd} failed (${code})${detail ? `: ${detail}` : ''}`));
|
|
124
|
+
} else {
|
|
125
|
+
resolve({ stdout, stderr });
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
proc.on('error', (err) => {
|
|
129
|
+
rejectAndKill(err);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parseJsonOutput(stdout: string): unknown {
|
|
135
|
+
const trimmed = stdout.trim();
|
|
136
|
+
if (!trimmed) return {};
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(trimmed);
|
|
139
|
+
} catch {
|
|
140
|
+
return trimmed;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isDaemonProxyFallback(message: string): boolean {
|
|
145
|
+
return message.includes('daemon proxy') && message.includes('Resource temporarily unavailable');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function sleep(ms: number): Promise<void> {
|
|
149
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function sanitizeArgs(args: Record<string, unknown>): Record<string, unknown> {
|
|
153
|
+
const sanitized: Record<string, unknown> = {};
|
|
154
|
+
for (const [key, value] of Object.entries(args)) {
|
|
155
|
+
if (
|
|
156
|
+
key === '__permission' ||
|
|
157
|
+
key === '_sig' ||
|
|
158
|
+
key === '_nonce' ||
|
|
159
|
+
key === 'permissionId' ||
|
|
160
|
+
key === 'permission_id' ||
|
|
161
|
+
key === 'tunnelId' ||
|
|
162
|
+
key === 'tunnel_id'
|
|
163
|
+
) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
sanitized[key] = value;
|
|
167
|
+
}
|
|
168
|
+
return sanitized;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export class CuaDriver {
|
|
172
|
+
private binary: string | null = null;
|
|
173
|
+
private daemonReady = false;
|
|
174
|
+
|
|
175
|
+
async ensureInstalled(): Promise<string> {
|
|
176
|
+
if (this.binary && existsSync(this.binary)) return this.binary;
|
|
177
|
+
|
|
178
|
+
const found = findCuaDriverBinary();
|
|
179
|
+
if (found) {
|
|
180
|
+
this.binary = found;
|
|
181
|
+
return found;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
throw new Error(
|
|
185
|
+
'cua-driver is not installed. Install it locally before enabling Computer Use. Agent Tunnel never downloads or executes remote installers.',
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async version(): Promise<string> {
|
|
190
|
+
const bin = await this.ensureInstalled();
|
|
191
|
+
const { stdout } = await execFile(bin, ['--version'], 10_000);
|
|
192
|
+
return stdout.trim();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async listTools(): Promise<string> {
|
|
196
|
+
const bin = await this.ensureInstalled();
|
|
197
|
+
const { stdout } = await execFile(bin, ['list-tools'], 10_000);
|
|
198
|
+
return stdout.trim();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async describe(tool: string): Promise<string> {
|
|
202
|
+
const bin = await this.ensureInstalled();
|
|
203
|
+
const { stdout } = await execFile(bin, ['describe', tool], 10_000);
|
|
204
|
+
return stdout.trim();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async status(): Promise<string> {
|
|
208
|
+
const bin = await this.ensureInstalled();
|
|
209
|
+
const { stdout } = await execFile(bin, ['status'], 10_000);
|
|
210
|
+
return stdout.trim();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async call(tool: string, args: Record<string, unknown> = {}): Promise<unknown> {
|
|
214
|
+
if (!tool || typeof tool !== 'string') throw new Error('CUA tool name is required');
|
|
215
|
+
await this.ensureDaemonReady();
|
|
216
|
+
const bin = await this.ensureInstalled();
|
|
217
|
+
const payload = JSON.stringify(sanitizeArgs(args));
|
|
218
|
+
let lastError: unknown;
|
|
219
|
+
|
|
220
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
221
|
+
try {
|
|
222
|
+
const { stdout, stderr } = await execFile(bin, ['call', tool, payload], 60_000);
|
|
223
|
+
if (isDaemonProxyFallback(stderr)) {
|
|
224
|
+
lastError = new Error(stderr.trim());
|
|
225
|
+
await sleep(150 * (attempt + 1));
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
return parseJsonOutput(stdout);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
231
|
+
if (!isDaemonProxyFallback(message)) {
|
|
232
|
+
throw err;
|
|
233
|
+
}
|
|
234
|
+
lastError = err;
|
|
235
|
+
await sleep(150 * (attempt + 1));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async startDaemon(): Promise<{ ok: true; status?: string }> {
|
|
243
|
+
const bin = await this.ensureInstalled();
|
|
244
|
+
|
|
245
|
+
if (platform() === 'darwin') {
|
|
246
|
+
const child = spawn('open', ['-n', '-g', '-a', 'CuaDriver', '--args', 'serve'], {
|
|
247
|
+
detached: true,
|
|
248
|
+
stdio: 'ignore',
|
|
249
|
+
env: driverEnvironment(),
|
|
250
|
+
});
|
|
251
|
+
child.unref();
|
|
252
|
+
} else {
|
|
253
|
+
const child = spawn(bin, ['serve'], {
|
|
254
|
+
detached: true,
|
|
255
|
+
stdio: 'ignore',
|
|
256
|
+
env: driverEnvironment(),
|
|
257
|
+
});
|
|
258
|
+
child.unref();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
await new Promise((resolve) => setTimeout(resolve, 750));
|
|
262
|
+
try {
|
|
263
|
+
const status = await this.status();
|
|
264
|
+
this.daemonReady = true;
|
|
265
|
+
return { ok: true, status };
|
|
266
|
+
} catch {
|
|
267
|
+
return { ok: true };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private async ensureDaemonReady(): Promise<void> {
|
|
272
|
+
if (this.daemonReady) return;
|
|
273
|
+
try {
|
|
274
|
+
const status = await this.status();
|
|
275
|
+
if (/not\s+running|stopped|unavailable/i.test(status)) {
|
|
276
|
+
throw new Error(status);
|
|
277
|
+
}
|
|
278
|
+
this.daemonReady = true;
|
|
279
|
+
} catch {
|
|
280
|
+
await this.startDaemon();
|
|
281
|
+
this.daemonReady = true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|