Ctrl K
ComunidadGuías…

butler/tools/terminal_impl.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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))