diff --git a/cron/scheduler.py b/cron/scheduler.py index c309c59c4e5a..984c18d31d16 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -2202,7 +2202,10 @@ def _windows_cron_python_invocation(python_exe: str) -> tuple[str, dict[str, str return str(interpreter), env_overlay -def _run_job_script(script_path: str) -> tuple[bool, str]: +def _run_job_script( + script_path: str, + workdir: Optional[str] = None, +) -> tuple[bool, str]: """Execute a cron job's data-collection script and capture its output. Scripts must reside within HERMES_HOME/scripts/. Both relative and @@ -2228,6 +2231,12 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: 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. The Python process cwd is NEVER + mutated, avoiding the global-side-effect bug where a cron + job's ``os.chdir()`` leaks into concurrent gateway sessions + (#69396). Returns: (success, output) — on failure *output* contains the error message so the @@ -2298,12 +2307,17 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: } env = _sanitize_subprocess_env(os.environ.copy()) env.update(env_overlay) + # Use the job's workdir as the subprocess cwd when configured, + # otherwise default to the scripts-dir parent (back-compat). + # NEVER mutate the Python process cwd — that would leak into + # concurrent gateway sessions (#69396). + _script_cwd = workdir or str(path.parent) result = subprocess.run( argv, capture_output=True, text=True, timeout=script_timeout, - cwd=str(path.parent), + cwd=_script_cwd, env=env, **popen_kwargs, ) @@ -2337,7 +2351,7 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: def _run_job_script_with_claim_heartbeat( - job: dict, script_path: str + job: dict, script_path: str, workdir: Optional[str] = None, ) -> tuple[bool, str]: """Run a cron script while keeping its owned one-shot claim fresh. @@ -2359,7 +2373,7 @@ def _run_job_script_with_claim_heartbeat( and schedule.get("kind") == "once" and owner ): - return _run_job_script(script_path) + return _run_job_script(script_path, workdir=workdir) job_id = str(job.get("id") or "") stop = threading.Event() @@ -2390,10 +2404,10 @@ def _run_job_script_with_claim_heartbeat( job_id, exc_info=True, ) - return _run_job_script(script_path) + return _run_job_script(script_path, workdir=workdir) try: - return _run_job_script(script_path) + return _run_job_script(script_path, workdir=workdir) finally: stop.set() # Event.wait() wakes immediately. Keep completion bounded if the @@ -2796,25 +2810,26 @@ def run_job( return False, "", "", err # Apply workdir if configured — lets scripts use predictable relative - # paths. For no_agent jobs this is just the subprocess cwd (not an - # agent TERMINAL_CWD bridge). + # paths. For no_agent jobs this is passed as the subprocess cwd so the + # Python process cwd is NEVER mutated — avoiding the global-side-effect + # bug where os.chdir() leaks into concurrent gateway sessions (#69396). _job_workdir = (job.get("workdir") or "").strip() or None - _prior_cwd = None - if _job_workdir and Path(_job_workdir).is_dir(): - _prior_cwd = os.getcwd() - try: - os.chdir(_job_workdir) - except OSError: - _prior_cwd = None + if _job_workdir and not Path(_job_workdir).is_dir(): + logger.warning( + "Job '%s': configured workdir %r no longer exists — running without it", + job_id, _job_workdir, + ) + _job_workdir = None try: - ok, output = _run_job_script_with_claim_heartbeat(job, script_path) - finally: - if _prior_cwd is not None: - try: - os.chdir(_prior_cwd) - except OSError: - pass + ok, output = _run_job_script_with_claim_heartbeat( + job, script_path, workdir=_job_workdir, + ) + except Exception: + logger.exception( + "Job '%s': script execution raised unexpectedly", job_id, + ) + ok, output = False, "Script execution failed" now_iso = _hermes_now().strftime("%Y-%m-%d %H:%M:%S") @@ -3064,7 +3079,8 @@ def run_job( _VAR_MAP[_var_name].set("") # Per-job working directory. When set (and validated at create/update - # time), we point TERMINAL_CWD at it so: + # time), we point both TERMINAL_CWD and the per-context _SESSION_CWD + # at it so: # - build_context_files_prompt() picks up AGENTS.md / CLAUDE.md / # .cursorrules from the job's project dir, AND # - the terminal, file, and code-exec tools run commands from there. @@ -3079,6 +3095,16 @@ def run_job( # file / code-exec commands in the wrong directory. For workdir-less jobs # we leave TERMINAL_CWD untouched — preserves the original behaviour # (skip_context_files=True, tools use whatever cwd the scheduler has). + # + # In addition to the lock-serialized TERMINAL_CWD, we also set the + # per-context _SESSION_CWD (agent.runtime_cwd.set_session_cwd). This + # ContextVar is scoped to the current context/thread, so it NEVER leaks + # into concurrent gateway sessions — resolving the root cause of #69396. + # Gateway sessions that read TERMINAL_CWD while this cron has temporarily + # overridden it will still see the cron's workdir, but the critical path + # (resolve_context_cwd / build_context_files_prompt) checks _SESSION_CWD + # first and finds no override in the gateway's context, so the gateway + # session loads the correct AGENTS.md from its own workspace. _job_workdir = (job.get("workdir") or "").strip() or None if _job_workdir and not Path(_job_workdir).is_dir(): # Directory was removed between create-time validation and now. Log @@ -3107,9 +3133,19 @@ def run_job( # (every future job blocks on acquire_*); a leaked reader blocks all # future writers. Acquire itself can't leak (it either blocks or returns). try: + # Set the per-context _SESSION_CWD before any tool or prompt + # construction, so resolve_context_cwd() and resolve_agent_cwd() + # prefer this scoped value over the process-global TERMINAL_CWD. + # Gateway sessions that lack this override fall through to the + # gateway's own TERMINAL_CWD, never picking up the cron's workdir + # (#69396). + _session_cwd_token = None + from agent.runtime_cwd import set_session_cwd, clear_session_cwd + if _job_workdir: os.environ["TERMINAL_CWD"] = _job_workdir logger.info("Job '%s': using workdir %s", job_id, _job_workdir) + _session_cwd_token = set_session_cwd(_job_workdir) # Re-read .env and config.yaml fresh every run so provider/key # changes take effect without a gateway restart. Route through @@ -3717,6 +3753,13 @@ def run_job( _terminal_cwd_lock.release_read() # Clean up ContextVar session/delivery state for this job. clear_session_vars(_ctx_tokens) + # Clear the per-context workdir override if one was set. This is + # the cleanup counterpart to the set_session_cwd() call above. + if _session_cwd_token is not None: + try: + clear_session_cwd() + except Exception: + pass for _var_name in _cron_delivery_vars: _VAR_MAP[_var_name].set("") if _session_db: