Skip to content

fix(cron): strip leading scripts/ prefix to avoid scripts/scripts/ trap (#26595)#26622

Closed
briandevans wants to merge 1 commit into
NousResearch:mainfrom
briandevans:fix/cron-script-prefix-26595
Closed

fix(cron): strip leading scripts/ prefix to avoid scripts/scripts/ trap (#26595)#26622
briandevans wants to merge 1 commit into
NousResearch:mainfrom
briandevans:fix/cron-script-prefix-26595

Conversation

@briandevans

Copy link
Copy Markdown
Contributor

Summary

  • Strip a leading scripts/ segment from relative cron script paths at create/update time.
  • Stops script=\"scripts/foo.py\" from silently resolving to HERMES_HOME/scripts/scripts/foo.py at runtime and falling through the "Script not found" branch with no surfaced error.
  • Absolute and ~-prefixed paths pass through unchanged so the scheduler's existing path-traversal guard still validates them.

The bug

cron.scheduler._run_job_script builds the runtime path as (scripts_dir / raw).resolve() where scripts_dir = HERMES_HOME / \"scripts\". So a user-provided \"scripts/foo.py\" resolves to HERMES_HOME/scripts/scripts/foo.py. The file doesn't exist, the runtime returns (False, \"Script not found: ...\"), and the agent silently falls back to processing stale data from a prior successful run — no error surfaces at configuration time, only a quiet skip at runtime.

This trap is non-obvious from the cronjob tool's docstring ("Paths resolve under ~/.hermes/scripts/") because the user reasonably reads the script field as "path relative to the profile root," not "path relative to the scripts dir."

The fix

New _normalize_script_path() in cron/jobs.py — small, local, mirrors the existing _normalize_workdir shape:

  • Strips whitespace; empty → None (preserves existing behaviour).
  • Absolute paths and ~-prefixed paths pass through verbatim — the scheduler's path-traversal guard validates them at runtime, and rewriting them here could silently re-point a user's explicit absolute path.
  • For relative paths only, if parts[0] == \"scripts\" and there is at least one trailing segment, drop the leading scripts part and log the rewrite at INFO level.

Wired into both create_job (replacing the inline strip) and update_job (new branch alongside the existing workdir normalization).

Test plan

  • Focused regression test: tests/cron/test_cron_script.py::TestScriptPathNormalization — 9 new cases covering: strip, nested-path strip, unprefixed passthrough, absolute passthrough, tilde passthrough, lone scripts segment passthrough, update-time strip, update-clear via whitespace, and an end-to-end runtime guard that calls _run_job_script on the normalized output and asserts the script actually executes (the symptom the issue reporter saw).
  • Adjacent suite: full tests/cron/356 passed locally (uv run --with pytest --with pytest-xdist --with pytest-asyncio python3 -m pytest tests/cron/).
  • Regression guard: removing _normalize_script_path() and restoring the inline strip causes the new test_normalized_script_resolves_to_real_file_at_runtime and test_create_job_strips_leading_scripts_prefix* tests to fail with the pre-fix "scripts/scripts/" path — confirmed before commit.

Scope notes

Intentionally limited to path normalization at the config layer. The issue also raises (a) create-time existence validation and (b) skill-prefixed script references — both deferred:

  • Existence validation at create-time would break the existing test pattern where jobs are created with placeholder paths (/path/to/monitor.py) and several call sites where the script is deployed after the cron is registered. Worth doing, but a larger surface change with policy implications.
  • Skill-prefixed script references (skill:foo/scripts/bar.py) are a feature, not a bug — separate scope.

Happy to widen if preferred. The current fix removes the silent-skip symptom for the common case.

Related

Copilot AI review requested due to automatic review settings May 15, 2026 22:16
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cron Cron scheduler and job management labels May 15, 2026
@briandevans

Copy link
Copy Markdown
Contributor Author

CI audit — all 4 test failures are pre-existing baselines on clean origin/main (7fee1f61e, CI run 25944419334). Zero failures are in touched code (cron/jobs.py, tests/cron/test_cron_script.py).

Test Symptom on CI Root cause on main
tests/hermes_cli/test_gateway_service.py::TestSystemUnitHermesHome::test_system_unit_uses_target_user_home_not_calling_user PermissionError: [Errno 13] Permission denied: '/root/.hermes/node/bin' Test mocks Path.home() to /root but does not mock _build_service_path_dirs()generate_systemd_unit() calls it, which probes real filesystem via hermes_node.is_dir() at hermes_cli/gateway.py:2125. On CI as root, the path exists with restricted perms.
tests/hermes_cli/test_gateway_service.py::TestSystemUnitHermesHome::test_system_unit_remaps_profile_to_target_user PermissionError: [Errno 13] Permission denied: '/root/.hermes/profiles/coder/node/bin' Same as above.
tests/tools/test_transcription_dotenv_fallback.py::TestProviderSelectionGate::test_explicit_xai_sees_dotenv AssertionError: assert 'none' == 'xai' After e13c1b8 (today), xAI credential resolution moved into tools.xai_http.resolve_xai_http_credentials(), but test_explicit_xai_sees_dotenv still only patches hermes_cli.config.load_env — not the new tools.xai_http.get_env_value entry point used by the resolver.
tests/tools/test_transcription_dotenv_fallback.py::TestEndToEndRegressionGuard::test_xai_key_only_in_dotenv_before_fix assert False is True Same root cause — _transcribe_xai now reaches the xAI key through the same resolver.

Happy to re-run CI once these are addressed on main.

…ap (NousResearch#26595)

The scheduler resolves relative cron script paths against
HERMES_HOME/scripts/, so a user-supplied "scripts/foo.py" resolves to
HERMES_HOME/scripts/scripts/foo.py and silently falls through the
"Script not found" branch at runtime. The script appears to vanish
between configuration time and execution, leaving the agent to deliver
stale data from the previous successful run with no surfaced error.

Normalize the script field at create_job and update_job time: strip a
leading "scripts" segment from relative paths and log the rewrite at
info level. Absolute and ~-prefixed paths pass through unchanged so the
scheduler's existing path-traversal guard keeps them honest.

Regression guard runs create_job(script="scripts/monitor.py") through
_run_job_script and asserts the script actually executes — the pre-fix
double-prefix path would have hit the "not found" branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@briandevans briandevans force-pushed the fix/cron-script-prefix-26595 branch from e3618ad to 0414125 Compare May 29, 2026 14:14
@briandevans

Copy link
Copy Markdown
Contributor Author

Closing to focus the queue on security/file-safety work where civilian merges are landing. Happy to reopen if maintainers want this picked up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cron Cron scheduler and job management P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cronjob: script path resolution silently doubles scripts/ prefix and skips missing-file validation

2 participants