@kortix/agent-tunnel 0.1.4 → 0.12.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +65 -0
  2. package/dist/agent-cli.js +4676 -3174
  3. package/dist/client-cli.js +202 -493
  4. package/package.json +24 -29
  5. package/src/agent/agent.ts +67 -17
  6. package/src/agent/capabilities/desktop/cua-driver.ts +284 -0
  7. package/src/agent/capabilities/desktop.ts +100 -167
  8. package/src/agent/capabilities/enabled-registry.ts +24 -0
  9. package/src/agent/capabilities/filesystem.ts +136 -32
  10. package/src/agent/capabilities/index.ts +1 -1
  11. package/src/agent/capabilities/security.test.ts +204 -0
  12. package/src/agent/capabilities/shell.ts +37 -3
  13. package/src/agent/cli-device-auth.test.ts +179 -0
  14. package/src/agent/cli-help.test.ts +25 -0
  15. package/src/agent/cli.ts +475 -38
  16. package/src/agent/config.test.ts +53 -0
  17. package/src/agent/config.ts +169 -7
  18. package/src/agent/index.ts +1 -0
  19. package/src/agent/security/command-validator.ts +4 -2
  20. package/src/agent/security/path-validator.ts +73 -18
  21. package/src/agent/security/permission-guard.test.ts +52 -0
  22. package/src/agent/security/permission-guard.ts +35 -8
  23. package/src/agent/service.test.ts +63 -0
  24. package/src/agent/service.ts +410 -0
  25. package/src/client/cli.test.ts +150 -547
  26. package/src/client/cli.ts +116 -539
  27. package/src/client/index.ts +1 -1
  28. package/src/client/tools.ts +95 -356
  29. package/src/client/tunnel-client.ts +50 -80
  30. package/src/index.ts +7 -1
  31. package/src/node-ws-polyfill.test.ts +18 -0
  32. package/src/node-ws-polyfill.ts +5 -3
  33. package/src/server/heartbeat.ts +13 -6
  34. package/src/server/relay.test.ts +72 -0
  35. package/src/server/relay.ts +50 -9
  36. package/src/server/server.test.ts +33 -0
  37. package/src/server/server.ts +26 -6
  38. package/src/server/ws-handler.test.ts +158 -0
  39. package/src/server/ws-handler.ts +94 -36
  40. package/src/shared/crypto.ts +2 -3
  41. package/src/shared/index.ts +8 -0
  42. package/src/shared/permissions.ts +292 -0
  43. package/src/shared/types.ts +70 -41
  44. package/dist/agent/index.d.ts +0 -140
  45. package/dist/agent/index.js +0 -21
  46. package/dist/agent/index.js.map +0 -1
  47. package/dist/chunk-7N7GSU6K.js +0 -34
  48. package/dist/client/index.d.ts +0 -183
  49. package/dist/client/index.js +0 -8
  50. package/dist/client/index.js.map +0 -1
  51. package/dist/index.d.ts +0 -7
  52. package/dist/index.js +0 -55
  53. package/dist/index.js.map +0 -1
  54. package/dist/server/index.d.ts +0 -89
  55. package/dist/server/index.js +0 -14
  56. package/dist/server/index.js.map +0 -1
  57. package/dist/shared/index.d.ts +0 -10
  58. package/dist/shared/index.js +0 -20
  59. package/dist/shared/index.js.map +0 -1
  60. package/dist/types-Dpwrd8Ai.d.ts +0 -194
  61. package/src/agent/capabilities/desktop/atspi-helper.ts +0 -345
  62. package/src/agent/capabilities/desktop/csharp-helper.ts +0 -914
  63. package/src/agent/capabilities/desktop/linux-driver.ts +0 -368
  64. package/src/agent/capabilities/desktop/macos-driver.ts +0 -601
  65. package/src/agent/capabilities/desktop/swift-helper.ts +0 -736
  66. package/src/agent/capabilities/desktop/types.ts +0 -201
  67. package/src/agent/capabilities/desktop/windows-driver.ts +0 -220
