Files
hermes-agent/tools/cronjob_job_args.py
T

475 lines
20 KiB
Python

"""Cron job argument normalization, validation and result shaping
(extracted from tools/cronjob_tools.py; re-exported there)."""
import logging
from typing import Any, Dict, List, Optional, Union
from cron.jobs import effective_job_state
# Logger parity with the origin module.
logger = logging.getLogger("tools.cronjob_tools")
def _origin_from_env() -> Optional[Dict[str, str]]:
from gateway.session_context import get_session_env
origin_platform = get_session_env("HERMES_SESSION_PLATFORM")
origin_chat_id = get_session_env("HERMES_SESSION_CHAT_ID")
if not (origin_platform and origin_chat_id):
return None
thread_id = get_session_env("HERMES_SESSION_THREAD_ID") or None
# Slack stamps every TOP-LEVEL message's own id as the session thread (a
# per-message session KEY, not a conversation location). Persisting it
# would pin all future deliveries inside an ephemeral thread, so a thread
# id equal to the creating message's id is synthetic and dropped; a real
# in-thread creation (thread == parent's id != this message) keeps it.
if thread_id and origin_platform == "slack":
message_id = get_session_env("HERMES_SESSION_MESSAGE_ID") or None
if message_id and str(thread_id) == str(message_id):
logger.debug(
"Cron origin: dropping synthetic per-message Slack "
"thread_id=%s (== creation message id)", thread_id,
)
thread_id = None
if thread_id:
logger.debug(
"Cron origin captured thread_id=%s for %s:%s",
thread_id, origin_platform, origin_chat_id,
)
return {
"platform": origin_platform,
"chat_id": origin_chat_id,
"chat_name": get_session_env("HERMES_SESSION_CHAT_NAME") or None,
"thread_id": thread_id,
# Lets an opt-in delivery mirror resolve the exact participant's
# session in per-user-isolated group chats (parity with send_message).
"user_id": get_session_env("HERMES_SESSION_USER_ID") or None, # harmless for DMs
# Workspace/server scope (Slack team, Discord guild...). Slack session
# keys embed it, so a continuable cron seed built without it would
# create a row no scoped reply ever resolves to.
"scope_id": get_session_env("HERMES_SESSION_SCOPE_ID") or None,
}
def _local_delivery_notice(job: Dict[str, Any], user_deliver: Optional[str]) -> Optional[str]:
"""Notice when a created job won't deliver anywhere.
CLI/TUI sessions have no capturable origin, so deliver='origin' (or an
omitted deliver) yields a job whose output is saved but never delivered.
Surface that at create time rather than silently dropping the user's
"tell me when it runs" intent. None when the user explicitly asked for
``local`` or the job resolves to a real target.
"""
if (user_deliver or "").strip().lower() == "local":
return None
try:
from cron.scheduler import _resolve_delivery_targets
if _resolve_delivery_targets(job):
return None
except Exception:
# Resolution unavailable — fall back to the origin signal.
if job.get("origin"):
return None
return (
"This is a local-only cron job: its output is saved (view it with "
"cronjob(action='list')) but will NOT be delivered back into this "
"session — CLI/TUI sessions have no live-delivery channel. To be "
"notified when it runs, recreate or update the job with deliver set to "
"a gateway-connected platform, e.g. deliver='telegram' or deliver='all'."
)
def _mode_guidance_notes(job: Dict[str, Any], user_deliver: Optional[str]) -> List[str]:
"""Mode-specific guidance echoed once in the create/update response
(instead of in the schema, which is paid for on every API call)."""
notes: List[str] = []
if job.get("monitor_script") or job.get("monitor_url"):
notes.append(
"Monitor mode: the source runs first each tick and its output is "
"hashed as exact bytes — unchanged output suppresses the agent run "
"(silent no_change tick), changed output injects a MONITOR CHANGE "
"DETECTED diff into the prompt. The first tick always runs as "
"baseline. The source must emit STABLE output (no timestamps, no "
"random ordering) or every tick will look changed."
)
if job.get("no_agent"):
notes.append(
"no_agent mode: stdout is delivered verbatim; EMPTY stdout sends "
"nothing at all (watchdog pattern — script should stay quiet when "
"there is nothing to report). Non-zero exit or timeout sends an "
"error alert. prompt/skills are ignored."
)
_deliver = (user_deliver or "").strip().lower()
if _deliver:
if "all" in _deliver.split(","):
notes.append(
"deliver='all' resolves at fire time and never includes "
"bot-chat targets — channels connected later are picked up "
"automatically."
)
if _deliver.startswith("bot-chat:"):
notes.append(
"Targeting another profile's Bot Chat costs that bot an agent "
"turn per run."
)
# platform:chat_id with no thread segment loses topic targeting.
for target in _deliver.split(","):
parts = target.strip().split(":")
if (
len(parts) == 2
and parts[0] not in ("bot-chat", "sms")
and parts[1]
and not parts[1].startswith("#")
):
notes.append(
f"deliver target '{target.strip()}' has no :thread_id "
"segment — on thread/topic platforms the delivery lands in "
"the main chat, not a topic."
)
break
return notes
def _split_monitor_arg(
monitor: Optional[str],
monitor_script: Optional[str],
monitor_url: Optional[str],
) -> tuple:
"""Resolve the single model-facing ``monitor`` field into the stored
``(monitor_script, monitor_url)`` pair.
Shape decides transport: http(s):// is a URL, anything else a script path
(a legal script path can never start with a URL scheme). Storage keeps the
two fields separate — interface merge, not a storage migration.
Update semantics: None = unchanged, '' = clear; setting one source clears
the other so switching transports never trips mutual exclusion. An
explicit ``monitor`` wins over the legacy alias fields.
"""
if monitor is None:
return monitor_script, monitor_url
value = monitor.strip()
if not value:
return "", ""
if value.lower().startswith(("http://", "https://")):
return "", value
return value, ""
def _repeat_display(job: Dict[str, Any]) -> str:
rep = job.get("repeat") or {}
times, completed = rep.get("times"), rep.get("completed", 0)
if times is None:
return "forever"
if times == 1:
return "once" if completed == 0 else "1/1"
return f"{completed}/{times}" if completed else f"{times} times"
def _clean_str_list(items: Any) -> List[str]:
"""Stripped, non-empty ``str(item)`` values from a str-or-iterable (order kept)."""
if items is None:
return []
if isinstance(items, str):
items = [items]
return [s for s in (str(i).strip() for i in items) if s]
def _canonical_skills(skill: Optional[str] = None, skills: Optional[Any] = None) -> List[str]:
if skills is None:
skills = [skill] if skill else []
elif isinstance(skills, str):
skills = [skills]
# `item or ""`: a None entry must drop out, not stringify to "None".
return list(dict.fromkeys(_clean_str_list(item or "" for item in skills)))
def _normalize_optional_job_value(value: Optional[Any], *, strip_trailing_slash: bool = False) -> Optional[str]:
if value is None:
return None
text = str(value).strip()
if strip_trailing_slash:
text = text.rstrip("/")
return text or None
def _normalize_deliver_param(value: Any) -> Optional[str]:
"""Canonical string form of ``deliver``; None for None/empty.
MCP clients / scripts may pass a list (``["telegram"]``); stored as-is the
scheduler's ``str(deliver).split(",")`` would yield the literal
``"['telegram']"``. Flatten at the API boundary.
"""
if value is None:
return None
if isinstance(value, (list, tuple)):
return ",".join(_clean_str_list(value)) or None
return str(value).strip() or None
def _validate_bot_chat_deliver(deliver: Optional[str]) -> Optional[str]:
"""Validate ``bot-chat[:<profile>]`` deliver elements at create time.
Bot Chat delivery is machine-local: the profile must exist where the
scheduler fires (Desktop multi-gateway rosters may show same-named profiles
from other machines). Fail loudly here rather than as a per-run delivery error.
Returns an error string or None.
"""
if not deliver:
return None
try:
from cron.scheduler import parse_bot_chat_deliver_token
from hermes_cli.profiles import normalize_profile_name, profile_exists
except Exception:
return None # best-effort; resolution re-checks at fire time
for part in str(deliver).split(","):
profile_arg = parse_bot_chat_deliver_token(part.strip())
if not profile_arg:
continue # not a bot-chat token, or bare token (own profile)
try:
canon = normalize_profile_name(profile_arg)
except Exception:
return f"invalid bot-chat profile name '{profile_arg}'"
if not profile_exists(canon):
return (
f"bot-chat delivery profile '{profile_arg}' not found on this "
"gateway's machine. Bot Chat delivery is machine-local — use a "
"profile that exists here (hermes profile list), or omit the "
"name (deliver='bot-chat') for the job's own profile."
)
return None
def _resolve_cron_context_deliver(deliver: Optional[str]) -> Optional[str]:
"""Resolve ``origin`` to a concrete target for creates made FROM a cron run.
The creating session is ephemeral, so by fire time there is no origin to
resolve. Non-cron sessions: returned unchanged. Cron sessions: ``origin``
(or an omitted value) becomes the creating run's ``platform:chat_id[:thread]``
from the HERMES_CRON_AUTO_DELIVER_* contextvars, or ``local`` when the
creating run has no concrete target; other elements pass through verbatim.
Without this the scheduler would fall back to guessing a home channel.
"""
from gateway.session_context import get_session_env
from utils import is_truthy_value
if not is_truthy_value(get_session_env("HERMES_CRON_SESSION", "")):
return deliver
def _creator_target() -> str:
platform = get_session_env("HERMES_CRON_AUTO_DELIVER_PLATFORM", "").strip()
chat_id = get_session_env("HERMES_CRON_AUTO_DELIVER_CHAT_ID", "").strip()
if not platform or not chat_id:
return "local"
thread_id = get_session_env("HERMES_CRON_AUTO_DELIVER_THREAD_ID", "").strip()
return f"{platform}:{chat_id}:{thread_id}" if thread_id else f"{platform}:{chat_id}"
if deliver is None:
return _creator_target()
resolved = [_creator_target() if p.lower() == "origin" else p for p in _clean_str_list(str(deliver).split(","))]
# Order-preserving de-dup: 'origin,local' with a local creator -> 'local'.
return ",".join(dict.fromkeys(resolved)) or None
def _validate_cron_base_url(
provider: Optional[Any], base_url: Optional[Any]
) -> Optional[str]:
"""Reject pairing a named provider's stored credential with an off-host base_url.
A prompt-injected job could name a real provider plus an attacker
base_url; at fire time the provider's stored key would be sent there
(credential exfil). Allowed: no override; bare 'custom' (pure BYOK, key
derived from the base_url itself); an override whose host matches the
named provider's own endpoint. Everything else fails closed.
Returns an error string if blocked, else None.
"""
bu = _normalize_optional_job_value(base_url, strip_trailing_slash=True)
if not bu:
return None
prov = _normalize_optional_job_value(provider)
if not prov:
# No provider inherits the default provider's stored key — same primitive.
return (
"base_url override requires an explicit provider. Set provider to a "
"configured custom provider to use a custom endpoint."
)
try:
from hermes_cli.runtime_provider import (
has_named_custom_provider,
resolve_requested_provider,
_get_named_custom_provider,
)
from hermes_cli.auth import PROVIDER_REGISTRY
from utils import base_url_host_matches, base_url_hostname
except Exception:
return f"Unable to validate base_url override for provider {prov!r}; refused."
if prov.lower() == "custom":
# Pure BYOK: key comes from a pool keyed by THIS base_url or host-gated
# env vars, never an arbitrary stored secret.
return None
if has_named_custom_provider(prov):
# A NAMED custom provider carries a STORED key that the runtime still
# sends to an override base_url — require the configured host.
try:
cp = _get_named_custom_provider(prov)
except Exception:
cp = None
cfg_host = base_url_hostname((cp or {}).get("base_url", "")) if cp else ""
if cfg_host and base_url_host_matches(bu, cfg_host):
return None
return (
f"base_url {bu!r} is not allowed for provider {prov!r}. A named "
f"custom provider's stored credential may only be sent to its own "
f"configured endpoint ({cfg_host or 'unknown'})."
)
try:
resolved = resolve_requested_provider(prov)
except Exception:
resolved = prov
pconfig = PROVIDER_REGISTRY.get(resolved) if isinstance(resolved, str) else None
known_host = base_url_hostname(getattr(pconfig, "inference_base_url", "") if pconfig else "")
if known_host and base_url_host_matches(bu, known_host):
return None
# Fail closed: covers named providers with stored credentials AND
# aliases/unknown names we cannot host-match.
return (
f"base_url {bu!r} is not allowed for provider {prov!r}. A named "
f"provider's stored credential may only be sent to its own endpoint; "
f'use a configured custom provider (provider="custom") for a custom base_url.'
)
def _validate_cron_script_path(script: Optional[str]) -> Optional[str]:
"""Scripts must be relative paths resolving within HERMES_HOME/scripts/
(absolute / ~ / drive-letter paths rejected — prompt-injection guard).
Returns an error string if blocked, else None; empty = clearing, OK."""
if not script or not script.strip():
return None
from hermes_constants import get_hermes_home
raw = script.strip()
if raw.startswith(("/", "~")) or (len(raw) >= 2 and raw[1] == ":"):
return (
f"Script path must be relative to ~/.hermes/scripts/. "
f"Got absolute or home-relative path: {raw!r}. "
f"Place scripts in ~/.hermes/scripts/ and use just the filename."
)
from tools.path_security import validate_within_dir
scripts_dir = get_hermes_home() / "scripts"
scripts_dir.mkdir(parents=True, exist_ok=True)
if validate_within_dir(scripts_dir / raw, scripts_dir):
return f"Script path escapes the scripts directory via traversal: {raw!r}"
return None
def _apply_continuity(
context_from: Optional[Union[str, List[str]]],
continuity: bool,
) -> Optional[List[str]]:
"""continuity=True ensures "self" is in context_from; False removes any
"self" entry. Other entries are preserved untouched."""
refs = _clean_str_list(context_from)
has_self = any(r.lower() == "self" for r in refs)
if continuity and not has_self:
refs.append("self")
elif not continuity and has_self:
refs = [r for r in refs if r.lower() != "self"]
return refs or None
def _validate_context_from_refs(refs: List[Any]) -> Optional[str]:
"""Error string if any non-"self" ref names a job that doesn't exist.
("self" resolves to the job's own id at run time, so it can't be checked
against the store — the job doesn't exist yet at create time.)"""
from cron.jobs import get_job as _get_job
for ref_id in refs:
if isinstance(ref_id, str) and ref_id.strip().lower() == "self":
continue
if not _get_job(ref_id):
return (
f"context_from job '{ref_id}' not found. "
"Use cronjob(action='list') to see available jobs."
)
return None
# Optional fields echoed by _format_job only when truthy on the job record
# (order matters: it is the JSON key order).
_FORMAT_JOB_OPTIONAL_KEYS = (
"script", "reasoning_effort", "monitor_script", "monitor_url",
"monitor_state", "no_agent", "enabled_toolsets", "workdir",
)
def _format_job(job: Dict[str, Any]) -> Dict[str, Any]:
prompt = str(job.get("prompt") or "")
skills = _canonical_skills(job.get("skill"), job.get("skills"))
job_id = str(job.get("id") or "unknown")
name = str(job.get("name") or prompt[:50] or (skills[0] if skills else "") or job_id or "cron job")
result = {
"job_id": job_id,
"name": name,
"skill": skills[0] if skills else None,
"skills": skills,
"prompt_preview": prompt[:100] + "..." if len(prompt) > 100 else prompt,
"model": job.get("model"),
"provider": job.get("provider"),
"base_url": job.get("base_url"),
"schedule": job.get("schedule_display") or "?",
"repeat": _repeat_display(job),
"deliver": job.get("deliver", "local"),
"next_run_at": job.get("next_run_at"),
"last_run_at": job.get("last_run_at"),
"last_status": job.get("last_status"),
"last_delivery_error": job.get("last_delivery_error"),
"last_delivery_unverified": job.get("last_delivery_unverified"),
"last_fire_error": job.get("last_fire_error"),
"enabled": job.get("enabled", True),
# Derive from enabled so half-paused records never render as paused.
"state": effective_job_state(job),
"paused_at": job.get("paused_at"),
"paused_reason": job.get("paused_reason"),
}
for key in _FORMAT_JOB_OPTIONAL_KEYS:
if job.get(key):
result[key] = True if key == "no_agent" else job[key]
stored_refs = job.get("context_from") or []
if isinstance(stored_refs, str):
stored_refs = [stored_refs]
is_self = lambda r: str(r).strip().lower() == "self" or r == job.get("id") # noqa: E731
if any(is_self(r) for r in stored_refs):
result["continuity"] = True
external_refs = [r for r in stored_refs if not is_self(r)]
if external_refs:
result["context_from"] = external_refs
if isinstance(job.get("attach_to_session"), bool):
result["attach_to_session"] = job["attach_to_session"]
return result
def _gateway_liveness_notice(plural: bool = False) -> dict:
"""``gateway_running``/``warning`` payload via the shared CLI helper so the
CLI and this tool agree on what "scheduler active" means. False -> warning
(builtin ticker has no gateway process), None -> probe failed."""
try:
from hermes_cli.cron import _builtin_gateway_liveness
_gw = _builtin_gateway_liveness()
except Exception:
return {"gateway_running": None}
if _gw is False:
subject = "these jobs are saved" if plural else "this job is saved"
return {
"gateway_running": False,
"warning": (
f"The Hermes gateway is not running — {subject} "
"but will NOT fire until the gateway is started "
"(hermes gateway install / hermes gateway start). "
"Tell the user the task is scheduled but not active yet."
),
}
return {"gateway_running": None if _gw is None else True}