Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
507 changes: 388 additions & 119 deletions agent/auxiliary_client.py

Large diffs are not rendered by default.

29 changes: 19 additions & 10 deletions agent/skill_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@
substitute_template_vars as _substitute_template_vars,
)

from hermes_constants import get_hermes_home

logger = logging.getLogger(__name__)

_skill_commands: Dict[str, Dict[str, Any]] = {}
_skill_commands_platform: Optional[str] = None
_skill_commands_home: Optional[str] = None
# Patterns for sanitizing skill names into clean hyphen-separated slugs.
_SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
_SKILL_MULTI_HYPHEN = re.compile(r"-{2,}")
Expand Down Expand Up @@ -142,13 +145,15 @@ def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tu
return None

try:
from tools.skills_tool import SKILLS_DIR, skill_view
from tools.skills_tool import get_skills_dir, skill_view
from agent.skill_utils import get_external_skills_dirs

skills_dir = get_skills_dir()

identifier_path = Path(raw_identifier).expanduser()
if identifier_path.is_absolute():
normalized = None
trusted_roots = [SKILLS_DIR]
trusted_roots = [skills_dir]
try:
trusted_roots.extend(get_external_skills_dirs())
except Exception:
Expand All @@ -169,7 +174,7 @@ def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tu

if normalized is None:
try:
normalized = str(identifier_path.resolve().relative_to(SKILLS_DIR.resolve()))
normalized = str(identifier_path.resolve().relative_to(skills_dir.resolve()))
except Exception:
normalized = raw_identifier
else:
Expand All @@ -196,7 +201,7 @@ def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tu
skill_dir = Path(abs_skill_dir)
elif skill_path:
try:
skill_dir = SKILLS_DIR / Path(skill_path).parent
skill_dir = get_skills_dir() / Path(skill_path).parent
except Exception:
skill_dir = None

Expand Down Expand Up @@ -251,9 +256,10 @@ def _build_skill_message(
session_id: str | None = None,
) -> str:
"""Format a loaded skill into a user/system message payload."""
from tools.skills_tool import SKILLS_DIR
from tools.skills_tool import get_skills_dir

content = str(loaded_skill.get("content") or "")
skills_dir = get_skills_dir()

