Files
hermes-agent/tests/tools/test_base_environment.py
ethernet e8fcb007b9 Merge remote-tracking branch 'upstream/main' into ethie/pm-clean
# Conflicts:
#	AGENTS.md
#	acp_adapter/edit_approval.py
#	acp_adapter/server.py
#	agent/agent_init.py
#	agent/anthropic_adapter.py
#	agent/anthropic_credentials.py
#	agent/auxiliary_client.py
#	agent/azure_identity_adapter.py
#	agent/bedrock_adapter.py
#	agent/browser_registry.py
#	agent/chat_completion_helpers.py
#	agent/coding_context.py
#	agent/context_references.py
#	agent/conversation_loop.py
#	agent/copilot_acp_client.py
#	agent/credits_tracker.py
#	agent/curator.py
#	agent/curator_backup.py
#	agent/deadline.py
#	agent/display.py
#	agent/errors.py
#	agent/estop.py
#	agent/i18n.py
#	agent/image_gen_registry.py
#	agent/image_routing.py
#	agent/learning_graph.py
#	agent/learning_mutations.py
#	agent/lsp/servers.py
#	agent/model_metadata.py
#	agent/models_dev.py
#	agent/monitoring/gateway_health_export.py
#	agent/monitoring/otlp_exporter.py
#	agent/pet/store.py
#	agent/process_bootstrap.py
#	agent/prompt_builder.py
#	agent/proxy_sources/iron_proxy.py
#	agent/secret_sources/_cache.py
#	agent/secret_sources/bitwarden.py
#	agent/secret_sources/registry.py
#	agent/shell_hooks.py
#	agent/skill_bundles.py
#	agent/skill_commands.py
#	agent/skill_utils.py
#	agent/ssl_guard.py
#	agent/ssl_verify.py
#	agent/system_prompt.py
#	agent/terminal_env_registry.py
#	agent/trace_upload.py
#	agent/transcription_registry.py
#	agent/tts_registry.py
#	agent/verify/environment.py
#	agent/vertex_adapter.py
#	agent/video_gen_registry.py
#	agent/web_search_registry.py
#	cli.py
#	cron/jobs.py
#	cron/scheduler.py
#	gateway/agent_cache_pressure.py
#	gateway/cgroup_cleanup.py
#	gateway/channel_directory.py
#	gateway/config.py
#	gateway/control_socket.py
#	gateway/dead_targets.py
#	gateway/drain_control.py
#	gateway/hooks.py
#	gateway/kanban_watchers.py
#	gateway/lifecycle_ledger.py
#	gateway/mirror.py
#	gateway/pairing.py
#	gateway/platform_registry.py
#	gateway/platforms/helpers.py
#	gateway/platforms/weixin.py
#	gateway/readiness.py
#	gateway/restart_loop_guard.py
#	gateway/rich_sent_store.py
#	gateway/run.py
#	gateway/session.py
#	gateway/shutdown_flush.py
#	gateway/shutdown_forensics.py
#	gateway/slash_commands.py
#	gateway/status.py
#	gateway/sticker_cache.py
#	gateway/whatsapp_identity.py
#	hermes_bootstrap.py
#	hermes_cli/_early_recovery.py
#	hermes_cli/_install_repair.py
#	hermes_cli/_startup_fast.py
#	hermes_cli/_subprocess_compat.py
#	hermes_cli/agent_plugins.py
#	hermes_cli/auth.py
#	hermes_cli/backup.py
#	hermes_cli/banner.py
#	hermes_cli/browser_connect.py
#	hermes_cli/build_info.py
#	hermes_cli/cli_agent_setup_mixin.py
#	hermes_cli/cli_commands_mixin.py
#	hermes_cli/codex_models.py
#	hermes_cli/config.py
#	hermes_cli/config_defaults.py
#	hermes_cli/config_migrations.py
#	hermes_cli/container_boot.py
#	hermes_cli/dashboard_auth/registry.py
#	hermes_cli/debug.py
#	hermes_cli/dep_ensure.py
#	hermes_cli/doctor.py
#	hermes_cli/doctor_live.py
#	hermes_cli/dump.py
#	hermes_cli/env_loader.py
#	hermes_cli/foreign_sessions.py
#	hermes_cli/gateway.py
#	hermes_cli/gateway_windows.py
#	hermes_cli/gui_uninstall.py
#	hermes_cli/image_provenance.py
#	hermes_cli/install_identity.py
#	hermes_cli/kanban.py
#	hermes_cli/kanban_db.py
#	hermes_cli/linux_desktop_entry.py
#	hermes_cli/local_runtime/binaries.py
#	hermes_cli/local_runtime/endpoint.py
#	hermes_cli/local_runtime/growth.py
#	hermes_cli/local_runtime/supervisor.py
#	hermes_cli/logs.py
#	hermes_cli/macos_tcc_anchor.py
#	hermes_cli/main.py
#	hermes_cli/memory_setup.py
#	hermes_cli/model_catalog.py
#	hermes_cli/models.py
#	hermes_cli/nous_subscription.py
#	hermes_cli/npm_engine.py
#	hermes_cli/plugin_index.py
#	hermes_cli/plugins.py
#	hermes_cli/plugins_cmd.py
#	hermes_cli/profile_distribution.py
#	hermes_cli/profiles.py
#	hermes_cli/prompt_size.py
#	hermes_cli/psutil_android.py
#	hermes_cli/runtime_repair.py
#	hermes_cli/security_advisories.py
#	hermes_cli/security_audit.py
#	hermes_cli/security_audit_startup.py
#	hermes_cli/service_manager.py
#	hermes_cli/session_export_md.py
#	hermes_cli/setup.py
#	hermes_cli/skills_hub.py
#	hermes_cli/slack_cli.py
#	hermes_cli/status.py
#	hermes_cli/subcommands/gateway.py
#	hermes_cli/subcommands/uninstall.py
#	hermes_cli/tools_config.py
#	hermes_cli/uninstall.py
#	hermes_cli/update_cmd.py
#	hermes_cli/update_contract.py
#	hermes_cli/update_inventory.py
#	hermes_cli/update_lock.py
#	hermes_cli/update_receipt.py
#	hermes_cli/urllib_security.py
#	hermes_cli/web_routers/local_models.py
#	hermes_cli/web_routers/profiles.py
#	hermes_cli/web_routers/skills.py
#	hermes_cli/web_server.py
#	hermes_constants.py
#	hermes_state.py
#	plugins/disk-cleanup/__init__.py
#	plugins/disk-cleanup/disk_cleanup.py
#	plugins/google_meet/node/registry.py
#	plugins/google_meet/node/server.py
#	plugins/google_meet/process_manager.py
#	plugins/google_meet/realtime/openai_client.py
#	plugins/hermes-achievements/dashboard/plugin_api.py
#	plugins/memory/hindsight/__init__.py
#	plugins/memory/honcho/__init__.py
#	plugins/memory/honcho/cli.py
#	plugins/memory/honcho/client.py
#	plugins/memory/honcho/oauth.py
#	plugins/memory/honcho/session.py
#	plugins/memory/mem0/__init__.py
#	plugins/memory/mem0/_setup.py
#	plugins/memory/openviking/__init__.py
#	plugins/memory/retaindb/__init__.py
#	plugins/memory/supermemory/__init__.py
#	plugins/platforms/a2a/protocol.py
#	plugins/platforms/dingtalk/adapter.py
#	plugins/platforms/discord/adapter.py
#	plugins/platforms/feishu/adapter.py
#	plugins/platforms/google_chat/adapter.py
#	plugins/platforms/matrix/adapter.py
#	plugins/platforms/photon/adapter.py
#	plugins/platforms/photon/auth.py
#	plugins/platforms/photon/cli.py
#	plugins/platforms/slack/adapter.py
#	plugins/platforms/teams/adapter.py
#	plugins/platforms/telegram/adapter.py
#	plugins/platforms/wecom/callback_adapter.py
#	plugins/platforms/whatsapp/adapter.py
#	plugins/teams_pipeline/store.py
#	plugins/video_gen/fal/__init__.py
#	plugins/web/ddgs/provider.py
#	plugins/web/exa/provider.py
#	plugins/web/firecrawl/provider.py
#	plugins/web/parallel/provider.py
#	tests/agent/test_ssl_ca_guard.py
#	tests/hermes_cli/test_certifi_repair.py
#	tests/hermes_cli/test_cmd_update.py
#	tests/hermes_cli/test_cmd_update_apt.py
#	tests/hermes_cli/test_dashboard_unified_launch.py
#	tests/hermes_cli/test_dep_ensure.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_doctor_live.py
#	tests/hermes_cli/test_gui_command.py
#	tests/hermes_cli/test_kanban_boards.py
#	tests/hermes_cli/test_kanban_db.py
#	tests/hermes_cli/test_lazy_refresh_venv_repair.py
#	tests/hermes_cli/test_memory_setup_provider_arg.py
#	tests/hermes_cli/test_nous_subscription.py
#	tests/hermes_cli/test_pip_install_detection.py
#	tests/hermes_cli/test_profile_export_credentials.py
#	tests/hermes_cli/test_psutil_android_extract.py
#	tests/hermes_cli/test_status.py
#	tests/hermes_cli/test_tui_npm_install.py
#	tests/hermes_cli/test_update_fleet_restart_pending.py
#	tests/hermes_cli/test_update_head_moved_gate.py
#	tests/hermes_cli/test_update_interrupted_recovery.py
#	tests/hermes_cli/test_web_server.py
#	tests/hermes_cli/test_web_ui_build.py
#	tests/test_hermes_logging.py
#	tests/test_managed_runtime_resolution.py
#	tests/tools/test_browser_chromium_autoinstall.py
#	tests/tools/test_browser_chromium_check.py
#	tests/tools/test_browser_homebrew_paths.py
#	tests/tools/test_browser_lightpanda.py
#	tests/tools/test_browser_npx_warmup.py
#	tests/tools/test_browser_open_timeout.py
#	tests/tools/test_browser_orphan_reaper.py
#	tests/tools/test_browser_real_profile.py
#	tests/tools/test_browser_suspect_recycle.py
#	tests/tools/test_find_shell.py
#	tests/tools/test_local_env_blocklist.py
#	tests/tools/test_macos_protected_search.py
#	tests/tui_gateway/test_compute_host.py
#	tools/approval.py
#	tools/blueprints.py
#	tools/bot_mode_dm.py
#	tools/bot_mode_probe.py
#	tools/bot_relay.py
#	tools/browser_tool.py
#	tools/browser_use_cli.py
#	tools/checkpoint_manager.py
#	tools/code_execution_tool.py
#	tools/code_kernel.py
#	tools/computer_use/cua_backend.py
#	tools/cronjob_tools.py
#	tools/discord_tool.py
#	tools/environments/base.py
#	tools/environments/daytona.py
#	tools/environments/local.py
#	tools/environments/modal.py
#	tools/environments/vercel_sandbox.py
#	tools/fal_common.py
#	tools/file_operations.py
#	tools/lazy_deps.py
#	tools/mcp_tool.py
#	tools/neutts_synth.py
#	tools/process_registry.py
#	tools/read_extract.py
#	tools/registry.py
#	tools/skill_ledger.py
#	tools/skill_linter.py
#	tools/skill_manager_tool.py
#	tools/skill_usage.py
#	tools/skills_ast_audit.py
#	tools/skills_guard.py
#	tools/skills_hub.py
#	tools/skills_sync.py
#	tools/skills_sync_client.py
#	tools/skills_tool.py
#	tools/terminal_scope.py
#	tools/terminal_tool.py
#	tools/tirith_security.py
#	tools/transcription_tools.py
#	tools/tts_tool.py
#	tools/vision_tools.py
#	tools/voice_mode.py
#	tools/wake_word.py
#	tools/web_result_cache.py
#	tools/website_policy.py
#	tools/working_diff.py
#	tools/write_approval.py
#	tui_gateway/entry.py
#	tui_gateway/methods_tools.py
#	tui_gateway/server.py
2026-09-04 13:03:39 -04:00

