@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.
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
package/package.json CHANGED
@@ -1,45 +1,33 @@
1
1
  {
2
2
  "name": "@kortix/agent-tunnel",
3
- "version": "0.1.4",
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": "dist/index.js",
8
- "types": "dist/index.d.ts",
7
+ "main": "src/index.ts",
8
+ "types": "src/index.ts",
9
9
  "exports": {
10
- ".": {
11
- "import": "./dist/index.js",
12
- "types": "./dist/index.d.ts"
13
- },
14
- "./shared": {
15
- "import": "./dist/shared/index.js",
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
- "src/"
24
+ "README.md"
38
25
  ],
39
26
  "scripts": {
40
- "build": "tsup",
41
- "prepublishOnly": "npm run build",
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": ["tunnel", "relay", "websocket", "json-rpc", "hmac", "agent", "sdk"],
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"
@@ -1,10 +1,12 @@
1
1
  import { hostname, platform, arch, release } from 'os';
2
- import type { TunnelConfig } from './config';
3
- import { CapabilityRegistry, type RpcHandler } from './capabilities/index';
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
- console.log(` ${icon} ${c.dim}${msg}${c.reset}`);
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
- this.ws = new WebSocket(wsUrl);
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({ type: 'auth', token: this.config.token });
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', (event) => {
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
- if (!this.permissionGuard.checkPermission(permissionId)) {
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(params);
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
- this.ws.send(JSON.stringify(signed));
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: '0.1.2',
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
+ }