Create Shell_Exec.py
Browse files- Modules/Shell_Exec.py +141 -0
Modules/Shell_Exec.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import platform
|
| 5 |
+
import shlex
|
| 6 |
+
import subprocess
|
| 7 |
+
from typing import Annotated
|
| 8 |
+
|
| 9 |
+
import gradio as gr
|
| 10 |
+
|
| 11 |
+
from app import _log_call_end, _log_call_start, _truncate_for_log
|
| 12 |
+
from ._docstrings import autodoc
|
| 13 |
+
from .File_System import _resolve_path, ROOT_DIR, _display_path
|
| 14 |
+
import shutil
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _detect_shell(prefer_powershell: bool = True) -> tuple[list[str], str]:
|
| 18 |
+
"""
|
| 19 |
+
Pick an appropriate shell for the host OS.
|
| 20 |
+
- Windows: use PowerShell by default, fall back to cmd.exe.
|
| 21 |
+
- POSIX: use /bin/bash if available, else /bin/sh.
|
| 22 |
+
Returns (shell_cmd_prefix, shell_name) where shell_cmd_prefix is the command list to launch the shell.
|
| 23 |
+
"""
|
| 24 |
+
system = platform.system().lower()
|
| 25 |
+
if system == "windows":
|
| 26 |
+
if prefer_powershell:
|
| 27 |
+
pwsh = shutil.which("pwsh")
|
| 28 |
+
candidates = [pwsh, shutil.which("powershell"), shutil.which("powershell.exe")]
|
| 29 |
+
for cand in candidates:
|
| 30 |
+
if cand:
|
| 31 |
+
return [cand, "-NoLogo", "-NoProfile", "-Command"], "powershell"
|
| 32 |
+
# Fallback to cmd
|
| 33 |
+
comspec = os.environ.get("ComSpec", r"C:\\Windows\\System32\\cmd.exe")
|
| 34 |
+
return [comspec, "/C"], "cmd"
|
| 35 |
+
# POSIX
|
| 36 |
+
bash = shutil.which("bash")
|
| 37 |
+
if bash:
|
| 38 |
+
return [bash, "-lc"], "bash"
|
| 39 |
+
sh = os.environ.get("SHELL", "/bin/sh")
|
| 40 |
+
return [sh, "-lc"], "sh"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# Detect shell at import time for docs/UI purposes
|
| 44 |
+
_DETECTED_SHELL_PREFIX, _DETECTED_SHELL_NAME = _detect_shell()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# Clarify path semantics and expose detected shell in summary
|
| 48 |
+
TOOL_SUMMARY = (
|
| 49 |
+
"Execute a shell command within a safe working directory under the tool root ('/'). "
|
| 50 |
+
"Paths must be relative to '/' (which maps to Nymbo-Tools/Filesystem by default); "
|
| 51 |
+
"absolute paths are disabled unless UNSAFE_ALLOW_ABS_PATHS=1. "
|
| 52 |
+
f"Detected shell: {_DETECTED_SHELL_NAME}."
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _run_command(command: str, cwd: str, timeout: int) -> tuple[str, str, int]:
|
| 57 |
+
shell_prefix, shell_name = _detect_shell()
|
| 58 |
+
full_cmd = shell_prefix + [command]
|
| 59 |
+
try:
|
| 60 |
+
proc = subprocess.run(
|
| 61 |
+
full_cmd,
|
| 62 |
+
cwd=cwd,
|
| 63 |
+
stdout=subprocess.PIPE,
|
| 64 |
+
stderr=subprocess.PIPE,
|
| 65 |
+
text=True,
|
| 66 |
+
encoding="utf-8",
|
| 67 |
+
errors="replace",
|
| 68 |
+
timeout=timeout if timeout and timeout > 0 else None,
|
| 69 |
+
)
|
| 70 |
+
return proc.stdout, proc.stderr, proc.returncode
|
| 71 |
+
except subprocess.TimeoutExpired as exc:
|
| 72 |
+
return exc.stdout or "", (exc.stderr or "") + "\n[timeout]", 124
|
| 73 |
+
except Exception as exc:
|
| 74 |
+
return "", f"Execution failed: {exc}", 1
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@autodoc(summary=TOOL_SUMMARY)
|
| 78 |
+
def Shell_Exec(
|
| 79 |
+
command: Annotated[str, "Shell command to execute. Accepts multi-part pipelines as a single string."],
|
| 80 |
+
workdir: Annotated[str, "Working directory (relative to root unless UNSAFE_ALLOW_ABS_PATHS=1)."] = ".",
|
| 81 |
+
timeout: Annotated[int, "Timeout in seconds (0 = no timeout, be careful on public hosting)."] = 60,
|
| 82 |
+
) -> str:
|
| 83 |
+
_log_call_start("Shell_Exec", command=command, workdir=workdir, timeout=timeout)
|
| 84 |
+
if not command or not command.strip():
|
| 85 |
+
result = "No command provided."
|
| 86 |
+
_log_call_end("Shell_Exec", _truncate_for_log(result))
|
| 87 |
+
return result
|
| 88 |
+
|
| 89 |
+
abs_cwd, err = _resolve_path(workdir)
|
| 90 |
+
if err:
|
| 91 |
+
_log_call_end("Shell_Exec", _truncate_for_log(err))
|
| 92 |
+
return err
|
| 93 |
+
if not os.path.exists(abs_cwd):
|
| 94 |
+
result = f"Working directory not found: {abs_cwd}"
|
| 95 |
+
_log_call_end("Shell_Exec", _truncate_for_log(result))
|
| 96 |
+
return result
|
| 97 |
+
|
| 98 |
+
# Capture shell used for transparency
|
| 99 |
+
_, shell_name = _detect_shell()
|
| 100 |
+
stdout, stderr, code = _run_command(command, cwd=abs_cwd, timeout=timeout)
|
| 101 |
+
display_cwd = _display_path(abs_cwd)
|
| 102 |
+
header = (
|
| 103 |
+
f"Command: {command}\n"
|
| 104 |
+
f"CWD: {display_cwd}\n"
|
| 105 |
+
f"Root: /\n"
|
| 106 |
+
f"Shell: {shell_name}\n"
|
| 107 |
+
f"Exit code: {code}\n"
|
| 108 |
+
f"--- STDOUT ---\n"
|
| 109 |
+
)
|
| 110 |
+
output = header + (stdout or "<empty>") + "\n--- STDERR ---\n" + (stderr or "<empty>")
|
| 111 |
+
_log_call_end("Shell_Exec", _truncate_for_log(f"exit={code} stdout={len(stdout)} stderr={len(stderr)}"))
|
| 112 |
+
return output
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def build_interface() -> gr.Interface:
|
| 116 |
+
return gr.Interface(
|
| 117 |
+
fn=Shell_Exec,
|
| 118 |
+
inputs=[
|
| 119 |
+
gr.Textbox(label="Command", placeholder="echo hello || dir", lines=2),
|
| 120 |
+
gr.Textbox(label="Workdir", value=".", max_lines=1),
|
| 121 |
+
gr.Slider(minimum=0, maximum=600, step=5, value=60, label="Timeout (seconds)"),
|
| 122 |
+
],
|
| 123 |
+
outputs=gr.Textbox(label="Output", lines=20),
|
| 124 |
+
title="Shell Exec",
|
| 125 |
+
description=(
|
| 126 |
+
"Run a shell command under the same safe root as File System. "
|
| 127 |
+
"Paths are relative to '/' (maps to Nymbo-Tools/Filesystem by default); absolute paths are disabled "
|
| 128 |
+
"unless UNSAFE_ALLOW_ABS_PATHS=1. Tip: set workdir to '.' to use the root. "
|
| 129 |
+
f"Detected shell: {_DETECTED_SHELL_NAME}. "
|
| 130 |
+
"<br><br><b>Examples</b>:"
|
| 131 |
+
"<br>- Command: <code>dir</code> (Windows) or <code>ls -la</code> (Linux)"
|
| 132 |
+
"<br>- Command: <code>echo hello | tee out.txt</code>"
|
| 133 |
+
"<br>- Workdir: <code>notes</code>, Command: <code>ls</code> to list /notes"
|
| 134 |
+
),
|
| 135 |
+
api_description=TOOL_SUMMARY,
|
| 136 |
+
flagging_mode="never",
|
| 137 |
+
submit_btn="Run",
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
__all__ = ["Shell_Exec", "build_interface"]
|