478 lines
18 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 pytest
from unittest.mock import MagicMock
from tools.environments.base import BaseEnvironment
from tools.environments.base_output import _BoundedOutputCollector
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 TestBoundedOutputCollector:
def test_large_stream_retains_bounded_head_and_tail(self):
collector = _BoundedOutputCollector(1_000)
collector.append("HEAD-SENTINEL\n")
for _ in range(2_000):
collector.append("x" * 4_096)
collector.append("\nTAIL-SENTINEL")
rendered = collector.render()
assert collector.total_chars > 8_000_000
assert collector.buffered_chars <= 1_000
assert len(rendered) <= 1_000
assert rendered.startswith("HEAD-SENTINEL")
assert rendered.endswith("TAIL-SENTINEL")
assert "[OUTPUT TRUNCATED" in rendered
def test_required_status_suffix_stays_inside_limit(self):
collector = _BoundedOutputCollector(120)
collector.append("A" * 10_000)
rendered = collector.render(suffix="\n[Command timed out after 1s]")
assert len(rendered) <= 120
assert rendered.endswith("[Command timed out after 1s]")
assert "[OUTPUT TRUNCATED" in rendered
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 and "> " in wrapped
# cwd travels via the stdout marker only — no temp-file write.
assert "pwd -P >" not 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_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
assemble in a temp file and ``mv -f`` it into place (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")
# Env dump goes to a temp file, not directly over the live snapshot.
assert "export -p" in wrapped and "> " 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"> {snap} " not in wrapped
assert f"> '{snap}'" not in wrapped
assert f"> {snap}\n" not in wrapped
def test_temp_path_uses_mktemp_not_pid_variables(self):
"""The temp name MUST be allocated by ``mktemp`` — never ``$$`` (in
``&``-launched concurrent subshells it stays the parent shell's PID, so
two writers would pick the same temp name and publish a torn file) and
never ``$BASHPID`` (macOS ships bash 3.2, which lacks it — the name
expands empty, collapsing every writer onto one temp path and
reopening the #38249 race). Regression for PR #54314."""
env = _TestableEnv()
env._snapshot_ready = True
wrapped = env._wrap_command("echo hi", "/tmp")
assert "mktemp " in wrapped
assert ".tmp.XXXXXXXXXX" in wrapped
assert "$BASHPID" not in wrapped
# The bare $$ temp form must be gone.
assert ".tmp.$$" not in wrapped
def test_init_session_bootstrap_also_atomic_and_mktemp(self):
"""The init_session bootstrap (first snapshot write) is the same shared
file a concurrent command could source — it must be atomic and use
``mktemp`` too (no ``$BASHPID``: absent on macOS bash 3.2)."""
env = _TestableEnv()
captured = {}
def fake_run_bash(cmd_string, *, login=False, timeout=120, stdin_data=None):
captured.setdefault("cmd", cmd_string) # only the bootstrap; ignore the failure-path probe
raise RuntimeError("stop after capture")
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
assert "mktemp " in boot
assert "$BASHPID" not in boot
assert ".tmp.$$" not in boot
def test_init_session_bootstrap_uses_private_umask(self):
env = _TestableEnv()
captured = {}
def fake_run_bash(cmd_string, *, login=False, timeout=120, stdin_data=None):
captured.setdefault("cmd", cmd_string) # only the bootstrap; ignore the failure-path probe
raise RuntimeError("stop after capture")
env._run_bash = fake_run_bash # type: ignore[assignment]
try:
env.init_session()
except Exception:
pass
boot = captured.get("cmd", "")
assert "umask 077" in boot
assert boot.index("umask 077") < boot.index("export -p")
class TestAtomicSnapshotConcurrencyBehavioral:
"""Behavioral regression for #38249 — actually EXECUTES the generated
snapshot write/read 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 under real concurrency: 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. Crucially it allocates the temp with ``mktemp``
(per-writer unique, works on macOS bash 3.2 which lacks ``$BASHPID``),
which is what closes the race; ``$$`` would still tear here.
"""
def _run(self, script):
import subprocess
return subprocess.run(["/bin/bash", "-c", script], capture_output=True, text=True)
@pytest.mark.platforms("linux")
def test_concurrent_writes_never_tear_the_snapshot(self, tmp_path):
import shutil
if not shutil.which("bash"):
import pytest
pytest.skip("bash required")
import shlex
snap = str(tmp_path / "hermes-snap-x.sh")
_q = shlex.quote
_tmpl = _q(snap + ".tmp.XXXXXXXXXX")
# One writer iteration = the exact atomic sequence _wrap_command emits.
writer = (
"for i in $(seq 1 80); do "
"export BIG_$i=$(head -c 600 /dev/zero | tr '\\0' x); "
f"__hermes_snap_tmp=$(mktemp {_tmpl}) && "
f"{{ export -p > \"$__hermes_snap_tmp\" && mv -f \"$__hermes_snap_tmp\" {_q(snap)}; }} "
f"2>/dev/null || rm -f \"$__hermes_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 = (
"export PATH=/usr/bin:/bin; "
"for i in $(seq 1 160); do "
f"( source {_q(snap)} >/dev/null 2>&1 || true; "
"case \"$PATH\" in *'declare -x'*|*'export '*) echo CORRUPT;; esac ); "
"done"
)
self._run(f"export -p > {_q(snap)}") # seed a valid snapshot
# 4 concurrent writers + 4 readers, repeated.
w = " & ".join([writer] * 4)
r = " & ".join([reader] * 4)
procs = [self._run(f"{w} & {r} & 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 = 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}"
@pytest.mark.platforms("linux")
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."""
import shutil
if not shutil.which("bash"):
import pytest
pytest.skip("bash required")
import shlex
snap = str(tmp_path / "snap.sh")
_q = shlex.quote
self._run(f"echo 'export GOOD=1' > {_q(snap)}") # seed good snapshot
# Redirect export into an unwritable dir so the export side fails; mv
# must then NOT run (&&) and not clobber snap.
bad_tmp = _q("/nonexistent-dir/snap.tmp.XXXXXXXXXX")
script = (
f"__hermes_snap_tmp=$(mktemp {bad_tmp}) && "
f"{{ export -p > \"$__hermes_snap_tmp\" && mv -f \"$__hermes_snap_tmp\" {_q(snap)}; }} "
f"2>/dev/null || rm -f \"$__hermes_snap_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"
class TestSnapshotFileModes:
"""Snapshot metadata files are private without changing user command umask."""
@pytest.mark.platforms("linux")
def test_snapshot_and_cwd_files_are_0600(self, tmp_path):
import os
from pathlib import Path
import shutil
import stat
import subprocess
if not shutil.which("bash"):
import pytest
pytest.skip("bash required")
class ExecutableEnv(BaseEnvironment):
def __init__(self, temp_dir):
self._temp_dir = str(temp_dir)
super().__init__(cwd=str(temp_dir), timeout=10)
def get_temp_dir(self):
return self._temp_dir
def _run_bash(self, cmd_string, *, login=False, timeout=120, stdin_data=None):
proc = subprocess.Popen(
["/bin/bash", "-lc", cmd_string],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
text=True,
cwd=self.cwd,
)
proc.communicate(timeout=timeout)
return proc
def cleanup(self):
pass
old_umask = os.umask(0o022)
try:
env = ExecutableEnv(tmp_path)
env.init_session()
user_file = tmp_path / "user-created.txt"
env.execute(f"touch {user_file}")
assert stat.S_IMODE(user_file.stat().st_mode) == 0o644
assert stat.S_IMODE(Path(env._snapshot_path).stat().st_mode) == 0o600
# The cwd temp file is no longer written (cwd travels via the
# stdout marker for every backend) — nothing to leak on disk.
assert not Path(env._cwd_file).exists()
finally:
os.umask(old_umask)
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_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_prefer_nonlogin_when_login_bash_is_dead(self):
"""Login snapshot failure + working non-login probe → don't use bash -l."""
env = _TestableEnv()
def mock_run_bash(cmd, *, login=False, timeout=120, stdin_data=None):
mock = MagicMock()
mock.poll.return_value = 0
mock.stdout = iter([])
if login:
mock.returncode = 1
else:
mock.returncode = 0
return mock
env._run_bash = mock_run_bash
env.init_session()
assert env._snapshot_ready is False
assert env._prefer_nonlogin is True
calls = []
def track_run_bash(cmd, *, login=False, timeout=120, stdin_data=None):
calls.append({"login": login})
mock = MagicMock()
mock.poll.return_value = 0
mock.returncode = 0
mock.stdout = iter([])
return mock
env._run_bash = track_run_bash
env.execute("echo test")
assert calls[0]["login"] is False
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 TestSanitizeTaskIdForPath:
"""sanitize_task_id_for_path must yield mountable, collision-free segments.
A raw task id like ``session:agent:main:telegram:dm:12345`` used as a
sandbox directory name made docker -v split the bind-mount on the embedded
colons and the daemon rejected it with "invalid mode" / exit 125 (#92414).
The helper is shared by every backend that builds host paths from task_id
(docker persistent sandboxes, singularity overlays), fixing the class once.
"""
def test_docker_unsafe_characters_are_replaced(self):
from tools.environments.path_utils import sanitize_task_id_for_path
out = sanitize_task_id_for_path("session:agent:main:telegram:dm:12345")
assert ":" not in out
assert "/" not in out and "\\" not in out
def test_safe_ids_pass_through_verbatim(self):
"""Existing sandboxes keep resolving to their current directory."""
from tools.environments.path_utils import sanitize_task_id_for_path
for value in ("default", "task-01.abc_def", "astropy__astropy-12907"):
assert sanitize_task_id_for_path(value) == value
def test_deterministic_and_collision_free_for_distinct_inputs(self):
from tools.environments.path_utils import sanitize_task_id_for_path
assert sanitize_task_id_for_path("a:b") == sanitize_task_id_for_path("a:b")
# substitution alone is not injective — the digest must disambiguate
assert sanitize_task_id_for_path("a:b") != sanitize_task_id_for_path("a_b")
assert sanitize_task_id_for_path("!!!") != sanitize_task_id_for_path("@@@")
def test_empty_and_traversal_inputs_are_neutralized(self):
from tools.environments.path_utils import sanitize_task_id_for_path
assert sanitize_task_id_for_path("") == "default"
for value in (".", "..", "../../etc", "..\\..\\escape"):
out = sanitize_task_id_for_path(value)
assert out not in {".", ".."}
assert "/" not in out and "\\" not in out
def test_oversized_input_truncates_with_unique_digest(self):
from tools.environments.path_utils import (
_SANDBOX_DIR_MAX_LEN,
sanitize_task_id_for_path,
)
long_a = "a" * 300 + ":1"
long_b = "a" * 300 + ":2"
out_a = sanitize_task_id_for_path(long_a)
out_b = sanitize_task_id_for_path(long_b)
assert len(out_a) <= _SANDBOX_DIR_MAX_LEN
assert ":" not in out_a
assert out_a != out_b
def test_sanitized_dir_is_creatable(self, tmp_path):
from tools.environments.path_utils import sanitize_task_id_for_path
target = tmp_path / "docker" / sanitize_task_id_for_path(
"session:agent:main:telegram:dm:12345"
)
target.mkdir(parents=True)
assert target.is_dir()