merge: resolve conflict with main (add mcp + homeassistant extras)
This commit is contained in:
commit
aefc330b8f
81 changed files with 8138 additions and 776 deletions
|
|
@ -60,7 +60,7 @@ def detect_dangerous_command(command: str) -> tuple:
|
|||
"""
|
||||
command_lower = command.lower()
|
||||
for pattern, description in DANGEROUS_PATTERNS:
|
||||
if re.search(pattern, command_lower, re.IGNORECASE):
|
||||
if re.search(pattern, command_lower, re.IGNORECASE | re.DOTALL):
|
||||
pattern_key = pattern.split(r'\b')[1] if r'\b' in pattern else pattern[:20]
|
||||
return (True, pattern_key, description)
|
||||
return (False, None, None)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ Platform: Linux / macOS only (Unix domain sockets). Disabled on Windows.
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
|
|
@ -28,6 +29,8 @@ import tempfile
|
|||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
_IS_WINDOWS = platform.system() == "Windows"
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Availability gate: UDS requires a POSIX OS
|
||||
|
|
@ -405,7 +408,7 @@ def execute_code(
|
|||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
stdin=subprocess.DEVNULL,
|
||||
preexec_fn=os.setsid,
|
||||
preexec_fn=None if _IS_WINDOWS else os.setsid,
|
||||
)
|
||||
|
||||
# --- Poll loop: watch for exit, timeout, and interrupt ---
|
||||
|
|
@ -514,7 +517,10 @@ def execute_code(
|
|||
def _kill_process_group(proc, escalate: bool = False):
|
||||
"""Kill the child and its entire process group."""
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
if _IS_WINDOWS:
|
||||
proc.terminate()
|
||||
else:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
try:
|
||||
proc.kill()
|
||||
|
|
@ -527,7 +533,10 @@ def _kill_process_group(proc, escalate: bool = False):
|
|||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||
if _IS_WINDOWS:
|
||||
proc.kill()
|
||||
else:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
try:
|
||||
proc.kill()
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ DELEGATE_BLOCKED_TOOLS = frozenset([
|
|||
|
||||
MAX_CONCURRENT_CHILDREN = 3
|
||||
MAX_DEPTH = 2 # parent (0) -> child (1) -> grandchild rejected (2)
|
||||
DEFAULT_MAX_ITERATIONS = 25
|
||||
DEFAULT_MAX_ITERATIONS = 50
|
||||
DEFAULT_TOOLSETS = ["terminal", "file", "web"]
|
||||
|
||||
|
||||
|
|
@ -531,8 +531,8 @@ DELEGATE_TASK_SCHEMA = {
|
|||
"max_iterations": {
|
||||
"type": "integer",
|
||||
"description": (
|
||||
"Max tool-calling turns per subagent (default: 25). "
|
||||
"Lower for simple tasks, higher for complex ones."
|
||||
"Max tool-calling turns per subagent (default: 50). "
|
||||
"Only set lower for simple tasks."
|
||||
),
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"""Docker execution environment wrapping mini-swe-agent's DockerEnvironment.
|
||||
|
||||
Adds security hardening, configurable resource limits (CPU, memory, disk),
|
||||
and optional filesystem persistence via `docker commit`/`docker create --image`.
|
||||
Adds security hardening (cap-drop ALL, no-new-privileges, PID limits),
|
||||
configurable resource limits (CPU, memory, disk), and optional filesystem
|
||||
persistence via bind mounts.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
|
@ -19,13 +20,15 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
|
||||
# Security flags applied to every container
|
||||
# Security flags applied to every container.
|
||||
# The container itself is the security boundary (isolated from host).
|
||||
# We drop all capabilities, block privilege escalation, and limit PIDs.
|
||||
# /tmp is size-limited and nosuid but allows exec (needed by pip/npm builds).
|
||||
_SECURITY_ARGS = [
|
||||
"--read-only",
|
||||
"--cap-drop", "ALL",
|
||||
"--security-opt", "no-new-privileges",
|
||||
"--pids-limit", "256",
|
||||
"--tmpfs", "/tmp:rw,noexec,nosuid,size=512m",
|
||||
"--tmpfs", "/tmp:rw,nosuid,size=512m",
|
||||
"--tmpfs", "/var/tmp:rw,noexec,nosuid,size=256m",
|
||||
"--tmpfs", "/run:rw,noexec,nosuid,size=64m",
|
||||
]
|
||||
|
|
@ -37,12 +40,13 @@ _storage_opt_ok: Optional[bool] = None # cached result across instances
|
|||
class DockerEnvironment(BaseEnvironment):
|
||||
"""Hardened Docker container execution with resource limits and persistence.
|
||||
|
||||
Security: read-only root, all capabilities dropped, no privilege escalation,
|
||||
PID limits, tmpfs for writable scratch. Writable overlay for /home and cwd
|
||||
via tmpfs or bind mounts.
|
||||
Security: all capabilities dropped, no privilege escalation, PID limits,
|
||||
size-limited tmpfs for scratch dirs. The container itself is the security
|
||||
boundary — the filesystem inside is writable so agents can install packages
|
||||
(pip, npm, apt) as needed. Writable workspace via tmpfs or bind mounts.
|
||||
|
||||
Persistence: when enabled, `docker commit` saves the container state on
|
||||
cleanup, and the next creation restores from that image.
|
||||
Persistence: when enabled, bind mounts preserve /workspace and /root
|
||||
across container restarts.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
|
|
@ -114,9 +118,9 @@ class DockerEnvironment(BaseEnvironment):
|
|||
"--tmpfs", "/root:rw,exec,size=1g",
|
||||
]
|
||||
|
||||
# All containers get full security hardening (read-only root + writable
|
||||
# mounts for the workspace). Persistence uses Docker volumes, not
|
||||
# filesystem layer commits, so --read-only is always safe.
|
||||
# All containers get security hardening (capabilities dropped, no privilege
|
||||
# escalation, PID limits). The container filesystem is writable so agents
|
||||
# can install packages as needed.
|
||||
# User-configured volume mounts (from config.yaml docker_volumes)
|
||||
volume_args = []
|
||||
for vol in (volumes or []):
|
||||
|
|
|
|||
|
|
@ -1,14 +1,54 @@
|
|||
"""Local execution environment with interrupt support and non-blocking I/O."""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
_IS_WINDOWS = platform.system() == "Windows"
|
||||
|
||||
from tools.environments.base import BaseEnvironment
|
||||
|
||||
|
||||
def _find_shell() -> str:
|
||||
"""Find the best shell for command execution.
|
||||
|
||||
On Unix: uses $SHELL, falls back to bash.
|
||||
On Windows: uses Git Bash (bundled with Git for Windows).
|
||||
Raises RuntimeError if no suitable shell is found on Windows.
|
||||
"""
|
||||
if not _IS_WINDOWS:
|
||||
return os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash"
|
||||
|
||||
# Windows: look for Git Bash (installed with Git for Windows).
|
||||
# Allow override via env var (same pattern as Claude Code).
|
||||
custom = os.environ.get("HERMES_GIT_BASH_PATH")
|
||||
if custom and os.path.isfile(custom):
|
||||
return custom
|
||||
|
||||
# shutil.which finds bash.exe if Git\bin is on PATH
|
||||
found = shutil.which("bash")
|
||||
if found:
|
||||
return found
|
||||
|
||||
# Check common Git for Windows install locations
|
||||
for candidate in (
|
||||
os.path.join(os.environ.get("ProgramFiles", r"C:\Program Files"), "Git", "bin", "bash.exe"),
|
||||
os.path.join(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"), "Git", "bin", "bash.exe"),
|
||||
os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Git", "bin", "bash.exe"),
|
||||
):
|
||||
if candidate and os.path.isfile(candidate):
|
||||
return candidate
|
||||
|
||||
raise RuntimeError(
|
||||
"Git Bash not found. Hermes Agent requires Git for Windows on Windows.\n"
|
||||
"Install it from: https://git-scm.com/download/win\n"
|
||||
"Or set HERMES_GIT_BASH_PATH to your bash.exe location."
|
||||
)
|
||||
|
||||
# Noise lines emitted by interactive shells when stdin is not a terminal.
|
||||
# Filtered from output to keep tool results clean.
|
||||
_SHELL_NOISE_SUBSTRINGS = (
|
||||
|
|
@ -63,7 +103,7 @@ class LocalEnvironment(BaseEnvironment):
|
|||
# tools like nvm, pyenv, and cargo install their init scripts.
|
||||
# -l alone isn't enough: .profile sources .bashrc, but the guard
|
||||
# returns early because the shell isn't interactive.
|
||||
user_shell = os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash"
|
||||
user_shell = _find_shell()
|
||||
proc = subprocess.Popen(
|
||||
[user_shell, "-lic", exec_command],
|
||||
text=True,
|
||||
|
|
@ -74,7 +114,7 @@ class LocalEnvironment(BaseEnvironment):
|
|||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.PIPE if stdin_data is not None else subprocess.DEVNULL,
|
||||
preexec_fn=os.setsid,
|
||||
preexec_fn=None if _IS_WINDOWS else os.setsid,
|
||||
)
|
||||
|
||||
if stdin_data is not None:
|
||||
|
|
@ -107,12 +147,15 @@ class LocalEnvironment(BaseEnvironment):
|
|||
while proc.poll() is None:
|
||||
if _interrupt_event.is_set():
|
||||
try:
|
||||
pgid = os.getpgid(proc.pid)
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
try:
|
||||
proc.wait(timeout=1.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
if _IS_WINDOWS:
|
||||
proc.terminate()
|
||||
else:
|
||||
pgid = os.getpgid(proc.pid)
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
try:
|
||||
proc.wait(timeout=1.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
proc.kill()
|
||||
reader.join(timeout=2)
|
||||
|
|
@ -122,7 +165,10 @@ class LocalEnvironment(BaseEnvironment):
|
|||
}
|
||||
if time.monotonic() > deadline:
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
if _IS_WINDOWS:
|
||||
proc.terminate()
|
||||
else:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
proc.kill()
|
||||
reader.join(timeout=2)
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ class ReadResult:
|
|||
similar_files: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {k: v for k, v in self.__dict__.items() if v is not None and v != [] and v != ""}
|
||||
return {k: v for k, v in self.__dict__.items() if v is not None and v != []}
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
|
|||
1047
tools/mcp_tool.py
Normal file
1047
tools/mcp_tool.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -32,6 +32,7 @@ Usage:
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
|
|
@ -39,6 +40,9 @@ import subprocess
|
|||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
_IS_WINDOWS = platform.system() == "Windows"
|
||||
from tools.environments.local import _find_shell
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
|
@ -145,11 +149,13 @@ class ProcessRegistry:
|
|||
# Try PTY mode for interactive CLI tools
|
||||
try:
|
||||
import ptyprocess
|
||||
user_shell = os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash"
|
||||
user_shell = _find_shell()
|
||||
pty_env = os.environ | (env_vars or {})
|
||||
pty_env["PYTHONUNBUFFERED"] = "1"
|
||||
pty_proc = ptyprocess.PtyProcess.spawn(
|
||||
[user_shell, "-lic", command],
|
||||
cwd=session.cwd,
|
||||
env=os.environ | (env_vars or {}),
|
||||
env=pty_env,
|
||||
dimensions=(30, 120),
|
||||
)
|
||||
session.pid = pty_proc.pid
|
||||
|
|
@ -181,18 +187,23 @@ class ProcessRegistry:
|
|||
# Standard Popen path (non-PTY or PTY fallback)
|
||||
# Use the user's login shell for consistency with LocalEnvironment --
|
||||
# ensures rc files are sourced and user tools are available.
|
||||
user_shell = os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash"
|
||||
user_shell = _find_shell()
|
||||
# Force unbuffered output for Python scripts so progress is visible
|
||||
# during background execution (libraries like tqdm/datasets buffer when
|
||||
# stdout is a pipe, hiding output from process(action="poll")).
|
||||
bg_env = os.environ | (env_vars or {})
|
||||
bg_env["PYTHONUNBUFFERED"] = "1"
|
||||
proc = subprocess.Popen(
|
||||
[user_shell, "-lic", command],
|
||||
text=True,
|
||||
cwd=session.cwd,
|
||||
env=os.environ | (env_vars or {}),
|
||||
env=bg_env,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.PIPE,
|
||||
preexec_fn=os.setsid,
|
||||
preexec_fn=None if _IS_WINDOWS else os.setsid,
|
||||
)
|
||||
|
||||
session.process = proc
|
||||
|
|
@ -544,7 +555,10 @@ class ProcessRegistry:
|
|||
elif session.process:
|
||||
# Local process -- kill the process group
|
||||
try:
|
||||
os.killpg(os.getpgid(session.process.pid), signal.SIGTERM)
|
||||
if _IS_WINDOWS:
|
||||
session.process.terminate()
|
||||
else:
|
||||
os.killpg(os.getpgid(session.process.pid), signal.SIGTERM)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
session.process.kill()
|
||||
elif session.env_ref and session.pid:
|
||||
|
|
|
|||
|
|
@ -520,8 +520,8 @@ class ClawHubSource(SkillSource):
|
|||
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{self.BASE_URL}/skills/search",
|
||||
params={"q": query, "limit": limit},
|
||||
f"{self.BASE_URL}/skills",
|
||||
params={"search": query, "limit": limit},
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
|
|
@ -530,82 +530,154 @@ class ClawHubSource(SkillSource):
|
|||
except (httpx.HTTPError, json.JSONDecodeError):
|
||||
return []
|
||||
|
||||
skills_data = data.get("skills", data) if isinstance(data, dict) else data
|
||||
skills_data = data.get("items", data) if isinstance(data, dict) else data
|
||||
if not isinstance(skills_data, list):
|
||||
return []
|
||||
|
||||
results = []
|
||||
for item in skills_data[:limit]:
|
||||
name = item.get("name", item.get("slug", ""))
|
||||
if not name:
|
||||
slug = item.get("slug")
|
||||
if not slug:
|
||||
continue
|
||||
meta = SkillMeta(
|
||||
name=name,
|
||||
description=item.get("description", ""),
|
||||
display_name = item.get("displayName") or item.get("name") or slug
|
||||
summary = item.get("summary") or item.get("description") or ""
|
||||
tags = item.get("tags", [])
|
||||
if not isinstance(tags, list):
|
||||
tags = []
|
||||
results.append(SkillMeta(
|
||||
name=display_name,
|
||||
description=summary,
|
||||
source="clawhub",
|
||||
identifier=item.get("slug", name),
|
||||
identifier=slug,
|
||||
trust_level="community",
|
||||
tags=item.get("tags", []),
|
||||
)
|
||||
results.append(meta)
|
||||
tags=[str(t) for t in tags],
|
||||
))
|
||||
|
||||
_write_index_cache(cache_key, [_skill_meta_to_dict(s) for s in results])
|
||||
return results
|
||||
|
||||
def fetch(self, identifier: str) -> Optional[SkillBundle]:
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{self.BASE_URL}/skills/{identifier}/versions/latest/files",
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
except (httpx.HTTPError, json.JSONDecodeError):
|
||||
slug = identifier.split("/")[-1]
|
||||
|
||||
skill_data = self._get_json(f"{self.BASE_URL}/skills/{slug}")
|
||||
if not isinstance(skill_data, dict):
|
||||
return None
|
||||
|
||||
files: Dict[str, str] = {}
|
||||
file_list = data.get("files", data) if isinstance(data, dict) else data
|
||||
if isinstance(file_list, list):
|
||||
for f in file_list:
|
||||
fname = f.get("name", f.get("path", ""))
|
||||
content = f.get("content", "")
|
||||
if fname and content:
|
||||
files[fname] = content
|
||||
elif isinstance(file_list, dict):
|
||||
files = {k: v for k, v in file_list.items() if isinstance(v, str)}
|
||||
latest_version = self._resolve_latest_version(slug, skill_data)
|
||||
if not latest_version:
|
||||
logger.warning("ClawHub fetch failed for %s: could not resolve latest version", slug)
|
||||
return None
|
||||
|
||||
version_data = self._get_json(f"{self.BASE_URL}/skills/{slug}/versions/{latest_version}")
|
||||
if not isinstance(version_data, dict):
|
||||
return None
|
||||
|
||||
files = self._extract_files(version_data)
|
||||
if "SKILL.md" not in files:
|
||||
logger.warning(
|
||||
"ClawHub fetch for %s resolved version %s but no inline/raw file content was available",
|
||||
slug,
|
||||
latest_version,
|
||||
)
|
||||
return None
|
||||
|
||||
return SkillBundle(
|
||||
name=identifier.split("/")[-1] if "/" in identifier else identifier,
|
||||
name=slug,
|
||||
files=files,
|
||||
source="clawhub",
|
||||
identifier=identifier,
|
||||
identifier=slug,
|
||||
trust_level="community",
|
||||
)
|
||||
|
||||
def inspect(self, identifier: str) -> Optional[SkillMeta]:
|
||||
slug = identifier.split("/")[-1]
|
||||
data = self._get_json(f"{self.BASE_URL}/skills/{slug}")
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
tags = data.get("tags", [])
|
||||
if not isinstance(tags, list):
|
||||
tags = []
|
||||
|
||||
return SkillMeta(
|
||||
name=data.get("displayName") or data.get("name") or data.get("slug") or slug,
|
||||
description=data.get("summary") or data.get("description") or "",
|
||||
source="clawhub",
|
||||
identifier=data.get("slug") or slug,
|
||||
trust_level="community",
|
||||
tags=[str(t) for t in tags],
|
||||
)
|
||||
|
||||
def _get_json(self, url: str, timeout: int = 20) -> Optional[Any]:
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{self.BASE_URL}/skills/{identifier}",
|
||||
timeout=15,
|
||||
)
|
||||
resp = httpx.get(url, timeout=timeout)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
return resp.json()
|
||||
except (httpx.HTTPError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
return SkillMeta(
|
||||
name=data.get("name", identifier),
|
||||
description=data.get("description", ""),
|
||||
source="clawhub",
|
||||
identifier=identifier,
|
||||
trust_level="community",
|
||||
tags=data.get("tags", []),
|
||||
)
|
||||
def _resolve_latest_version(self, slug: str, skill_data: Dict[str, Any]) -> Optional[str]:
|
||||
latest = skill_data.get("latestVersion")
|
||||
if isinstance(latest, dict):
|
||||
version = latest.get("version")
|
||||
if isinstance(version, str) and version:
|
||||
return version
|
||||
|
||||
tags = skill_data.get("tags")
|
||||
if isinstance(tags, dict):
|
||||
latest_tag = tags.get("latest")
|
||||
if isinstance(latest_tag, str) and latest_tag:
|
||||
return latest_tag
|
||||
|
||||
versions_data = self._get_json(f"{self.BASE_URL}/skills/{slug}/versions")
|
||||
if isinstance(versions_data, list) and versions_data:
|
||||
first = versions_data[0]
|
||||
if isinstance(first, dict):
|
||||
version = first.get("version")
|
||||
if isinstance(version, str) and version:
|
||||
return version
|
||||
return None
|
||||
|
||||
def _extract_files(self, version_data: Dict[str, Any]) -> Dict[str, str]:
|
||||
files: Dict[str, str] = {}
|
||||
file_list = version_data.get("files")
|
||||
|
||||
if isinstance(file_list, dict):
|
||||
return {k: v for k, v in file_list.items() if isinstance(v, str)}
|
||||
|
||||
if not isinstance(file_list, list):
|
||||
return files
|
||||
|
||||
for file_meta in file_list:
|
||||
if not isinstance(file_meta, dict):
|
||||
continue
|
||||
|
||||
fname = file_meta.get("path") or file_meta.get("name")
|
||||
if not fname or not isinstance(fname, str):
|
||||
continue
|
||||
|
||||
inline_content = file_meta.get("content")
|
||||
if isinstance(inline_content, str):
|
||||
files[fname] = inline_content
|
||||
continue
|
||||
|
||||
raw_url = file_meta.get("rawUrl") or file_meta.get("downloadUrl") or file_meta.get("url")
|
||||
if isinstance(raw_url, str) and raw_url.startswith("http"):
|
||||
content = self._fetch_text(raw_url)
|
||||
if content is not None:
|
||||
files[fname] = content
|
||||
|
||||
return files
|
||||
|
||||
def _fetch_text(self, url: str) -> Optional[str]:
|
||||
try:
|
||||
resp = httpx.get(url, timeout=20)
|
||||
if resp.status_code == 200:
|
||||
return resp.text
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -443,7 +443,33 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
|
|||
|
||||
# If a specific file path is requested, read that instead
|
||||
if file_path and skill_dir:
|
||||
# Security: Prevent path traversal attacks
|
||||
normalized_path = Path(file_path)
|
||||
if ".." in normalized_path.parts:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": "Path traversal ('..') is not allowed.",
|
||||
"hint": "Use a relative path within the skill directory"
|
||||
}, ensure_ascii=False)
|
||||
|
||||
target_file = skill_dir / file_path
|
||||
|
||||
# Security: Verify resolved path is still within skill directory
|
||||
try:
|
||||
resolved = target_file.resolve()
|
||||
skill_dir_resolved = skill_dir.resolve()
|
||||
if not str(resolved).startswith(str(skill_dir_resolved) + "/") and resolved != skill_dir_resolved:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": "Path escapes skill directory boundary.",
|
||||
"hint": "Use a relative path within the skill directory"
|
||||
}, ensure_ascii=False)
|
||||
except (OSError, ValueError):
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": f"Invalid file path: '{file_path}'",
|
||||
"hint": "Use a valid relative path within the skill directory"
|
||||
}, ensure_ascii=False)
|
||||
if not target_file.exists():
|
||||
# List available files in the skill directory, organized by type
|
||||
available_files = {
|
||||
|
|
|
|||
|
|
@ -346,7 +346,9 @@ Do NOT use sed/awk to edit files — use patch instead.
|
|||
Do NOT use echo/cat heredoc to create files — use write_file instead.
|
||||
Reserve terminal for: builds, installs, git, processes, scripts, network, package managers, and anything that needs a shell.
|
||||
|
||||
Background processes: Set background=true to get a session_id, then use the 'process' tool to poll/wait/kill/write.
|
||||
Foreground (default): Commands return INSTANTLY when done, even if the timeout is high. Set timeout=300 for long builds/scripts — you'll still get the result in seconds if it's fast. Prefer foreground for everything that finishes.
|
||||
Background: ONLY for long-running servers, watchers, or processes that never exit. Set background=true to get a session_id, then use process(action="wait") to block until done — it returns instantly on completion, same as foreground. Use process(action="poll") only when you need a progress check without blocking.
|
||||
Do NOT use background for scripts, builds, or installs — foreground with a generous timeout is always better (fewer tool calls, instant results).
|
||||
Working directory: Use 'workdir' for per-command cwd.
|
||||
PTY mode: Set pty=true for interactive CLI tools (Codex, Claude Code, Python REPL).
|
||||
|
||||
|
|
@ -435,7 +437,7 @@ def _get_env_config() -> Dict[str, Any]:
|
|||
"singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"),
|
||||
"modal_image": os.getenv("TERMINAL_MODAL_IMAGE", default_image),
|
||||
"cwd": cwd,
|
||||
"timeout": int(os.getenv("TERMINAL_TIMEOUT", "60")),
|
||||
"timeout": int(os.getenv("TERMINAL_TIMEOUT", "180")),
|
||||
"lifetime_seconds": int(os.getenv("TERMINAL_LIFETIME_SECONDS", "300")),
|
||||
# SSH-specific config
|
||||
"ssh_host": os.getenv("TERMINAL_SSH_HOST", ""),
|
||||
|
|
@ -636,19 +638,18 @@ def get_active_environments_info() -> Dict[str, Any]:
|
|||
"workdirs": {},
|
||||
}
|
||||
|
||||
# Calculate total disk usage
|
||||
# Calculate total disk usage (per-task to avoid double-counting)
|
||||
total_size = 0
|
||||
for task_id in _active_environments.keys():
|
||||
# Check sandbox and workdir sizes
|
||||
scratch_dir = _get_scratch_dir()
|
||||
for pattern in [f"hermes-*{task_id[:8]}*"]:
|
||||
import glob
|
||||
for path in glob.glob(str(scratch_dir / "hermes-*")):
|
||||
try:
|
||||
size = sum(f.stat().st_size for f in Path(path).rglob('*') if f.is_file())
|
||||
total_size += size
|
||||
except OSError:
|
||||
pass
|
||||
pattern = f"hermes-*{task_id[:8]}*"
|
||||
import glob
|
||||
for path in glob.glob(str(scratch_dir / pattern)):
|
||||
try:
|
||||
size = sum(f.stat().st_size for f in Path(path).rglob('*') if f.is_file())
|
||||
total_size += size
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
info["total_disk_usage_mb"] = round(total_size / (1024 * 1024), 2)
|
||||
return info
|
||||
|
|
@ -1154,12 +1155,12 @@ TERMINAL_SCHEMA = {
|
|||
},
|
||||
"background": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to run the command in the background (default: false)",
|
||||
"description": "ONLY for servers/watchers that never exit. For scripts, builds, installs — use foreground with timeout instead (it returns instantly when done).",
|
||||
"default": False
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "Command timeout in seconds (optional)",
|
||||
"description": "Max seconds to wait (default: 180). Returns INSTANTLY when command finishes — set high for long tasks, you won't wait unnecessarily.",
|
||||
"minimum": 1
|
||||
},
|
||||
"workdir": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue