hermes-agent/tests/tools/test_base_environment.py
kyssta-exe a54be30b6e fix(terminal): atomic env-snapshot replacement to prevent PATH corruption (#38249)
Concurrent terminal calls in one session both source AND rewrite the shared
env snapshot. The per-command re-dump used `export -p > snap` — a non-atomic
truncate-then-write in place (the code even noted "last-writer-wins"). A
concurrent `source snap` could read a half-written file and embed literal
`declare -x` / `export` fragments into PATH, breaking `ls`/`git`/`tr` with
command-not-found until PATH was manually repaired. The corruption persisted
because the malformed env got saved back into the snapshot.

Write to a unique temp file then `mv -f` over the snapshot. `mv`/rename is
atomic on POSIX (same filesystem), so a reader always sees the old-complete or
new-complete file — never a torn one. Applied at both the init_session
bootstrap and the per-command re-dump. `$$` (bash PID) makes the temp name
unique per concurrent process so their temp writes can't collide before the mv.

Salvaged from #38279 by @kyssta-exe (authorship preserved via cherry-pick).
On top of the original I hardened the temp-path quoting: the static path is now
shlex-quoted with `$$` left outside the quotes to expand, so a snapshot path
with a space or a Windows `C:/Users/...` drive letter doesn't break the shell
(matching the existing quoting care for the snapshot path itself; `bash -n`
verified on a spaced path). Added a regression test class (atomic temp+mv used,
not in-place write; per-process-unique temp; static part quoted; bootstrap also
atomic). 22 tests pass, mutation-verified (reverting to the in-place write fails
the atomic tests), ruff clean.

Closes #38249. Supersedes #38267 (serialize-execution lock — heavier and
serializes the spawn-per-call concurrency this fix preserves).
2026-06-26 21:56:59 +05:30

332 lines
12 KiB
Python

