feat(cron): allow Python scripts to use an external interpreter
Add an optional per-job `interpreter` field so a cron Python `script` / `monitor_script` can run under a user-managed venv instead of Hermes' own Python, letting scripts import packages the Hermes runtime does not carry (#8714). Nothing is installed, frozen, or restored automatically. - cron/jobs.py: persist + normalize the field (absent => record unchanged; empty string clears it on update). - cron/scheduler_script.py: _resolve_cron_interpreter() validates the path at run time (absolute/~ required, regular file, executable on POSIX); _script_argv runs [interpreter, script] and skips the managed-store bootstrap/PYTHONPATH overlays, which exist for Hermes' own venv. Threaded through _run_job_script, the claim-heartbeat wrapper, the pre-run prompt path and monitor scripts. - hermes_cli: --interpreter on `cron create` / `cron edit`; shown in details and `cron list`. - tools/cronjob_tools.py: programmatic/CLI lane only, like model and reasoning_effort — absent from the model-facing schema. Shell scripts (.sh/.bash) still always run under bash. Revives #8741. Ported onto current main from #70500 (the scheduler moved to cron/scheduler_script.py and the CLI/tool became table-driven since the PR's base). Co-authored-by: MestreY0d4-Uninter <241404605+MestreY0d4-Uninter@users.noreply.github.com>
This commit is contained in:
11
cron/jobs.py
11
cron/jobs.py
@@ -1724,11 +1724,13 @@ _CREATE_FIELD_NORMALIZERS: Dict[str, Callable[[Any], Any]] = {
|
||||
"no_agent": bool,
|
||||
"context_from": _normalize_context_from,
|
||||
"failure_deliver": _normalize_failure_deliver,
|
||||
"interpreter": _normalize_job_optional_text,
|
||||
}
|
||||
_UPDATE_FIELD_NORMALIZERS: Dict[str, Callable[[Any], Any]] = {
|
||||
"workdir": lambda v: None if v in {None, "", False} else _normalize_workdir(v),
|
||||
"monitor_script": _normalize_job_optional_text,
|
||||
"monitor_url": _normalize_job_optional_text,
|
||||
"interpreter": _normalize_job_optional_text,
|
||||
"reasoning_effort": _normalize_reasoning_effort,
|
||||
}
|
||||
|
||||
@@ -1800,6 +1802,7 @@ def create_job(
|
||||
paused: bool = False,
|
||||
paused_reason: Optional[str] = None,
|
||||
pinned: bool = False,
|
||||
interpreter: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new cron job and return the stored record.
|
||||
|
||||
@@ -1808,7 +1811,9 @@ def create_job(
|
||||
delivered verbatim, requires ``script``). context_from: job id(s) whose latest output is
|
||||
injected. workdir: absolute cwd for tools/scripts. monitor_script/monitor_url: cheap monitor
|
||||
source run FIRST each tick; unchanged output suppresses the agent run (mutually exclusive,
|
||||
incompatible with ``no_agent``). reasoning_effort: per-job pin; capability NOT validated."""
|
||||
incompatible with ``no_agent``). reasoning_effort: per-job pin; capability NOT validated.
|
||||
interpreter: absolute/``~`` Python for ``.py`` script/monitor_script, validated at run time
|
||||
(a venv can be rebuilt or moved after creation)."""
|
||||
if not isinstance(paused, bool):
|
||||
raise ValueError("paused must be a boolean.")
|
||||
if paused_reason is not None and not isinstance(paused_reason, str):
|
||||
@@ -1893,7 +1898,7 @@ def create_job(
|
||||
# jobs.
|
||||
for key, value in (
|
||||
("attach_to_session", normalized_attach), ("reasoning_effort", normalized_reasoning_effort),
|
||||
("failure_deliver", f["failure_deliver"]),
|
||||
("failure_deliver", f["failure_deliver"]), ("interpreter", f["interpreter"]),
|
||||
):
|
||||
if value is not None:
|
||||
job[key] = value
|
||||
@@ -2080,6 +2085,8 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]
|
||||
_normalize_job_updates(job, updates)
|
||||
_apply_pin_update(job, updates)
|
||||
updated = _apply_skill_fields({**job, **updates})
|
||||
if updated.get("interpreter") is None:
|
||||
updated.pop("interpreter", None) # cleared: absent key = Hermes' own Python
|
||||
_reject_terminal_activation(job, updated, job_id)
|
||||
# Re-check on the MERGED record; scoped to changed fields so legacy records keep loading.
|
||||
if {"monitor_script", "monitor_url", "no_agent", "script"}.intersection(updates):
|
||||
|
||||
@@ -111,7 +111,8 @@ def _run_monitor_source(job: dict) -> tuple[bool, str]:
|
||||
# Same containment + interpreter rules as the existing `script` field.
|
||||
from cron.scheduler_script import _run_job_script
|
||||
|
||||
return _run_job_script(monitor_script, workdir=_field(job, "workdir") or None)
|
||||
return _run_job_script(monitor_script, workdir=_field(job, "workdir") or None,
|
||||
interpreter=job.get("interpreter"))
|
||||
monitor_url = _field(job, "monitor_url")
|
||||
if monitor_url:
|
||||
return _fetch_monitor_url(monitor_url)
|
||||
|
||||
@@ -273,7 +273,8 @@ def _build_job_prompt(
|
||||
success, script_output = (
|
||||
prerun_script if prerun_script is not None
|
||||
else _script._run_job_script(
|
||||
script_path, workdir=_sched._resolve_job_workdir(job, str(job.get("id") or ""))))
|
||||
script_path, workdir=_sched._resolve_job_workdir(job, str(job.get("id") or "")),
|
||||
interpreter=job.get("interpreter")))
|
||||
if success and not script_output:
|
||||
return None # no output → nothing to report, skip the AI call
|
||||
heading, intro = (
|
||||
|
||||
@@ -14,6 +14,7 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
@@ -365,12 +366,36 @@ def _resolve_script_path(script_path: str) -> tuple[Optional[Path], Optional[str
|
||||
return path, None
|
||||
|
||||
|
||||
def _script_argv(path: Path) -> tuple[Optional[list[str]], dict[str, str], Optional[str]]:
|
||||
def _resolve_cron_interpreter(interpreter: str) -> tuple[Optional[str], Optional[str]]:
|
||||
"""``(python_exe, error)`` for a job's ``interpreter`` field. Checked at run time, not create
|
||||
time: a user venv can be rebuilt or moved while the job lives. Bare names are refused — they
|
||||
silently change meaning with PATH."""
|
||||
raw = interpreter.strip()
|
||||
try:
|
||||
resolved = Path(raw).expanduser()
|
||||
if not resolved.is_absolute():
|
||||
return None, (f"Interpreter must be an absolute or ~-prefixed path (got {raw!r}). "
|
||||
"Bare names like 'python3' are not stable across PATH changes.")
|
||||
mode = resolved.stat().st_mode
|
||||
except FileNotFoundError:
|
||||
return None, f"Interpreter not found: {raw}"
|
||||
except (RuntimeError, OSError) as exc: # unknown ~user, broken symlink, unreadable parent
|
||||
return None, f"Unable to resolve interpreter path {raw!r}: {exc}"
|
||||
if not stat.S_ISREG(mode):
|
||||
return None, f"Interpreter path is not a file: {resolved}"
|
||||
if sys.platform != "win32" and not mode & 0o111:
|
||||
return None, f"Interpreter is not executable: {resolved}"
|
||||
return str(resolved), None
|
||||
|
||||
|
||||
def _script_argv(
|
||||
path: Path, interpreter: Optional[str] = None,
|
||||
) -> tuple[Optional[list[str]], dict[str, str], Optional[str]]:
|
||||
"""``(argv, env_overlay, error)`` for a validated script. Interpreter by extension — the
|
||||
shebang is deliberately NOT honoured (small, auditable surface): ``.sh``/``.bash`` → bash,
|
||||
else a Python chosen by ``_posix_cron_script_argv`` / ``_windows_cron_python_invocation``.
|
||||
Interpreter selection reads PM's install records and may raise; callers run this inside
|
||||
their ``try``."""
|
||||
else the job's ``interpreter`` when set, else a Python chosen by ``_posix_cron_script_argv``
|
||||
/ ``_windows_cron_python_invocation``. Interpreter selection reads PM's install records and
|
||||
may raise; callers run this inside their ``try``."""
|
||||
if path.suffix.lower() in {".sh", ".bash"}:
|
||||
# which() finds Git Bash on Windows; None there → clear error instead of a "[WinError 2]".
|
||||
_bash = shutil.which("bash") or ("/bin/bash" if os.path.isfile("/bin/bash") else None)
|
||||
@@ -381,6 +406,11 @@ def _script_argv(path: Path) -> tuple[Optional[list[str]], dict[str, str], Optio
|
||||
"or rewrite the script as Python (.py)."
|
||||
)
|
||||
return [_bash, str(path)], {}, None
|
||||
if isinstance(interpreter, str) and interpreter.strip():
|
||||
# A user venv gets none of the managed-store overlays: the repo bootstrap / PYTHONPATH
|
||||
# exist to run Hermes' own dependency venv and would shadow the user's packages.
|
||||
python_exe, err = _resolve_cron_interpreter(interpreter)
|
||||
return ([python_exe, str(path)] if python_exe else None), {}, err
|
||||
if sys.platform != "win32":
|
||||
argv, env_overlay = _posix_cron_script_argv(path)
|
||||
return argv, env_overlay, None
|
||||
@@ -392,7 +422,7 @@ def _script_argv(path: Path) -> tuple[Optional[list[str]], dict[str, str], Optio
|
||||
|
||||
def _run_job_script(
|
||||
script_path: str, workdir: Optional[str] = None,
|
||||
cancel_event: Optional[_CancelEventLike] = None,
|
||||
cancel_event: Optional[_CancelEventLike] = None, interpreter: Optional[str] = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""Execute a cron job's script and return ``(success, output)``; on failure *output* is the
|
||||
error message for the LLM to report. Env goes through ``build_subprocess_env`` (SECURITY.md
|
||||
@@ -402,14 +432,15 @@ def _run_job_script(
|
||||
Args: script_path: Path to the script. Relative paths are resolved against HERMES_HOME/scripts/.
|
||||
Absolute and ~-prefixed paths are also validated to ensure they stay within the scripts dir. workdir:
|
||||
Optional absolute path to use as the script's cwd. When set, the subprocess runs in this directory
|
||||
instead of the scripts-dir parent. See #69396.
|
||||
instead of the scripts-dir parent. See #69396. interpreter: the job's optional Python for
|
||||
``.py`` scripts (#8714).
|
||||
"""
|
||||
path, err = _resolve_script_path(script_path)
|
||||
if path is None:
|
||||
return False, err
|
||||
script_timeout = _get_script_timeout()
|
||||
try:
|
||||
argv, env_overlay, err = _script_argv(path)
|
||||
argv, env_overlay, err = _script_argv(path, interpreter)
|
||||
if argv is None:
|
||||
return False, err
|
||||
from tools.environments.local import build_subprocess_env
|
||||
@@ -517,11 +548,15 @@ def _run_job_script_with_claim_heartbeat(
|
||||
the stale-claim TTL; without a heartbeat another scheduler would re-dispatch the one-shot.
|
||||
Recurring/unclaimed runs have no durable claim → no thread. The owner is captured from the
|
||||
dispatched job, never re-read, so a stale runner cannot extend a replacement owner's claim."""
|
||||
def run() -> tuple[bool, str]:
|
||||
return _run_job_script(script_path, workdir=workdir, cancel_event=cancel_event,
|
||||
interpreter=job.get("interpreter"))
|
||||
|
||||
schedule = job.get("schedule")
|
||||
claim = job.get("run_claim")
|
||||
owner = str(claim.get("by") or "") if isinstance(claim, dict) else ""
|
||||
if not (isinstance(schedule, dict) and schedule.get("kind") == "once" and owner):
|
||||
return _run_job_script(script_path, workdir=workdir, cancel_event=cancel_event)
|
||||
return run()
|
||||
|
||||
job_id = str(job.get("id") or "")
|
||||
stop = threading.Event()
|
||||
@@ -539,10 +574,10 @@ def _run_job_script_with_claim_heartbeat(
|
||||
"Job '%s': could not start script run_claim heartbeat", job_id, exc_info=True),
|
||||
)
|
||||
if heartbeat_thread is None:
|
||||
return _run_job_script(script_path, workdir=workdir, cancel_event=cancel_event)
|
||||
return run()
|
||||
|
||||
try:
|
||||
return _run_job_script(script_path, workdir=workdir, cancel_event=cancel_event)
|
||||
return run()
|
||||
finally:
|
||||
stop.set()
|
||||
# Bounded join: the heartbeat may be blocked on another process's jobs-file lock.
|
||||
|
||||
@@ -235,6 +235,7 @@ def _job_rows(job: Dict[str, Any]) -> List[tuple[str, str]]:
|
||||
("Mode", color("no-agent", Colors.DIM) + " (script stdout delivered directly)"
|
||||
if job.get("no_agent") else ""),
|
||||
("Workdir", job.get("workdir")),
|
||||
("Python", job.get("interpreter")),
|
||||
("Last run", f"{job.get('last_run_at', '?')} {_last_run_display(job)}"
|
||||
if job.get("last_status") else ""),
|
||||
("Dispatch", _dispatch_display(job.get("last_dispatch"))),
|
||||
@@ -671,7 +672,8 @@ _JOB_ARG_FIELDS = (("name", "name"), ("deliver", "deliver"), ("failure_deliver",
|
||||
("repeat", "repeat"), ("script", "script"), ("workdir", "workdir"),
|
||||
("model", "model"), ("provider", "model_provider"), ("pinned", "pinned"),
|
||||
("monitor_script", "monitor_script"), ("monitor_url", "monitor_url"),
|
||||
("continuity", "continuity"), ("reasoning_effort", "reasoning_effort"))
|
||||
("continuity", "continuity"), ("reasoning_effort", "reasoning_effort"),
|
||||
("interpreter", "interpreter"))
|
||||
|
||||
|
||||
def _job_api_kwargs(args) -> Dict[str, Any]:
|
||||
@@ -685,7 +687,8 @@ _JOB_DETAIL_LINES = (
|
||||
("monitor_url", " Monitor: {} (agent runs only on output change)"),
|
||||
("no_agent", " Mode: no-agent (script stdout delivered directly)"),
|
||||
("continuity", " Continuity: on (each run sees the previous run's output)"),
|
||||
("workdir", " Workdir: {}"))
|
||||
("workdir", " Workdir: {}"),
|
||||
("interpreter", " Python: {}"))
|
||||
|
||||
|
||||
def _print_job_details(job_data: Dict[str, Any]) -> None:
|
||||
|
||||
@@ -77,6 +77,10 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None:
|
||||
"medium, high, xhigh, max, or ultra. Overrides agent.reasoning_effort "
|
||||
"and agent.reasoning_overrides for this job; unsupported levels are "
|
||||
"clamped by the provider at request time. Omit to follow config.")
|
||||
cron_create.add_argument("--interpreter",
|
||||
help="Absolute or ~ path to a Python in your own venv (e.g. ~/venvs/report/bin/python) "
|
||||
"for a .py --script / --monitor-script, so it can import packages Hermes does not "
|
||||
"ship. .sh/.bash still run under bash. Omit to use Hermes' Python.")
|
||||
cron_create.add_argument(
|
||||
"--continuity", dest="continuity", action="store_const", const=True, default=None,
|
||||
help="Each run wakes up with the job's own previous output injected "
|
||||
@@ -144,6 +148,9 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None:
|
||||
help="Pin this job's reasoning (thinking) effort: none, minimal, low, "
|
||||
"medium, high, xhigh, max, or ultra. Pass empty string to clear "
|
||||
"the pin and follow config resolution.")
|
||||
cron_edit.add_argument("--interpreter",
|
||||
help="Absolute or ~ path to a Python for a .py script / monitor script. "
|
||||
"Pass empty string to clear (back to Hermes' Python).")
|
||||
|
||||
# lifecycle actions
|
||||
cron_pause = cron_subparsers.add_parser("pause", help="Pause a scheduled job")
|
||||
|
||||
@@ -270,3 +270,53 @@ def test_agent_job_provider_classification_unchanged(error, expected):
|
||||
|
||||
job = {"name": "daily-digest", "no_agent": False}
|
||||
assert expected in _summarize_cron_failure_for_delivery(job, error)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# no_agent jobs honoring a configured Python interpreter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_run_job_no_agent_uses_configured_interpreter(hermes_env):
|
||||
"""A no-agent job's script must run through the configured interpreter.
|
||||
|
||||
Proves the override survives the no_agent branch of ``run_job`` →
|
||||
``_run_job_script_with_claim_heartbeat`` → ``_run_job_script``.
|
||||
"""
|
||||
import os
|
||||
import stat as _stat
|
||||
import sys
|
||||
|
||||
from cron.jobs import create_job
|
||||
from cron.scheduler import run_job
|
||||
|
||||
# A wrapper that re-execs the real interpreter with an env marker.
|
||||
wrapper = hermes_env / "scripts" / "python-wrapper"
|
||||
wrapper.write_text(
|
||||
f"#!{sys.executable}\n"
|
||||
"import os, sys\n"
|
||||
"env = os.environ.copy()\n"
|
||||
'env["CRON_WRAPPER_USED"] = "1"\n'
|
||||
"os.execve(sys.executable, [sys.executable, *sys.argv[1:]], env)\n"
|
||||
)
|
||||
wrapper.chmod(wrapper.stat().st_mode | _stat.S_IXUSR)
|
||||
|
||||
script_path = hermes_env / "scripts" / "marker.py"
|
||||
script_path.write_text(
|
||||
'import os\nprint(os.environ.get("CRON_WRAPPER_USED", "0"))\n'
|
||||
)
|
||||
|
||||
job = create_job(
|
||||
prompt=None,
|
||||
schedule="every 5m",
|
||||
script="marker.py",
|
||||
no_agent=True,
|
||||
deliver="local",
|
||||
interpreter=str(wrapper),
|
||||
)
|
||||
|
||||
success, doc, final_response, error = run_job(job)
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert final_response == "1"
|
||||
assert "1" in doc
|
||||
|
||||
@@ -351,7 +351,7 @@ def test_build_job_prompt_inline_script_receives_configured_workdir(monkeypatch,
|
||||
workdir.mkdir()
|
||||
observed: dict = {}
|
||||
|
||||
def run_script(script_path, workdir=None, cancel_event=None):
|
||||
def run_script(script_path, workdir=None, cancel_event=None, interpreter=None):
|
||||
observed["script_workdir"] = workdir
|
||||
return True, "collected data"
|
||||
|
||||
|
||||
@@ -282,6 +282,45 @@ def test_bidi_monitor_output_is_sanitized_before_agent(hermes_env, monkeypatch):
|
||||
assert "Alice Work" in observed["prompts"][0]
|
||||
|
||||
|
||||
def test_monitor_script_uses_configured_interpreter(hermes_env, monkeypatch):
|
||||
"""A monitor script uses the same job-level interpreter as `script`."""
|
||||
import stat
|
||||
|
||||
from cron.jobs import create_job
|
||||
from cron.scheduler import run_job
|
||||
|
||||
wrapper = hermes_env / "scripts" / "python-wrapper"
|
||||
wrapper.write_text(
|
||||
f"#!{sys.executable}\n"
|
||||
"import os, sys\n"
|
||||
"env = os.environ.copy()\n"
|
||||
'env["CRON_MONITOR_WRAPPER_USED"] = "1"\n'
|
||||
"os.execve(sys.executable, [sys.executable, *sys.argv[1:]], env)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
wrapper.chmod(wrapper.stat().st_mode | stat.S_IXUSR)
|
||||
_write_script(
|
||||
hermes_env,
|
||||
"mon.py",
|
||||
'import os\nprint(os.environ.get("CRON_MONITOR_WRAPPER_USED", "0"))\n',
|
||||
)
|
||||
job = create_job(
|
||||
prompt="React to the change",
|
||||
schedule="every 5m",
|
||||
monitor_script="mon.py",
|
||||
interpreter=str(wrapper),
|
||||
deliver="local",
|
||||
)
|
||||
observed: dict = {}
|
||||
_install_agent_stubs(monkeypatch, observed)
|
||||
|
||||
success, _, _, error = run_job(job)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert "1" in observed["prompts"][0]
|
||||
|
||||
|
||||
def test_unchanged_output_suppresses_agent_run(hermes_env, monkeypatch):
|
||||
from cron.jobs import get_job
|
||||
from cron.scheduler import SILENT_MARKER, run_job
|
||||
|
||||
@@ -419,7 +419,7 @@ def _validate_context_from_refs(refs: List[Any]) -> Optional[str]:
|
||||
# Optional fields echoed by _format_job only when truthy (order = JSON key order).
|
||||
_FORMAT_JOB_OPTIONAL_KEYS = (
|
||||
"script", "reasoning_effort", "monitor_script", "monitor_url",
|
||||
"monitor_state", "no_agent", "enabled_toolsets", "workdir")
|
||||
"monitor_state", "no_agent", "enabled_toolsets", "workdir", "interpreter")
|
||||
|
||||
|
||||
def _format_job(job: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
@@ -616,7 +616,7 @@ def _action_create(a: Dict[str, Any]) -> str:
|
||||
monitor_script=_normalize_optional_job_value(a["monitor_script"]),
|
||||
monitor_url=_normalize_optional_job_value(a["monitor_url"]),
|
||||
# CLI-only lane: absent from CRONJOB_SCHEMA and the model dispatch (models don't pick models).
|
||||
reasoning_effort=a["reasoning_effort"],
|
||||
reasoning_effort=a["reasoning_effort"], interpreter=a["interpreter"],
|
||||
pinned=bool(a["pinned"]),
|
||||
failure_deliver=_resolve_cron_context_deliver(_normalize_deliver_param(a["failure_deliver"])),
|
||||
**({"paused": a["paused"], "paused_reason": a["paused_reason"]}
|
||||
@@ -772,6 +772,9 @@ def _update_core_fields(job: Dict[str, Any], a: Dict[str, Any], updates: Dict[st
|
||||
if a["reasoning_effort"] is not None:
|
||||
# CLI-only lane; update_job validates, empty string clears the pin.
|
||||
updates["reasoning_effort"] = a["reasoning_effort"]
|
||||
if a["interpreter"] is not None:
|
||||
# CLI-only lane like reasoning_effort; update_job trims, empty string clears.
|
||||
updates["interpreter"] = a["interpreter"]
|
||||
# Re-validate the EFFECTIVE provider/base_url on EVERY update: a job persisted before
|
||||
# this guard may hold an unsafe pair, and editing an unrelated field must not leave it
|
||||
# schedulable. Merging this update over the stored job lets an operator remediate.
|
||||
@@ -926,7 +929,8 @@ def cronjob(
|
||||
session_id: Optional[str] = None,
|
||||
paused: bool = False,
|
||||
paused_reason: Optional[str] = None,
|
||||
pinned: Optional[bool] = None) -> str:
|
||||
pinned: Optional[bool] = None,
|
||||
interpreter: Optional[str] = None) -> str:
|
||||
"""Unified cron job management tool."""
|
||||
a = dict(locals())
|
||||
del a["task_id"] # unused but kept for handler signature compatibility
|
||||
|
||||
@@ -28,7 +28,7 @@ Hermes calls this **no-agent mode**. It's the cron system minus the LLM.
|
||||
|
||||
- **No LLM call.** Zero tokens, zero agent loop, zero model spend.
|
||||
- **Script is the job.** The script decides whether to alert. Emit output → message gets sent. Emit nothing → silent tick.
|
||||
- **Bash or Python.** `.sh` / `.bash` files run under `bash` from `PATH` when available, otherwise `/bin/bash`; any other extension runs under the current Python interpreter. Paths must resolve inside `~/.hermes/scripts/` (relative, absolute, or `~` forms are OK if they stay in that directory). Cron scripts do **not** inherit provider credentials from the Hermes process environment.
|
||||
- **Bash or Python.** `.sh` / `.bash` files run under `bash` from `PATH` when available, otherwise `/bin/bash`; any other extension runs under the current Python interpreter. A Python script can also pin a **user-managed venv** via `--interpreter` (see [Using your own Python environment](#using-your-own-python-environment)). Paths must resolve inside `~/.hermes/scripts/` (relative, absolute, or `~` forms are OK if they stay in that directory). Cron scripts do **not** inherit provider credentials from the Hermes process environment.
|
||||
- **Same scheduler.** Lives in `cronjob` alongside LLM jobs — pausing, resuming, listing, logs, and delivery targeting all work the same way.
|
||||
|
||||
## When to Use It
|
||||
@@ -151,17 +151,47 @@ The "silent when empty" behavior is the key to the classic watchdog pattern: the
|
||||
|
||||
## Script Rules
|
||||
|
||||
Scripts must live in `~/.hermes/scripts/`. This is enforced at both job-creation time and run time — absolute paths, `~/` expansion, and path-traversal patterns (`../`) are rejected. The same directory is shared with the pre-check script gate used by LLM jobs.
|
||||
Scripts must resolve inside `~/.hermes/scripts/`. This is enforced at run time — relative names, absolute paths, and `~`-prefixed paths are accepted when the resolved target stays in that directory; path traversal and symlink escapes are rejected. The same directory is shared with the pre-check script gate used by LLM jobs.
|
||||
|
||||
Interpreter choice is by file extension:
|
||||
|
||||
| Extension | Interpreter |
|
||||
|-----------|-------------|
|
||||
| `.sh`, `.bash` | `bash` from `PATH` (fallback `/bin/bash`) |
|
||||
| anything else | `sys.executable` (current Python) |
|
||||
| anything else | `sys.executable` (current Python), or a [configured interpreter](#using-your-own-python-environment) |
|
||||
|
||||
We intentionally do NOT honour `#!/...` shebangs — keeping the interpreter set explicit and small reduces the surface the scheduler trusts.
|
||||
|
||||
### Using your own Python environment
|
||||
|
||||
By default a Python cron script runs under Hermes' own Python environment, which only carries Hermes' own dependencies — so a script that imports `openpyxl`, a database driver, or any other package you installed would fail with `ModuleNotFoundError`.
|
||||
|
||||
You can point the job at a **user-managed venv** instead with `--interpreter`:
|
||||
|
||||
```bash
|
||||
# 1. Create a venv you own — it survives Hermes reinstalls/rebuilds.
|
||||
uv venv ~/venvs/hermes-reporting --python 3.11
|
||||
uv pip install --python ~/venvs/hermes-reporting/bin/python openpyxl
|
||||
|
||||
# 2. Schedule the job with that interpreter.
|
||||
hermes cron create "0 8 * * *" \
|
||||
--no-agent \
|
||||
--script daily-report.py \
|
||||
--interpreter ~/venvs/hermes-reporting/bin/python \
|
||||
--deliver telegram
|
||||
```
|
||||
|
||||
Like `--model`, this is a user-owned setting: set it with `hermes cron create/edit`; the agent's `cronjob` tool can't.
|
||||
|
||||
Rules:
|
||||
|
||||
- The venv is **user-managed**. Hermes does not create, freeze, restore, or install packages into it — it just invokes the path you give.
|
||||
- The path must be **absolute or `~`-prefixed** (e.g. `~/venvs/reporting/bin/python3`). Bare names like `python3` are rejected, because they are not stable across `PATH` changes.
|
||||
- Applies **only to Python scripts**. `.sh` / `.bash` always run under bash regardless.
|
||||
- The job-level setting applies to both `script` and `monitor_script` when they are Python files.
|
||||
- It is validated **at run time**, not at creation — a cron job is long-lived, and the venv may be rebuilt or moved between when you create the job and when it fires. A missing or non-executable interpreter produces a clear script failure that is delivered like any other error.
|
||||
- To clear it later: `hermes cron edit <job_id> --interpreter ""`.
|
||||
|
||||
## Schedule Syntax
|
||||
|
||||
Same as all other cron jobs:
|
||||
|
||||
@@ -904,7 +904,7 @@ Semantics:
|
||||
- `{"wakeAgent": false}` on the last line → silent tick (same gate LLM jobs use).
|
||||
- No tokens, no model, no provider fallback — the job never touches the inference layer.
|
||||
|
||||
`.sh` / `.bash` files run under `bash` from `PATH` when available, otherwise `/bin/bash` (important on Windows Git Bash). Anything else runs under the current Python interpreter (`sys.executable`). Scripts must resolve inside `$HERMES_HOME/scripts/` — relative names, absolute paths, and `~`-prefixed paths are accepted when the resolved target stays in that directory; paths that escape it are rejected. Subprocess env is sanitized (`_sanitize_subprocess_env`): provider API credentials and other Hermes-managed secrets are **not** inherited by cron scripts.
|
||||
`.sh` / `.bash` files run under `bash` from `PATH` when available, otherwise `/bin/bash` (important on Windows Git Bash). Anything else runs under the current Python interpreter (`sys.executable`). Scripts must resolve inside `$HERMES_HOME/scripts/` — relative names, absolute paths, and `~`-prefixed paths are accepted when the resolved target stays in that directory; paths that escape it are rejected. A Python `script` or `monitor_script` can also pin a user-managed venv (for packages the Hermes runtime doesn't carry) by passing `--interpreter ~/venvs/.../bin/python` at create/edit time — see [Using your own Python environment](../../guides/cron-script-only.md#using-your-own-python-environment). The Hermes-managed venv stays Hermes-owned; nothing is installed or restored automatically. The subprocess environment is sanitized, so provider API credentials and other Hermes-managed secrets are **not** inherited by cron scripts.
|
||||
|
||||
#### Giving a script a credential
|
||||
|
||||
|
||||
@@ -158,10 +158,40 @@ hermes cron run <job_id> # 触发一次以测试
|
||||
| 扩展名 | 解释器 |
|
||||
|-----------|-------------|
|
||||
| `.sh`、`.bash` | `/bin/bash` |
|
||||
| 其他任意扩展名 | `sys.executable`(当前 Python) |
|
||||
| 其他任意扩展名 | `sys.executable`(当前 Python),或一个[自定义解释器](#使用你自己的-python-环境) |
|
||||
|
||||
我们有意**不**遵循 `#!/...` shebang——保持解释器集合明确且精简,可减少调度器信任的攻击面。
|
||||
|
||||
### 使用你自己的 Python 环境
|
||||
|
||||
默认情况下,Python cron 脚本运行在 Hermes 自身的 Python 环境中,该环境只包含 Hermes 自身的依赖——因此如果你的脚本 `import openpyxl`、数据库驱动或任何其他你安装的包,会因 `ModuleNotFoundError` 而失败。
|
||||
|
||||
你可以通过 `--interpreter` 让任务指向一个**用户自管的 venv**:
|
||||
|
||||
```bash
|
||||
# 1. 创建一个属于你自己的 venv——它在 Hermes 重装/重建后依然保留。
|
||||
uv venv ~/venvs/hermes-reporting --python 3.11
|
||||
uv pip install --python ~/venvs/hermes-reporting/bin/python openpyxl
|
||||
|
||||
# 2. 用该解释器调度任务。
|
||||
hermes cron create "0 8 * * *" \
|
||||
--no-agent \
|
||||
--script daily-report.py \
|
||||
--interpreter ~/venvs/hermes-reporting/bin/python \
|
||||
--deliver telegram
|
||||
```
|
||||
|
||||
与 `--model` 一样,这是用户自有的设置:通过 `hermes cron create/edit` 设置;agent 的 `cronjob` 工具无法设置它。
|
||||
|
||||
规则:
|
||||
|
||||
- 该 venv 是**用户自管**的。Hermes 不会创建、冻结、恢复或向其中安装包——它只是调用你指定的路径。
|
||||
- 路径必须是**绝对路径或以 `~` 开头**(例如 `~/venvs/reporting/bin/python3`)。像 `python3` 这样的裸名会被拒绝,因为它们在 `PATH` 变化时并不稳定。
|
||||
- 仅对 **Python 脚本**生效。`.sh` / `.bash` 始终用 bash 运行,不受影响。
|
||||
- 这是任务级设置;当 `script` 或 `monitor_script` 是 Python 文件时都会使用它。
|
||||
- 解释器在**运行时**校验,而非创建时——cron 任务是长期存在的,venv 可能在你创建任务和它真正触发之间被重建或移动。缺失或不可执行的解释器会产生一个清晰的脚本失败,像其他错误一样被投递。
|
||||
- 之后想清除它:`hermes cron edit <job_id> --interpreter ""`。
|
||||
|
||||
## 计划语法
|
||||
|
||||
与所有其他 cron 任务相同:
|
||||
|
||||
@@ -443,7 +443,7 @@ hermes cron create "every 5m" \
|
||||
- 最后一行输出 `{"wakeAgent": false}` → 静默 tick(与 LLM 任务使用相同的门控)。
|
||||
- 无 token、无模型、无 provider 回退——任务永远不会触及推理层。
|
||||
|
||||
`.sh`/`.bash` 文件在 `/bin/bash` 下运行;其他文件在当前 Python 解释器(`sys.executable`)下运行。脚本必须位于 `~/.hermes/scripts/`(与预运行脚本门控相同的沙箱规则)。
|
||||
`.sh`/`.bash` 文件优先使用 `PATH` 中的 `bash`,不可用时回退到 `/bin/bash`(这对 Windows Git Bash 尤其重要);其他文件默认使用当前 Python 解释器(`sys.executable`)。脚本路径必须解析到 `$HERMES_HOME/scripts/` 内部——只要解析后的目标仍位于该目录,相对路径、绝对路径和以 `~` 开头的路径都可以;逃逸该目录的路径会被拒绝。Python `script` 或 `monitor_script` 也可以通过在创建/编辑时传入 `--interpreter ~/venvs/.../bin/python` 来指定一个用户自管的 venv(用于 Hermes 运行时不携带的包)——参见[使用你自己的 Python 环境](../../guides/cron-script-only.md#使用你自己的-python-环境)。Hermes 管理的 venv 仍归 Hermes 所有;系统不会自动安装或恢复任何包。子进程环境会被净化,因此 cron 脚本**不会**继承 provider API 凭据和其他由 Hermes 管理的秘密。
|
||||
|
||||
### Agent 为你设置这些
|
||||
|
||||
@@ -760,4 +760,4 @@ Cron 任务在完全全新的 agent 会话中运行。Prompt 必须包含 agent
|
||||
|
||||
## 安全性
|
||||
|
||||
定时任务的 prompt 在创建和更新时会扫描 prompt 注入和凭证外泄模式。包含不可见 Unicode 技巧、SSH 后门尝试或明显的密钥外泄载荷的 prompt 会被拦截。
|
||||
定时任务的 prompt 在创建和更新时会扫描 prompt 注入和凭证外泄模式。包含不可见 Unicode 技巧、SSH 后门尝试或明显的密钥外泄载荷的 prompt 会被拦截。
|
||||
|
||||
Reference in New Issue
Block a user