From 6f7cc7e74ce66fbe286968badafec4229101ddee Mon Sep 17 00:00:00 2001 From: M1racleShih Date: Sun, 27 Sep 2026 20:50:10 +0530 Subject: [PATCH] feat(cron): allow Python scripts to use an external interpreter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- cron/jobs.py | 11 +++- cron/monitor.py | 3 +- cron/scheduler_prompt.py | 3 +- cron/scheduler_script.py | 55 +++++++++++++++---- hermes_cli/cron.py | 7 ++- hermes_cli/subcommands/cron.py | 7 +++ tests/cron/test_cron_no_agent.py | 50 +++++++++++++++++ tests/cron/test_cron_workdir.py | 2 +- tests/cron/test_monitor_kind.py | 39 +++++++++++++ tools/cronjob_job_args.py | 2 +- tools/cronjob_tools.py | 8 ++- website/docs/guides/cron-script-only.md | 36 +++++++++++- website/docs/user-guide/features/cron.md | 2 +- .../current/guides/cron-script-only.md | 32 ++++++++++- .../current/user-guide/features/cron.md | 4 +- 15 files changed, 234 insertions(+), 27 deletions(-) diff --git a/cron/jobs.py b/cron/jobs.py index 3d52b34d3b..06e95f7f8a 100644 --- a/cron/jobs.py +++ b/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): diff --git a/cron/monitor.py b/cron/monitor.py index 46a74a8cbb..f169bb1319 100644 --- a/cron/monitor.py +++ b/cron/monitor.py @@ -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) diff --git a/cron/scheduler_prompt.py b/cron/scheduler_prompt.py index 269b21bc06..731c2ffe53 100644 --- a/cron/scheduler_prompt.py +++ b/cron/scheduler_prompt.py @@ -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 = ( diff --git a/cron/scheduler_script.py b/cron/scheduler_script.py index c04f446df1..e5faf9c64d 100644 --- a/cron/scheduler_script.py +++ b/cron/scheduler_script.py @@ -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. diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py index 5431dda516..f452f9b03f 100644 --- a/hermes_cli/cron.py +++ b/hermes_cli/cron.py @@ -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: diff --git a/hermes_cli/subcommands/cron.py b/hermes_cli/subcommands/cron.py index 365ac5368b..ae6115293a 100644 --- a/hermes_cli/subcommands/cron.py +++ b/hermes_cli/subcommands/cron.py @@ -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") diff --git a/tests/cron/test_cron_no_agent.py b/tests/cron/test_cron_no_agent.py index a3d98a984c..1d30f9b86b 100644 --- a/tests/cron/test_cron_no_agent.py +++ b/tests/cron/test_cron_no_agent.py @@ -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 diff --git a/tests/cron/test_cron_workdir.py b/tests/cron/test_cron_workdir.py index 21d238547f..7d179fabc2 100644 --- a/tests/cron/test_cron_workdir.py +++ b/tests/cron/test_cron_workdir.py @@ -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" diff --git a/tests/cron/test_monitor_kind.py b/tests/cron/test_monitor_kind.py index fe75091257..b2b2047b48 100644 --- a/tests/cron/test_monitor_kind.py +++ b/tests/cron/test_monitor_kind.py @@ -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 diff --git a/tools/cronjob_job_args.py b/tools/cronjob_job_args.py index 36a1cde4fb..29ad04cabe 100644 --- a/tools/cronjob_job_args.py +++ b/tools/cronjob_job_args.py @@ -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]: diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 749ffc67a4..b2e4efe841 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -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 diff --git a/website/docs/guides/cron-script-only.md b/website/docs/guides/cron-script-only.md index c5d2250ab4..b950e90ff2 100644 --- a/website/docs/guides/cron-script-only.md +++ b/website/docs/guides/cron-script-only.md @@ -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 --interpreter ""`. + ## Schedule Syntax Same as all other cron jobs: diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index f64cd64782..b79c7deb2b 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -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 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/cron-script-only.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/cron-script-only.md index 1b1202ad3a..7f541f9c20 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/cron-script-only.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/cron-script-only.md @@ -158,10 +158,40 @@ hermes cron run # 触发一次以测试 | 扩展名 | 解释器 | |-----------|-------------| | `.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 --interpreter ""`。 + ## 计划语法 与所有其他 cron 任务相同: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/cron.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/cron.md index 0234ff7f5d..305e604850 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/cron.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/cron.md @@ -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 会被拦截。 \ No newline at end of file +定时任务的 prompt 在创建和更新时会扫描 prompt 注入和凭证外泄模式。包含不可见 Unicode 技巧、SSH 后门尝试或明显的密钥外泄载荷的 prompt 会被拦截。