kiwi-code 0.0.4__py3-none-any.whl

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.
kiwi_runtime/main.py ADDED
@@ -0,0 +1,989 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Kiwi AI CLI Agent — connects to the server via WebSocket and executes
4
+ terminal commands on behalf of the LLM agent.
5
+
6
+ Authenticates with email and password.
7
+
8
+ Usage:
9
+ kiwi connect --server local
10
+ kiwi connect --server dev
11
+ kiwi connect --server wss://custom.server.com
12
+ kiwi connect --server dev --scope full
13
+ kiwi connect --server dev --allow /path/to/extra/dir
14
+ """
15
+
16
+ import argparse
17
+ import asyncio
18
+ import getpass
19
+ import json
20
+ import os
21
+ import re
22
+ import shlex
23
+ import signal
24
+ import sys
25
+
26
+ IS_WINDOWS = sys.platform == "win32"
27
+
28
+ if not IS_WINDOWS:
29
+ import fcntl
30
+ import pty
31
+ import struct
32
+ import termios
33
+
34
+ import httpx
35
+ import websockets
36
+
37
+ MAX_OUTPUT_BYTES = 50 * 1024 # 50KB cap on command output
38
+
39
+ # Sentinel must match the server-side terminal_tool.py so blocked commands
40
+ # are detected as "completed" instead of timing out.
41
+ _SENTINEL = "__CMD_DONE__"
42
+
43
+
44
+ def _clean_cmd_for_display(data: str) -> str:
45
+ """Strip the sentinel wrapper from a PTY input command for display."""
46
+ cmd = data.strip()
47
+ # Remove sentinel suffix: '; echo __CMD_DONE__$?__CMD_DONE__' (Unix)
48
+ # or '& echo __CMD_DONE__%ERRORLEVEL%__CMD_DONE__' (Windows)
49
+ idx = cmd.find(_SENTINEL)
50
+ if idx > 0:
51
+ cmd = cmd[:idx]
52
+ # Remove the separator before the sentinel ('; echo ' or '& echo ')
53
+ for sep in ("; echo ", "& echo "):
54
+ if cmd.endswith(sep):
55
+ cmd = cmd[:-len(sep)]
56
+ break
57
+ # Handle without space
58
+ if cmd.endswith(sep.rstrip()):
59
+ cmd = cmd[:-len(sep.rstrip())]
60
+ break
61
+ return cmd.strip()
62
+
63
+
64
+ SERVER_PRESETS = {
65
+ "app": {"ws": "wss://api.meetkiwi.ai", "http": "https://api.meetkiwi.ai"},
66
+ "dev": {"ws": "wss://dev.api.myautobots.com", "http": "https://dev.api.myautobots.com"},
67
+ "local": {"ws": "ws://localhost:8000", "http": "http://localhost:8000"},
68
+ }
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # ANSI colors
72
+ # ---------------------------------------------------------------------------
73
+
74
+ RESET = "\033[0m"
75
+ BOLD = "\033[1m"
76
+
77
+ # Brand color — ANSI cyan (works reliably across all terminals)
78
+ C = "\033[36m"
79
+ CB = "\033[1;36m"
80
+ GREY = "\033[90m"
81
+ RED = "\033[91m"
82
+ YELLOW = "\033[93m"
83
+ GREEN = "\033[92m"
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Banner — Kiwi logo (from SVG) + KIWI AI text side by side
88
+ # ---------------------------------------------------------------------------
89
+
90
+ # Logo: 32 wide x 16 tall (half-block, perfectly symmetric from SVG)
91
+ LOGO = [
92
+ " ▄██▄ ",
93
+ " ████ ",
94
+ " ▄██▄▄ ████ ▄▄██▄ ",
95
+ " ▀█████▄ █▀▀█ ▄█████▀ ",
96
+ " ▀███▀▀█ ████ █▀▀███▀ ",
97
+ " ▀█▄ █ ████ █ ▄█▀ ",
98
+ " ▀▀▄██████▄▀▀ ",
99
+ "▄██████▀▀█▄██████████▄█▀▀██████▄",
100
+ "▀██████▄▄█▀██████████▀█▄▄██████▀",
101
+ " ▄▄▀██████▀▄▄ ",
102
+ " ▄█▀ █ ████ █ ▀█▄ ",
103
+ " ▄███▄▄█ ████ █▄▄███▄ ",
104
+ " ▄█████▀ █▄▄█ ▀█████▄ ",
105
+ " ▀██▀▀ ████ ▀▀██▀ ",
106
+ " ████ ",
107
+ " ▀██▀ ",
108
+ ]
109
+
110
+ # Text: 6 tall
111
+ TEXT = [
112
+ "██╗ ██╗██╗██╗ ██╗██╗ █████╗ ██╗",
113
+ "██║ ██╔╝██║██║ ██║██║ ██╔══██╗██║",
114
+ "█████╔╝ ██║██║ █╗ ██║██║ ███████║██║",
115
+ "██╔═██╗ ██║██║███╗██║██║ ██╔══██║██║",
116
+ "██║ ██╗██║╚███╔███╔╝██║ ██║ ██║██║",
117
+ "╚═╝ ╚═╝╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ╚═╝╚═╝",
118
+ ]
119
+
120
+ TAGLINE = "Terminal Agent for AI-Powered Automation"
121
+
122
+
123
+ def print_banner():
124
+ """Print the Kiwi AI startup banner."""
125
+ print()
126
+
127
+ # Side-by-side: logo (16 lines) with text (6 lines) vertically centered
128
+ gap = " "
129
+ text_start = 5
130
+ logo_w = 33
131
+
132
+ for i, logo_line in enumerate(LOGO):
133
+ padded_logo = logo_line.ljust(logo_w)
134
+ text_idx = i - text_start
135
+ if 0 <= text_idx < len(TEXT):
136
+ row = f"{padded_logo}{gap}{TEXT[text_idx]}"
137
+ elif text_idx == len(TEXT) + 1:
138
+ row = f"{padded_logo}{gap}{TAGLINE}"
139
+ else:
140
+ row = padded_logo
141
+
142
+ print(f" {CB}{row}{RESET}")
143
+
144
+ print(f" {GREY}{'━' * 72}{RESET}")
145
+ print()
146
+
147
+
148
+ def print_status(icon: str, message: str, color: str = C):
149
+ """Print a styled status line."""
150
+ print(f" {color}{icon}{RESET} {message}")
151
+
152
+
153
+ def print_section(title: str):
154
+ """Print a section header."""
155
+ print(f"\n {CB}{title}{RESET}")
156
+ print(f" {GREY}{'─' * 40}{RESET}")
157
+
158
+
159
+ def print_cmd_log(request_id: str, message: str, success: bool | None = None):
160
+ """Print a command execution log line."""
161
+ tag = f"{GREY}[{request_id[:8]}]{RESET}"
162
+ if success is True:
163
+ icon = f"{GREEN}>{RESET}"
164
+ elif success is False:
165
+ icon = f"{RED}x{RESET}"
166
+ else:
167
+ icon = f"{C}>{RESET}"
168
+ print(f" {icon} {tag} {message}")
169
+
170
+
171
+ def print_pty_log(session_id: str, message: str, level: str = "info"):
172
+ """Print a PTY session log line."""
173
+ tag = f"{GREY}[PTY {session_id[:8]}]{RESET}"
174
+ if level == "error":
175
+ icon = f"{RED}x{RESET}"
176
+ elif level == "success":
177
+ icon = f"{GREEN}>{RESET}"
178
+ else:
179
+ icon = f"{C}~{RESET}"
180
+ print(f" {icon} {tag} {message}")
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # Path validator for restricted mode
185
+ # ---------------------------------------------------------------------------
186
+
187
+ # Regex to detect Windows absolute paths like C:\ or D:/
188
+ _WIN_ABS_PATH = re.compile(r'^[A-Za-z]:[/\\]')
189
+ # Regex to detect UNC paths like \\server\share
190
+ _UNC_PATH = re.compile(r'^\\\\')
191
+ # Windows environment variables like %USERPROFILE%
192
+ _WIN_ENV_VAR = re.compile(r'%([^%]+)%')
193
+
194
+ # Regex patterns to find paths embedded anywhere in a command string
195
+ _EMBEDDED_UNIX_PATH = re.compile(r'(?:^|[\s\'";=])(/[^\s\'";|&>]+)')
196
+ _EMBEDDED_HOME_PATH = re.compile(r'(?:^|[\s\'";=])(~[^\s\'";|&>]*)')
197
+ _EMBEDDED_WIN_PATH = re.compile(r'(?:^|[\s\'";=])([A-Za-z]:[/\\][^\s\'";|&>]*)')
198
+ _EMBEDDED_UNC_PATH = re.compile(r'(?:^|[\s\'";=])(\\\\[^\s\'";|&>]+)')
199
+ _EMBEDDED_DOTDOT = re.compile(r'(?:^|[\s\'";=])(\.\.[/\\][^\s\'";|&>]*)')
200
+
201
+
202
+ def _scan_paths(command: str) -> list[str]:
203
+ """Scan the raw command string for embedded path patterns."""
204
+ paths = []
205
+ patterns = [_EMBEDDED_HOME_PATH, _EMBEDDED_WIN_PATH, _EMBEDDED_UNC_PATH, _EMBEDDED_DOTDOT]
206
+ # Unix absolute paths don't exist on Windows; /x tokens are command flags
207
+ if not IS_WINDOWS:
208
+ patterns.insert(0, _EMBEDDED_UNIX_PATH)
209
+ for pattern in patterns:
210
+ for m in pattern.finditer(command):
211
+ p = m.group(1).rstrip(";|&'\"")
212
+ if not p:
213
+ continue
214
+ # Skip standard system device paths
215
+ if p in _SAFE_PATHS or any(p.startswith(sp + "/") for sp in _SAFE_PATHS):
216
+ continue
217
+ paths.append(p)
218
+ return paths
219
+
220
+
221
+ def _get_process_cwd(pid: int) -> str | None:
222
+ """Get the current working directory of a process by PID."""
223
+ try:
224
+ import psutil
225
+ return psutil.Process(pid).cwd()
226
+ except Exception:
227
+ return None
228
+
229
+
230
+ def validate_command(
231
+ command: str, allowed_dirs: list[str], pid: int | None = None
232
+ ) -> tuple[bool, str]:
233
+ """Check if a command references paths outside the allowed directories.
234
+
235
+ Returns (True, "") if allowed, (False, reason) if blocked.
236
+ """
237
+ # Use the shell's actual cwd for resolving relative paths (e.g. ..)
238
+ base_dir = allowed_dirs[0]
239
+ if pid is not None:
240
+ real_cwd = _get_process_cwd(pid)
241
+ if real_cwd:
242
+ base_dir = real_cwd
243
+
244
+ # Token-based extraction
245
+ try:
246
+ tokens = shlex.split(command)
247
+ except ValueError:
248
+ tokens = command.split()
249
+
250
+ candidates = []
251
+ for token in tokens:
252
+ path = _extract_path(token)
253
+ if path is not None:
254
+ candidates.append((path, False))
255
+
256
+ # Regex scan for embedded paths (catches paths inside quoted strings)
257
+ for path in _scan_paths(command):
258
+ candidates.append((path, True))
259
+
260
+ # Deduplicate
261
+ seen = set()
262
+ for path, from_regex in candidates:
263
+ if path in seen:
264
+ continue
265
+ seen.add(path)
266
+ resolved = _resolve_path(path, base_dir)
267
+ if resolved is None:
268
+ continue
269
+ # Regex-scanned paths may be string literals (e.g. echo "hello /world"),
270
+ # so only flag them if the path actually exists on disk.
271
+ if from_regex and not os.path.exists(resolved):
272
+ continue
273
+ if not _is_within_allowed(resolved, allowed_dirs):
274
+ return False, (
275
+ f"Restricted: '{path}' resolves to '{resolved}' "
276
+ f"which is outside the allowed directories"
277
+ )
278
+
279
+ return True, ""
280
+
281
+
282
+ _SAFE_PATHS = frozenset({
283
+ "/dev/null", "/dev/stdin", "/dev/stdout", "/dev/stderr",
284
+ "/dev/zero", "/dev/random", "/dev/urandom", "/dev/tty",
285
+ "/dev/fd", "/proc/self",
286
+ })
287
+
288
+
289
+ def _extract_path(token: str) -> str | None:
290
+ """Extract a file path from a token, or None if it doesn't look like one."""
291
+ # Strip trailing shell metacharacters (;, &&, ||, |, &)
292
+ token = token.rstrip(";|&")
293
+ if not token:
294
+ return None
295
+ # Strip common shell prefixes that precede paths in redirections
296
+ for prefix in (">", ">>", "<", "2>", "2>>", "&>", "&>>"):
297
+ if token.startswith(prefix) and len(token) > len(prefix):
298
+ token = token[len(prefix):]
299
+ break
300
+
301
+ # Whitelist standard system device paths
302
+ if token in _SAFE_PATHS or any(token.startswith(p + "/") for p in _SAFE_PATHS):
303
+ return None
304
+
305
+ # Home directory references
306
+ if token.startswith("~"):
307
+ return token
308
+
309
+ # Unix absolute paths (skip on Windows — /x tokens are command flags like /d, /b, /ad)
310
+ if token.startswith("/"):
311
+ if IS_WINDOWS:
312
+ return None
313
+ return token
314
+
315
+ # Windows absolute paths
316
+ if IS_WINDOWS:
317
+ if _WIN_ABS_PATH.match(token):
318
+ return token
319
+ if _UNC_PATH.match(token):
320
+ return token
321
+ # Expand %VAR% and check
322
+ expanded = _WIN_ENV_VAR.sub(_expand_win_var, token)
323
+ if expanded != token and (_WIN_ABS_PATH.match(expanded) or expanded.startswith("/")):
324
+ return expanded
325
+
326
+ # Relative paths with .. traversal
327
+ if ".." in token.split(os.sep) or ".." in token.split("/"):
328
+ return token
329
+
330
+ return None
331
+
332
+
333
+ def _expand_win_var(match: re.Match) -> str:
334
+ """Expand a Windows %VAR% environment variable."""
335
+ return os.environ.get(match.group(1), match.group(0))
336
+
337
+
338
+ def _resolve_path(path: str, base_dir: str) -> str | None:
339
+ """Resolve a path to its real absolute location."""
340
+ try:
341
+ # Expand ~ to home directory
342
+ path = os.path.expanduser(path)
343
+
344
+ # Make relative paths absolute against base_dir
345
+ if not os.path.isabs(path):
346
+ path = os.path.join(base_dir, path)
347
+
348
+ # Normalize (resolve ..) and resolve symlinks
349
+ return os.path.realpath(path)
350
+ except (OSError, ValueError):
351
+ return None
352
+
353
+
354
+ def _is_within_allowed(resolved_path: str, allowed_dirs: list[str]) -> bool:
355
+ """Check if a resolved path is within any of the allowed directories."""
356
+ resolved = os.path.normcase(resolved_path)
357
+ # macOS filesystem is typically case-insensitive
358
+ if sys.platform == "darwin":
359
+ resolved = resolved.lower()
360
+ for allowed in allowed_dirs:
361
+ allowed_norm = os.path.normcase(allowed)
362
+ if sys.platform == "darwin":
363
+ allowed_norm = allowed_norm.lower()
364
+ if resolved == allowed_norm or resolved.startswith(allowed_norm + os.sep):
365
+ return True
366
+ return False
367
+
368
+
369
+ # ---------------------------------------------------------------------------
370
+ # Core logic
371
+ # ---------------------------------------------------------------------------
372
+
373
+ async def login(http_base_url: str, email: str, password: str) -> str:
374
+ """Authenticate with email/password and return the JWT access token."""
375
+ url = f"{http_base_url}/v1/auth/token"
376
+ async with httpx.AsyncClient(timeout=30) as client:
377
+ resp = await client.post(
378
+ url,
379
+ data={"username": email, "password": password},
380
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
381
+ )
382
+ if resp.status_code != 200:
383
+ detail = resp.text
384
+ try:
385
+ detail = resp.json().get("detail", resp.text)
386
+ except Exception:
387
+ pass
388
+ raise Exception(f"Login failed (HTTP {resp.status_code}): {detail}")
389
+
390
+ body = resp.json()
391
+ token = body.get("access_token")
392
+ if not token:
393
+ raise Exception(f"No access_token in response: {body}")
394
+ return token
395
+
396
+
397
+ async def run_command(
398
+ command: str,
399
+ timeout: int = 120,
400
+ mode: str = "restricted",
401
+ allowed_dirs: list[str] | None = None,
402
+ ) -> dict:
403
+ """Execute a shell command and return stdout, stderr, exit_code."""
404
+ # Validate in restricted mode
405
+ if mode == "restricted" and allowed_dirs:
406
+ ok, reason = validate_command(command, allowed_dirs)
407
+ if not ok:
408
+ return {"stdout": "", "stderr": reason, "exit_code": 1}
409
+
410
+ try:
411
+ cwd = allowed_dirs[0] if allowed_dirs else None
412
+ proc = await asyncio.create_subprocess_shell(
413
+ command,
414
+ stdout=asyncio.subprocess.PIPE,
415
+ stderr=asyncio.subprocess.PIPE,
416
+ cwd=cwd,
417
+ )
418
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
419
+ proc.communicate(), timeout=timeout
420
+ )
421
+ stdout = stdout_bytes.decode("utf-8", errors="replace")[:MAX_OUTPUT_BYTES]
422
+ stderr = stderr_bytes.decode("utf-8", errors="replace")[:MAX_OUTPUT_BYTES]
423
+ return {"stdout": stdout, "stderr": stderr, "exit_code": proc.returncode}
424
+ except asyncio.TimeoutError:
425
+ proc.kill()
426
+ await proc.wait()
427
+ return {"stdout": "", "stderr": f"Command timed out after {timeout}s", "exit_code": -1}
428
+ except Exception as e:
429
+ return {"stdout": "", "stderr": str(e), "exit_code": -1}
430
+
431
+
432
+ if not IS_WINDOWS:
433
+
434
+ class PTYProcess:
435
+ """Manages a single interactive PTY session (Unix/macOS only)."""
436
+
437
+ def __init__(self, session_id: str, command: str, cols: int = 120, rows: int = 40):
438
+ self.session_id = session_id
439
+ self.command = command
440
+ self.cols = cols
441
+ self.rows = rows
442
+ self.master_fd = None
443
+ self.slave_fd = None
444
+ self.proc = None
445
+ self._read_task = None
446
+
447
+ async def start(self, ws, cwd: str | None = None):
448
+ """Spawn the PTY process and begin reading output."""
449
+ self.master_fd, self.slave_fd = pty.openpty()
450
+
451
+ winsize = struct.pack("HHHH", self.rows, self.cols, 0, 0)
452
+ fcntl.ioctl(self.slave_fd, termios.TIOCSWINSZ, winsize)
453
+
454
+ self.proc = await asyncio.create_subprocess_exec(
455
+ "/bin/sh", "-c", self.command,
456
+ stdin=self.slave_fd,
457
+ stdout=self.slave_fd,
458
+ stderr=self.slave_fd,
459
+ preexec_fn=os.setsid,
460
+ cwd=cwd,
461
+ )
462
+
463
+ os.close(self.slave_fd)
464
+ self.slave_fd = None
465
+
466
+ flags = fcntl.fcntl(self.master_fd, fcntl.F_GETFL)
467
+ fcntl.fcntl(self.master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
468
+
469
+ self._read_task = asyncio.create_task(self._read_loop(ws))
470
+
471
+ async def _read_loop(self, ws):
472
+ """Read output from master fd and send over WebSocket."""
473
+ loop = asyncio.get_event_loop()
474
+ try:
475
+ while True:
476
+ try:
477
+ data = await loop.run_in_executor(None, self._blocking_read)
478
+ if data is None:
479
+ break
480
+ # Echo output to stdout for observability
481
+ for line in data.splitlines():
482
+ print_cmd_log(self.session_id, line)
483
+ await ws.send(json.dumps({
484
+ "type": "pty_output",
485
+ "session_id": self.session_id,
486
+ "data": data,
487
+ }))
488
+ except OSError:
489
+ break
490
+ except Exception as e:
491
+ print_pty_log(self.session_id, f"Read error: {e}", "error")
492
+ break
493
+
494
+ if self.proc:
495
+ try:
496
+ exit_code = await asyncio.wait_for(self.proc.wait(), timeout=5.0)
497
+ except asyncio.TimeoutError:
498
+ self.proc.kill()
499
+ exit_code = -1
500
+ else:
501
+ exit_code = -1
502
+
503
+ await ws.send(json.dumps({
504
+ "type": "pty_exited",
505
+ "session_id": self.session_id,
506
+ "exit_code": exit_code,
507
+ }))
508
+ except Exception as e:
509
+ try:
510
+ await ws.send(json.dumps({
511
+ "type": "pty_error",
512
+ "session_id": self.session_id,
513
+ "error": str(e),
514
+ }))
515
+ except Exception:
516
+ pass
517
+
518
+ def _blocking_read(self) -> str | None:
519
+ """Blocking read from master fd. Returns None on EOF."""
520
+ import select
521
+ while True:
522
+ ready, _, _ = select.select([self.master_fd], [], [], 0.5)
523
+ if ready:
524
+ try:
525
+ data = os.read(self.master_fd, 4096)
526
+ if not data:
527
+ return None
528
+ return data.decode("utf-8", errors="replace")
529
+ except OSError:
530
+ return None
531
+ if self.proc and self.proc.returncode is not None:
532
+ try:
533
+ data = os.read(self.master_fd, 4096)
534
+ if data:
535
+ return data.decode("utf-8", errors="replace")
536
+ except OSError:
537
+ pass
538
+ return None
539
+
540
+ async def send_input(self, data: str) -> None:
541
+ """Write data to the PTY master fd."""
542
+ if self.master_fd is not None:
543
+ os.write(self.master_fd, data.encode("utf-8"))
544
+
545
+ async def close(self) -> None:
546
+ """Terminate the process and clean up fds."""
547
+ if self._read_task and not self._read_task.done():
548
+ self._read_task.cancel()
549
+ try:
550
+ await self._read_task
551
+ except asyncio.CancelledError:
552
+ pass
553
+
554
+ if self.proc and self.proc.returncode is None:
555
+ try:
556
+ self.proc.terminate()
557
+ await asyncio.wait_for(self.proc.wait(), timeout=3.0)
558
+ except (asyncio.TimeoutError, ProcessLookupError):
559
+ try:
560
+ self.proc.kill()
561
+ except ProcessLookupError:
562
+ pass
563
+
564
+ if self.master_fd is not None:
565
+ try:
566
+ os.close(self.master_fd)
567
+ except OSError:
568
+ pass
569
+ self.master_fd = None
570
+
571
+ if self.slave_fd is not None:
572
+ try:
573
+ os.close(self.slave_fd)
574
+ except OSError:
575
+ pass
576
+ self.slave_fd = None
577
+
578
+
579
+ class PipeProcess:
580
+ """Manages a persistent shell session using subprocess pipes (Windows-compatible)."""
581
+
582
+ def __init__(self, session_id: str, command: str, cols: int = 120, rows: int = 40):
583
+ self.session_id = session_id
584
+ self.command = command
585
+ self.proc = None
586
+ self._read_task = None
587
+
588
+ async def start(self, ws, cwd: str | None = None):
589
+ """Spawn the shell process with piped stdin/stdout."""
590
+ if IS_WINDOWS:
591
+ shell = os.environ.get("COMSPEC", "cmd.exe")
592
+ else:
593
+ shell = self.command
594
+
595
+ self.proc = await asyncio.create_subprocess_exec(
596
+ shell,
597
+ stdin=asyncio.subprocess.PIPE,
598
+ stdout=asyncio.subprocess.PIPE,
599
+ stderr=asyncio.subprocess.STDOUT,
600
+ cwd=cwd,
601
+ )
602
+ self._read_task = asyncio.create_task(self._read_loop(ws))
603
+
604
+ async def _read_loop(self, ws):
605
+ """Read output from stdout and send over WebSocket."""
606
+ try:
607
+ while True:
608
+ data = await self.proc.stdout.read(4096)
609
+ if not data:
610
+ break
611
+ decoded = data.decode("utf-8", errors="replace")
612
+ # Echo output to stdout for observability
613
+ for line in decoded.splitlines():
614
+ print_cmd_log(self.session_id, line)
615
+ await ws.send(json.dumps({
616
+ "type": "pty_output",
617
+ "session_id": self.session_id,
618
+ "data": decoded,
619
+ }))
620
+
621
+ if self.proc:
622
+ try:
623
+ exit_code = await asyncio.wait_for(self.proc.wait(), timeout=5.0)
624
+ except asyncio.TimeoutError:
625
+ self.proc.kill()
626
+ exit_code = -1
627
+ else:
628
+ exit_code = -1
629
+
630
+ await ws.send(json.dumps({
631
+ "type": "pty_exited",
632
+ "session_id": self.session_id,
633
+ "exit_code": exit_code,
634
+ }))
635
+ except Exception as e:
636
+ try:
637
+ await ws.send(json.dumps({
638
+ "type": "pty_error",
639
+ "session_id": self.session_id,
640
+ "error": str(e),
641
+ }))
642
+ except Exception:
643
+ pass
644
+
645
+ async def send_input(self, data: str) -> None:
646
+ """Write data to the process stdin."""
647
+ if self.proc and self.proc.stdin:
648
+ self.proc.stdin.write(data.encode("utf-8"))
649
+ await self.proc.stdin.drain()
650
+
651
+ async def close(self) -> None:
652
+ """Terminate the process and clean up."""
653
+ if self._read_task and not self._read_task.done():
654
+ self._read_task.cancel()
655
+ try:
656
+ await self._read_task
657
+ except asyncio.CancelledError:
658
+ pass
659
+
660
+ if self.proc and self.proc.returncode is None:
661
+ try:
662
+ self.proc.terminate()
663
+ await asyncio.wait_for(self.proc.wait(), timeout=3.0)
664
+ except (asyncio.TimeoutError, ProcessLookupError):
665
+ try:
666
+ self.proc.kill()
667
+ except ProcessLookupError:
668
+ pass
669
+
670
+
671
+ async def connect(
672
+ ws_url: str,
673
+ token: str,
674
+ mode: str = "restricted",
675
+ allowed_dirs: list[str] | None = None,
676
+ ):
677
+ """Connect to the server WebSocket and process commands."""
678
+ ws_endpoint = f"{ws_url}/v1/terminal/ws/connect"
679
+ print_status("~", f"Connecting to {BOLD}{ws_endpoint}{RESET}", C)
680
+
681
+ try:
682
+ async with websockets.connect(ws_endpoint) as ws:
683
+ await ws.send(json.dumps({"type": "auth", "token": token}))
684
+ auth_resp = json.loads(await ws.recv())
685
+
686
+ if auth_resp.get("type") == "error":
687
+ print_status("x", f"Auth failed: {auth_resp.get('message')}", RED)
688
+ return
689
+
690
+ if auth_resp.get("type") == "auth_ok":
691
+ user_id = auth_resp.get('user_id', 'unknown')
692
+ print_status(">", f"Connected as {BOLD}{user_id}{RESET}", GREEN)
693
+ else:
694
+ print_status("x", f"Unexpected response: {auth_resp}", RED)
695
+ return
696
+
697
+ print()
698
+ if mode == "restricted" and allowed_dirs:
699
+ mode_label = f"{GREEN}restricted{RESET}"
700
+ print(f" {C}Ready{RESET} {GREY}| Mode: {mode_label} {GREY}| Ctrl+C to disconnect{RESET}")
701
+ for d in allowed_dirs:
702
+ print(f" {GREY}Allowed: {d}{RESET}")
703
+ else:
704
+ mode_label = f"{YELLOW}unrestricted{RESET}"
705
+ print(f" {C}Ready{RESET} {GREY}| Mode: {mode_label} {GREY}| Ctrl+C to disconnect{RESET}")
706
+ print(f" {GREY}{'─' * 50}{RESET}")
707
+ print()
708
+
709
+ active_pty_sessions: dict[str, PTYProcess] = {}
710
+
711
+ try:
712
+ async for message in ws:
713
+ msg = json.loads(message)
714
+ msg_type = msg.get("type")
715
+
716
+ if msg_type == "command":
717
+ request_id = msg.get("request_id", "")
718
+ command = msg.get("command", "")
719
+ print_cmd_log(request_id, f"$ {BOLD}{command}{RESET}")
720
+
721
+ result = await run_command(
722
+ command,
723
+ mode=mode,
724
+ allowed_dirs=allowed_dirs,
725
+ )
726
+ exit_code = result["exit_code"]
727
+ success = exit_code == 0
728
+ status_text = (
729
+ f"{GREEN}OK{RESET}" if success
730
+ else f"{RED}FAILED (exit {exit_code}){RESET}"
731
+ )
732
+ print_cmd_log(request_id, status_text, success)
733
+
734
+ await ws.send(json.dumps({
735
+ "type": "result",
736
+ "request_id": request_id,
737
+ **result,
738
+ }))
739
+
740
+ elif msg_type == "pty_start":
741
+ session_id = msg.get("session_id", "")
742
+ command = msg.get("command", "bash")
743
+ cols = msg.get("cols", 120)
744
+ rows = msg.get("rows", 40)
745
+ print_pty_log(session_id, f"Starting: {BOLD}{command}{RESET}")
746
+
747
+ try:
748
+ if IS_WINDOWS:
749
+ pty_proc = PipeProcess(session_id, command, cols, rows)
750
+ else:
751
+ pty_proc = PTYProcess(session_id, command, cols, rows)
752
+ cwd = allowed_dirs[0] if allowed_dirs else None
753
+ await pty_proc.start(ws, cwd=cwd)
754
+ active_pty_sessions[session_id] = pty_proc
755
+ started_msg = {
756
+ "type": "pty_started",
757
+ "session_id": session_id,
758
+ "platform": sys.platform,
759
+ "mode": mode,
760
+ }
761
+ if mode == "restricted" and allowed_dirs:
762
+ started_msg["allowed_dirs"] = allowed_dirs
763
+ await ws.send(json.dumps(started_msg))
764
+ print_pty_log(session_id, "Started", "success")
765
+ except Exception as e:
766
+ print_pty_log(session_id, f"Failed: {e}", "error")
767
+ await ws.send(json.dumps({
768
+ "type": "pty_error",
769
+ "session_id": session_id,
770
+ "error": str(e),
771
+ }))
772
+
773
+ elif msg_type == "pty_input":
774
+ session_id = msg.get("session_id", "")
775
+ data = msg.get("data", "")
776
+ display_cmd = _clean_cmd_for_display(data)
777
+ if display_cmd:
778
+ print_pty_log(session_id, f"$ {BOLD}{display_cmd}{RESET}")
779
+ pty_proc = active_pty_sessions.get(session_id)
780
+ if pty_proc:
781
+ # Validate input in restricted mode
782
+ if mode == "restricted" and allowed_dirs:
783
+ pty_pid = pty_proc.proc.pid if pty_proc.proc else None
784
+ ok, reason = validate_command(data, allowed_dirs, pid=pty_pid)
785
+ if not ok:
786
+ # Include sentinel so server detects completion
787
+ err_msg = (
788
+ f"\r\n{reason}\r\n"
789
+ f"{_SENTINEL}1{_SENTINEL}\r\n"
790
+ )
791
+ await ws.send(json.dumps({
792
+ "type": "pty_output",
793
+ "session_id": session_id,
794
+ "data": err_msg,
795
+ }))
796
+ print_pty_log(session_id, f"Blocked: {reason}", "error")
797
+ continue
798
+ try:
799
+ await pty_proc.send_input(data)
800
+ except Exception as e:
801
+ print_pty_log(session_id, f"Input error: {e}", "error")
802
+ else:
803
+ print_pty_log(session_id, "Input for unknown session", "error")
804
+
805
+ elif msg_type == "pty_close":
806
+ session_id = msg.get("session_id", "")
807
+ pty_proc = active_pty_sessions.pop(session_id, None)
808
+ if pty_proc:
809
+ print_pty_log(session_id, "Closing")
810
+ await pty_proc.close()
811
+ else:
812
+ print_pty_log(session_id, "Close for unknown session", "error")
813
+
814
+ elif msg_type == "ping":
815
+ await ws.send(json.dumps({"type": "pong"}))
816
+
817
+ else:
818
+ print_status("?", f"Unknown message: {msg_type}", YELLOW)
819
+ finally:
820
+ for sid, pty_proc in active_pty_sessions.items():
821
+ try:
822
+ await pty_proc.close()
823
+ except Exception:
824
+ pass
825
+
826
+ except websockets.exceptions.ConnectionClosed as e:
827
+ print()
828
+ print_status("!", f"Connection closed: {e}", YELLOW)
829
+ except ConnectionRefusedError:
830
+ print()
831
+ print_status("x", f"Could not connect to {ws_endpoint}. Is the server running?", RED)
832
+ except Exception as e:
833
+ print()
834
+ print_status("x", f"Error: {e}", RED)
835
+
836
+
837
+ def main():
838
+ parser = argparse.ArgumentParser(
839
+ description="Kiwi AI CLI Agent — execute terminal commands for LLM agents"
840
+ )
841
+ subparsers = parser.add_subparsers(dest="command")
842
+
843
+ connect_parser = subparsers.add_parser("connect", help="Connect to the server")
844
+ connect_parser.add_argument(
845
+ "--server",
846
+ default="app",
847
+ help=(
848
+ "Server to connect to. Use a preset name (app, dev, local) "
849
+ "or a full URL (e.g. https://custom.server.com). "
850
+ "Presets: app=api.meetkiwi.ai, dev=dev.api.myautobots.com, "
851
+ "local=localhost:8000 (default: app)"
852
+ ),
853
+ )
854
+ connect_parser.add_argument(
855
+ "--email",
856
+ default=None,
857
+ help="Email address (will prompt if not provided)",
858
+ )
859
+ connect_parser.add_argument(
860
+ "--token",
861
+ default=None,
862
+ help="Access token to skip login (used by autobots-tui for shared auth)",
863
+ )
864
+ connect_parser.add_argument(
865
+ "--scope",
866
+ choices=["restricted", "full"],
867
+ default="restricted",
868
+ help="Execution scope. restricted (default) limits commands to the current directory.",
869
+ )
870
+ connect_parser.add_argument(
871
+ "--allow",
872
+ action="append",
873
+ dest="allow_dirs",
874
+ default=[],
875
+ metavar="PATH",
876
+ help="Additional allowed directory (can be specified multiple times).",
877
+ )
878
+
879
+ args = parser.parse_args()
880
+
881
+ if args.command == "connect":
882
+ print_banner()
883
+
884
+ preset = SERVER_PRESETS.get(args.server)
885
+ if preset:
886
+ ws_url = preset["ws"]
887
+ http_url = preset["http"]
888
+ else:
889
+ url = args.server.rstrip("/")
890
+ if url.startswith("wss://"):
891
+ ws_url = url
892
+ http_url = url.replace("wss://", "https://", 1)
893
+ elif url.startswith("ws://"):
894
+ ws_url = url
895
+ http_url = url.replace("ws://", "http://", 1)
896
+ elif url.startswith("https://"):
897
+ http_url = url
898
+ ws_url = url.replace("https://", "wss://", 1)
899
+ elif url.startswith("http://"):
900
+ http_url = url
901
+ ws_url = url.replace("http://", "ws://", 1)
902
+ else:
903
+ http_url = f"https://{url}"
904
+ ws_url = f"wss://{url}"
905
+
906
+ server_label = args.server if args.server in SERVER_PRESETS else "custom"
907
+ print_status(">", f"Server: {BOLD}{server_label}{RESET} ({http_url})", C)
908
+
909
+ # Build allowed_dirs list
910
+ mode = args.scope
911
+ allowed_dirs = None
912
+ if mode == "restricted":
913
+ allowed_dirs = [os.path.realpath(os.getcwd())]
914
+ for d in args.allow_dirs:
915
+ resolved = os.path.realpath(d)
916
+ if not os.path.isdir(resolved):
917
+ print_status("x", f"Directory not found: {d}", RED)
918
+ sys.exit(1)
919
+ if resolved not in allowed_dirs:
920
+ allowed_dirs.append(resolved)
921
+
922
+ print_status(">", f"Mode: {BOLD}restricted{RESET}", C)
923
+ for d in allowed_dirs:
924
+ print_status(" ", f" Allowed: {d}", GREY)
925
+ else:
926
+ print_status(">", f"Mode: {BOLD}unrestricted{RESET}", YELLOW)
927
+
928
+ print()
929
+
930
+ loop = asyncio.new_event_loop()
931
+
932
+ provided_token = args.token or os.environ.get("KIWI_AUTH_TOKEN")
933
+ if provided_token:
934
+ # Use provided token — skip interactive login
935
+ token = provided_token
936
+ print_section("Authentication")
937
+ print_status(">", "Using provided access token", GREEN)
938
+ else:
939
+ print_section("Authentication")
940
+ email = args.email
941
+ if email:
942
+ print_status(">", f"Email: {email}", GREY)
943
+ else:
944
+ email = input(f" {C}>{RESET} Email: ")
945
+ password = getpass.getpass(f" {C}>{RESET} Password: ")
946
+ print()
947
+
948
+ print_status("~", f"Authenticating with {BOLD}{http_url}{RESET}...", C)
949
+ try:
950
+ token = loop.run_until_complete(login(http_url, email, password))
951
+ print_status(">", "Login successful!", GREEN)
952
+ except Exception as e:
953
+ print_status("x", f"Login failed: {e}", RED)
954
+ loop.close()
955
+ sys.exit(1)
956
+
957
+ print_section("Connection")
958
+
959
+ _shutting_down = False
960
+
961
+ def shutdown():
962
+ nonlocal _shutting_down
963
+ if _shutting_down:
964
+ os._exit(0)
965
+ _shutting_down = True
966
+ print()
967
+ print_status("!", "Disconnecting...", YELLOW)
968
+ for task in asyncio.all_tasks(loop):
969
+ task.cancel()
970
+
971
+ signal.signal(signal.SIGINT, lambda *_: shutdown())
972
+
973
+ try:
974
+ loop.run_until_complete(connect(ws_url, token, mode=mode, allowed_dirs=allowed_dirs))
975
+ except asyncio.CancelledError:
976
+ pass
977
+ finally:
978
+ loop.close()
979
+ print()
980
+ print(f" {GREY}{'─' * 50}{RESET}")
981
+ print_status(">", "Disconnected. Goodbye!", C)
982
+ print()
983
+ else:
984
+ print_banner()
985
+ parser.print_help()
986
+
987
+
988
+ if __name__ == "__main__":
989
+ main()