@kortix/agent-tunnel 0.1.0 → 0.1.1

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,758 @@
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 KORTIX_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 {
27
+ 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":
35
+ return { path: params.path, size: (params.content as string).length };
36
+ case "fs.list":
37
+ return {
38
+ 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
+ },
51
+ ],
52
+ count: 2,
53
+ };
54
+ case "shell.exec":
55
+ return {
56
+ exitCode: 0,
57
+ signal: null,
58
+ stdout: `ran ${params.command} ${((params.args as string[]) || []).join(" ")}`,
59
+ stderr: "",
60
+ stdoutTruncated: false,
61
+ stderrTruncated: false,
62
+ };
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
+ };
189
+ default:
190
+ return { error: `Unknown method: ${method}` };
191
+ }
192
+ }
193
+
194
+ let mockServer: ReturnType<typeof Bun.serve>;
195
+ let mockPort = 0;
196
+
197
+ beforeAll(() => {
198
+ mockServer = Bun.serve({
199
+ port: 0, // random available port
200
+ async fetch(req) {
201
+ const url = new URL(req.url);
202
+
203
+ // GET /v1/tunnel/connections — list connections
204
+ if (
205
+ url.pathname === "/v1/tunnel/connections" &&
206
+ req.method === "GET"
207
+ ) {
208
+ return Response.json([
209
+ {
210
+ tunnelId: TUNNEL_ID,
211
+ name: "Test Tunnel",
212
+ isLive: true,
213
+ capabilities: ["filesystem", "shell", "desktop"],
214
+ machineInfo: {
215
+ hostname: "test-machine",
216
+ platform: "darwin",
217
+ arch: "arm64",
218
+ },
219
+ },
220
+ ]);
221
+ }
222
+
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 });
234
+ }
235
+
236
+ return new Response("Not Found", { status: 404 });
237
+ },
238
+ });
239
+ mockPort = mockServer.port!;
240
+ });
241
+
242
+ afterAll(() => {
243
+ mockServer?.stop(true);
244
+ });
245
+
246
+ // ── Test helpers ──────────────────────────────────────────────────────────
247
+
248
+ const CLI_PATH = resolve(dirname(import.meta.dir), "client/cli.ts");
249
+
250
+ interface CliResult {
251
+ stdout: string;
252
+ stderr: string;
253
+ exitCode: number;
254
+ json: Record<string, unknown> | null;
255
+ }
256
+
257
+ function runCli(
258
+ command: string,
259
+ argsJson?: string,
260
+ envOverrides?: Record<string, string>
261
+ ): Promise<CliResult> {
262
+ return new Promise((resolve) => {
263
+ const cliArgs = ["run", CLI_PATH];
264
+ if (command) cliArgs.push(command);
265
+ if (argsJson) cliArgs.push(argsJson);
266
+
267
+ const child = spawn("bun", cliArgs, {
268
+ env: {
269
+ ...process.env,
270
+ KORTIX_API_URL: `http://localhost:${mockPort}`,
271
+ KORTIX_TOKEN: "test-token",
272
+ KORTIX_TUNNEL_ID: "",
273
+ ...envOverrides,
274
+ },
275
+ cwd: dirname(dirname(CLI_PATH)),
276
+ });
277
+
278
+ let stdout = "";
279
+ let stderr = "";
280
+
281
+ child.stdout.on("data", (d: Buffer) => (stdout += d.toString()));
282
+ child.stderr.on("data", (d: Buffer) => (stderr += d.toString()));
283
+
284
+ child.on("close", (code: number | null) => {
285
+ let json: Record<string, unknown> | null = null;
286
+ try {
287
+ json = JSON.parse(stdout.trim());
288
+ } catch {}
289
+ resolve({
290
+ stdout: stdout.trim(),
291
+ stderr: stderr.trim(),
292
+ exitCode: code ?? 1,
293
+ json,
294
+ });
295
+ });
296
+ });
297
+ }
298
+
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("");
306
+ expect(r.exitCode).toBe(1);
307
+ expect(r.stderr).toContain("Usage:");
308
+ expect(r.stderr).toContain("Available:");
309
+ });
310
+
311
+ test("unknown command shows error and exits 1", async () => {
312
+ const r = await runCli("nonexistent");
313
+ expect(r.exitCode).toBe(1);
314
+ expect(r.stderr).toContain("Unknown command: nonexistent");
315
+ expect(r.stderr).toContain("Available:");
316
+ });
317
+
318
+ test("invalid JSON arg exits 1 with error", async () => {
319
+ const r = await runCli("fs_read", "{invalid json}");
320
+ expect(r.exitCode).toBe(1);
321
+ });
322
+
323
+ // ── status ──
324
+
325
+ test("status returns connections list", async () => {
326
+ const r = await runCli("status");
327
+ expect(r.exitCode).toBe(0);
328
+ expect(r.json).not.toBeNull();
329
+ expect(r.json!.success).toBe(true);
330
+ expect(Array.isArray(r.json!.connections)).toBe(true);
331
+ const conns = r.json!.connections as Array<Record<string, unknown>>;
332
+ expect(conns.length).toBe(1);
333
+ expect(conns[0].tunnelId).toBe(TUNNEL_ID);
334
+ expect(conns[0].status).toBe("ONLINE");
335
+ expect(r.json!.hasOnline).toBe(true);
336
+ });
337
+
338
+ // ── Filesystem ──
339
+
340
+ test("fs_read returns file content", async () => {
341
+ const r = await runCli("fs_read", '{"path":"/tmp/test.txt"}');
342
+ expect(r.exitCode).toBe(0);
343
+ 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");
347
+ });
348
+
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
+ );
354
+ expect(r.exitCode).toBe(0);
355
+ expect(r.json!.success).toBe(true);
356
+ expect(r.json!.path).toBe("/tmp/out.txt");
357
+ expect(r.json!.size).toBe(11);
358
+ });
359
+
360
+ test("fs_list returns entries", async () => {
361
+ const r = await runCli("fs_list", '{"path":"/tmp"}');
362
+ expect(r.exitCode).toBe(0);
363
+ expect(r.json!.success).toBe(true);
364
+ 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
+ });
386
+
387
+ // ── Screenshot ──
388
+
389
+ test("screenshot saves image and returns path", async () => {
390
+ const r = await runCli("screenshot");
391
+ expect(r.exitCode).toBe(0);
392
+ 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 {}
406
+ });
407
+
408
+ test("screenshot with region params", async () => {
409
+ const r = await runCli(
410
+ "screenshot",
411
+ '{"x":0,"y":0,"width":800,"height":600}'
412
+ );
413
+ expect(r.exitCode).toBe(0);
414
+ expect(r.json!.success).toBe(true);
415
+ });
416
+
417
+ // ── Mouse ──
418
+
419
+ test("click returns success", async () => {
420
+ const r = await runCli("click", '{"x":100,"y":200}');
421
+ 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");
426
+ });
427
+
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
+ );
433
+ 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);
437
+ });
438
+
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);
545
+ });
546
+
547
+ test("cursor_image saves image", async () => {
548
+ const r = await runCli("cursor_image", '{"radius":50}');
549
+ 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 {}
558
+ });
559
+
560
+ // ── Accessibility ──
561
+
562
+ test("ax_tree returns formatted tree", async () => {
563
+ const r = await runCli("ax_tree");
564
+ 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");
571
+ });
572
+
573
+ test("ax_tree with pid filter", async () => {
574
+ const r = await runCli("ax_tree", '{"pid":1234,"maxDepth":4}');
575
+ expect(r.exitCode).toBe(0);
576
+ expect(r.json!.success).toBe(true);
577
+ });
578
+
579
+ test("ax_action returns state change", async () => {
580
+ const r = await runCli(
581
+ "ax_action",
582
+ '{"elementId":"0.0","action":"AXPress"}'
583
+ );
584
+ 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");
590
+ });
591
+
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
+ );
597
+ 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");
601
+ });
602
+
603
+ test("ax_focus returns focus state", async () => {
604
+ const r = await runCli("ax_focus", '{"elementId":"0.1"}');
605
+ 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);
609
+ });
610
+
611
+ test("ax_search returns matching elements", async () => {
612
+ const r = await runCli("ax_search", '{"query":"Submit"}');
613
+ 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");
619
+ });
620
+
621
+ test("ax_search with role filter", async () => {
622
+ const r = await runCli(
623
+ "ax_search",
624
+ '{"query":"Submit","role":"button","maxResults":5}'
625
+ );
626
+ expect(r.exitCode).toBe(0);
627
+ expect(r.json!.success).toBe(true);
628
+ });
629
+
630
+ // ── Permission flow ──
631
+
632
+ describe("permission handling", () => {
633
+ let permServer: ReturnType<typeof Bun.serve>;
634
+ let permPort = 0;
635
+
636
+ beforeAll(() => {
637
+ permServer = Bun.serve({
638
+ port: 0,
639
+ async fetch(req) {
640
+ const url = new URL(req.url);
641
+
642
+ if (url.pathname === "/v1/tunnel/connections") {
643
+ return Response.json([
644
+ { tunnelId: "perm-tunnel", isLive: true },
645
+ ]);
646
+ }
647
+
648
+ // Always return 403 with requestId for RPC calls
649
+ if (url.pathname.startsWith("/v1/tunnel/rpc/")) {
650
+ return Response.json(
651
+ {
652
+ code: -32003,
653
+ error: "Permission denied",
654
+ requestId: "req-abc-123",
655
+ },
656
+ { status: 403 }
657
+ );
658
+ }
659
+
660
+ return new Response("Not Found", { status: 404 });
661
+ },
662
+ });
663
+ permPort = permServer.port!;
664
+ });
665
+
666
+ afterAll(() => {
667
+ permServer?.stop(true);
668
+ });
669
+
670
+ test("permission denied returns structured response", async () => {
671
+ const r = await runCli("fs_read", '{"path":"/etc/passwd"}', {
672
+ KORTIX_API_URL: `http://localhost:${permPort}`,
673
+ });
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
+ { KORTIX_API_URL: `http://localhost:${permPort}` }
687
+ );
688
+ expect(r.json!.success).toBe(false);
689
+ expect(r.json!.permissionRequired).toBe(true);
690
+ });
691
+ });
692
+
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
+ KORTIX_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
+ KORTIX_API_URL: "http://localhost:1",
709
+ });
710
+ expect(r.exitCode).toBe(1);
711
+ expect(r.json!.success).toBe(false);
712
+ });
713
+ });
714
+
715
+ // ── Empty connections ──
716
+
717
+ describe("no connections", () => {
718
+ let emptyServer: ReturnType<typeof Bun.serve>;
719
+ let emptyPort = 0;
720
+
721
+ beforeAll(() => {
722
+ emptyServer = Bun.serve({
723
+ port: 0,
724
+ async fetch(req) {
725
+ 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 });
730
+ },
731
+ });
732
+ emptyPort = emptyServer.port!;
733
+ });
734
+
735
+ afterAll(() => {
736
+ emptyServer?.stop(true);
737
+ });
738
+
739
+ test("status with no connections returns empty list", async () => {
740
+ const r = await runCli("status", undefined, {
741
+ KORTIX_API_URL: `http://localhost:${emptyPort}`,
742
+ });
743
+ expect(r.exitCode).toBe(0);
744
+ expect(r.json!.success).toBe(true);
745
+ expect((r.json!.connections as unknown[]).length).toBe(0);
746
+ expect(typeof r.json!.message).toBe("string");
747
+ });
748
+
749
+ test("fs_read with no connections returns error", async () => {
750
+ const r = await runCli("fs_read", '{"path":"/tmp/x"}', {
751
+ KORTIX_API_URL: `http://localhost:${emptyPort}`,
752
+ });
753
+ expect(r.exitCode).toBe(1);
754
+ expect(r.json!.success).toBe(false);
755
+ expect(r.json!.error).toContain("No tunnel connection found");
756
+ });
757
+ });
758
+ });