Plugins could only see a cron run as the task_id string `cron:<job>:<execution>`, with no way to tell a scheduled fire from a manual `hermes cron run`. A security plugin that may only act inside a real scheduled occurrence had nothing to check. The scheduler now binds a frozen CronExecution (job id/name, execution id, source, scheduled_instant, started_at, profile) in a ContextVar once the run wins its claimed->running CAS (or a restart-safe worker adopted the row), and resets it when the fire ends. ContextVar scoping keeps concurrent jobs and profiles apart and follows the agent onto the threads the scheduler already copies context into. Delegated children read None. Also trims duplicated comments in _run_one_job_body to keep the facade within its code-health line cap. Closes #130722
100 lines
4.2 KiB
Python
100 lines
4.2 KiB
Python
"""Plugins can tell which cron execution they run inside (#130722)."""
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
|
|
from tests.fakes.fake_llm_provider import FakeLLMServer, Text, ToolCall, write_hermes_home
|
|
|
|
_PLUGIN = '''
|
|
import dataclasses, json, os
|
|
from pathlib import Path
|
|
|
|
CTX = None
|
|
|
|
def _record(**kwargs):
|
|
ident = CTX.current_cron_execution()
|
|
out = Path(os.environ["HERMES_HOME"]) / "seen.json"
|
|
out.write_text(json.dumps({"tool": kwargs.get("tool_name"),
|
|
"identity": dataclasses.asdict(ident) if ident else None}))
|
|
|
|
def register(ctx):
|
|
global CTX
|
|
CTX = ctx
|
|
ctx.register_hook("pre_tool_call", _record)
|
|
'''
|
|
|
|
_TICK = '''
|
|
import json, sys
|
|
from cron import scheduler
|
|
from cron.executions import list_executions
|
|
from hermes_cli.plugins import get_plugin_manager
|
|
scheduler.tick(verbose=False, sync=True)
|
|
ctx = get_plugin_manager()._plugins["cron-identity-probe"].module.CTX
|
|
print(json.dumps({"outside": ctx.current_cron_execution(), "profile": ctx.profile_name,
|
|
"executions": list_executions(job_id="probe")}, default=str))
|
|
'''
|
|
|
|
|
|
def test_tool_hook_in_a_ticked_job_sees_its_execution_and_none_outside(tmp_path):
|
|
from hermes_time import now
|
|
|
|
home = tmp_path / "home"
|
|
plugin_dir = home / "plugins" / "cron-identity-probe"
|
|
plugin_dir.mkdir(parents=True)
|
|
(plugin_dir / "plugin.yaml").write_text("name: cron-identity-probe\nversion: 0.0.1\n")
|
|
(plugin_dir / "__init__.py").write_text(_PLUGIN)
|
|
note = home / "note.txt"
|
|
with FakeLLMServer([ToolCall("read_file", {"path": str(note)}), Text("done")]) as srv:
|
|
write_hermes_home(home, srv.base_url,
|
|
extra_config="plugins:\n enabled: [cron-identity-probe]\n")
|
|
note.write_text("hello\n")
|
|
(home / "cron").mkdir()
|
|
(home / "cron" / "jobs.json").write_text(json.dumps({"jobs": [{
|
|
"id": "probe", "name": "identity probe", "prompt": "read the note",
|
|
"schedule": {"kind": "interval", "minutes": 240},
|
|
"next_run_at": (now() - timedelta(minutes=1)).isoformat(),
|
|
"enabled": True, "state": "scheduled", "deliver": "local",
|
|
"repeat": {"times": None, "completed": 0}}]}))
|
|
env = {k: v for k, v in os.environ.items()
|
|
if not k.startswith(("HERMES_", "_HERMES_"))
|
|
and not k.endswith(("_API_KEY", "_TOKEN"))}
|
|
env.update(HERMES_HOME=str(home), PYTHONPATH=str(Path(__file__).resolve().parents[2]))
|
|
result = subprocess.run([sys.executable, "-c", _TICK], env=env, stdin=subprocess.DEVNULL,
|
|
capture_output=True, text=True, timeout=180)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
report = json.loads(result.stdout.strip().splitlines()[-1])
|
|
seen = json.loads((home / "seen.json").read_text())
|
|
(execution,) = report["executions"]
|
|
|
|
assert seen["tool"] == "read_file"
|
|
ident = seen["identity"]
|
|
assert ident is not None, "pre_tool_call fired inside a cron run saw no cron identity"
|
|
assert ident["job_id"] == "probe"
|
|
assert ident["job_name"] == "identity probe"
|
|
assert ident["execution_id"] == execution["id"]
|
|
assert ident["source"] == execution["source"] == "builtin"
|
|
assert ident["scheduled_instant"] == execution["scheduled_instant"] is not None
|
|
assert ident["started_at"] == execution["started_at"]
|
|
assert ident["profile"] == report["profile"]
|
|
assert report["outside"] is None
|
|
|
|
|
|
def test_identity_is_scoped_and_not_inherited_by_delegated_children():
|
|
from agent.delegation_context import delegated_child_context
|
|
from cron.execution_identity import (
|
|
current_cron_execution, enter_cron_execution, exit_cron_execution)
|
|
|
|
record = {"source": "builtin", "scheduled_instant": None, "started_at": "2026-10-04T00:00:00"}
|
|
assert current_cron_execution() is None
|
|
token = enter_cron_execution({"id": "j", "name": "J"}, "exec-1", record)
|
|
try:
|
|
assert current_cron_execution().execution_id == "exec-1"
|
|
with delegated_child_context("child"):
|
|
assert current_cron_execution() is None
|
|
finally:
|
|
exit_cron_execution(token)
|
|
assert current_cron_execution() is None
|