# 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
478 lines
18 KiB
Python
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()
|