@@ -1,191 +1,58 @@
1
- /**
2
- * E2E tests for the Agent Tunnel CLI (src/client/cli.ts).
3
- *
4
- * Strategy: spin up a lightweight mock HTTP server that simulates the
5
- * tunnel relay API, then invoke the CLI as a child process via `bun run`
6
- * with TUNNEL_API_URL pointing at the mock. Assert on JSON stdout,
7
- * stderr, and exit codes.
8
- *
9
- * Run: bun test src/client/cli.test.ts
10
- */
11
-
12
- import { describe, test, expect, beforeAll, afterAll } from "bun:test";
13
- import { spawn } from "child_process";
14
- import { resolve, dirname } from "path";
15
- import { tmpdir } from "os";
16
- import { existsSync, unlinkSync } from "fs";
17
-
18
- // ── Mock tunnel server ────────────────────────────────────────────────────
19
-
20
- const TUNNEL_ID = "test-tunnel-001";
21
-
22
- /** Builds JSON-RPC–style responses for each method. */
23
- function mockRpcResponse(
24
- method: string,
25
- params: Record<string, unknown>
26
- ): unknown {
1
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2
+ import { spawn } from 'child_process';
3
+ import { resolve, dirname } from 'path';
4
+
5
+ const TUNNEL_ID = 'test-tunnel-001';
6
+
7
+ function mockRpcResponse(method: string, params: Record<string, unknown>): unknown {
27
8
  switch (method) {
28
- case "fs.read":
29
- return {
30
- content: `mock content of ${params.path}`,
31
- size: 28,
32
- path: params.path,
33
- };
34
- case "fs.write":
9
+ case 'fs.read':
10
+ return { content: `mock content of ${params.path}`, size: 28, path: params.path };
11
+ case 'fs.write':
35
12
  return { path: params.path, size: (params.content as string).length };
36
- case "fs.list":
13
+ case 'fs.list':
37
14
  return {
38
15
  entries: [
39
- {
40
- name: "file.txt",
41
- path: `${params.path}/file.txt`,
42
- isDirectory: false,
43
- isFile: true,
44
- },
45
- {
46
- name: "subdir",
47
- path: `${params.path}/subdir`,
48
- isDirectory: true,
49
- isFile: false,
50
- },
16
+ { name: 'file.txt', path: `${params.path}/file.txt`, isDirectory: false, isFile: true },
17
+ { name: 'subdir', path: `${params.path}/subdir`, isDirectory: true, isFile: false },
51
18
  ],
52
19
  count: 2,
53
20
  };
54
- case "shell.exec":
21
+ case 'shell.exec':
55
22
  return {
56
23
  exitCode: 0,
57
24
  signal: null,
58
- stdout: `ran ${params.command} ${((params.args as string[]) || []).join(" ")}`,
59
- stderr: "",
25
+ stdout: `ran ${params.command} ${((params.args as string[]) || []).join(' ')}`,
26
+ stderr: '',
60
27
  stdoutTruncated: false,
61
28
  stderrTruncated: false,
62
29
  };
63
- case "desktop.screenshot":
64
- // 1x1 red PNG as base64
65
- return {
66
- image:
67
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
68
- width: 1,
69
- height: 1,
70
- format: "png",
71
- };
72
- case "desktop.mouse.click":
73
- return { ok: true };
74
- case "desktop.mouse.move":
75
- return { ok: true };
76
- case "desktop.mouse.drag":
77
- return { ok: true };
78
- case "desktop.mouse.scroll":
79
- return { ok: true };
80
- case "desktop.keyboard.type":
81
- return { ok: true };
82
- case "desktop.keyboard.key":
83
- return { ok: true };
84
- case "desktop.window.list":
85
- return {
86
- windows: [
87
- {
88
- id: 42,
89
- app: "Finder",
90
- title: "Desktop",
91
- bounds: { x: 0, y: 0, width: 1920, height: 1080 },
92
- minimized: false,
93
- },
94
- ],
95
- };
96
- case "desktop.window.focus":
97
- return { ok: true };
98
- case "desktop.app.launch":
99
- return { ok: true };
100
- case "desktop.app.quit":
101
- return { ok: true };
102
- case "desktop.clipboard.read":
103
- return { text: "clipboard mock content" };
104
- case "desktop.clipboard.write":
105
- return { ok: true };
106
- case "desktop.screen.info":
107
- return { width: 1920, height: 1080, scaleFactor: 2 };
108
- case "desktop.cursor.image":
109
- return {
110
- image:
111
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
112
- width: 100,
113
- height: 100,
114
- format: "png",
115
- };
116
- case "desktop.ax.tree":
117
- return {
118
- root: {
119
- id: "0",
120
- role: "AXApplication",
121
- title: "TestApp",
122
- value: "",
123
- description: "",
124
- bounds: { x: 0, y: 0, width: 800, height: 600 },
125
- children: [
126
- {
127
- id: "0.0",
128
- role: "AXButton",
129
- title: "OK",
130
- value: "",
131
- description: "",
132
- bounds: { x: 100, y: 100, width: 80, height: 30 },
133
- children: [],
134
- actions: ["AXPress"],
135
- enabled: true,
136
- focused: false,
137
- },
138
- ],
139
- actions: [],
140
- enabled: true,
141
- focused: false,
142
- },
143
- elementCount: 2,
144
- };
145
- case "desktop.ax.action":
146
- return {
147
- ok: true,
148
- action: params.action,
149
- elementId: params.elementId,
150
- before: { focused: false, value: "" },
151
- after: { focused: true, value: "" },
152
- stateChanged: true,
153
- role: "AXButton",
154
- title: "OK",
155
- };
156
- case "desktop.ax.set_value":
157
- return {
158
- ok: true,
159
- elementId: params.elementId,
160
- requestedValue: params.value,
161
- actualValue: params.value,
162
- };
163
- case "desktop.ax.focus":
164
- return {
165
- ok: true,
166
- elementId: params.elementId,
167
- role: "AXTextField",
168
- title: "Search",
169
- before: { focused: false },
170
- after: { focused: true },
171
- };
172
- case "desktop.ax.search":
173
- return {
174
- elements: [
175
- {
176
- id: "0.1",
177
- role: "AXButton",
178
- title: params.query as string,
179
- value: "",
180
- description: "",
181
- bounds: { x: 200, y: 200, width: 60, height: 30 },
182
- children: [],
183
- actions: ["AXPress"],
184
- enabled: true,
185
- focused: false,
186
- },
187
- ],
188
- };
30
+ case 'desktop.cua.ensure':
31
+ return { ok: true, binary: '/Users/me/.local/bin/cua-driver', version: 'cua-driver 0.5.7' };
32
+ case 'desktop.cua.start_daemon':
33
+ return { ok: true, status: 'ok' };
34
+ case 'desktop.cua.status':
35
+ return { status: 'ok' };
36
+ case 'desktop.cua.version':
37
+ return { version: 'cua-driver 0.5.7' };
38
+ case 'desktop.cua.list_tools':
39
+ return { tools: 'list_apps: List apps\nget_window_state: Get window state' };
40
+ case 'desktop.cua.describe':
41
+ return { description: `${params.tool}: mocked description` };
42
+ case 'desktop.cua.call':
43
+ return { tool: params.tool, args: params.args ?? {} };
44
+ case 'desktop.cua.list_apps':
45
+ return { applications: [{ name: 'TextEdit', pid: 123, bundle_id: 'com.apple.TextEdit' }] };
46
+ case 'desktop.cua.list_windows':
47
+ return { windows: [{ window_id: 77, pid: 123, title: 'Untitled', app: 'TextEdit' }] };
48
+ case 'desktop.cua.get_window_state':
49
+ return { pid: params.pid, window_id: params.window_id, accessibility_tree: '[element_index 1] text area' };
50
+ case 'desktop.cua.click':
51
+ return { ok: true, element_index: params.element_index };
52
+ case 'desktop.cua.type_text':
53
+ return { ok: true, text: params.text };
54
+ case 'desktop.cua.hotkey':
55
+ return { ok: true, keys: params.keys };
189
56
  default:
190
57
  return { error: `Unknown method: ${method}` };
191
58
  }
@@ -196,44 +63,29 @@ let mockPort = 0;
196
63
 
197
64
  beforeAll(() => {
198
65
  mockServer = Bun.serve({
199
- port: 0, // random available port
66
+ port: 0,
200
67
  async fetch(req) {
201
68
  const url = new URL(req.url);
202
69
 
203
- // GET /v1/tunnel/connections list connections
204
- if (
205
- url.pathname === "/v1/tunnel/connections" &&
206
- req.method === "GET"
207
- ) {
70
+ if (url.pathname === '/v1/tunnel/connections' && req.method === 'GET') {
208
71
  return Response.json([
209
72
  {
210
73
  tunnelId: TUNNEL_ID,
211
- name: "Test Tunnel",
74
+ name: 'Test Tunnel',
212
75
  isLive: true,
213
- capabilities: ["filesystem", "shell", "desktop"],
214
- machineInfo: {
215
- hostname: "test-machine",
216
- platform: "darwin",
217
- arch: "arm64",
218
- },
76
+ capabilities: ['filesystem', 'shell', 'desktop'],
77
+ machineInfo: { hostname: 'test-machine', platform: 'darwin', arch: 'arm64' },
219
78
  },
220
79
  ]);
221
80
  }
222
81
 
223
- // POST /v1/tunnel/rpc/:tunnelId — RPC call
224
- const rpcMatch = url.pathname.match(
225
- /^\/v1\/tunnel\/rpc\/(.+)$/
226
- );
227
- if (rpcMatch && req.method === "POST") {
228
- const body = (await req.json()) as {
229
- method: string;
230
- params: Record<string, unknown>;
231
- };
232
- const result = mockRpcResponse(body.method, body.params || {});
233
- return Response.json({ result });
82
+ const rpcMatch = url.pathname.match(/^\/v1\/tunnel\/rpc\/(.+)$/);
83
+ if (rpcMatch && req.method === 'POST') {
84
+ const body = (await req.json()) as { method: string; params: Record<string, unknown> };
85
+ return Response.json({ result: mockRpcResponse(body.method, body.params || {}) });
234
86
  }
235
87
 
236
- return new Response("Not Found", { status: 404 });
88
+ return new Response('Not Found', { status: 404 });
237
89
  },
238
90
  });
239
91
  mockPort = mockServer.port!;
@@ -243,9 +95,7 @@ afterAll(() => {
243
95
  mockServer?.stop(true);
244
96
  });
245
97
 
246
- // ── Test helpers ──────────────────────────────────────────────────────────
247
-
248
- const CLI_PATH = resolve(dirname(import.meta.dir), "client/cli.ts");
98
+ const CLI_PATH = resolve(dirname(import.meta.dir), 'client/cli.ts');
249
99
 
250
100
  interface CliResult {
251
101
  stdout: string;
@@ -257,379 +107,173 @@ interface CliResult {
257
107
  function runCli(
258
108
  command: string,
259
109
  argsJson?: string,
260
- envOverrides?: Record<string, string>
110
+ envOverrides?: Record<string, string>,
261
111
  ): Promise<CliResult> {
262
112
  return new Promise((resolve) => {
263
- const cliArgs = ["run", CLI_PATH];
113
+ const cliArgs = ['run', CLI_PATH];
264
114
  if (command) cliArgs.push(command);
265
115
  if (argsJson) cliArgs.push(argsJson);
266
116
 
267
- const child = spawn("bun", cliArgs, {
117
+ const child = spawn('bun', cliArgs, {
268
118
  env: {
269
119
  ...process.env,
270
120
  TUNNEL_API_URL: `http://localhost:${mockPort}`,
271
- TUNNEL_TOKEN: "test-token",
272
- TUNNEL_ID: "",
121
+ TUNNEL_TOKEN: 'test-token',
122
+ TUNNEL_ID: '',
273
123
  ...envOverrides,
274
124
  },
275
125
  cwd: dirname(dirname(CLI_PATH)),
276
126
  });
277
127
 
278
- let stdout = "";
279
- let stderr = "";
128
+ let stdout = '';
129
+ let stderr = '';
280
130
 
281
- child.stdout.on("data", (d: Buffer) => (stdout += d.toString()));
282
- child.stderr.on("data", (d: Buffer) => (stderr += d.toString()));
131
+ child.stdout.on('data', (d: Buffer) => (stdout += d.toString()));
132
+ child.stderr.on('data', (d: Buffer) => (stderr += d.toString()));
283
133
 
284
- child.on("close", (code: number | null) => {
134
+ child.on('close', (code: number | null) => {
285
135
  let json: Record<string, unknown> | null = null;
286
136
  try {
287
137
  json = JSON.parse(stdout.trim());
288
138
  } catch {}
289
- resolve({
290
- stdout: stdout.trim(),
291
- stderr: stderr.trim(),
292
- exitCode: code ?? 1,
293
- json,
294
- });
139
+ resolve({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: code ?? 1, json });
295
140
  });
296
141
  });
297
142
  }
298
143
 
299
- // ── Tests ─────────────────────────────────────────────────────────────────
300
-
301
- describe("Agent Tunnel CLI", () => {
302
- // ── Dispatch & error handling ──
303
-
304
- test("no command shows usage and exits 1", async () => {
305
- const r = await runCli("");
144
+ describe('Agent Tunnel CLI', () => {
145
+ test('no command shows usage and exits 1', async () => {
146
+ const r = await runCli('');
306
147
  expect(r.exitCode).toBe(1);
307
- expect(r.stderr).toContain("Usage:");
308
- expect(r.stderr).toContain("Available:");
148
+ expect(r.stderr).toContain('Usage:');
149
+ expect(r.stderr).toContain('Available:');
309
150
  });
310
151
 
311
- test("unknown command shows error and exits 1", async () => {
312
- const r = await runCli("nonexistent");
152
+ test('unknown command shows error and exits 1', async () => {
153
+ const r = await runCli('nonexistent');
313
154
  expect(r.exitCode).toBe(1);
314
- expect(r.stderr).toContain("Unknown command: nonexistent");
315
- expect(r.stderr).toContain("Available:");
155
+ expect(r.stderr).toContain('Unknown command: nonexistent');
316
156
  });
317
157
 
318
- test("invalid JSON arg exits 1 with error", async () => {
319
- const r = await runCli("fs_read", "{invalid json}");
158
+ test('invalid JSON arg exits 1 with JSON error', async () => {
159
+ const r = await runCli('fs_read', '{invalid json}');
320
160
  expect(r.exitCode).toBe(1);
161
+ expect(r.json!.success).toBe(false);
321
162
  });
322
163
 
323
- // ── status ──
324
-
325
- test("status returns connections list", async () => {
326
- const r = await runCli("status");
164
+ test('status returns connections list', async () => {
165
+ const r = await runCli('status');
327
166
  expect(r.exitCode).toBe(0);
328
- expect(r.json).not.toBeNull();
329
167
  expect(r.json!.success).toBe(true);
330
- expect(Array.isArray(r.json!.connections)).toBe(true);
331
168
  const conns = r.json!.connections as Array<Record<string, unknown>>;
332
- expect(conns.length).toBe(1);
333
169
  expect(conns[0].tunnelId).toBe(TUNNEL_ID);
334
- expect(conns[0].status).toBe("ONLINE");
335
- expect(r.json!.hasOnline).toBe(true);
170
+ expect(conns[0].capabilities).toEqual(['filesystem', 'shell', 'desktop']);
336
171
  });
337
172
 
338
- // ── Filesystem ──
339
-
340
- test("fs_read returns file content", async () => {
341
- const r = await runCli("fs_read", '{"path":"/tmp/test.txt"}');
173
+ test('fs_read returns file content', async () => {
174
+ const r = await runCli('fs_read', '{"path":"/tmp/test.txt"}');
342
175
  expect(r.exitCode).toBe(0);
343
176
  expect(r.json!.success).toBe(true);
344
- expect(r.json!.path).toBe("/tmp/test.txt");
345
- expect(r.json!.content).toContain("mock content");
346
- expect(typeof r.json!.size).toBe("number");
177
+ expect(r.json!.content).toContain('mock content');
347
178
  });
348
179
 
349
- test("fs_write returns path and size", async () => {
350
- const r = await runCli(
351
- "fs_write",
352
- '{"path":"/tmp/out.txt","content":"hello world"}'
353
- );
180
+ test('fs_write returns path and size', async () => {
181
+ const r = await runCli('fs_write', '{"path":"/tmp/out.txt","content":"hello world"}');
354
182
  expect(r.exitCode).toBe(0);
355
- expect(r.json!.success).toBe(true);
356
- expect(r.json!.path).toBe("/tmp/out.txt");
183
+ expect(r.json!.path).toBe('/tmp/out.txt');
357
184
  expect(r.json!.size).toBe(11);
358
185
  });
359
186
 
360
- test("fs_list returns entries", async () => {
361
- const r = await runCli("fs_list", '{"path":"/tmp"}');
187
+ test('fs_list returns entries', async () => {
188
+ const r = await runCli('fs_list', '{"path":"/tmp"}');
362
189
  expect(r.exitCode).toBe(0);
363
- expect(r.json!.success).toBe(true);
364
190
  expect(r.json!.count).toBe(2);
365
- const entries = r.json!.entries as Array<Record<string, unknown>>;
366
- expect(entries.length).toBe(2);
367
- expect(entries[0].name).toBe("file.txt");
368
- expect(entries[0].isFile).toBe(true);
369
- expect(entries[1].name).toBe("subdir");
370
- expect(entries[1].isDirectory).toBe(true);
371
- });
372
-
373
- // ── Shell ──
374
-
375
- test("shell returns command output", async () => {
376
- const r = await runCli(
377
- "shell",
378
- '{"command":"echo","args":["hello","world"]}'
379
- );
380
- expect(r.exitCode).toBe(0);
381
- expect(r.json!.success).toBe(true);
382
- expect(r.json!.exitCode).toBe(0);
383
- expect(r.json!.stdout).toContain("echo hello world");
384
- expect(r.json!.stderr).toBe("");
385
191
  });
386
192
 
387
- // ── Screenshot ──
388
-
389
- test("screenshot saves image and returns path", async () => {
390
- const r = await runCli("screenshot");
193
+ test('shell returns command output', async () => {
194
+ const r = await runCli('shell', '{"command":"echo","args":["hello","world"]}');
391
195
  expect(r.exitCode).toBe(0);
392
196
  expect(r.json!.success).toBe(true);
393
- expect(r.json!.width).toBe(1);
394
- expect(r.json!.height).toBe(1);
395
- expect(r.json!.format).toBe("png");
396
- expect(typeof r.json!.path).toBe("string");
397
- const imgPath = r.json!.path as string;
398
- expect(imgPath).toContain("tunnel-screenshots");
399
- expect(imgPath).toEndWith(".png");
400
- // Verify the file was actually written
401
- expect(existsSync(imgPath)).toBe(true);
402
- // Clean up
403
- try {
404
- unlinkSync(imgPath);
405
- } catch {}
197
+ expect(r.json!.stdout).toContain('echo hello world');
406
198
  });
407
199
 
408
- test("screenshot with region params", async () => {
409
- const r = await runCli(
410
- "screenshot",
411
- '{"x":0,"y":0,"width":800,"height":600}'
412
- );
200
+ test('cua_ensure returns binary and version', async () => {
201
+ const r = await runCli('cua_ensure');
413
202
  expect(r.exitCode).toBe(0);
414
203
  expect(r.json!.success).toBe(true);
204
+ expect((r.json!.result as Record<string, unknown>).version).toBe('cua-driver 0.5.7');
415
205
  });
416
206
 
417
- // ── Mouse ──
418
-
419
- test("click returns success", async () => {
420
- const r = await runCli("click", '{"x":100,"y":200}');
207
+ test('cua_start_daemon returns daemon status', async () => {
208
+ const r = await runCli('cua_start_daemon');
421
209
  expect(r.exitCode).toBe(0);
422
- expect(r.json!.success).toBe(true);
423
- expect(r.json!.x).toBe(100);
424
- expect(r.json!.y).toBe(200);
425
- expect(r.json!.button).toBe("left");
210
+ expect((r.json!.result as Record<string, unknown>).ok).toBe(true);
426
211
  });
427
212
 
428
- test("click with button and modifiers", async () => {
429
- const r = await runCli(
430
- "click",
431
- '{"x":50,"y":50,"button":"right","clicks":2,"modifiers":["cmd"]}'
432
- );
213
+ test('cua_list_tools returns CUA tool descriptions', async () => {
214
+ const r = await runCli('cua_list_tools');
433
215
  expect(r.exitCode).toBe(0);
434
- expect(r.json!.success).toBe(true);
435
- expect(r.json!.button).toBe("right");
436
- expect(r.json!.clicks).toBe(2);
216
+ expect((r.json!.result as Record<string, unknown>).tools).toContain('list_apps');
437
217
  });
438
218
 
439
- test("mouse_move returns success", async () => {
440
- const r = await runCli("mouse_move", '{"x":500,"y":300}');
441
- expect(r.exitCode).toBe(0);
442
- expect(r.json!.success).toBe(true);
443
- expect(r.json!.x).toBe(500);
444
- expect(r.json!.y).toBe(300);
445
- });
446
-
447
- test("mouse_drag returns success", async () => {
448
- const r = await runCli(
449
- "mouse_drag",
450
- '{"fromX":0,"fromY":0,"toX":100,"toY":100}'
451
- );
452
- expect(r.exitCode).toBe(0);
453
- expect(r.json!.success).toBe(true);
454
- expect(r.json!.fromX).toBe(0);
455
- expect(r.json!.toX).toBe(100);
456
- });
457
-
458
- test("mouse_scroll returns success", async () => {
459
- const r = await runCli(
460
- "mouse_scroll",
461
- '{"x":500,"y":500,"deltaY":3}'
462
- );
463
- expect(r.exitCode).toBe(0);
464
- expect(r.json!.success).toBe(true);
465
- expect(r.json!.deltaY).toBe(3);
466
- });
467
-
468
- // ── Keyboard ──
469
-
470
- test("type returns char count", async () => {
471
- const r = await runCli("type", '{"text":"hello world"}');
472
- expect(r.exitCode).toBe(0);
473
- expect(r.json!.success).toBe(true);
474
- expect(r.json!.chars).toBe(11);
475
- });
476
-
477
- test("key returns pressed keys", async () => {
478
- const r = await runCli("key", '{"keys":["cmd","s"]}');
479
- expect(r.exitCode).toBe(0);
480
- expect(r.json!.success).toBe(true);
481
- const keys = r.json!.keys as string[];
482
- expect(keys).toEqual(["cmd", "s"]);
483
- });
484
-
485
- // ── Windows ──
486
-
487
- test("window_list returns windows", async () => {
488
- const r = await runCli("window_list");
489
- expect(r.exitCode).toBe(0);
490
- expect(r.json!.success).toBe(true);
491
- const windows = r.json!.windows as Array<Record<string, unknown>>;
492
- expect(windows.length).toBe(1);
493
- expect(windows[0].id).toBe(42);
494
- expect(windows[0].app).toBe("Finder");
495
- });
496
-
497
- test("window_focus returns success", async () => {
498
- const r = await runCli("window_focus", '{"windowId":42}');
499
- expect(r.exitCode).toBe(0);
500
- expect(r.json!.success).toBe(true);
501
- expect(r.json!.windowId).toBe(42);
502
- });
503
-
504
- // ── Apps ──
505
-
506
- test("app_launch returns success", async () => {
507
- const r = await runCli("app_launch", '{"app":"Safari"}');
508
- expect(r.exitCode).toBe(0);
509
- expect(r.json!.success).toBe(true);
510
- expect(r.json!.app).toBe("Safari");
511
- });
512
-
513
- test("app_quit returns success", async () => {
514
- const r = await runCli("app_quit", '{"app":"Safari"}');
515
- expect(r.exitCode).toBe(0);
516
- expect(r.json!.success).toBe(true);
517
- expect(r.json!.app).toBe("Safari");
518
- });
519
-
520
- // ── Clipboard ──
521
-
522
- test("clipboard_read returns text", async () => {
523
- const r = await runCli("clipboard_read");
524
- expect(r.exitCode).toBe(0);
525
- expect(r.json!.success).toBe(true);
526
- expect(r.json!.text).toBe("clipboard mock content");
527
- });
528
-
529
- test("clipboard_write returns char count", async () => {
530
- const r = await runCli("clipboard_write", '{"text":"test copy"}');
531
- expect(r.exitCode).toBe(0);
532
- expect(r.json!.success).toBe(true);
533
- expect(r.json!.chars).toBe(9);
534
- });
535
-
536
- // ── Screen ──
537
-
538
- test("screen_info returns dimensions", async () => {
539
- const r = await runCli("screen_info");
540
- expect(r.exitCode).toBe(0);
541
- expect(r.json!.success).toBe(true);
542
- expect(r.json!.width).toBe(1920);
543
- expect(r.json!.height).toBe(1080);
544
- expect(r.json!.scaleFactor).toBe(2);
219
+ test('cua_describe requires a tool', async () => {
220
+ const r = await runCli('cua_describe');
221
+ expect(r.exitCode).toBe(1);
222
+ expect(r.json!.success).toBe(false);
545
223
  });
546
224
 
547
- test("cursor_image saves image", async () => {
548
- const r = await runCli("cursor_image", '{"radius":50}');
225
+ test('cua_describe calls desktop.cua.describe', async () => {
226
+ const r = await runCli('cua_describe', '{"tool":"get_window_state"}');
549
227
  expect(r.exitCode).toBe(0);
550
- expect(r.json!.success).toBe(true);
551
- expect(r.json!.width).toBe(100);
552
- expect(r.json!.height).toBe(100);
553
- const imgPath = r.json!.path as string;
554
- expect(existsSync(imgPath)).toBe(true);
555
- try {
556
- unlinkSync(imgPath);
557
- } catch {}
228
+ expect((r.json!.result as Record<string, unknown>).description).toContain('get_window_state');
558
229
  });
559
230
 
560
- // ── Accessibility ──
561
-
562
- test("ax_tree returns formatted tree", async () => {
563
- const r = await runCli("ax_tree");
231
+ test('cua_call calls arbitrary CUA tools', async () => {
232
+ const r = await runCli('cua_call', '{"tool":"check_permissions","args":{"format":"json"}}');
564
233
  expect(r.exitCode).toBe(0);
565
- expect(r.json!.success).toBe(true);
566
- expect(r.json!.elementCount).toBe(2);
567
- expect(typeof r.json!.tree).toBe("string");
568
- const tree = r.json!.tree as string;
569
- expect(tree).toContain("[AXApplication]");
570
- expect(tree).toContain("[AXButton] OK");
234
+ expect(r.json!.tool).toBe('check_permissions');
235
+ expect((r.json!.result as Record<string, unknown>).tool).toBe('check_permissions');
571
236
  });
572
237
 
573
- test("ax_tree with pid filter", async () => {
574
- const r = await runCli("ax_tree", '{"pid":1234,"maxDepth":4}');
238
+ test('cua_list_apps returns app list', async () => {
239
+ const r = await runCli('cua_list_apps');
575
240
  expect(r.exitCode).toBe(0);
576
- expect(r.json!.success).toBe(true);
241
+ const result = r.json!.result as Record<string, unknown>;
242
+ expect(Array.isArray(result.applications)).toBe(true);
577
243
  });
578
244
 
579
- test("ax_action returns state change", async () => {
580
- const r = await runCli(
581
- "ax_action",
582
- '{"elementId":"0.0","action":"AXPress"}'
583
- );
245
+ test('cua_list_windows returns window list', async () => {
246
+ const r = await runCli('cua_list_windows', '{"pid":123}');
584
247
  expect(r.exitCode).toBe(0);
585
- expect(r.json!.success).toBe(true);
586
- expect(r.json!.action).toBe("AXPress");
587
- expect(r.json!.elementId).toBe("0.0");
588
- expect(r.json!.stateChanged).toBe(true);
589
- expect(r.json!.role).toBe("AXButton");
248
+ const result = r.json!.result as Record<string, unknown>;
249
+ expect((result.windows as unknown[]).length).toBe(1);
590
250
  });
591
251
 
592
- test("ax_set_value returns verified value", async () => {
593
- const r = await runCli(
594
- "ax_set_value",
595
- '{"elementId":"0.1","value":"search text"}'
596
- );
252
+ test('cua_get_window_state returns accessibility markdown', async () => {
253
+ const r = await runCli('cua_get_window_state', '{"pid":123,"window_id":77}');
597
254
  expect(r.exitCode).toBe(0);
598
- expect(r.json!.success).toBe(true);
599
- expect(r.json!.requestedValue).toBe("search text");
600
- expect(r.json!.actualValue).toBe("search text");
255
+ expect((r.json!.result as Record<string, unknown>).accessibility_tree).toContain('element_index');
601
256
  });
602
257
 
603
- test("ax_focus returns focus state", async () => {
604
- const r = await runCli("ax_focus", '{"elementId":"0.1"}');
258
+ test('cua_click supports element-indexed input', async () => {
259
+ const r = await runCli('cua_click', '{"pid":123,"window_id":77,"element_index":1}');
605
260
  expect(r.exitCode).toBe(0);
606
- expect(r.json!.success).toBe(true);
607
- expect(r.json!.role).toBe("AXTextField");
608
- expect((r.json!.after as Record<string, unknown>).focused).toBe(true);
261
+ expect((r.json!.result as Record<string, unknown>).ok).toBe(true);
609
262
  });
610
263
 
611
- test("ax_search returns matching elements", async () => {
612
- const r = await runCli("ax_search", '{"query":"Submit"}');
264
+ test('cua_type_text sends text', async () => {
265
+ const r = await runCli('cua_type_text', '{"pid":123,"text":"hello"}');
613
266
  expect(r.exitCode).toBe(0);
614
- expect(r.json!.success).toBe(true);
615
- expect(r.json!.count).toBe(1);
616
- const elements = r.json!.elements as Array<Record<string, unknown>>;
617
- expect(elements[0].role).toBe("AXButton");
618
- expect(elements[0].title).toBe("Submit");
267
+ expect((r.json!.result as Record<string, unknown>).text).toBe('hello');
619
268
  });
620
269
 
621
- test("ax_search with role filter", async () => {
622
- const r = await runCli(
623
- "ax_search",
624
- '{"query":"Submit","role":"button","maxResults":5}'
625
- );
270
+ test('cua_hotkey sends keys', async () => {
271
+ const r = await runCli('cua_hotkey', '{"pid":123,"keys":["cmd","s"]}');
626
272
  expect(r.exitCode).toBe(0);
627
- expect(r.json!.success).toBe(true);
273
+ expect((r.json!.result as Record<string, unknown>).keys).toEqual(['cmd', 's']);
628
274
  });
629
275
 
630
- // ── Permission flow ──
631
-
632
- describe("permission handling", () => {
276
+ describe('permission handling', () => {
633
277
  let permServer: ReturnType<typeof Bun.serve>;
634
278
  let permPort = 0;
635
279
 
@@ -639,25 +283,18 @@ describe("Agent Tunnel CLI", () => {
639
283
  async fetch(req) {
640
284
  const url = new URL(req.url);
641
285
 
642
- if (url.pathname === "/v1/tunnel/connections") {
643
- return Response.json([
644
- { tunnelId: "perm-tunnel", isLive: true },
645
- ]);
286
+ if (url.pathname === '/v1/tunnel/connections') {
287
+ return Response.json([{ tunnelId: 'perm-tunnel', isLive: true }]);
646
288
  }
647
289
 
648
- // Always return 403 with requestId for RPC calls
649
- if (url.pathname.startsWith("/v1/tunnel/rpc/")) {
290
+ if (url.pathname.startsWith('/v1/tunnel/rpc/')) {
650
291
  return Response.json(
651
- {
652
- code: -32003,
653
- error: "Permission denied",
654
- requestId: "req-abc-123",
655
- },
656
- { status: 403 }
292
+ { code: -32003, error: 'Permission denied', requestId: 'req-abc-123' },
293
+ { status: 403 },
657
294
  );
658
295
  }
659
296
 
660
- return new Response("Not Found", { status: 404 });
297
+ return new Response('Not Found', { status: 404 });
661
298
  },
662
299
  });
663
300
  permPort = permServer.port!;
@@ -667,54 +304,25 @@ describe("Agent Tunnel CLI", () => {
667
304
  permServer?.stop(true);
668
305
  });
669
306
 
670
- test("permission denied returns structured response", async () => {
671
- const r = await runCli("fs_read", '{"path":"/etc/passwd"}', {
307
+ test('permission denied returns structured response', async () => {
308
+ const r = await runCli('cua_list_apps', undefined, {
672
309
  TUNNEL_API_URL: `http://localhost:${permPort}`,
673
310
  });
674
- // CLI should output permission-required JSON (not crash)
675
- expect(r.json).not.toBeNull();
676
- expect(r.json!.success).toBe(false);
677
- expect(r.json!.permissionRequired).toBe(true);
678
- expect(r.json!.requestId).toBe("req-abc-123");
679
- expect(typeof r.json!.message).toBe("string");
680
- });
681
-
682
- test("shell permission denied returns structured response", async () => {
683
- const r = await runCli(
684
- "shell",
685
- '{"command":"rm","args":["-rf","/"]}',
686
- { TUNNEL_API_URL: `http://localhost:${permPort}` }
687
- );
688
311
  expect(r.json!.success).toBe(false);
689
312
  expect(r.json!.permissionRequired).toBe(true);
313
+ expect(r.json!.requestId).toBe('req-abc-123');
690
314
  });
691
315
  });
692
316
 
693
- // ── Error handling: server down ──
694
-
695
- describe("server unreachable", () => {
696
- test("status with dead server returns error JSON", async () => {
697
- const r = await runCli("status", undefined, {
698
- TUNNEL_API_URL: "http://localhost:1",
699
- });
700
- expect(r.exitCode).toBe(1);
701
- expect(r.json).not.toBeNull();
702
- expect(r.json!.success).toBe(false);
703
- expect(typeof r.json!.error).toBe("string");
704
- });
705
-
706
- test("fs_read with dead server returns error JSON", async () => {
707
- const r = await runCli("fs_read", '{"path":"/tmp/x"}', {
708
- TUNNEL_API_URL: "http://localhost:1",
709
- });
317
+ describe('server unreachable', () => {
318
+ test('status with dead server returns error JSON', async () => {
319
+ const r = await runCli('status', undefined, { TUNNEL_API_URL: 'http://localhost:1' });
710
320
  expect(r.exitCode).toBe(1);
711
321
  expect(r.json!.success).toBe(false);
712
322
  });
713
323
  });
714
324
 
715
- // ── Empty connections ──
716
-
717
- describe("no connections", () => {
325
+ describe('no connections', () => {
718
326
  let emptyServer: ReturnType<typeof Bun.serve>;
719
327
  let emptyPort = 0;
720
328
 
@@ -723,10 +331,8 @@ describe("Agent Tunnel CLI", () => {
723
331
  port: 0,
724
332
  async fetch(req) {
725
333
  const url = new URL(req.url);
726
- if (url.pathname === "/v1/tunnel/connections") {
727
- return Response.json([]);
728
- }
729
- return new Response("Not Found", { status: 404 });
334
+ if (url.pathname === '/v1/tunnel/connections') return Response.json([]);
335
+ return new Response('Not Found', { status: 404 });
730
336
  },
731
337
  });
732
338
  emptyPort = emptyServer.port!;
@@ -736,23 +342,20 @@ describe("Agent Tunnel CLI", () => {
736
342
  emptyServer?.stop(true);
737
343
  });
738
344
 
739
- test("status with no connections returns empty list", async () => {
740
- const r = await runCli("status", undefined, {
741
- TUNNEL_API_URL: `http://localhost:${emptyPort}`,
742
- });
345
+ test('status with no connections returns empty list', async () => {
346
+ const r = await runCli('status', undefined, { TUNNEL_API_URL: `http://localhost:${emptyPort}` });
743
347
  expect(r.exitCode).toBe(0);
744
348
  expect(r.json!.success).toBe(true);
745
349
  expect((r.json!.connections as unknown[]).length).toBe(0);
746
- expect(typeof r.json!.message).toBe("string");
747
350
  });
748
351
 
749
- test("fs_read with no connections returns error", async () => {
750
- const r = await runCli("fs_read", '{"path":"/tmp/x"}', {
352
+ test('fs_read with no connections returns error', async () => {
353
+ const r = await runCli('fs_read', '{"path":"/tmp/x"}', {
751
354
  TUNNEL_API_URL: `http://localhost:${emptyPort}`,
752
355
  });
753
356
  expect(r.exitCode).toBe(1);
754
357
  expect(r.json!.success).toBe(false);
755
- expect(r.json!.error).toContain("No tunnel connection found");
358
+ expect(r.json!.error).toContain('No tunnel connection found');
756
359
  });
757
360
  });
758
361
  });