Merge branch 'main' into rewbs/tool-use-charge-to-subscription
This commit is contained in:
@@ -41,8 +41,8 @@ DANGEROUS_PATTERNS = [
|
||||
(r'\brm\s+(-[^\s]*\s+)*/', "delete in root path"),
|
||||
(r'\brm\s+-[^\s]*r', "recursive delete"),
|
||||
(r'\brm\s+--recursive\b', "recursive delete (long flag)"),
|
||||
(r'\bchmod\s+(-[^\s]*\s+)*777\b', "world-writable permissions"),
|
||||
(r'\bchmod\s+--recursive\b.*777', "recursive world-writable (long flag)"),
|
||||
(r'\bchmod\s+(-[^\s]*\s+)*(777|666|o\+[rwx]*w|a\+[rwx]*w)\b', "world/other-writable permissions"),
|
||||
(r'\bchmod\s+--recursive\b.*(777|666|o\+[rwx]*w|a\+[rwx]*w)', "recursive world/other-writable (long flag)"),
|
||||
(r'\bchown\s+(-[^\s]*)?R\s+root', "recursive chown to root"),
|
||||
(r'\bchown\s+--recursive\b.*root', "recursive chown to root (long flag)"),
|
||||
(r'\bmkfs\b', "format filesystem"),
|
||||
@@ -71,6 +71,10 @@ DANGEROUS_PATTERNS = [
|
||||
(r'\bnohup\b.*gateway\s+run\b', "start gateway outside systemd (use 'systemctl --user restart hermes-gateway')"),
|
||||
# Self-termination protection: prevent agent from killing its own process
|
||||
(r'\b(pkill|killall)\b.*\b(hermes|gateway|cli\.py)\b', "kill hermes/gateway process (self-termination)"),
|
||||
# File copy/move/edit into sensitive system paths
|
||||
(r'\b(cp|mv|install)\b.*\s/etc/', "copy/move file into /etc/"),
|
||||
(r'\bsed\s+-[^\s]*i.*\s/etc/', "in-place edit of system config"),
|
||||
(r'\bsed\s+--in-place\b.*\s/etc/', "in-place edit of system config (long flag)"),
|
||||
]
|
||||
|
||||
|
||||
@@ -237,7 +241,7 @@ def save_permanent_allowlist(patterns: set):
|
||||
# =========================================================================
|
||||
|
||||
def prompt_dangerous_approval(command: str, description: str,
|
||||
timeout_seconds: int = 60,
|
||||
timeout_seconds: int | None = None,
|
||||
allow_permanent: bool = True,
|
||||
approval_callback=None) -> str:
|
||||
"""Prompt the user to approve a dangerous command (CLI only).
|
||||
@@ -252,6 +256,9 @@ def prompt_dangerous_approval(command: str, description: str,
|
||||
|
||||
Returns: 'once', 'session', 'always', or 'deny'
|
||||
"""
|
||||
if timeout_seconds is None:
|
||||
timeout_seconds = _get_approval_timeout()
|
||||
|
||||
if approval_callback is not None:
|
||||
try:
|
||||
return approval_callback(command, description,
|
||||
@@ -332,15 +339,28 @@ def _normalize_approval_mode(mode) -> str:
|
||||
return "manual"
|
||||
|
||||
|
||||
def _get_approval_mode() -> str:
|
||||
"""Read the approval mode from config. Returns 'manual', 'smart', or 'off'."""
|
||||
def _get_approval_config() -> dict:
|
||||
"""Read the approvals config block. Returns a dict with 'mode', 'timeout', etc."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
mode = config.get("approvals", {}).get("mode", "manual")
|
||||
return _normalize_approval_mode(mode)
|
||||
return config.get("approvals", {}) or {}
|
||||
except Exception:
|
||||
return "manual"
|
||||
return {}
|
||||
|
||||
|
||||
def _get_approval_mode() -> str:
|
||||
"""Read the approval mode from config. Returns 'manual', 'smart', or 'off'."""
|
||||
mode = _get_approval_config().get("mode", "manual")
|
||||
return _normalize_approval_mode(mode)
|
||||
|
||||
|
||||
def _get_approval_timeout() -> int:
|
||||
"""Read the approval timeout from config. Defaults to 60 seconds."""
|
||||
try:
|
||||
return int(_get_approval_config().get("timeout", 60))
|
||||
except (ValueError, TypeError):
|
||||
return 60
|
||||
|
||||
|
||||
def _smart_approve(command: str, description: str) -> str:
|
||||
|
||||
496
tools/browser_camofox.py
Normal file
496
tools/browser_camofox.py
Normal file
@@ -0,0 +1,496 @@
|
||||
"""Camofox browser backend — local anti-detection browser via REST API.
|
||||
|
||||
Camofox-browser is a self-hosted Node.js server wrapping Camoufox (Firefox
|
||||
fork with C++ fingerprint spoofing). It exposes a REST API that maps 1:1
|
||||
to our browser tool interface: accessibility snapshots with element refs,
|
||||
click/type/scroll by ref, screenshots, etc.
|
||||
|
||||
When ``CAMOFOX_URL`` is set (e.g. ``http://localhost:9377``), the browser
|
||||
tools route through this module instead of the ``agent-browser`` CLI.
|
||||
|
||||
Setup::
|
||||
|
||||
# Option 1: npm
|
||||
git clone https://github.com/jo-inc/camofox-browser && cd camofox-browser
|
||||
npm install && npm start # downloads Camoufox (~300MB) on first run
|
||||
|
||||
# Option 2: Docker
|
||||
docker run -p 9377:9377 jo-inc/camofox-browser
|
||||
|
||||
Then set ``CAMOFOX_URL=http://localhost:9377`` in ``~/.hermes/.env``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DEFAULT_TIMEOUT = 30 # seconds per HTTP request
|
||||
_SNAPSHOT_MAX_CHARS = 80_000 # camofox paginates at this limit
|
||||
|
||||
|
||||
def get_camofox_url() -> str:
|
||||
"""Return the configured Camofox server URL, or empty string."""
|
||||
return os.getenv("CAMOFOX_URL", "").rstrip("/")
|
||||
|
||||
|
||||
def is_camofox_mode() -> bool:
|
||||
"""True when Camofox backend is configured."""
|
||||
return bool(get_camofox_url())
|
||||
|
||||
|
||||
def check_camofox_available() -> bool:
|
||||
"""Verify the Camofox server is reachable."""
|
||||
url = get_camofox_url()
|
||||
if not url:
|
||||
return False
|
||||
try:
|
||||
resp = requests.get(f"{url}/health", timeout=5)
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session management
|
||||
# ---------------------------------------------------------------------------
|
||||
# Maps task_id -> {"user_id": str, "tab_id": str|None}
|
||||
_sessions: Dict[str, Dict[str, Any]] = {}
|
||||
_sessions_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_session(task_id: Optional[str]) -> Dict[str, Any]:
|
||||
"""Get or create a camofox session for the given task."""
|
||||
task_id = task_id or "default"
|
||||
with _sessions_lock:
|
||||
if task_id in _sessions:
|
||||
return _sessions[task_id]
|
||||
session = {
|
||||
"user_id": f"hermes_{uuid.uuid4().hex[:10]}",
|
||||
"tab_id": None,
|
||||
"session_key": f"task_{task_id[:16]}",
|
||||
}
|
||||
_sessions[task_id] = session
|
||||
return session
|
||||
|
||||
|
||||
def _ensure_tab(task_id: Optional[str], url: str = "about:blank") -> Dict[str, Any]:
|
||||
"""Ensure a tab exists for the session, creating one if needed."""
|
||||
session = _get_session(task_id)
|
||||
if session["tab_id"]:
|
||||
return session
|
||||
base = get_camofox_url()
|
||||
resp = requests.post(
|
||||
f"{base}/tabs",
|
||||
json={
|
||||
"userId": session["user_id"],
|
||||
"sessionKey": session["session_key"],
|
||||
"url": url,
|
||||
},
|
||||
timeout=_DEFAULT_TIMEOUT,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
session["tab_id"] = data.get("tabId")
|
||||
return session
|
||||
|
||||
|
||||
def _drop_session(task_id: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
"""Remove and return session info."""
|
||||
task_id = task_id or "default"
|
||||
with _sessions_lock:
|
||||
return _sessions.pop(task_id, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _post(path: str, body: dict, timeout: int = _DEFAULT_TIMEOUT) -> dict:
|
||||
"""POST JSON to camofox and return parsed response."""
|
||||
url = f"{get_camofox_url()}{path}"
|
||||
resp = requests.post(url, json=body, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _get(path: str, params: dict = None, timeout: int = _DEFAULT_TIMEOUT) -> dict:
|
||||
"""GET from camofox and return parsed response."""
|
||||
url = f"{get_camofox_url()}{path}"
|
||||
resp = requests.get(url, params=params, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _get_raw(path: str, params: dict = None, timeout: int = _DEFAULT_TIMEOUT) -> requests.Response:
|
||||
"""GET from camofox and return raw response (for binary data)."""
|
||||
url = f"{get_camofox_url()}{path}"
|
||||
resp = requests.get(url, params=params, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp
|
||||
|
||||
|
||||
def _delete(path: str, body: dict = None, timeout: int = _DEFAULT_TIMEOUT) -> dict:
|
||||
"""DELETE to camofox and return parsed response."""
|
||||
url = f"{get_camofox_url()}{path}"
|
||||
resp = requests.delete(url, json=body, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool implementations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def camofox_navigate(url: str, task_id: Optional[str] = None) -> str:
|
||||
"""Navigate to a URL via Camofox."""
|
||||
try:
|
||||
session = _get_session(task_id)
|
||||
if not session["tab_id"]:
|
||||
# Create tab with the target URL directly
|
||||
session = _ensure_tab(task_id, url)
|
||||
data = {"ok": True, "url": url}
|
||||
else:
|
||||
# Navigate existing tab
|
||||
data = _post(
|
||||
f"/tabs/{session['tab_id']}/navigate",
|
||||
{"userId": session["user_id"], "url": url},
|
||||
timeout=60,
|
||||
)
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"url": data.get("url", url),
|
||||
"title": data.get("title", ""),
|
||||
})
|
||||
except requests.HTTPError as e:
|
||||
return json.dumps({"success": False, "error": f"Navigation failed: {e}"})
|
||||
except requests.ConnectionError:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": f"Cannot connect to Camofox at {get_camofox_url()}. "
|
||||
"Is the server running? Start with: npm start (in camofox-browser dir) "
|
||||
"or: docker run -p 9377:9377 jo-inc/camofox-browser",
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
def camofox_snapshot(full: bool = False, task_id: Optional[str] = None,
|
||||
user_task: Optional[str] = None) -> str:
|
||||
"""Get accessibility tree snapshot from Camofox."""
|
||||
try:
|
||||
session = _get_session(task_id)
|
||||
if not session["tab_id"]:
|
||||
return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."})
|
||||
|
||||
data = _get(
|
||||
f"/tabs/{session['tab_id']}/snapshot",
|
||||
params={"userId": session["user_id"]},
|
||||
)
|
||||
|
||||
snapshot = data.get("snapshot", "")
|
||||
refs_count = data.get("refsCount", 0)
|
||||
|
||||
# Apply same summarization logic as the main browser tool
|
||||
from tools.browser_tool import (
|
||||
SNAPSHOT_SUMMARIZE_THRESHOLD,
|
||||
_extract_relevant_content,
|
||||
_truncate_snapshot,
|
||||
)
|
||||
|
||||
if len(snapshot) > SNAPSHOT_SUMMARIZE_THRESHOLD:
|
||||
if user_task:
|
||||
snapshot = _extract_relevant_content(snapshot, user_task)
|
||||
else:
|
||||
snapshot = _truncate_snapshot(snapshot)
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"snapshot": snapshot,
|
||||
"element_count": refs_count,
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
def camofox_click(ref: str, task_id: Optional[str] = None) -> str:
|
||||
"""Click an element by ref via Camofox."""
|
||||
try:
|
||||
session = _get_session(task_id)
|
||||
if not session["tab_id"]:
|
||||
return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."})
|
||||
|
||||
# Strip @ prefix if present (our tool convention)
|
||||
clean_ref = ref.lstrip("@")
|
||||
|
||||
data = _post(
|
||||
f"/tabs/{session['tab_id']}/click",
|
||||
{"userId": session["user_id"], "ref": clean_ref},
|
||||
)
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"clicked": clean_ref,
|
||||
"url": data.get("url", ""),
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
def camofox_type(ref: str, text: str, task_id: Optional[str] = None) -> str:
|
||||
"""Type text into an element by ref via Camofox."""
|
||||
try:
|
||||
session = _get_session(task_id)
|
||||
if not session["tab_id"]:
|
||||
return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."})
|
||||
|
||||
clean_ref = ref.lstrip("@")
|
||||
|
||||
_post(
|
||||
f"/tabs/{session['tab_id']}/type",
|
||||
{"userId": session["user_id"], "ref": clean_ref, "text": text},
|
||||
)
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"typed": text,
|
||||
"element": clean_ref,
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
def camofox_scroll(direction: str, task_id: Optional[str] = None) -> str:
|
||||
"""Scroll the page via Camofox."""
|
||||
try:
|
||||
session = _get_session(task_id)
|
||||
if not session["tab_id"]:
|
||||
return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."})
|
||||
|
||||
_post(
|
||||
f"/tabs/{session['tab_id']}/scroll",
|
||||
{"userId": session["user_id"], "direction": direction},
|
||||
)
|
||||
return json.dumps({"success": True, "scrolled": direction})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
def camofox_back(task_id: Optional[str] = None) -> str:
|
||||
"""Navigate back via Camofox."""
|
||||
try:
|
||||
session = _get_session(task_id)
|
||||
if not session["tab_id"]:
|
||||
return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."})
|
||||
|
||||
data = _post(
|
||||
f"/tabs/{session['tab_id']}/back",
|
||||
{"userId": session["user_id"]},
|
||||
)
|
||||
return json.dumps({"success": True, "url": data.get("url", "")})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
def camofox_press(key: str, task_id: Optional[str] = None) -> str:
|
||||
"""Press a keyboard key via Camofox."""
|
||||
try:
|
||||
session = _get_session(task_id)
|
||||
if not session["tab_id"]:
|
||||
return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."})
|
||||
|
||||
_post(
|
||||
f"/tabs/{session['tab_id']}/press",
|
||||
{"userId": session["user_id"], "key": key},
|
||||
)
|
||||
return json.dumps({"success": True, "pressed": key})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
def camofox_close(task_id: Optional[str] = None) -> str:
|
||||
"""Close the browser session via Camofox."""
|
||||
try:
|
||||
session = _drop_session(task_id)
|
||||
if not session:
|
||||
return json.dumps({"success": True, "closed": True})
|
||||
|
||||
_delete(
|
||||
f"/sessions/{session['user_id']}",
|
||||
)
|
||||
return json.dumps({"success": True, "closed": True})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": True, "closed": True, "warning": str(e)})
|
||||
|
||||
|
||||
def camofox_get_images(task_id: Optional[str] = None) -> str:
|
||||
"""Get images on the current page via Camofox.
|
||||
|
||||
Extracts image information from the accessibility tree snapshot,
|
||||
since Camofox does not expose a dedicated /images endpoint.
|
||||
"""
|
||||
try:
|
||||
session = _get_session(task_id)
|
||||
if not session["tab_id"]:
|
||||
return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."})
|
||||
|
||||
import re
|
||||
|
||||
data = _get(
|
||||
f"/tabs/{session['tab_id']}/snapshot",
|
||||
params={"userId": session["user_id"]},
|
||||
)
|
||||
snapshot = data.get("snapshot", "")
|
||||
|
||||
# Parse img elements from the accessibility tree.
|
||||
# Format: img "alt text" or img "alt text" [eN]
|
||||
# URLs appear on /url: lines following img entries
|
||||
images = []
|
||||
lines = snapshot.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("- img ") or stripped.startswith("img "):
|
||||
alt_match = re.search(r'img\s+"([^"]*)"', stripped)
|
||||
alt = alt_match.group(1) if alt_match else ""
|
||||
# Look for URL on the next line
|
||||
src = ""
|
||||
if i + 1 < len(lines):
|
||||
url_match = re.search(r'/url:\s*(\S+)', lines[i + 1].strip())
|
||||
if url_match:
|
||||
src = url_match.group(1)
|
||||
if alt or src:
|
||||
images.append({"src": src, "alt": alt})
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"images": images,
|
||||
"count": len(images),
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
def camofox_vision(question: str, annotate: bool = False,
|
||||
task_id: Optional[str] = None) -> str:
|
||||
"""Take a screenshot and analyze it with vision AI via Camofox."""
|
||||
try:
|
||||
session = _get_session(task_id)
|
||||
if not session["tab_id"]:
|
||||
return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."})
|
||||
|
||||
# Get screenshot as binary PNG
|
||||
resp = _get_raw(
|
||||
f"/tabs/{session['tab_id']}/screenshot",
|
||||
params={"userId": session["user_id"]},
|
||||
)
|
||||
|
||||
# Save screenshot to cache
|
||||
from hermes_constants import get_hermes_home
|
||||
screenshots_dir = get_hermes_home() / "browser_screenshots"
|
||||
screenshots_dir.mkdir(parents=True, exist_ok=True)
|
||||
screenshot_path = str(screenshots_dir / f"browser_screenshot_{uuid.uuid4().hex[:8]}.png")
|
||||
|
||||
with open(screenshot_path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
|
||||
# Encode for vision LLM
|
||||
img_b64 = base64.b64encode(resp.content).decode("utf-8")
|
||||
|
||||
# Also get annotated snapshot if requested
|
||||
annotation_context = ""
|
||||
if annotate:
|
||||
try:
|
||||
snap_data = _get(
|
||||
f"/tabs/{session['tab_id']}/snapshot",
|
||||
params={"userId": session["user_id"]},
|
||||
)
|
||||
annotation_context = f"\n\nAccessibility tree (element refs for interaction):\n{snap_data.get('snapshot', '')[:3000]}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Send to vision LLM
|
||||
from agent.auxiliary_client import call_llm
|
||||
|
||||
vision_prompt = (
|
||||
f"Analyze this browser screenshot and answer: {question}"
|
||||
f"{annotation_context}"
|
||||
)
|
||||
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
_cfg = load_config()
|
||||
_vision_timeout = int(_cfg.get("auxiliary", {}).get("vision", {}).get("timeout", 120))
|
||||
except Exception:
|
||||
_vision_timeout = 120
|
||||
|
||||
analysis = call_llm(
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": vision_prompt},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/png;base64,{img_b64}",
|
||||
},
|
||||
},
|
||||
],
|
||||
}],
|
||||
task="vision",
|
||||
timeout=_vision_timeout,
|
||||
)
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"analysis": analysis,
|
||||
"screenshot_path": screenshot_path,
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
def camofox_console(clear: bool = False, task_id: Optional[str] = None) -> str:
|
||||
"""Get console output — limited support in Camofox.
|
||||
|
||||
Camofox does not expose browser console logs via its REST API.
|
||||
Returns an empty result with a note.
|
||||
"""
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"console_messages": [],
|
||||
"js_errors": [],
|
||||
"total_messages": 0,
|
||||
"total_errors": 0,
|
||||
"note": "Console log capture is not available with the Camofox backend. "
|
||||
"Use browser_snapshot or browser_vision to inspect page state.",
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cleanup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cleanup_all_camofox_sessions() -> None:
|
||||
"""Close all active camofox sessions."""
|
||||
with _sessions_lock:
|
||||
sessions = list(_sessions.items())
|
||||
for task_id, session in sessions:
|
||||
try:
|
||||
_delete(f"/sessions/{session['user_id']}")
|
||||
except Exception:
|
||||
pass
|
||||
with _sessions_lock:
|
||||
_sessions.clear()
|
||||
@@ -80,6 +80,14 @@ from tools.browser_providers.browserbase import BrowserbaseProvider
|
||||
from tools.browser_providers.browser_use import BrowserUseProvider
|
||||
from tools.tool_backend_helpers import normalize_browser_cloud_provider
|
||||
|
||||
# Camofox local anti-detection browser backend (optional).
|
||||
# When CAMOFOX_URL is set, all browser operations route through the
|
||||
# camofox REST API instead of the agent-browser CLI.
|
||||
try:
|
||||
from tools.browser_camofox import is_camofox_mode as _is_camofox_mode
|
||||
except ImportError:
|
||||
_is_camofox_mode = lambda: False # noqa: E731
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Standard PATH entries for environments with minimal PATH (e.g. systemd services).
|
||||
@@ -1080,6 +1088,11 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
|
||||
"blocked_by_policy": {"host": blocked["host"], "rule": blocked["rule"], "source": blocked["source"]},
|
||||
})
|
||||
|
||||
# Camofox backend — delegate after safety checks pass
|
||||
if _is_camofox_mode():
|
||||
from tools.browser_camofox import camofox_navigate
|
||||
return camofox_navigate(url, task_id)
|
||||
|
||||
effective_task_id = task_id or "default"
|
||||
|
||||
# Get session info to check if this is a new session
|
||||
@@ -1169,6 +1182,10 @@ def browser_snapshot(
|
||||
Returns:
|
||||
JSON string with page snapshot
|
||||
"""
|
||||
if _is_camofox_mode():
|
||||
from tools.browser_camofox import camofox_snapshot
|
||||
return camofox_snapshot(full, task_id, user_task)
|
||||
|
||||
effective_task_id = task_id or "default"
|
||||
|
||||
# Build command args based on full flag
|
||||
@@ -1214,6 +1231,10 @@ def browser_click(ref: str, task_id: Optional[str] = None) -> str:
|
||||
Returns:
|
||||
JSON string with click result
|
||||
"""
|
||||
if _is_camofox_mode():
|
||||
from tools.browser_camofox import camofox_click
|
||||
return camofox_click(ref, task_id)
|
||||
|
||||
effective_task_id = task_id or "default"
|
||||
|
||||
# Ensure ref starts with @
|
||||
@@ -1246,6 +1267,10 @@ def browser_type(ref: str, text: str, task_id: Optional[str] = None) -> str:
|
||||
Returns:
|
||||
JSON string with type result
|
||||
"""
|
||||
if _is_camofox_mode():
|
||||
from tools.browser_camofox import camofox_type
|
||||
return camofox_type(ref, text, task_id)
|
||||
|
||||
effective_task_id = task_id or "default"
|
||||
|
||||
# Ensure ref starts with @
|
||||
@@ -1279,6 +1304,10 @@ def browser_scroll(direction: str, task_id: Optional[str] = None) -> str:
|
||||
Returns:
|
||||
JSON string with scroll result
|
||||
"""
|
||||
if _is_camofox_mode():
|
||||
from tools.browser_camofox import camofox_scroll
|
||||
return camofox_scroll(direction, task_id)
|
||||
|
||||
effective_task_id = task_id or "default"
|
||||
|
||||
# Validate direction
|
||||
@@ -1312,6 +1341,10 @@ def browser_back(task_id: Optional[str] = None) -> str:
|
||||
Returns:
|
||||
JSON string with navigation result
|
||||
"""
|
||||
if _is_camofox_mode():
|
||||
from tools.browser_camofox import camofox_back
|
||||
return camofox_back(task_id)
|
||||
|
||||
effective_task_id = task_id or "default"
|
||||
result = _run_browser_command(effective_task_id, "back", [])
|
||||
|
||||
@@ -1339,6 +1372,10 @@ def browser_press(key: str, task_id: Optional[str] = None) -> str:
|
||||
Returns:
|
||||
JSON string with key press result
|
||||
"""
|
||||
if _is_camofox_mode():
|
||||
from tools.browser_camofox import camofox_press
|
||||
return camofox_press(key, task_id)
|
||||
|
||||
effective_task_id = task_id or "default"
|
||||
result = _run_browser_command(effective_task_id, "press", [key])
|
||||
|
||||
@@ -1364,6 +1401,10 @@ def browser_close(task_id: Optional[str] = None) -> str:
|
||||
Returns:
|
||||
JSON string with close result
|
||||
"""
|
||||
if _is_camofox_mode():
|
||||
from tools.browser_camofox import camofox_close
|
||||
return camofox_close(task_id)
|
||||
|
||||
effective_task_id = task_id or "default"
|
||||
with _cleanup_lock:
|
||||
had_session = effective_task_id in _active_sessions
|
||||
@@ -1392,6 +1433,10 @@ def browser_console(clear: bool = False, task_id: Optional[str] = None) -> str:
|
||||
Returns:
|
||||
JSON string with console messages and JS errors
|
||||
"""
|
||||
if _is_camofox_mode():
|
||||
from tools.browser_camofox import camofox_console
|
||||
return camofox_console(clear, task_id)
|
||||
|
||||
effective_task_id = task_id or "default"
|
||||
|
||||
console_args = ["--clear"] if clear else []
|
||||
@@ -1486,6 +1531,10 @@ def browser_get_images(task_id: Optional[str] = None) -> str:
|
||||
Returns:
|
||||
JSON string with list of images (src and alt)
|
||||
"""
|
||||
if _is_camofox_mode():
|
||||
from tools.browser_camofox import camofox_get_images
|
||||
return camofox_get_images(task_id)
|
||||
|
||||
effective_task_id = task_id or "default"
|
||||
|
||||
# Use eval to run JavaScript that extracts images
|
||||
@@ -1550,6 +1599,10 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
|
||||
Returns:
|
||||
JSON string with vision analysis results and screenshot_path
|
||||
"""
|
||||
if _is_camofox_mode():
|
||||
from tools.browser_camofox import camofox_vision
|
||||
return camofox_vision(question, annotate, task_id)
|
||||
|
||||
import base64
|
||||
import uuid as uuid_mod
|
||||
from pathlib import Path
|
||||
@@ -1838,6 +1891,10 @@ def check_browser_requirements() -> bool:
|
||||
Returns:
|
||||
True if all requirements are met, False otherwise
|
||||
"""
|
||||
# Camofox backend — only needs the server URL, no agent-browser CLI
|
||||
if _is_camofox_mode():
|
||||
return True
|
||||
|
||||
# The agent-browser CLI is always required
|
||||
try:
|
||||
_find_agent_browser()
|
||||
|
||||
@@ -83,7 +83,7 @@ def register_credential_files(
|
||||
if isinstance(entry, str):
|
||||
rel_path = entry.strip()
|
||||
elif isinstance(entry, dict):
|
||||
rel_path = (entry.get("path") or "").strip()
|
||||
rel_path = (entry.get("path") or entry.get("name") or "").strip()
|
||||
else:
|
||||
continue
|
||||
if not rel_path:
|
||||
@@ -152,6 +152,107 @@ def get_credential_file_mounts() -> List[Dict[str, str]]:
|
||||
]
|
||||
|
||||
|
||||
def get_skills_directory_mount(
|
||||
container_base: str = "/root/.hermes",
|
||||
) -> Dict[str, str] | None:
|
||||
"""Return mount info for a symlink-safe copy of the skills directory.
|
||||
|
||||
Skills may include ``scripts/``, ``templates/``, and ``references/``
|
||||
subdirectories that the agent needs to execute inside remote sandboxes.
|
||||
|
||||
**Security:** Bind mounts follow symlinks, so a malicious symlink inside
|
||||
the skills tree could expose arbitrary host files to the container. When
|
||||
symlinks are detected, this function creates a sanitized copy (regular
|
||||
files only) in a temp directory and returns that path instead. When no
|
||||
symlinks are present (the common case), the original directory is returned
|
||||
directly with zero overhead.
|
||||
|
||||
Returns a dict with ``host_path`` and ``container_path`` keys, or None.
|
||||
"""
|
||||
hermes_home = _resolve_hermes_home()
|
||||
skills_dir = hermes_home / "skills"
|
||||
if not skills_dir.is_dir():
|
||||
return None
|
||||
|
||||
host_path = _safe_skills_path(skills_dir)
|
||||
return {
|
||||
"host_path": host_path,
|
||||
"container_path": f"{container_base.rstrip('/')}/skills",
|
||||
}
|
||||
|
||||
|
||||
_safe_skills_tempdir: Path | None = None
|
||||
|
||||
|
||||
def _safe_skills_path(skills_dir: Path) -> str:
|
||||
"""Return *skills_dir* if symlink-free, else a sanitized temp copy."""
|
||||
global _safe_skills_tempdir
|
||||
|
||||
symlinks = [p for p in skills_dir.rglob("*") if p.is_symlink()]
|
||||
if not symlinks:
|
||||
return str(skills_dir)
|
||||
|
||||
for link in symlinks:
|
||||
logger.warning("credential_files: skipping symlink in skills dir: %s -> %s",
|
||||
link, os.readlink(link))
|
||||
|
||||
import atexit
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
# Reuse the same temp dir across calls to avoid accumulation.
|
||||
if _safe_skills_tempdir and _safe_skills_tempdir.is_dir():
|
||||
shutil.rmtree(_safe_skills_tempdir, ignore_errors=True)
|
||||
|
||||
safe_dir = Path(tempfile.mkdtemp(prefix="hermes-skills-safe-"))
|
||||
_safe_skills_tempdir = safe_dir
|
||||
|
||||
for item in skills_dir.rglob("*"):
|
||||
if item.is_symlink():
|
||||
continue
|
||||
rel = item.relative_to(skills_dir)
|
||||
target = safe_dir / rel
|
||||
if item.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
elif item.is_file():
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(str(item), str(target))
|
||||
|
||||
def _cleanup():
|
||||
if safe_dir.is_dir():
|
||||
shutil.rmtree(safe_dir, ignore_errors=True)
|
||||
|
||||
atexit.register(_cleanup)
|
||||
logger.info("credential_files: created symlink-safe skills copy at %s", safe_dir)
|
||||
return str(safe_dir)
|
||||
|
||||
|
||||
def iter_skills_files(
|
||||
container_base: str = "/root/.hermes",
|
||||
) -> List[Dict[str, str]]:
|
||||
"""Yield individual (host_path, container_path) entries for skills files.
|
||||
|
||||
Skips symlinks entirely. Preferred for backends that upload files
|
||||
individually (Daytona, Modal) rather than mounting a directory.
|
||||
"""
|
||||
hermes_home = _resolve_hermes_home()
|
||||
skills_dir = hermes_home / "skills"
|
||||
if not skills_dir.is_dir():
|
||||
return []
|
||||
|
||||
container_root = f"{container_base.rstrip('/')}/skills"
|
||||
result: List[Dict[str, str]] = []
|
||||
for item in skills_dir.rglob("*"):
|
||||
if item.is_symlink() or not item.is_file():
|
||||
continue
|
||||
rel = item.relative_to(skills_dir)
|
||||
result.append({
|
||||
"host_path": str(item),
|
||||
"container_path": f"{container_root}/{rel}",
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def clear_credential_files() -> None:
|
||||
"""Reset the skill-scoped registry (e.g. on session reset)."""
|
||||
_registered_files.clear()
|
||||
|
||||
@@ -113,15 +113,61 @@ class DaytonaEnvironment(BaseEnvironment):
|
||||
logger.info("Daytona: created sandbox %s for task %s",
|
||||
self._sandbox.id, task_id)
|
||||
|
||||
# Resolve cwd: detect actual home dir inside the sandbox
|
||||
if self._requested_cwd in ("~", "/home/daytona"):
|
||||
try:
|
||||
home = self._sandbox.process.exec("echo $HOME").result.strip()
|
||||
if home:
|
||||
# Detect remote home dir first so mounts go to the right place.
|
||||
self._remote_home = "/root"
|
||||
try:
|
||||
home = self._sandbox.process.exec("echo $HOME").result.strip()
|
||||
if home:
|
||||
self._remote_home = home
|
||||
if self._requested_cwd in ("~", "/home/daytona"):
|
||||
self.cwd = home
|
||||
except Exception:
|
||||
pass # leave cwd as-is; sandbox will use its own default
|
||||
logger.info("Daytona: resolved cwd to %s", self.cwd)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("Daytona: resolved home to %s, cwd to %s", self._remote_home, self.cwd)
|
||||
|
||||
# Track synced files to avoid redundant uploads.
|
||||
# Key: remote_path, Value: (mtime, size)
|
||||
self._synced_files: Dict[str, tuple] = {}
|
||||
|
||||
# Upload credential files and skills directory into the sandbox.
|
||||
self._sync_skills_and_credentials()
|
||||
|
||||
def _upload_if_changed(self, host_path: str, remote_path: str) -> bool:
|
||||
"""Upload a file if its mtime/size changed since last sync."""
|
||||
hp = Path(host_path)
|
||||
try:
|
||||
stat = hp.stat()
|
||||
file_key = (stat.st_mtime, stat.st_size)
|
||||
except OSError:
|
||||
return False
|
||||
if self._synced_files.get(remote_path) == file_key:
|
||||
return False
|
||||
try:
|
||||
parent = str(Path(remote_path).parent)
|
||||
self._sandbox.process.exec(f"mkdir -p {parent}")
|
||||
self._sandbox.fs.upload_file(host_path, remote_path)
|
||||
self._synced_files[remote_path] = file_key
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("Daytona: upload failed %s: %s", host_path, e)
|
||||
return False
|
||||
|
||||
def _sync_skills_and_credentials(self) -> None:
|
||||
"""Upload changed credential files and skill files into the sandbox."""
|
||||
container_base = f"{self._remote_home}/.hermes"
|
||||
try:
|
||||
from tools.credential_files import get_credential_file_mounts, iter_skills_files
|
||||
|
||||
for mount_entry in get_credential_file_mounts():
|
||||
remote_path = mount_entry["container_path"].replace("/root/.hermes", container_base, 1)
|
||||
if self._upload_if_changed(mount_entry["host_path"], remote_path):
|
||||
logger.debug("Daytona: synced credential %s", remote_path)
|
||||
|
||||
for entry in iter_skills_files(container_base=container_base):
|
||||
if self._upload_if_changed(entry["host_path"], entry["container_path"]):
|
||||
logger.debug("Daytona: synced skill %s", entry["container_path"])
|
||||
except Exception as e:
|
||||
logger.debug("Daytona: could not sync skills/credentials: %s", e)
|
||||
|
||||
def _ensure_sandbox_ready(self):
|
||||
"""Restart sandbox if it was stopped (e.g., by a previous interrupt)."""
|
||||
@@ -191,6 +237,9 @@ class DaytonaEnvironment(BaseEnvironment):
|
||||
stdin_data: Optional[str] = None) -> dict:
|
||||
with self._lock:
|
||||
self._ensure_sandbox_ready()
|
||||
# Incremental sync before each command so mid-session credential
|
||||
# refreshes and skill updates are picked up.
|
||||
self._sync_skills_and_credentials()
|
||||
|
||||
if stdin_data is not None:
|
||||
marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
@@ -315,7 +315,7 @@ class DockerEnvironment(BaseEnvironment):
|
||||
# Mount credential files (OAuth tokens, etc.) declared by skills.
|
||||
# Read-only so the container can authenticate but not modify host creds.
|
||||
try:
|
||||
from tools.credential_files import get_credential_file_mounts
|
||||
from tools.credential_files import get_credential_file_mounts, get_skills_directory_mount
|
||||
|
||||
for mount_entry in get_credential_file_mounts():
|
||||
volume_args.extend([
|
||||
@@ -327,6 +327,20 @@ class DockerEnvironment(BaseEnvironment):
|
||||
mount_entry["host_path"],
|
||||
mount_entry["container_path"],
|
||||
)
|
||||
|
||||
# Mount the skills directory so skill scripts/templates are
|
||||
# available inside the container at the same relative path.
|
||||
skills_mount = get_skills_directory_mount()
|
||||
if skills_mount:
|
||||
volume_args.extend([
|
||||
"-v",
|
||||
f"{skills_mount['host_path']}:{skills_mount['container_path']}:ro",
|
||||
])
|
||||
logger.info(
|
||||
"Docker: mounting skills dir %s -> %s",
|
||||
skills_mount["host_path"],
|
||||
skills_mount["container_path"],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Docker: could not load credential file mounts: %s", e)
|
||||
|
||||
|
||||
@@ -473,7 +473,12 @@ class LocalEnvironment(PersistentShellMixin, BaseEnvironment):
|
||||
except (ProcessLookupError, PermissionError):
|
||||
proc.kill()
|
||||
reader.join(timeout=2)
|
||||
return self._timeout_result(effective_timeout)
|
||||
partial = "".join(_output_chunks)
|
||||
timeout_msg = f"\n[Command timed out after {effective_timeout}s]"
|
||||
return {
|
||||
"output": partial + timeout_msg if partial else timeout_msg.lstrip(),
|
||||
"returncode": 124,
|
||||
}
|
||||
time.sleep(0.2)
|
||||
|
||||
reader.join(timeout=5)
|
||||
|
||||
@@ -155,7 +155,7 @@ class ModalEnvironment(BaseEnvironment):
|
||||
self._sandbox = None
|
||||
self._app = None
|
||||
self._worker = _AsyncWorker()
|
||||
self._synced_creds: Dict[str, tuple] = {}
|
||||
self._synced_files: Dict[str, tuple] = {}
|
||||
|
||||
sandbox_kwargs = dict(modal_sandbox_kwargs or {})
|
||||
|
||||
@@ -172,7 +172,7 @@ class ModalEnvironment(BaseEnvironment):
|
||||
|
||||
cred_mounts = []
|
||||
try:
|
||||
from tools.credential_files import get_credential_file_mounts
|
||||
from tools.credential_files import get_credential_file_mounts, iter_skills_files
|
||||
|
||||
for mount_entry in get_credential_file_mounts():
|
||||
cred_mounts.append(
|
||||
@@ -186,6 +186,18 @@ class ModalEnvironment(BaseEnvironment):
|
||||
mount_entry["host_path"],
|
||||
mount_entry["container_path"],
|
||||
)
|
||||
|
||||
# Mount individual skill files (symlinks filtered out).
|
||||
skills_files = iter_skills_files()
|
||||
for entry in skills_files:
|
||||
cred_mounts.append(
|
||||
_modal.Mount.from_local_file(
|
||||
entry["host_path"],
|
||||
remote_path=entry["container_path"],
|
||||
)
|
||||
)
|
||||
if skills_files:
|
||||
logger.info("Modal: mounting %d skill files", len(skills_files))
|
||||
except Exception as e:
|
||||
logger.debug("Modal: could not load credential file mounts: %s", e)
|
||||
|
||||
@@ -212,8 +224,9 @@ class ModalEnvironment(BaseEnvironment):
|
||||
target_image_spec = restored_snapshot_id or image
|
||||
try:
|
||||
# _resolve_modal_image keeps the Modal bootstrap fix together:
|
||||
# it applies ensurepip via setup_dockerfile_commands before
|
||||
# Modal builds or restores the image.
|
||||
# it applies setup_dockerfile_commands with ensurepip before
|
||||
# Modal builds registry images, while snapshot ids restore via
|
||||
# modal.Image.from_id() without rebuilding.
|
||||
effective_image = _resolve_modal_image(target_image_spec)
|
||||
self._app, self._sandbox = self._worker.run_coroutine(
|
||||
_create_sandbox(effective_image),
|
||||
@@ -247,55 +260,57 @@ class ModalEnvironment(BaseEnvironment):
|
||||
|
||||
logger.info("Modal: sandbox created (task=%s)", self._task_id)
|
||||
|
||||
def _sync_credential_files(self) -> None:
|
||||
"""Push credential files into the running sandbox."""
|
||||
def _push_file_to_sandbox(self, host_path: str, container_path: str) -> bool:
|
||||
"""Push a single file into the sandbox if changed. Returns True if synced."""
|
||||
hp = Path(host_path)
|
||||
try:
|
||||
from tools.credential_files import get_credential_file_mounts
|
||||
stat = hp.stat()
|
||||
file_key = (stat.st_mtime, stat.st_size)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
mounts = get_credential_file_mounts()
|
||||
if not mounts:
|
||||
return
|
||||
if self._synced_files.get(container_path) == file_key:
|
||||
return False
|
||||
|
||||
for entry in mounts:
|
||||
host_path = entry["host_path"]
|
||||
container_path = entry["container_path"]
|
||||
hp = Path(host_path)
|
||||
try:
|
||||
stat = hp.stat()
|
||||
file_key = (stat.st_mtime, stat.st_size)
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
content = hp.read_bytes()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if self._synced_creds.get(container_path) == file_key:
|
||||
continue
|
||||
import base64
|
||||
b64 = base64.b64encode(content).decode("ascii")
|
||||
container_dir = str(Path(container_path).parent)
|
||||
cmd = (
|
||||
f"mkdir -p {shlex.quote(container_dir)} && "
|
||||
f"echo {shlex.quote(b64)} | base64 -d > {shlex.quote(container_path)}"
|
||||
)
|
||||
|
||||
try:
|
||||
content = hp.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
async def _write():
|
||||
proc = await self._sandbox.exec.aio("bash", "-c", cmd)
|
||||
await proc.wait.aio()
|
||||
|
||||
import base64
|
||||
self._worker.run_coroutine(_write(), timeout=15)
|
||||
self._synced_files[container_path] = file_key
|
||||
return True
|
||||
|
||||
b64 = base64.b64encode(content.encode("utf-8")).decode("ascii")
|
||||
container_dir = str(Path(container_path).parent)
|
||||
cmd = (
|
||||
f"mkdir -p {shlex.quote(container_dir)} && "
|
||||
f"echo {shlex.quote(b64)} | base64 -d > {shlex.quote(container_path)}"
|
||||
)
|
||||
def _sync_files(self) -> None:
|
||||
"""Push credential files and skill files into the running sandbox.
|
||||
|
||||
async def _write():
|
||||
proc = await self._sandbox.exec.aio("bash", "-c", cmd)
|
||||
await proc.wait.aio()
|
||||
Runs before each command. Uses mtime+size caching so only changed
|
||||
files are pushed (~13μs overhead in the no-op case).
|
||||
"""
|
||||
try:
|
||||
from tools.credential_files import get_credential_file_mounts, iter_skills_files
|
||||
|
||||
self._worker.run_coroutine(_write(), timeout=15)
|
||||
self._synced_creds[container_path] = file_key
|
||||
logger.debug(
|
||||
"Modal: synced credential %s -> %s",
|
||||
host_path,
|
||||
container_path,
|
||||
)
|
||||
for entry in get_credential_file_mounts():
|
||||
if self._push_file_to_sandbox(entry["host_path"], entry["container_path"]):
|
||||
logger.debug("Modal: synced credential %s", entry["container_path"])
|
||||
|
||||
for entry in iter_skills_files():
|
||||
if self._push_file_to_sandbox(entry["host_path"], entry["container_path"]):
|
||||
logger.debug("Modal: synced skill file %s", entry["container_path"])
|
||||
except Exception as e:
|
||||
logger.debug("Modal: credential file sync failed: %s", e)
|
||||
logger.debug("Modal: file sync failed: %s", e)
|
||||
|
||||
def execute(
|
||||
self,
|
||||
@@ -305,7 +320,7 @@ class ModalEnvironment(BaseEnvironment):
|
||||
timeout: int | None = None,
|
||||
stdin_data: str | None = None,
|
||||
) -> dict:
|
||||
self._sync_credential_files()
|
||||
self._sync_files()
|
||||
|
||||
if stdin_data is not None:
|
||||
marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
@@ -254,6 +254,28 @@ class SingularityEnvironment(BaseEnvironment):
|
||||
else:
|
||||
cmd.append("--writable-tmpfs")
|
||||
|
||||
# Mount credential files and skills directory (read-only).
|
||||
try:
|
||||
from tools.credential_files import get_credential_file_mounts, get_skills_directory_mount
|
||||
|
||||
for mount_entry in get_credential_file_mounts():
|
||||
cmd.extend(["--bind", f"{mount_entry['host_path']}:{mount_entry['container_path']}:ro"])
|
||||
logger.info(
|
||||
"Singularity: binding credential %s -> %s",
|
||||
mount_entry["host_path"],
|
||||
mount_entry["container_path"],
|
||||
)
|
||||
skills_mount = get_skills_directory_mount()
|
||||
if skills_mount:
|
||||
cmd.extend(["--bind", f"{skills_mount['host_path']}:{skills_mount['container_path']}:ro"])
|
||||
logger.info(
|
||||
"Singularity: binding skills dir %s -> %s",
|
||||
skills_mount["host_path"],
|
||||
skills_mount["container_path"],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Singularity: could not load credential/skills mounts: %s", e)
|
||||
|
||||
# Resource limits (cgroup-based, may require root or appropriate config)
|
||||
if self._memory > 0:
|
||||
cmd.extend(["--memory", f"{self._memory}M"])
|
||||
|
||||
@@ -55,6 +55,8 @@ class SSHEnvironment(PersistentShellMixin, BaseEnvironment):
|
||||
self.control_socket = self.control_dir / f"{user}@{host}:{port}.sock"
|
||||
_ensure_ssh_available()
|
||||
self._establish_connection()
|
||||
self._remote_home = self._detect_remote_home()
|
||||
self._sync_skills_and_credentials()
|
||||
|
||||
if self.persistent:
|
||||
self._init_persistent_shell()
|
||||
@@ -87,6 +89,79 @@ class SSHEnvironment(PersistentShellMixin, BaseEnvironment):
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError(f"SSH connection to {self.user}@{self.host} timed out")
|
||||
|
||||
def _detect_remote_home(self) -> str:
|
||||
"""Detect the remote user's home directory."""
|
||||
try:
|
||||
cmd = self._build_ssh_command()
|
||||
cmd.append("echo $HOME")
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
home = result.stdout.strip()
|
||||
if home and result.returncode == 0:
|
||||
logger.debug("SSH: remote home = %s", home)
|
||||
return home
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback: guess from username
|
||||
if self.user == "root":
|
||||
return "/root"
|
||||
return f"/home/{self.user}"
|
||||
|
||||
def _sync_skills_and_credentials(self) -> None:
|
||||
"""Rsync skills directory and credential files to the remote host."""
|
||||
try:
|
||||
container_base = f"{self._remote_home}/.hermes"
|
||||
from tools.credential_files import get_credential_file_mounts, get_skills_directory_mount
|
||||
|
||||
rsync_base = ["rsync", "-az", "--timeout=30", "--safe-links"]
|
||||
ssh_opts = f"ssh -o ControlPath={self.control_socket} -o ControlMaster=auto"
|
||||
if self.port != 22:
|
||||
ssh_opts += f" -p {self.port}"
|
||||
if self.key_path:
|
||||
ssh_opts += f" -i {self.key_path}"
|
||||
rsync_base.extend(["-e", ssh_opts])
|
||||
dest_prefix = f"{self.user}@{self.host}"
|
||||
|
||||
# Sync individual credential files (remap /root/.hermes to detected home)
|
||||
for mount_entry in get_credential_file_mounts():
|
||||
remote_path = mount_entry["container_path"].replace("/root/.hermes", container_base, 1)
|
||||
parent_dir = str(Path(remote_path).parent)
|
||||
mkdir_cmd = self._build_ssh_command()
|
||||
mkdir_cmd.append(f"mkdir -p {parent_dir}")
|
||||
subprocess.run(mkdir_cmd, capture_output=True, text=True, timeout=10)
|
||||
cmd = rsync_base + [mount_entry["host_path"], f"{dest_prefix}:{remote_path}"]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
if result.returncode == 0:
|
||||
logger.info("SSH: synced credential %s -> %s", mount_entry["host_path"], remote_path)
|
||||
else:
|
||||
logger.debug("SSH: rsync credential failed: %s", result.stderr.strip())
|
||||
|
||||
# Sync skills directory (remap to detected home)
|
||||
skills_mount = get_skills_directory_mount(container_base=container_base)
|
||||
if skills_mount:
|
||||
remote_path = skills_mount["container_path"]
|
||||
mkdir_cmd = self._build_ssh_command()
|
||||
mkdir_cmd.append(f"mkdir -p {remote_path}")
|
||||
subprocess.run(mkdir_cmd, capture_output=True, text=True, timeout=10)
|
||||
cmd = rsync_base + [
|
||||
skills_mount["host_path"].rstrip("/") + "/",
|
||||
f"{dest_prefix}:{remote_path}/",
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
if result.returncode == 0:
|
||||
logger.info("SSH: synced skills dir %s -> %s", skills_mount["host_path"], remote_path)
|
||||
else:
|
||||
logger.debug("SSH: rsync skills dir failed: %s", result.stderr.strip())
|
||||
except Exception as e:
|
||||
logger.debug("SSH: could not sync skills/credentials: %s", e)
|
||||
|
||||
def execute(self, command: str, cwd: str = "", *,
|
||||
timeout: int | None = None,
|
||||
stdin_data: str | None = None) -> dict:
|
||||
# Incremental sync before each command so mid-session credential
|
||||
# refreshes and skill updates are picked up.
|
||||
self._sync_skills_and_credentials()
|
||||
return super().execute(command, cwd, timeout=timeout, stdin_data=stdin_data)
|
||||
|
||||
_poll_interval_start: float = 0.15 # SSH: higher initial interval (150ms) for network latency
|
||||
|
||||
@property
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
import errno
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from tools.file_operations import ShellFileOperations
|
||||
from agent.redact import redact_sensitive_text
|
||||
|
||||
@@ -13,6 +15,31 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_EXPECTED_WRITE_ERRNOS = {errno.EACCES, errno.EPERM, errno.EROFS}
|
||||
|
||||
# Paths that file tools should refuse to write to without going through the
|
||||
# terminal tool's approval system. These match prefixes after os.path.realpath.
|
||||
_SENSITIVE_PATH_PREFIXES = ("/etc/", "/boot/", "/usr/lib/systemd/")
|
||||
_SENSITIVE_EXACT_PATHS = {"/var/run/docker.sock", "/run/docker.sock"}
|
||||
|
||||
|
||||
def _check_sensitive_path(filepath: str) -> str | None:
|
||||
"""Return an error message if the path targets a sensitive system location."""
|
||||
try:
|
||||
resolved = os.path.realpath(os.path.expanduser(filepath))
|
||||
except (OSError, ValueError):
|
||||
resolved = filepath
|
||||
for prefix in _SENSITIVE_PATH_PREFIXES:
|
||||
if resolved.startswith(prefix):
|
||||
return (
|
||||
f"Refusing to write to sensitive system path: {filepath}\n"
|
||||
"Use the terminal tool with sudo if you need to modify system files."
|
||||
)
|
||||
if resolved in _SENSITIVE_EXACT_PATHS:
|
||||
return (
|
||||
f"Refusing to write to sensitive system path: {filepath}\n"
|
||||
"Use the terminal tool with sudo if you need to modify system files."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _is_expected_write_exception(exc: Exception) -> bool:
|
||||
"""Return True for expected write denials that should not hit error logs."""
|
||||
@@ -287,6 +314,9 @@ def notify_other_tool_call(task_id: str = "default"):
|
||||
|
||||
def write_file_tool(path: str, content: str, task_id: str = "default") -> str:
|
||||
"""Write content to a file."""
|
||||
sensitive_err = _check_sensitive_path(path)
|
||||
if sensitive_err:
|
||||
return json.dumps({"error": sensitive_err}, ensure_ascii=False)
|
||||
try:
|
||||
file_ops = _get_file_ops(task_id)
|
||||
result = file_ops.write_file(path, content)
|
||||
@@ -303,6 +333,18 @@ def patch_tool(mode: str = "replace", path: str = None, old_string: str = None,
|
||||
new_string: str = None, replace_all: bool = False, patch: str = None,
|
||||
task_id: str = "default") -> str:
|
||||
"""Patch a file using replace mode or V4A patch format."""
|
||||
# Check sensitive paths for both replace (explicit path) and V4A patch (extract paths)
|
||||
_paths_to_check = []
|
||||
if path:
|
||||
_paths_to_check.append(path)
|
||||
if mode == "patch" and patch:
|
||||
import re as _re
|
||||
for _m in _re.finditer(r'^\*\*\*\s+(?:Update|Add|Delete)\s+File:\s*(.+)$', patch, _re.MULTILINE):
|
||||
_paths_to_check.append(_m.group(1).strip())
|
||||
for _p in _paths_to_check:
|
||||
sensitive_err = _check_sensitive_path(_p)
|
||||
if sensitive_err:
|
||||
return json.dumps({"error": sensitive_err}, ensure_ascii=False)
|
||||
try:
|
||||
file_ops = _get_file_ops(task_id)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
@@ -85,6 +85,43 @@ class SkillBundle:
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _normalize_bundle_path(path_value: str, *, field_name: str, allow_nested: bool) -> str:
|
||||
"""Normalize and validate bundle-controlled paths before touching disk."""
|
||||
if not isinstance(path_value, str):
|
||||
raise ValueError(f"Unsafe {field_name}: expected a string")
|
||||
|
||||
raw = path_value.strip()
|
||||
if not raw:
|
||||
raise ValueError(f"Unsafe {field_name}: empty path")
|
||||
|
||||
normalized = raw.replace("\\", "/")
|
||||
path = PurePosixPath(normalized)
|
||||
parts = [part for part in path.parts if part not in ("", ".")]
|
||||
|
||||
if normalized.startswith("/") or path.is_absolute():
|
||||
raise ValueError(f"Unsafe {field_name}: {path_value}")
|
||||
if not parts or any(part == ".." for part in parts):
|
||||
raise ValueError(f"Unsafe {field_name}: {path_value}")
|
||||
if re.fullmatch(r"[A-Za-z]:", parts[0]):
|
||||
raise ValueError(f"Unsafe {field_name}: {path_value}")
|
||||
if not allow_nested and len(parts) != 1:
|
||||
raise ValueError(f"Unsafe {field_name}: {path_value}")
|
||||
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def _validate_skill_name(name: str) -> str:
|
||||
return _normalize_bundle_path(name, field_name="skill name", allow_nested=False)
|
||||
|
||||
|
||||
def _validate_category_name(category: str) -> str:
|
||||
return _normalize_bundle_path(category, field_name="category", allow_nested=False)
|
||||
|
||||
|
||||
def _validate_bundle_rel_path(rel_path: str) -> str:
|
||||
return _normalize_bundle_path(rel_path, field_name="bundle file path", allow_nested=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitHub Authentication
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -701,6 +738,12 @@ class WellKnownSkillSource(SkillSource):
|
||||
if not parsed:
|
||||
return None
|
||||
|
||||
try:
|
||||
skill_name = _validate_skill_name(parsed["skill_name"])
|
||||
except ValueError:
|
||||
logger.warning("Well-known skill identifier contained unsafe skill name: %s", identifier)
|
||||
return None
|
||||
|
||||
entry = self._index_entry(parsed["index_url"], parsed["skill_name"])
|
||||
if not entry:
|
||||
return None
|
||||
@@ -713,19 +756,28 @@ class WellKnownSkillSource(SkillSource):
|
||||
for rel_path in files:
|
||||
if not isinstance(rel_path, str) or not rel_path:
|
||||
continue
|
||||
text = self._fetch_text(f"{parsed['skill_url']}/{rel_path}")
|
||||
try:
|
||||
safe_rel_path = _validate_bundle_rel_path(rel_path)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Well-known skill %s advertised unsafe file path: %r",
|
||||
identifier,
|
||||
rel_path,
|
||||
)
|
||||
return None
|
||||
text = self._fetch_text(f"{parsed['skill_url']}/{safe_rel_path}")
|
||||
if text is None:
|
||||
return None
|
||||
downloaded[rel_path] = text
|
||||
downloaded[safe_rel_path] = text
|
||||
|
||||
if "SKILL.md" not in downloaded:
|
||||
return None
|
||||
|
||||
return SkillBundle(
|
||||
name=parsed["skill_name"],
|
||||
name=skill_name,
|
||||
files=downloaded,
|
||||
source="well-known",
|
||||
identifier=self._wrap_identifier(parsed["base_url"], parsed["skill_name"]),
|
||||
identifier=self._wrap_identifier(parsed["base_url"], skill_name),
|
||||
trust_level="community",
|
||||
metadata={
|
||||
"index_url": parsed["index_url"],
|
||||
@@ -1752,9 +1804,10 @@ class ClawHubSource(SkillSource):
|
||||
for info in zf.infolist():
|
||||
if info.is_dir():
|
||||
continue
|
||||
# Sanitize path — strip leading slashes and ..
|
||||
name = info.filename.lstrip("/")
|
||||
if ".." in name or name.startswith("/"):
|
||||
try:
|
||||
name = _validate_bundle_rel_path(info.filename)
|
||||
except ValueError:
|
||||
logger.debug("Skipping unsafe ZIP member path: %s", info.filename)
|
||||
continue
|
||||
# Only extract text-sized files (skip large binaries)
|
||||
if info.file_size > 500_000:
|
||||
@@ -2423,13 +2476,19 @@ def ensure_hub_dirs() -> None:
|
||||
def quarantine_bundle(bundle: SkillBundle) -> Path:
|
||||
"""Write a skill bundle to the quarantine directory for scanning."""
|
||||
ensure_hub_dirs()
|
||||
dest = QUARANTINE_DIR / bundle.name
|
||||
skill_name = _validate_skill_name(bundle.name)
|
||||
validated_files: List[Tuple[str, Union[str, bytes]]] = []
|
||||
for rel_path, file_content in bundle.files.items():
|
||||
safe_rel_path = _validate_bundle_rel_path(rel_path)
|
||||
validated_files.append((safe_rel_path, file_content))
|
||||
|
||||
dest = QUARANTINE_DIR / skill_name
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
dest.mkdir(parents=True)
|
||||
|
||||
for rel_path, file_content in bundle.files.items():
|
||||
file_dest = dest / rel_path
|
||||
for rel_path, file_content in validated_files:
|
||||
file_dest = dest.joinpath(*rel_path.split("/"))
|
||||
file_dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
if isinstance(file_content, bytes):
|
||||
file_dest.write_bytes(file_content)
|
||||
@@ -2447,10 +2506,17 @@ def install_from_quarantine(
|
||||
scan_result: ScanResult,
|
||||
) -> Path:
|
||||
"""Move a scanned skill from quarantine into the skills directory."""
|
||||
if category:
|
||||
install_dir = SKILLS_DIR / category / skill_name
|
||||
safe_skill_name = _validate_skill_name(skill_name)
|
||||
safe_category = _validate_category_name(category) if category else ""
|
||||
quarantine_resolved = quarantine_path.resolve()
|
||||
quarantine_root = QUARANTINE_DIR.resolve()
|
||||
if not quarantine_resolved.is_relative_to(quarantine_root):
|
||||
raise ValueError(f"Unsafe quarantine path: {quarantine_path}")
|
||||
|
||||
if safe_category:
|
||||
install_dir = SKILLS_DIR / safe_category / safe_skill_name
|
||||
else:
|
||||
install_dir = SKILLS_DIR / skill_name
|
||||
install_dir = SKILLS_DIR / safe_skill_name
|
||||
|
||||
if install_dir.exists():
|
||||
shutil.rmtree(install_dir)
|
||||
@@ -2461,7 +2527,7 @@ def install_from_quarantine(
|
||||
# Record in lock file
|
||||
lock = HubLockFile()
|
||||
lock.record_install(
|
||||
name=skill_name,
|
||||
name=safe_skill_name,
|
||||
source=bundle.source,
|
||||
identifier=bundle.identifier,
|
||||
trust_level=bundle.trust_level,
|
||||
@@ -2473,7 +2539,7 @@ def install_from_quarantine(
|
||||
)
|
||||
|
||||
append_audit_log(
|
||||
"INSTALL", skill_name, bundle.source,
|
||||
"INSTALL", safe_skill_name, bundle.source,
|
||||
bundle.trust_level, scan_result.verdict,
|
||||
content_hash(install_dir),
|
||||
)
|
||||
|
||||
@@ -45,6 +45,28 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_debug = DebugSession("vision_tools", env_var="VISION_TOOLS_DEBUG")
|
||||
|
||||
# Configurable HTTP download timeout for _download_image().
|
||||
# Separate from auxiliary.vision.timeout which governs the LLM API call.
|
||||
# Resolution: config.yaml auxiliary.vision.download_timeout → env var → 30s default.
|
||||
def _resolve_download_timeout() -> float:
|
||||
env_val = os.getenv("HERMES_VISION_DOWNLOAD_TIMEOUT", "").strip()
|
||||
if env_val:
|
||||
try:
|
||||
return float(env_val)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
val = cfg.get("auxiliary", {}).get("vision", {}).get("download_timeout")
|
||||
if val is not None:
|
||||
return float(val)
|
||||
except Exception:
|
||||
pass
|
||||
return 30.0
|
||||
|
||||
_VISION_DOWNLOAD_TIMEOUT = _resolve_download_timeout()
|
||||
|
||||
|
||||
def _validate_image_url(url: str) -> bool:
|
||||
"""
|
||||
@@ -146,7 +168,7 @@ async def _download_image(image_url: str, destination: Path, max_retries: int =
|
||||
# Enable follow_redirects to handle image CDNs that redirect (e.g., Imgur, Picsum)
|
||||
# SSRF: event_hooks validates each redirect target against private IP ranges
|
||||
async with httpx.AsyncClient(
|
||||
timeout=30.0,
|
||||
timeout=_VISION_DOWNLOAD_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
event_hooks={"response": [_ssrf_redirect_guard]},
|
||||
) as client:
|
||||
@@ -183,6 +205,10 @@ async def _download_image(image_url: str, destination: Path, max_retries: int =
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if last_error is None:
|
||||
raise RuntimeError(
|
||||
f"_download_image exited retry loop without attempting (max_retries={max_retries})"
|
||||
)
|
||||
raise last_error
|
||||
|
||||
|
||||
|
||||
@@ -91,24 +91,20 @@ def _get_backend() -> str:
|
||||
if configured in ("parallel", "firecrawl", "tavily", "exa"):
|
||||
return configured
|
||||
|
||||
# Fallback for manual / legacy config — use whichever key is present.
|
||||
has_firecrawl = (
|
||||
_has_env("FIRECRAWL_API_KEY")
|
||||
or _has_env("FIRECRAWL_API_URL")
|
||||
or _is_tool_gateway_ready()
|
||||
# Fallback for manual / legacy config — pick the highest-priority
|
||||
# available backend. Firecrawl also counts as available when the managed
|
||||
# tool gateway is configured for Nous subscribers.
|
||||
backend_candidates = (
|
||||
("firecrawl", _has_env("FIRECRAWL_API_KEY") or _has_env("FIRECRAWL_API_URL") or _is_tool_gateway_ready()),
|
||||
("parallel", _has_env("PARALLEL_API_KEY")),
|
||||
("tavily", _has_env("TAVILY_API_KEY")),
|
||||
("exa", _has_env("EXA_API_KEY")),
|
||||
)
|
||||
has_parallel = _has_env("PARALLEL_API_KEY")
|
||||
has_tavily = _has_env("TAVILY_API_KEY")
|
||||
has_exa = _has_env("EXA_API_KEY")
|
||||
if has_exa and not has_firecrawl and not has_parallel and not has_tavily:
|
||||
return "exa"
|
||||
if has_tavily and not has_firecrawl and not has_parallel:
|
||||
return "tavily"
|
||||
if has_parallel and not has_firecrawl:
|
||||
return "parallel"
|
||||
for backend, available in backend_candidates:
|
||||
if available:
|
||||
return backend
|
||||
|
||||
# Default to firecrawl (backward compat, or when both are set)
|
||||
return "firecrawl"
|
||||
return "firecrawl" # default (backward compat)
|
||||
|
||||
|
||||
def _is_backend_available(backend: str) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user