mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-17 04:31:55 +00:00
feat(skills+terminal): make bundled skill scripts runnable out of the box (#13384)
* feat(skills): inject absolute skill dir and expand ${HERMES_SKILL_DIR} templates
When a skill loads, the activation message now exposes the absolute
skill directory and substitutes ${HERMES_SKILL_DIR} /
${HERMES_SESSION_ID} tokens in the SKILL.md body, so skills with
bundled scripts can instruct the agent to run them by absolute path
without an extra skill_view round-trip.
Also adds opt-in inline-shell expansion: !`cmd` snippets in SKILL.md
are pre-executed (with the skill directory as CWD) and their stdout is
inlined into the message before the agent reads it. Off by default —
enable via skills.inline_shell in config.yaml — because any snippet
runs on the host without approval.
Changes:
- agent/skill_commands.py: template substitution, inline-shell
expansion, absolute skill-dir header, supporting-files list now
shows both relative and absolute forms.
- hermes_cli/config.py: new skills.template_vars,
skills.inline_shell, skills.inline_shell_timeout knobs.
- tests/agent/test_skill_commands.py: coverage for header, both
template tokens (present and missing session id), template_vars
disable, inline-shell default-off, enabled, CWD, and timeout.
- website/docs/developer-guide/creating-skills.md: documents the
template tokens, the absolute-path header, and the opt-in inline
shell with its security caveat.
Validation: tests/agent/ 1591 passed (includes 9 new tests).
E2E: loaded a real skill in an isolated HERMES_HOME; confirmed
${HERMES_SKILL_DIR} resolves to the absolute path, ${HERMES_SESSION_ID}
resolves to the passed task_id, !`date` runs when opt-in is set, and
stays literal when it isn't.
* feat(terminal): source ~/.bashrc (and user-listed init files) into session snapshot
bash login shells don't source ~/.bashrc, so tools that install themselves
there — nvm, asdf, pyenv, cargo, custom PATH exports — stay invisible to
the environment snapshot Hermes builds once per session. Under systemd
or any context with a minimal parent env, that surfaces as
'node: command not found' in the terminal tool even though the binary
is reachable from every interactive shell on the machine.
Changes:
- tools/environments/local.py: before the login-shell snapshot bootstrap
runs, prepend guarded 'source <file>' lines for each resolved init
file. Missing files are skipped, each source is wrapped with a
'[ -r ... ] && . ... || true' guard so a broken rc can't abort the
bootstrap.
- hermes_cli/config.py: new terminal.shell_init_files (explicit list,
supports ~ and ${VAR}) and terminal.auto_source_bashrc (default on)
knobs. When shell_init_files is set it takes precedence; when it's
empty and auto_source_bashrc is on, ~/.bashrc gets auto-sourced.
- tests/tools/test_local_shell_init.py: 10 tests covering the resolver
(auto-bashrc, missing file, explicit override, ~/${VAR} expansion,
opt-out) and the prelude builder (quoting, guarded sourcing), plus
a real-LocalEnvironment snapshot test that confirms exports in the
init file land in subsequent commands' environment.
- website/docs/reference/faq.md: documents the fix in Troubleshooting,
including the zsh-user pattern of sourcing ~/.zshrc or nvm.sh
directly via shell_init_files.
Validation: 10/10 new tests pass; tests/tools/test_local_*.py 40/40
pass; tests/agent/ 1591/1591 pass; tests/hermes_cli/test_config.py
50/50 pass. E2E in an isolated HERMES_HOME: confirmed that a fake
~/.bashrc setting a marker var and PATH addition shows up in a real
LocalEnvironment().execute() call, that auto_source_bashrc=false
suppresses it, that an explicit shell_init_files entry wins over the
auto default, and that a missing bashrc is silently skipped.
This commit is contained in:
parent
b48ea41d27
commit
328223576b
7 changed files with 665 additions and 3 deletions
162
tests/tools/test_local_shell_init.py
Normal file
162
tests/tools/test_local_shell_init.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""Tests for terminal.shell_init_files / terminal.auto_source_bashrc.
|
||||
|
||||
A bash ``-l -c`` invocation does NOT source ``~/.bashrc``, so tools that
|
||||
register themselves there (nvm, asdf, pyenv) stay invisible to the
|
||||
environment snapshot built by ``LocalEnvironment.init_session``. These
|
||||
tests verify the config-driven prelude that fixes that.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.environments.local import (
|
||||
LocalEnvironment,
|
||||
_prepend_shell_init,
|
||||
_read_terminal_shell_init_config,
|
||||
_resolve_shell_init_files,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveShellInitFiles:
|
||||
def test_auto_sources_bashrc_when_present(self, tmp_path, monkeypatch):
|
||||
bashrc = tmp_path / ".bashrc"
|
||||
bashrc.write_text('export MARKER=seen\n')
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
|
||||
# Default config: auto_source_bashrc on, no explicit list.
|
||||
with patch(
|
||||
"tools.environments.local._read_terminal_shell_init_config",
|
||||
return_value=([], True),
|
||||
):
|
||||
resolved = _resolve_shell_init_files()
|
||||
|
||||
assert resolved == [str(bashrc)]
|
||||
|
||||
def test_skips_bashrc_when_missing(self, tmp_path, monkeypatch):
|
||||
# No bashrc written.
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
|
||||
with patch(
|
||||
"tools.environments.local._read_terminal_shell_init_config",
|
||||
return_value=([], True),
|
||||
):
|
||||
resolved = _resolve_shell_init_files()
|
||||
|
||||
assert resolved == []
|
||||
|
||||
def test_auto_source_bashrc_off_suppresses_default(self, tmp_path, monkeypatch):
|
||||
bashrc = tmp_path / ".bashrc"
|
||||
bashrc.write_text('export MARKER=seen\n')
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
|
||||
with patch(
|
||||
"tools.environments.local._read_terminal_shell_init_config",
|
||||
return_value=([], False),
|
||||
):
|
||||
resolved = _resolve_shell_init_files()
|
||||
|
||||
assert resolved == []
|
||||
|
||||
def test_explicit_list_wins_over_auto(self, tmp_path, monkeypatch):
|
||||
bashrc = tmp_path / ".bashrc"
|
||||
bashrc.write_text('export FROM_BASHRC=1\n')
|
||||
custom = tmp_path / "custom.sh"
|
||||
custom.write_text('export FROM_CUSTOM=1\n')
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
|
||||
# auto_source_bashrc stays True but the explicit list takes precedence.
|
||||
with patch(
|
||||
"tools.environments.local._read_terminal_shell_init_config",
|
||||
return_value=([str(custom)], True),
|
||||
):
|
||||
resolved = _resolve_shell_init_files()
|
||||
|
||||
assert resolved == [str(custom)]
|
||||
assert str(bashrc) not in resolved
|
||||
|
||||
def test_expands_home_and_env_vars(self, tmp_path, monkeypatch):
|
||||
target = tmp_path / "rc" / "custom.sh"
|
||||
target.parent.mkdir()
|
||||
target.write_text('export A=1\n')
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("CUSTOM_RC_DIR", str(tmp_path / "rc"))
|
||||
|
||||
with patch(
|
||||
"tools.environments.local._read_terminal_shell_init_config",
|
||||
return_value=(["~/rc/custom.sh"], False),
|
||||
):
|
||||
resolved_home = _resolve_shell_init_files()
|
||||
|
||||
with patch(
|
||||
"tools.environments.local._read_terminal_shell_init_config",
|
||||
return_value=(["${CUSTOM_RC_DIR}/custom.sh"], False),
|
||||
):
|
||||
resolved_var = _resolve_shell_init_files()
|
||||
|
||||
assert resolved_home == [str(target)]
|
||||
assert resolved_var == [str(target)]
|
||||
|
||||
def test_missing_explicit_files_are_skipped_silently(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
with patch(
|
||||
"tools.environments.local._read_terminal_shell_init_config",
|
||||
return_value=([str(tmp_path / "does-not-exist.sh")], False),
|
||||
):
|
||||
resolved = _resolve_shell_init_files()
|
||||
|
||||
assert resolved == []
|
||||
|
||||
|
||||
class TestPrependShellInit:
|
||||
def test_empty_list_returns_command_unchanged(self):
|
||||
assert _prepend_shell_init("echo hi", []) == "echo hi"
|
||||
|
||||
def test_prepends_guarded_source_lines(self):
|
||||
wrapped = _prepend_shell_init("echo hi", ["/tmp/a.sh", "/tmp/b.sh"])
|
||||
assert "echo hi" in wrapped
|
||||
# Each file is sourced through a guarded [ -r … ] && . '…' || true
|
||||
# pattern so a missing/broken rc can't abort the bootstrap.
|
||||
assert "/tmp/a.sh" in wrapped
|
||||
assert "/tmp/b.sh" in wrapped
|
||||
assert "|| true" in wrapped
|
||||
assert "set +e" in wrapped
|
||||
|
||||
def test_escapes_single_quotes(self):
|
||||
wrapped = _prepend_shell_init("echo hi", ["/tmp/o'malley.sh"])
|
||||
# The path must survive as the shell receives it; embedded single
|
||||
# quote is escaped as '\'' rather than breaking the outer quoting.
|
||||
assert "o'\\''malley" in wrapped
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("CI") == "true" and not os.path.isfile("/bin/bash"),
|
||||
reason="Requires bash; CI sandbox may strip it.",
|
||||
)
|
||||
class TestSnapshotEndToEnd:
|
||||
"""Spin up a real LocalEnvironment and confirm the snapshot sources
|
||||
extra init files."""
|
||||
|
||||
def test_snapshot_picks_up_init_file_exports(self, tmp_path, monkeypatch):
|
||||
init_file = tmp_path / "custom-init.sh"
|
||||
init_file.write_text(
|
||||
'export HERMES_SHELL_INIT_PROBE="probe-ok"\n'
|
||||
'export PATH="/opt/shell-init-probe/bin:$PATH"\n'
|
||||
)
|
||||
|
||||
with patch(
|
||||
"tools.environments.local._read_terminal_shell_init_config",
|
||||
return_value=([str(init_file)], False),
|
||||
):
|
||||
env = LocalEnvironment(cwd=str(tmp_path), timeout=15)
|
||||
try:
|
||||
result = env.execute(
|
||||
'echo "PROBE=$HERMES_SHELL_INIT_PROBE"; echo "PATH=$PATH"'
|
||||
)
|
||||
finally:
|
||||
env.cleanup()
|
||||
|
||||
output = result.get("output", "")
|
||||
assert "PROBE=probe-ok" in output
|
||||
assert "/opt/shell-init-probe/bin" in output
|
||||
Loading…
Add table
Add a link
Reference in a new issue