butler/tools/terminal_impl.py
Rama main · réplica de lectura del repositorio original.
"""Terminal, search, and directory listing tool implementations."""
from __future__ import annotations
import json
import logging
import os
import subprocess
import threading
import time
from typing import IO, Any
from butler.execution_context import get_current_session_key
from butler.tools.interrupt import is_interrupted
from butler.tools.path_safety import (
check_tool_path,
default_tool_workdir,
prepare_shell_command,
safe_subprocess_env,
)
from butler.tools.terminal_approval import approval_allows_unsandboxed
from butler.tools.terminal_impl_ops import (
close_pipe_safe,
inc_terminal_metric_safe,
is_selectable_pipe_safe,
run_list_directory_loud,
run_search_files_loud,
run_subprocess_loud,
run_terminal_with_gates_safe,
)
from butler.tools.terminal_sandbox import (
classify_sandbox_failure,
enrich_subprocess_env,
format_sandbox_error_payload,
load_terminal_sandbox_config,
sandbox_runtime_available,
scrub_credential_env,
should_run_sandboxed,
wrap_argv_with_bubblewrap,
)
from butler.defaults.env_defaults import ENABLE_TERMINAL_DEFAULT
logger = logging.getLogger(__name__)
MAX_TERMINAL_TIMEOUT_SECONDS = 120
MAX_TERMINAL_OUTPUT_CHARS = 50000
def _is_selectable_pipe(pipe: Any) -> bool:
return bool(is_selectable_pipe_safe(pipe))
def _close_pipe(pipe: Any) -> None:
close_pipe_safe(pipe)
def _communicate_limited(
proc: subprocess.Popen[Any],
*,
timeout: int,
max_output_chars: int,
) -> tuple[str, str, bool]:
"""Read process pipes incrementally so tool output cannot exhaust memory."""
stdout_pipe = proc.stdout
stderr_pipe = proc.stderr
if not (
stdout_pipe is not None
and stderr_pipe is not None
and _is_selectable_pipe(stdout_pipe)
and _is_selectable_pipe(stderr_pipe)
):
stdout, stderr = proc.communicate(timeout=timeout)
return str(stdout or ""), str(stderr or ""), (
len(str(stdout or "")) + len(str(stderr or "")) > max_output_chars
)
import selectors
selector = selectors.DefaultSelector()
stdout_io = stdout_pipe
stderr_io = stderr_pipe
selector.register(stdout_io, selectors.EVENT_READ, "stdout")
selector.register(stderr_io, selectors.EVENT_READ, "stderr")
stdout_parts: list[bytes] = []
stderr_parts: list[bytes] = []
total = 0
truncated = False
deadline = time.monotonic() + timeout
try:
while selector.get_map():
remaining = deadline - time.monotonic()
if remaining <= 0:
raise subprocess.TimeoutExpired(proc.args, timeout)
events = selector.select(timeout=min(0.2, remaining))
if not events:
if proc.poll() is not None:
# Give pipes one final chance to report EOF.
continue
continue
for key, _mask in events:
pipe = key.fileobj
assert isinstance(pipe, IO)
chunk = pipe.read1(8192) if hasattr(pipe, "read1") else pipe.read(8192)
if not chunk:
selector.unregister(pipe)
continue
if isinstance(chunk, str):
chunk = chunk.encode("utf-8", errors="replace")
room = max(0, max_output_chars - total)
if room:
kept = chunk[:room]
if key.data == "stdout":
stdout_parts.append(kept)
else:
stderr_parts.append(kept)
total += len(kept)
if len(chunk) > room:
truncated = True
proc.wait(timeout=max(0, deadline - time.monotonic()))
finally:
selector.close()
stdout = b"".join(stdout_parts).decode("utf-8", errors="replace")
stderr = b"".join(stderr_parts).decode("utf-8", errors="replace")
return stdout, stderr, truncated
def _tool_terminal(command: str, timeout: int = 30, workdir: str | None = None, **_: Any) -> str:
if os.getenv("BUTLER_ENABLE_TERMINAL", ENABLE_TERMINAL_DEFAULT).strip() != "1":
return json.dumps({
"error": "Terminal tool is disabled by default. Set BUTLER_ENABLE_TERMINAL=1 to enable restricted commands."
})
thread_id = threading.get_ident()
proc: subprocess.Popen[str] | None = None
interrupted = False
try:
timeout = int(timeout)
except (TypeError, ValueError):
return json.dumps({"error": "Terminal timeout must be an integer number of seconds"})
if timeout < 1 or timeout > MAX_TERMINAL_TIMEOUT_SECONDS:
return json.dumps({
"error": f"Terminal timeout must be between 1 and {MAX_TERMINAL_TIMEOUT_SECONDS} seconds"
})
command_safety = prepare_shell_command(command)
if not command_safety.allowed:
return json.dumps({"error": command_safety.error})
cwd_hint = str(workdir or default_tool_workdir() or "")
cmd_text = " ".join(command_safety.argv) if command_safety.argv else command
session_key = str(get_current_session_key() or "")
if workdir:
safety = check_tool_path(workdir)
else:
safety = default_tool_workdir()
if not safety.allowed:
return json.dumps({"error": safety.error})
if not safety.path.is_dir():
return json.dumps({"error": f"Not a directory: {workdir or safety.path}"})
resolved_workdir = str(safety.path)
workspace_path = safety.path
sandbox_config = load_terminal_sandbox_config(workspace_path)
unsandboxed = approval_allows_unsandboxed(
cmd_text,
cwd=resolved_workdir,
session_key=session_key,
)
run_sandboxed = should_run_sandboxed(
sandbox_config,
unsandboxed_approved=unsandboxed,
)
if run_sandboxed and not sandbox_runtime_available():
if sandbox_config.fail_if_unavailable:
return json.dumps({
"error": "bubblewrap (bwrap) 未安装,且 BUTLER_TERMINAL_SANDBOX_FAIL_UNAVAILABLE=1",
"code": "SANDBOX_UNAVAILABLE",
})
logger.warning("terminal sandbox enabled but bwrap missing; running unsandboxed")
run_sandboxed = False
inc_terminal_metric_safe("terminal_sandbox_unavailable_fallback")
exec_argv = list(command_safety.argv)
if run_sandboxed:
try:
exec_argv = wrap_argv_with_bubblewrap(
exec_argv,
workspace=workspace_path,
config=sandbox_config,
)
except RuntimeError as exc:
return json.dumps({"error": str(exc), "code": "SANDBOX_UNAVAILABLE"})
def _watch() -> None:
nonlocal interrupted
while proc is not None and proc.poll() is None:
if is_interrupted(thread_id):
interrupted = True
proc.kill()
return
time.sleep(0.2)
def _run_subprocess() -> str:
nonlocal proc, interrupted
base_env = safe_subprocess_env()
if run_sandboxed:
base_env = scrub_credential_env(base_env, sandbox_config)
proc_env = enrich_subprocess_env(base_env, sandboxed=run_sandboxed)
if run_sandboxed:
inc_terminal_metric_safe(
"terminal_sandbox_run",
labels={"sandboxed": "1"},
session_key=session_key,
)
proc = subprocess.Popen(
exec_argv,
shell=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
cwd=resolved_workdir,
env=proc_env,
)
assert proc is not None
watcher = threading.Thread(target=_watch, daemon=True)
watcher.start()
try:
try:
stdout, stderr, truncated = _communicate_limited(
proc,
timeout=timeout,
max_output_chars=MAX_TERMINAL_OUTPUT_CHARS,
)
except subprocess.TimeoutExpired:
proc.kill()
try:
proc.wait(timeout=1)
finally:
_close_pipe(proc.stdout)
_close_pipe(proc.stderr)
return json.dumps({"error": f"Command timed out after {timeout}s"})
if interrupted:
return json.dumps({"error": "interrupted", "output": ""})
output = stdout or ""
if stderr:
output += "\n[stderr]\n" + stderr
if truncated or len(output) > MAX_TERMINAL_OUTPUT_CHARS:
output = output[:MAX_TERMINAL_OUTPUT_CHARS] + "\n... (truncated)"
failure = classify_sandbox_failure(
exit_code=proc.returncode,
stdout=stdout or "",
stderr=stderr or "",
sandboxed=run_sandboxed,
)
if failure is not None:
inc_terminal_metric_safe(
"terminal_sandbox_failure",
labels={"constraint": failure.constraint},
session_key=session_key,
)
return json.dumps(
format_sandbox_error_payload(
failure,
command=cmd_text,
exit_code=proc.returncode,
output=output,
),
ensure_ascii=False,
)
return json.dumps({
"exit_code": proc.returncode,
"output": output,
"sandboxed": run_sandboxed,
})
finally:
_close_pipe(proc.stdout)
_close_pipe(proc.stderr)
return str(
run_terminal_with_gates_safe(
cmd_text,
cwd=cwd_hint,
session_key=session_key,
run_fn=lambda: run_subprocess_loud(_run_subprocess),
)
)
def _tool_search_files(pattern: str = "", path: str = ".", include: str | None = None, **_: Any) -> str:
needle = str(pattern or "").strip() or "*"
safety = check_tool_path(path)
if not safety.allowed:
return json.dumps({"error": safety.error})
cmd = ["rg", "--no-config", "--json", "-m", "20"]
if include:
cmd.extend(["--glob", include])
cmd.extend(["--", needle, str(safety.path)])
def _run() -> str:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=15,
env=safe_subprocess_env(),
)
matches = []
for line in result.stdout.strip().split("\n"):
if not line:
continue
try:
obj = json.loads(line)
if obj.get("type") == "match":
data = obj["data"]
path_text = data["path"]["text"]
match_safety = check_tool_path(path_text)
if not match_safety.allowed:
continue
matches.append({
"path": str(match_safety.path),
"line": data["line_number"],
"text": data["lines"]["text"].rstrip(),
})
except (json.JSONDecodeError, KeyError):
continue
return json.dumps({"matches": matches[:50], "total": len(matches)})
return str(run_search_files_loud(_run))
def _tool_list_directory(path: str = ".", **_: Any) -> str:
safety = check_tool_path(path)
if not safety.allowed:
return json.dumps({"error": safety.error})
p = safety.path
if not p.is_dir():
return json.dumps({"error": f"Not a directory: {path}"})
def _run() -> str:
entries = []
for item in sorted(p.iterdir()):
entries.append({
"name": item.name,
"type": "dir" if item.is_dir() else "file",
"size": item.stat().st_size if item.is_file() else None,
})
return json.dumps({"path": str(p), "entries": entries[:200]})
return str(run_list_directory_loud(_run))