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:
M1racleShih
2026-09-27 20:50:10 +05:30
committed by kshitij
parent 04ea129bbf
commit 6f7cc7e74c
15 changed files with 234 additions and 27 deletions

View File

@@ -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):

View File

@@ -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)

View File

@@ -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 = (

View File

@@ -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.

View File

@@ -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:

View File

@@ -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")

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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]:

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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 任务相同:

View File

@@ -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 会被拦截。