# ── Template substitution and inline-shell expansion ──
# Done before anything else so downstream blocks (setup notes,
Expand Down Expand Up @@ -320,7 +326,7 @@ def _build_skill_message(

if supporting and skill_dir:
try:
skill_view_target = str(skill_dir.relative_to(SKILLS_DIR))
skill_view_target = str(skill_dir.relative_to(skills_dir))
except ValueError:
# Skill is from an external dir β€” use the skill name instead
skill_view_target = skill_dir.name
Expand Down Expand Up @@ -351,19 +357,21 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
Returns:
Dict mapping "/skill-name" to {name, description, skill_md_path, skill_dir}.
"""
global _skill_commands, _skill_commands_platform
global _skill_commands, _skill_commands_platform, _skill_commands_home
_skill_commands_platform = _resolve_skill_commands_platform()
_skill_commands_home = str(get_hermes_home())
_skill_commands = {}
try:
from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, skill_matches_environment, _get_disabled_skill_names
from tools.skills_tool import get_skills_dir, _parse_frontmatter, skill_matches_platform, skill_matches_environment, _get_disabled_skill_names
from agent.skill_utils import get_external_skills_dirs, iter_skill_index_files
disabled = _get_disabled_skill_names()
seen_names: set = set()

# Scan local dir first, then external dirs
dirs_to_scan = []
if SKILLS_DIR.exists():
dirs_to_scan.append(SKILLS_DIR)
skills_dir = get_skills_dir()
if skills_dir.exists():
dirs_to_scan.append(skills_dir)
dirs_to_scan.extend(get_external_skills_dirs())

for scan_dir in dirs_to_scan:
Expand Down Expand Up @@ -425,6 +433,7 @@ def get_skill_commands() -> Dict[str, Dict[str, Any]]:
if (
not _skill_commands
or _skill_commands_platform != _resolve_skill_commands_platform()
or _skill_commands_home != str(get_hermes_home())
):
scan_skill_commands()
return _skill_commands
Expand Down
108 changes: 66 additions & 42 deletions cron/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,30 +49,55 @@
# Configuration
# =============================================================================

# Cron is per-profile by design (issue #4707). Each profile owns its own cron
# store under its own HERMES_HOME, and a profile-scoped gateway runs that
# profile's jobs under that same HERMES_HOME β€” so a job authored in profile
# `coder` lives in `~/.hermes/profiles/coder/cron/jobs.json` and executes with
# `coder`'s `.env`, `config.yaml`, and skills. We deliberately anchor on
# `get_hermes_home()` (the active profile home), NOT `get_default_hermes_root()`
# (the shared root). Anchoring at the root would funnel every profile's jobs
# into one shared `jobs.json` and run them under whatever HERMES_HOME the
# ticker process happens to have β€” leaking config/credentials/skills across
# profiles (the security boundary #4707 was filed for). Do NOT change this to
# the default root: that re-breaks per-profile isolation. See also the dynamic
# `_get_hermes_home()` / `_get_lock_paths()` resolution in cron/scheduler.py.
HERMES_DIR = get_hermes_home().resolve()
CRON_DIR = HERMES_DIR / "cron"
JOBS_FILE = CRON_DIR / "jobs.json"
# Heartbeat file the in-process ticker touches on every loop iteration. The
# gateway process and the (separate) ``hermes cron status`` process share it
# so status can tell whether the ticker THREAD is alive, not just whether the
# gateway PROCESS exists β€” a ticker that dies silently inside a live gateway
# would otherwise report healthy (#32612, #32895).
TICKER_HEARTBEAT_FILE = CRON_DIR / "ticker_heartbeat"
# Last tick that completed WITHOUT raising. Distinguishing this from the plain
# heartbeat lets status detect a ticker that is alive but failing every tick.
TICKER_SUCCESS_FILE = CRON_DIR / "ticker_last_success"
# Cron is per-profile by design. Each profile owns its own cron store under its
# active HERMES_HOME, and profile-scoped turns must resolve these paths lazily
# rather than freezing them at import time.
#
# Module-level attributes default to None; they can be overridden by tests/web_server.
HERMES_DIR = None
CRON_DIR = None
JOBS_FILE = None
TICKER_HEARTBEAT_FILE = None
TICKER_SUCCESS_FILE = None
OUTPUT_DIR = None


def _hermes_dir() -> Path:
if HERMES_DIR is not None:
return HERMES_DIR
return get_hermes_home().resolve()


def _cron_dir() -> Path:
if CRON_DIR is not None:
return CRON_DIR
return _hermes_dir() / "cron"


def _jobs_file() -> Path:
if JOBS_FILE is not None:
return JOBS_FILE
return _cron_dir() / "jobs.json"


def _ticker_heartbeat_file() -> Path:
if TICKER_HEARTBEAT_FILE is not None:
return TICKER_HEARTBEAT_FILE
return _cron_dir() / "ticker_heartbeat"


def _ticker_success_file() -> Path:
if TICKER_SUCCESS_FILE is not None:
return TICKER_SUCCESS_FILE
return _cron_dir() / "ticker_last_success"


def _output_dir() -> Path:
if OUTPUT_DIR is not None:
return OUTPUT_DIR
return _cron_dir() / "output"


# Default ticker loop interval (seconds). The single source of truth shared by
# the in-process ticker (cron/scheduler_provider.py) and the staleness
# threshold in `hermes cron status` (hermes_cli/cron.py), so the two never
Expand All @@ -84,13 +109,12 @@
# concurrent mark_job_run / advance_next_run calls can clobber each other.
_jobs_file_lock = threading.RLock()
_jobs_lock_state = threading.local()
OUTPUT_DIR = CRON_DIR / "output"
ONESHOT_GRACE_SECONDS = 120


def _jobs_lock_file() -> Path:
"""Return the advisory lock path for the current cron directory."""
return CRON_DIR / ".jobs.lock"
return _cron_dir() / ".jobs.lock"


@contextlib.contextmanager
Expand Down Expand Up @@ -175,7 +199,7 @@ def _job_output_dir(job_id: str) -> Path:
raise ValueError(f"Invalid cron job id for output path: {job_id!r}")
if Path(text).is_absolute() or Path(text).drive:
raise ValueError(f"Invalid cron job id for output path: {job_id!r}")
return OUTPUT_DIR / text
return _output_dir() / text


def _normalize_skill_list(skill: Optional[str] = None, skills: Optional[Any] = None) -> List[str]:
Expand Down Expand Up @@ -282,10 +306,10 @@ def _secure_file(path: Path):

def ensure_dirs():
"""Ensure cron directories exist with secure permissions."""
CRON_DIR.mkdir(parents=True, exist_ok=True)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
_secure_dir(CRON_DIR)
_secure_dir(OUTPUT_DIR)
_cron_dir().mkdir(parents=True, exist_ok=True)
_output_dir().mkdir(parents=True, exist_ok=True)
_secure_dir(_cron_dir())
_secure_dir(_output_dir())


# =============================================================================
Expand Down Expand Up @@ -574,7 +598,7 @@ def _atomic_write_epoch(path: Path) -> None:
torn/truncated file. Best-effort: failures are swallowed by callers.
"""
ensure_dirs()
fd, tmp_path = tempfile.mkstemp(dir=str(CRON_DIR), suffix=".tmp", prefix=".hb_")
fd, tmp_path = tempfile.mkstemp(dir=str(_cron_dir()), suffix=".tmp", prefix=".hb_")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(str(time.time()))
Expand Down Expand Up @@ -602,12 +626,12 @@ def record_ticker_heartbeat(success: bool = False) -> None:
Best-effort: a write failure must never disrupt the tick loop.
"""
try:
_atomic_write_epoch(TICKER_HEARTBEAT_FILE)
_atomic_write_epoch(_ticker_heartbeat_file())
except Exception:
pass
if success:
try:
_atomic_write_epoch(TICKER_SUCCESS_FILE)
_atomic_write_epoch(_ticker_success_file())
except Exception:
pass

Expand All @@ -626,12 +650,12 @@ def get_ticker_heartbeat_age() -> Optional[float]:
None = heartbeat file missing/unreadable (older build, never ran, or a
torn read). Callers treat None as "cannot determine", not "dead".
"""
return _epoch_file_age(TICKER_HEARTBEAT_FILE)
return _epoch_file_age(_ticker_heartbeat_file())


def get_ticker_success_age() -> Optional[float]:
"""Seconds since the ticker last completed a tick WITHOUT raising, or None."""
return _epoch_file_age(TICKER_SUCCESS_FILE)
return _epoch_file_age(_ticker_success_file())


# =============================================================================
Expand All @@ -641,19 +665,19 @@ def get_ticker_success_age() -> Optional[float]:
def load_jobs() -> List[Dict[str, Any]]:
"""Load all jobs from storage."""
ensure_dirs()
if not JOBS_FILE.exists():
if not _jobs_file().exists():
return []

_strict_retry = False # track whether we used the strict=False fallback

try:
with open(JOBS_FILE, 'r', encoding='utf-8') as f:
with open(_jobs_file(), 'r', encoding='utf-8') as f:
data = json.load(f)
except json.JSONDecodeError:
# Retry with strict=False to handle bare control chars in string values
_strict_retry = True
try:
with open(JOBS_FILE, 'r', encoding='utf-8') as f:
with open(_jobs_file(), 'r', encoding='utf-8') as f:
data = json.loads(f.read(), strict=False)
except Exception as e:
logger.error("Failed to auto-repair jobs.json: %s", e)
Expand Down Expand Up @@ -689,14 +713,14 @@ def load_jobs() -> List[Dict[str, Any]]:
def _save_jobs_unlocked(jobs: List[Dict[str, Any]]):
"""Save all jobs to storage. Caller must hold _jobs_lock()."""
ensure_dirs()
fd, tmp_path = tempfile.mkstemp(dir=str(JOBS_FILE.parent), suffix='.tmp', prefix='.jobs_')
fd, tmp_path = tempfile.mkstemp(dir=str(_jobs_file().parent), suffix='.tmp', prefix='.jobs_')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump({"jobs": jobs, "updated_at": _hermes_now().isoformat()}, f, indent=2)
f.flush()
os.fsync(f.fileno())
atomic_replace(tmp_path, JOBS_FILE)
_secure_file(JOBS_FILE)
atomic_replace(tmp_path, _jobs_file())
_secure_file(_jobs_file())
except BaseException:
try:
os.unlink(tmp_path)
Expand Down
Loading