"""Tests for BaseEnvironment unified execution model.
Tests _wrap_command(), _extract_cwd_from_output(), _embed_stdin_heredoc(),
init_session() failure handling, and the CWD marker contract.
"""
import subprocess
from unittest.mock import MagicMock
from tools.environments.base import BaseEnvironment
class _TestableEnv(BaseEnvironment):
"""Concrete subclass for testing base class methods."""
def __init__(self, cwd="/tmp", timeout=10):
super().__init__(cwd=cwd, timeout=timeout)
def _run_bash(self, cmd_string, *, login=False, timeout=120, stdin_data=None):
raise NotImplementedError("Use mock")
def cleanup(self):
pass
class TestWrapCommand:
def test_basic_shape(self):
env = _TestableEnv()
env._snapshot_ready = True
wrapped = env._wrap_command("echo hello", "/tmp")
assert "source" in wrapped
assert "cd -- /tmp" in wrapped or "cd -- '/tmp'" in wrapped
assert "eval 'echo hello'" in wrapped
assert "__hermes_ec=$?" in wrapped
assert "export -p >" in wrapped
assert "pwd -P >" in wrapped
assert env._cwd_marker in wrapped
assert "exit $__hermes_ec" in wrapped
def test_no_snapshot_skips_source(self):
env = _TestableEnv()
env._snapshot_ready = False
wrapped = env._wrap_command("echo hello", "/tmp")
assert "source" not in wrapped
def test_single_quote_escaping(self):
env = _TestableEnv()
env._snapshot_ready = True
wrapped = env._wrap_command("echo 'hello world'", "/tmp")
assert "eval 'echo '\\''hello world'\\'''" in wrapped
def test_tilde_not_quoted(self):
env = _TestableEnv()
env._snapshot_ready = True
wrapped = env._wrap_command("ls", "~")
assert "cd -- ~" in wrapped
assert "cd -- '~'" not in wrapped
def test_tilde_subpath_with_spaces_uses_home_and_quotes_suffix(self):
env = _TestableEnv()
env._snapshot_ready = True
wrapped = env._wrap_command("ls", "~/my repo")
assert "cd -- $HOME/'my repo'" in wrapped
assert "cd -- ~/my repo" not in wrapped
def test_tilde_slash_maps_to_home(self):
env = _TestableEnv()
env._snapshot_ready = True
wrapped = env._wrap_command("ls", "~/")
assert "cd -- $HOME" in wrapped
assert "cd -- ~/" not in wrapped
def test_hyphen_prefixed_workdir_is_passed_after_double_dash(self):
env = _TestableEnv()
env._snapshot_ready = True
wrapped = env._wrap_command("pwd", "-demo")
assert "builtin cd -- -demo || exit 126" in wrapped
def test_cd_failure_exit_126(self):
env = _TestableEnv()
env._snapshot_ready = True
wrapped = env._wrap_command("ls", "/nonexistent")
assert "exit 126" in wrapped
class TestAtomicSnapshotWrite:
"""Regression for #38249: concurrent terminal calls in one session both
source AND rewrite the shared env snapshot. A non-atomic ``export -p >
snap`` truncates-then-writes in place, so a concurrent ``source snap`` can
read a half-written file and embed ``declare -x``/``export`` fragments into
PATH, breaking ``ls``/``git``/``tr`` with command-not-found. The write must
be atomic: ``export -p > snap.tmp.$$`` then ``mv -f snap.tmp.$$ snap`` (mv
is atomic on POSIX same-fs), so a reader sees the old-or-new complete file,
never a torn one."""
def test_wrap_command_uses_atomic_temp_then_mv(self):
env = _TestableEnv()
env._snapshot_ready = True
wrapped = env._wrap_command("echo hi", "/tmp")
# Writes go to a temp file, not directly over the live snapshot.
assert "export -p > " in wrapped
assert ".tmp." in wrapped
# Then an atomic rename onto the real snapshot path.
assert "mv -f " in wrapped
# The env-dump must NOT write the live snapshot in place (the bug).
snap = env._snapshot_path
assert f"export -p > {snap} " not in wrapped
assert f"export -p > '{snap}'" not in wrapped
def test_temp_path_is_per_process_unique(self):
"""``$$`` (bash PID) makes concurrent processes use distinct temp files
so their temp writes can't clobber each other before the mv."""
env = _TestableEnv()
env._snapshot_ready = True
wrapped = env._wrap_command("echo hi", "/tmp")
assert ".tmp.$$" in wrapped or ".tmp.'$$'" in wrapped or "'.tmp.'$$" in wrapped
def test_temp_path_static_part_is_quoted(self):
"""The static path portion must be shlex-quoted (Windows/Git-Bash
``C:/Users/...`` or spaces) while ``$$`` stays outside the quotes so
it still expands. The bug-prone form is a bare unquoted temp path."""
env = _TestableEnv()
env._snapshot_ready = True
env._snapshot_path = "/tmp/has space/hermes-snap-x.sh"
wrapped = env._wrap_command("echo hi", "/tmp")
# The space in the path must be quoted, not left bare in the temp name.
assert "/tmp/has space/hermes-snap-x.sh.tmp.$$" not in wrapped
# $$ must remain shell-expandable (not swallowed inside quotes as a literal).
assert "$$" in wrapped
def test_init_session_bootstrap_also_atomic(self):
"""The init_session bootstrap (first snapshot write) must be atomic too
— it's the same shared file a concurrent command could source."""
env = _TestableEnv()
captured = {}
def fake_run_bash(cmd_string, *, login=False, timeout=120, stdin_data=None):
captured["cmd"] = cmd_string
raise RuntimeError("stop after capture") # we only need the script
env._run_bash = fake_run_bash # type: ignore[assignment]
try:
env.init_session()
except Exception:
pass
boot = captured.get("cmd", "")
assert ".tmp." in boot and "mv -f " in boot, boot
class TestExtractCwdFromOutput:
def test_happy_path(self):
env = _TestableEnv()
marker = env._cwd_marker
result = {
"output": f"hello\n{marker}/home/user{marker}\n",
}
env._extract_cwd_from_output(result)
assert env.cwd == "/home/user"
assert marker not in result["output"]
def test_missing_marker(self):
env = _TestableEnv()
result = {"output": "hello world\n"}
env._extract_cwd_from_output(result)
assert env.cwd == "/tmp" # unchanged
def test_marker_in_command_output(self):
"""If the marker appears in command output AND as the real marker,
rfind grabs the last (real) one."""
env = _TestableEnv()
marker = env._cwd_marker
result = {
"output": f"user typed {marker} in their output\nreal output\n{marker}/correct/path{marker}\n",
}
env._extract_cwd_from_output(result)
assert env.cwd == "/correct/path"
def test_output_cleaned(self):
env = _TestableEnv()
marker = env._cwd_marker
result = {
"output": f"hello\n{marker}/tmp{marker}\n",
}
env._extract_cwd_from_output(result)
assert "hello" in result["output"]
assert marker not in result["output"]
class TestEmbedStdinHeredoc:
def test_heredoc_format(self):
result = BaseEnvironment._embed_stdin_heredoc("cat", "hello world")
assert result.startswith("cat << '")
assert "hello world" in result
assert "HERMES_STDIN_" in result
def test_unique_delimiter_each_call(self):
r1 = BaseEnvironment._embed_stdin_heredoc("cat", "data")
r2 = BaseEnvironment._embed_stdin_heredoc("cat", "data")
# Extract delimiters
d1 = r1.split("'")[1]
d2 = r2.split("'")[1]
assert d1 != d2 # UUID-based, should be unique
class TestInitSessionFailure:
def test_snapshot_ready_false_on_failure(self):
env = _TestableEnv()
def failing_run_bash(*args, **kwargs):
raise RuntimeError("bash not found")
env._run_bash = failing_run_bash
env.init_session()
assert env._snapshot_ready is False
def test_login_flag_when_snapshot_not_ready(self):
"""When _snapshot_ready=False, execute() should pass login=True to _run_bash."""
env = _TestableEnv()
env._snapshot_ready = False
calls = []
def mock_run_bash(cmd, *, login=False, timeout=120, stdin_data=None):
calls.append({"login": login})
# Return a mock process handle
mock = MagicMock()
mock.poll.return_value = 0
mock.returncode = 0
mock.stdout = iter([])
return mock
env._run_bash = mock_run_bash
env.execute("echo test")
assert len(calls) == 1
assert calls[0]["login"] is True
class TestCwdMarker:
def test_marker_contains_session_id(self):
env = _TestableEnv()
assert env._session_id in env._cwd_marker
def test_unique_per_instance(self):
env1 = _TestableEnv()
env2 = _TestableEnv()
assert env1._cwd_marker != env2._cwd_marker
class TestAtomicSnapshotConcurrencyBehavioral:
"""Behavioral regression for #38249 — actually EXECUTES the generated
snapshot write concurrently and asserts the file never tears.
The string-inspection tests prove the right script is emitted; this proves
the emitted script's guarantee holds: N concurrent writers + readers, and
the snapshot is ALWAYS a complete, parseable env dump — never truncated
mid-line with a `declare -x` / `export` fragment that would corrupt PATH.
"""
def _run(self, script, env=None):
return subprocess.run(["/bin/bash", "-c", script], capture_output=True,
text=True, env=env)
def test_concurrent_writes_never_tear_the_snapshot(self, tmp_path):
import shutil
if not shutil.which("bash"):
import pytest; pytest.skip("bash required")
snap = str(tmp_path / "hermes-snap-x.sh")
_q = __import__("shlex").quote
_snap_tmp = _q(snap + ".tmp.") + "$$"
# One writer iteration = the exact atomic sequence _wrap_command emits.
writer = (
"for i in $(seq 1 40); do "
f"export BIG_$i=$(head -c 400 /dev/zero | tr '\\0' x); "
f"{{ export -p > {_snap_tmp} && mv -f {_snap_tmp} {_q(snap)}; }} "
f"2>/dev/null || rm -f {_snap_tmp} 2>/dev/null || true; "
"done"
)
# Reader: repeatedly source the snapshot and check PATH never absorbs
# an `export `/`declare -x` fragment (the corruption signature).
reader = (
f"export PATH=/usr/bin:/bin; "
"for i in $(seq 1 80); do "
f"( source {_q(snap)} >/dev/null 2>&1 || true; "
"case \"$PATH\" in *'declare -x'*|*'export '*) echo CORRUPT; ;; esac ); "
"done"
)
# seed a valid snapshot first
self._run(f"export -p > {_q(snap)}")
# launch 6 concurrent writers + 6 readers
procs = [self._run(f"{writer} & {writer} & {reader} & {reader} & wait")
for _ in range(3)]
corrupt = any("CORRUPT" in p.stdout for p in procs)
assert not corrupt, "snapshot tore — PATH absorbed a declare-x/export fragment"
# final snapshot must still be a complete, sourceable env dump
final = self._run(f"source {_q(snap)} >/dev/null 2>&1 && echo OK || echo BROKEN")
assert "OK" in final.stdout, f"final snapshot not sourceable: {final.stdout} {final.stderr}"
def test_failed_export_does_not_destroy_good_snapshot(self, tmp_path):
"""If `export -p` fails, the `&&`-chained mv must NOT clobber the
existing good snapshot (the failure-path fix)."""
import shutil
if not shutil.which("bash"):
import pytest; pytest.skip("bash required")
snap = str(tmp_path / "snap.sh")
_q = __import__("shlex").quote
self._run(f"echo 'export GOOD=1' > {_q(snap)}") # seed good snapshot
_snap_tmp = _q(snap + ".tmp.") + "$$"
# Simulate a failed dump: redirect export into an unwritable temp dir so
# the export side fails; mv must then NOT run (&&) and not clobber snap.
bad_tmp = _q("/nonexistent-dir/snap.tmp.") + "$$"
script = (
f"{{ export -p > {bad_tmp} && mv -f {bad_tmp} {_q(snap)}; }} "
f"2>/dev/null || rm -f {bad_tmp} 2>/dev/null || true"
)
self._run(script)
out = self._run(f"cat {_q(snap)}")
assert "export GOOD=1" in out.stdout, "good snapshot was destroyed by a failed export"