Files
hermes-agent/tests/test_scratch_dir.py
ethernet 890bbbda1f Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	apps/desktop/e2e/archived-hidden-session-recoverable.spec.ts
#	apps/desktop/e2e/bot-chat-message-agent-friendly-name.spec.ts
#	apps/desktop/e2e/bot-mailbox-unreadable-ticket.spec.ts
#	apps/desktop/e2e/bot-mode-roster-localized.spec.ts
#	apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts
#	apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts
#	apps/desktop/e2e/bot-roster-group-row-organisation.spec.ts
#	apps/desktop/e2e/bot-roster-ignores-infra-dirs.spec.ts
#	apps/desktop/e2e/bot-roster-timestamp-meta.spec.ts
#	apps/desktop/e2e/bot-roster-user-sections.spec.ts
#	apps/desktop/e2e/bot-routines-pane-narrow.spec.ts
#	apps/desktop/e2e/bot-row-open-recent-session.spec.ts
#	apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts
#	apps/desktop/e2e/group-composer-auto-grow.spec.ts
#	apps/desktop/e2e/group-create-gate-remote-roster.spec.ts
#	apps/desktop/e2e/group-prompt-renamed-primary-handle.spec.ts
#	apps/desktop/e2e/hosted-room-backend-continuity.spec.ts
#	apps/desktop/e2e/hosted-room-legacy-store-migration.spec.ts
#	apps/desktop/e2e/settings-scope-chips-bot-title.spec.ts
#	apps/desktop/e2e/worktree-branch-status.spec.ts
#	apps/desktop/electron/backend-probes.test.ts
#	apps/desktop/electron/connection-apply.test.ts
#	apps/desktop/electron/desktop-electron-pin.test.ts
#	apps/desktop/electron/desktop-uninstall.test.ts
#	apps/desktop/electron/gateway-file-download-transport.test.ts
#	apps/desktop/electron/gateway-stop-before-update.test.ts
#	apps/desktop/electron/github-api-auth.test.ts
#	apps/desktop/electron/registry-primary-profile-scope.test.ts
#	apps/desktop/electron/update-api-check.test.ts
#	apps/desktop/electron/update-handoff-marker.test.ts
#	apps/desktop/electron/venv-blocker-scan.test.ts
#	apps/desktop/scripts/after-extract.test.mjs
#	apps/desktop/scripts/local-pack-publish.test.mjs
#	apps/desktop/scripts/tasks-scroll.test.mjs
#	apps/desktop/src/app/settings/model-settings.test.tsx
#	apps/desktop/src/app/updates-overlay.blockers.test.tsx
#	apps/desktop/src/components/desktop-install-overlay.test.tsx
#	apps/desktop/src/lib/update-copy.test.ts
#	scripts/ci/check_os_marker_fakes.py
#	tests-js/desktop-mac-usage-descriptions.test.ts
#	tests-js/node-engine-alignment.test.ts
#	tests/agent/lsp/test_install_and_lint_fixes.py
#	tests/agent/test_command_token_source.py
#	tests/agent/test_compression_boundary_hook.py
#	tests/agent/test_create_openai_client_ssl_verify.py
#	tests/agent/test_custom_provider_ca_probes.py
#	tests/agent/test_endpoint_blackhole.py
#	tests/agent/test_estimator_parity.py
#	tests/agent/test_in_place_compaction.py
#	tests/agent/test_moa_loop_mode.py
#	tests/agent/test_model_metadata.py
#	tests/agent/test_skill_session_platform_gate.py
#	tests/agent/test_skill_utils.py
#	tests/agent/test_ssl_ca_guard.py
#	tests/computer_use/test_doctor.py
#	tests/cron/test_codex_execution_paths.py
#	tests/cron/test_cron_bot_chat_delivery.py
#	tests/cron/test_cron_script.py
#	tests/cron/test_media_delivery_parity.py
#	tests/cron/test_misfire_catchup.py
#	tests/cron/test_parallel_pool.py
#	tests/cron/test_recurring_eagain_redispatch.py
#	tests/gateway/test_choice_picker.py
#	tests/gateway/test_control_socket_windows_live.py
#	tests/gateway/test_dingtalk.py
#	tests/gateway/test_feishu.py
#	tests/gateway/test_feishu_onboard.py
#	tests/gateway/test_gateway_shutdown.py
#	tests/gateway/test_matrix.py
#	tests/gateway/test_model_command_custom_providers.py
#	tests/gateway/test_reasoning_command.py
#	tests/gateway/test_runtime_footer.py
#	tests/gateway/test_session.py
#	tests/gateway/test_session_hygiene.py
#	tests/gateway/test_status.py
#	tests/gateway/test_teams.py
#	tests/gateway/test_turn_lease.py
#	tests/gateway/test_whatsapp_connect.py
#	tests/hermes_cli/test_approvals_command.py
#	tests/hermes_cli/test_auth_store_lock_concurrent.py
#	tests/hermes_cli/test_backup.py
#	tests/hermes_cli/test_banner_git_state.py
#	tests/hermes_cli/test_certifi_repair.py
#	tests/hermes_cli/test_cmd_update.py
#	tests/hermes_cli/test_compat_manifest_targets.py
#	tests/hermes_cli/test_computer_use_cli.py
#	tests/hermes_cli/test_cpr_local_leak.py
#	tests/hermes_cli/test_dashboard_auth_gate.py
#	tests/hermes_cli/test_dashboard_procs_kill_grace.py
#	tests/hermes_cli/test_desktop_lifecycle_windows_live.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_doctor_command_install.py
#	tests/hermes_cli/test_fleet_config_migration_windows_live.py
#	tests/hermes_cli/test_gateway.py
#	tests/hermes_cli/test_gateway_platform_gating.py
#	tests/hermes_cli/test_gateway_restart_loop.py
#	tests/hermes_cli/test_gateway_task_probe.py
#	tests/hermes_cli/test_gateway_wsl.py
#	tests/hermes_cli/test_gui_command.py
#	tests/hermes_cli/test_install_cua_driver.py
#	tests/hermes_cli/test_kanban_db.py
#	tests/hermes_cli/test_lazy_command_exports.py
#	tests/hermes_cli/test_lazy_refresh_venv_repair.py
#	tests/hermes_cli/test_linux_desktop_entry.py
#	tests/hermes_cli/test_local_runtime.py
#	tests/hermes_cli/test_local_runtime_updates.py
#	tests/hermes_cli/test_managed_uv.py
#	tests/hermes_cli/test_mcp_reload_confirm_gate.py
#	tests/hermes_cli/test_nous_subscription.py
#	tests/hermes_cli/test_npm_engine.py
#	tests/hermes_cli/test_personality_none.py
#	tests/hermes_cli/test_pet_toggle.py
#	tests/hermes_cli/test_plan_reconciliation_windows_live.py
#	tests/hermes_cli/test_plugin_event_bus.py
#	tests/hermes_cli/test_plugin_manifest_v2.py
#	tests/hermes_cli/test_plugin_packs.py
#	tests/hermes_cli/test_plugins_cmd.py
#	tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py
#	tests/hermes_cli/test_process_identity.py
#	tests/hermes_cli/test_profiles.py
#	tests/hermes_cli/test_profiles_sidebar_cache.py
#	tests/hermes_cli/test_pty_bridge.py
#	tests/hermes_cli/test_resolve_turn_limit.py
#	tests/hermes_cli/test_serve_runtime_inventory.py
#	tests/hermes_cli/test_session_vacuum_config.py
#	tests/hermes_cli/test_set_config_value.py
#	tests/hermes_cli/test_signal_handler_kanban_worker.py
#	tests/hermes_cli/test_slash_confirm_windows.py
#	tests/hermes_cli/test_stale_pid_guard.py
#	tests/hermes_cli/test_startup_fast_guards.py
#	tests/hermes_cli/test_status.py
#	tests/hermes_cli/test_telegram_managed_bot.py
#	tests/hermes_cli/test_tools_config.py
#	tests/hermes_cli/test_update_apply_shallow_count.py
#	tests/hermes_cli/test_update_autostash.py
#	tests/hermes_cli/test_update_concurrent_quarantine.py
#	tests/hermes_cli/test_update_fetch_failure_classifier.py
#	tests/hermes_cli/test_update_fleet_probe_resume_token.py
#	tests/hermes_cli/test_update_handoff_backend_reap.py
#	tests/hermes_cli/test_update_handoff_desktop_rebuild.py
#	tests/hermes_cli/test_update_head_moved_gate.py
#	tests/hermes_cli/test_update_host_obligation.py
#	tests/hermes_cli/test_update_import_guard.py
#	tests/hermes_cli/test_update_interrupted_recovery.py
#	tests/hermes_cli/test_update_inventory.py
#	tests/hermes_cli/test_update_launchd_unloaded_gateway.py
#	tests/hermes_cli/test_update_missing_configured_deps.py
#	tests/hermes_cli/test_update_modified_notice.py
#	tests/hermes_cli/test_update_multiplex_migration_hook.py
#	tests/hermes_cli/test_update_no_gateway_restart.py
#	tests/hermes_cli/test_update_orphan_backend_reap.py
#	tests/hermes_cli/test_update_parked_branch_guard.py
#	tests/hermes_cli/test_update_post_pull_syntax_guard.py
#	tests/hermes_cli/test_update_receipt.py
#	tests/hermes_cli/test_update_self_lock.py
#	tests/hermes_cli/test_update_shim_fail_closed.py
#	tests/hermes_cli/test_update_shim_self_lock.py
#	tests/hermes_cli/test_update_sqlite_remediation.py
#	tests/hermes_cli/test_update_stale_dashboard.py
#	tests/hermes_cli/test_update_stale_virtualenv.py
#	tests/hermes_cli/test_update_venv_health.py
#	tests/hermes_cli/test_update_venv_ownership_preflight.py
#	tests/hermes_cli/test_update_wedged_gateway.py
#	tests/hermes_cli/test_update_yes_flag.py
#	tests/hermes_cli/test_update_zip_two_phase.py
#	tests/hermes_cli/test_urllib_security.py
#	tests/hermes_cli/test_ux_messages_auth_config.py
#	tests/hermes_cli/test_ux_messages_startup.py
#	tests/hermes_cli/test_venv_holder_classifier.py
#	tests/hermes_cli/test_verify_console_scripts.py
#	tests/hermes_cli/test_verify_core_dependencies.py
#	tests/hermes_cli/test_web_server.py
#	tests/hermes_cli/test_web_server_console_ws.py
#	tests/hermes_cli/test_web_server_ws_ping.py
#	tests/hermes_cli/test_web_ui_build.py
#	tests/hermes_state/test_fts_rebuild_admission.py
#	tests/hermes_state/test_hermes_state.py
#	tests/plugins/memory/test_memory_lazy_install.py
#	tests/plugins/test_google_meet_plugin.py
#	tests/plugins/test_langfuse_plugin.py
#	tests/plugins/test_security_guidance_plugin.py
#	tests/plugins/test_transform_llm_output_hook.py
#	tests/scripts/desktop_update/test_desktop_update_windows_gateway_flag.py
#	tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py
#	tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py
#	tests/scripts/install/test_install_clone_throttle_fallback.py
#	tests/scripts/install/test_install_lockfile_churn.py
#	tests/scripts/install/test_install_no_initial_commit.py
#	tests/scripts/install/test_install_sh_browser_install.py
#	tests/scripts/install/test_install_sh_node_prerelease.py
#	tests/scripts/install/test_install_sh_symlink_stomp.py
#	tests/scripts/install/test_install_sh_uv_lock_config.py
#	tests/scripts/install/test_install_unmerged_index.py
#	tests/scripts/test_contributor_map.py
#	tests/scripts/test_run_tests_parallel.py
#	tests/skills/test_competitor_news_monitor_skill.py
#	tests/skills/test_document_to_action_items_skill.py
#	tests/skills/test_google_workspace_setup.py
#	tests/skills/test_google_workspace_setup_deps.py
#	tests/skills/test_grounded_citations_skill.py
#	tests/skills/test_ip_as_logo_skill.py
#	tests/skills/test_live_dashboard_skill.py
#	tests/skills/test_mcp_oauth_remote_gateway_skill.py
#	tests/skills/test_office_document_skills.py
#	tests/skills/test_openclaw_migration.py
#	tests/skills/test_product_price_monitor_skill.py
#	tests/skills/test_scrollcraft_skill.py
#	tests/skills/test_setup_wizard_generator_skill.py
#	tests/skills/test_weekly_review_planning_skill.py
#	tests/test_engines_satisfiable.py
#	tests/test_fast_safe_load.py
#	tests/test_hermes_bootstrap.py
#	tests/test_hermes_constants.py
#	tests/test_hermes_logging.py
#	tests/test_managed_runtime_resolution.py
#	tests/test_model_tools_async_bridge.py
#	tests/test_packaging_build_guard.py
#	tests/test_packaging_metadata.py
#	tests/test_yaml_indent_consistency.py
#	tests/tools/test_approval_timeout_overflow.py
#	tests/tools/test_base_environment.py
#	tests/tools/test_bot_mode_dm.py
#	tests/tools/test_browser_chromium_check.py
#	tests/tools/test_browser_hardening.py
#	tests/tools/test_browser_homebrew_paths.py
#	tests/tools/test_browser_npx_warmup.py
#	tests/tools/test_browser_orphan_reaper.py
#	tests/tools/test_browser_real_profile.py
#	tests/tools/test_browser_use_cli.py
#	tests/tools/test_clipboard.py
#	tests/tools/test_code_execution.py
#	tests/tools/test_code_execution_modes.py
#	tests/tools/test_code_execution_windows_env.py
#	tests/tools/test_computer_use.py
#	tests/tools/test_delegate_liveness_timeout.py
#	tests/tools/test_execute_code_approval_cluster.py
#	tests/tools/test_execution_flag_detection.py
#	tests/tools/test_fal_common.py
#	tests/tools/test_file_operations.py
#	tests/tools/test_file_tools.py
#	tests/tools/test_file_tools_cwd_resolution.py
#	tests/tools/test_file_tools_live.py
#	tests/tools/test_lazy_deps.py
#	tests/tools/test_lazy_deps_durable_target.py
#	tests/tools/test_lazy_deps_managed.py
#	tests/tools/test_local_env_blocklist.py
#	tests/tools/test_local_tempdir.py
#	tests/tools/test_macos_protected_search.py
#	tests/tools/test_mcp_npx_cached_bin.py
#	tests/tools/test_oneshot_completion_linger.py
#	tests/tools/test_process_registry.py
#	tests/tools/test_read_file_schema_gating.py
#	tests/tools/test_skill_improvements.py
#	tests/tools/test_skills_sync.py
#	tests/tools/test_termux_api_detection.py
#	tests/tools/test_tirith_security.py
#	tests/tools/test_transcription_tools.py
#	tests/tools/test_tts_streaming.py
#	tests/tools/test_wake_word.py
#	tests/tui_gateway/test_compute_host_borrowed_lease.py
#	tests/tui_gateway/test_compute_host_turn_protocol.py
#	tests/tui_gateway/test_isolated_orphan_activity.py
#	tests/tui_gateway/test_protocol.py
#	tests/tui_gateway/test_slash_worker_profile_home.py
#	tests/tui_gateway/test_subprocess_encoding.py
#	tests/tui_gateway/test_tui_gateway_server.py
#	ui-tui/src/__tests__/terminalParity.test.ts
#	ui-tui/src/__tests__/termuxComposerLayout.test.ts
#	ui-tui/src/__tests__/textInputFastEcho.test.ts
2026-09-23 07:02:44 -04:00

287 lines
14 KiB
Python

"""Scratch dir contract: TMPDIR/TMP/TEMP follow HERMES_HOME/cache/scratch unless the user set them."""
import os
import stat
import subprocess
import sys
import time
from unittest.mock import patch
import pytest
from hermes_constants import apply_scratch_tmp_env, get_scratch_dir, prune_scratch_dir
def test_scratch_env_follows_home_and_respects_user_tmpdir(tmp_path):
"""Unset temp vars → scratch of env HERMES_HOME; a Hermes-exported value re-derives for a routed
home; a user/OS-set value (macOS ``/var/folders``, ``%TEMP%``) is never touched."""
home_a, home_b = tmp_path / "a", tmp_path / "b"
env = {"HERMES_HOME": str(home_a)}
assert apply_scratch_tmp_env(env) is True
scratch_a = str(home_a / "cache" / "scratch")
assert env["TMPDIR"] == env["TMP"] == env["TEMP"] == env["HERMES_SCRATCH_DIR"] == scratch_a
assert os.path.isdir(scratch_a)
env["HERMES_HOME"] = str(home_b) # child served under another profile's home
assert apply_scratch_tmp_env(env) is True
assert env["TMPDIR"] == str(home_b / "cache" / "scratch")
user_env = {"HERMES_HOME": str(home_a), "TMPDIR": "/var/folders/zz"}
assert apply_scratch_tmp_env(user_env) is False
assert user_env["TMPDIR"] == "/var/folders/zz" and "HERMES_SCRATCH_DIR" not in user_env
assert "TMP" not in user_env # a partially user-set triple is left exactly as found
def test_bootstrap_import_exports_scratch_to_process_and_children(tmp_path):
"""``import hermes_bootstrap`` alone makes ``tempfile`` (this process AND a child) land in scratch."""
env = {k: v for k, v in os.environ.items() if k not in ("TMPDIR", "TMP", "TEMP", "HERMES_SCRATCH_DIR")}
env["HERMES_HOME"] = str(tmp_path)
code = ("import tempfile, os, subprocess, sys; import hermes_bootstrap; "
"print(tempfile.gettempdir()); "
"print(subprocess.run([sys.executable, '-c', 'import tempfile;print(tempfile.gettempdir())'],"
" capture_output=True, text=True).stdout.strip())")
out = subprocess.run([sys.executable, "-c", code], env=env, capture_output=True, text=True, encoding="utf-8",
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), check=True)
expected = str(tmp_path / "cache" / "scratch")
assert out.stdout.split() == [expected, expected]
def test_prune_removes_idle_entries_and_keeps_trees_written_deep_inside(tmp_path):
"""Idle retention: an entry goes when nothing in its subtree was written within the window;
a tree whose only recent write is three levels down is still in use and stays, even though
its top-level mtime is ancient (a directory's mtime ignores writes below its children)."""
scratch = get_scratch_dir(tmp_path, prune=False)
idle, live, fresh = scratch / "idle", scratch / "live", scratch / "fresh.txt"
deep = live / "lane" / "wt"
deep.mkdir(parents=True)
idle.mkdir()
(idle / "f").write_text("x", encoding="utf-8")
(deep / "log").write_text("x", encoding="utf-8")
fresh.write_text("y", encoding="utf-8")
ancient = time.time() - 30 * 3600
for path in (idle, idle / "f", live, live / "lane", deep):
os.utime(path, (ancient, ancient))
assert prune_scratch_dir(scratch) == 1
assert not idle.exists() and live.exists() and fresh.exists()
@pytest.mark.platforms("posix") # POSIX directory modes
class TestScratchDirPermissionPolicy:
"""get_scratch_dir must honor the home permission policy instead of a blanket 0700:
an explicit HERMES_HOME_MODE and a managed/shared home win (#117347)."""
@staticmethod
def _native_mode_after_chmod(path, requested=0o2770):
"""Return what this host/filesystem preserves from a real chmod request.
macOS may clear setgid when the directory group is not one of the caller's groups.
The portable contract is to request the operator's mode and preserve every bit the
host accepts, not to pretend all POSIX filesystems retain identical special bits.
"""
os.chmod(path, requested)
return stat.S_IMODE(os.stat(path).st_mode)
def _isolate_env(self, monkeypatch, tmp_path):
# A known, marker-free effective home: each case below plants `.managed` in the home whose
# scratch dir it exercises (`get_scratch_dir(home)` reads the marker there), and the real
# home's own marker must not leak into callers that still resolve the effective home.
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
for var in ("HERMES_HOME_MODE", "HERMES_MANAGED", "HERMES_CONTAINER",
"HERMES_SKIP_CHMOD", "HERMES_UID", "HERMES_GID"):
monkeypatch.delenv(var, raising=False)
def test_default_is_owner_only(self, tmp_path, monkeypatch):
self._isolate_env(monkeypatch, tmp_path)
scratch = get_scratch_dir(tmp_path, prune=False)
assert stat.S_IMODE(os.stat(scratch).st_mode) == 0o700
def test_explicit_home_mode_uses_native_chmod_semantics(self, tmp_path, monkeypatch):
self._isolate_env(monkeypatch, tmp_path)
probe = tmp_path / "mode-probe"
probe.mkdir()
expected = self._native_mode_after_chmod(probe)
monkeypatch.setenv("HERMES_HOME_MODE", "2770")
scratch = get_scratch_dir(tmp_path, prune=False)
assert stat.S_IMODE(os.stat(scratch).st_mode) == expected
def test_managed_env_leaves_preexisting_mode_untouched(self, tmp_path, monkeypatch):
self._isolate_env(monkeypatch, tmp_path)
monkeypatch.setenv("HERMES_MANAGED", "nixos")
pre = tmp_path / "cache" / "scratch"
pre.mkdir(parents=True)
expected = self._native_mode_after_chmod(pre)
scratch = get_scratch_dir(tmp_path, prune=False)
assert stat.S_IMODE(os.stat(scratch).st_mode) == expected
def test_managed_marker_file_leaves_preexisting_mode_untouched(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
self._isolate_env(monkeypatch, tmp_path)
(home / ".managed").write_text("nixos", encoding="utf-8")
pre = home / "cache" / "scratch"
pre.mkdir(parents=True)
expected = self._native_mode_after_chmod(pre)
scratch = get_scratch_dir(home, prune=False)
assert stat.S_IMODE(os.stat(scratch).st_mode) == expected
def test_empty_managed_marker_counts_as_managed(self, tmp_path, monkeypatch):
# Legacy NixOS module wrote an empty marker; config.get_managed_system treats it as
# managed, so the parity twin must too (not re-enable chmod on managed installs).
home = tmp_path / "home"
home.mkdir()
self._isolate_env(monkeypatch, tmp_path)
(home / ".managed").write_text("", encoding="utf-8")
pre = home / "cache" / "scratch"
pre.mkdir(parents=True)
expected = self._native_mode_after_chmod(pre)
scratch = get_scratch_dir(home, prune=False)
assert stat.S_IMODE(os.stat(scratch).st_mode) == expected
def test_unreadable_managed_marker_counts_as_managed(self, tmp_path, monkeypatch):
# A marker that exists but cannot be read (OSError -> "") still counts as managed.
home = tmp_path / "home"
home.mkdir()
self._isolate_env(monkeypatch, tmp_path)
(home / ".managed").mkdir()
pre = home / "cache" / "scratch"
pre.mkdir(parents=True)
expected = self._native_mode_after_chmod(pre)
scratch = get_scratch_dir(home, prune=False)
assert stat.S_IMODE(os.stat(scratch).st_mode) == expected
def test_effective_home_marker_does_not_govern_another_homes_scratch(self, tmp_path, monkeypatch):
# The caller's home decides the policy. A boot caller (``export_scratch_tmp_env``) and
# ``hermes doctor`` have already resolved theirs, so the marker lookup must not fall back
# to ``get_hermes_home()``: for a sticky profile with HERMES_HOME unset that lookup warns
# about the default profile on every command, and it is the wrong home to judge by anyway.
selected = tmp_path / "home"
selected.mkdir()
self._isolate_env(monkeypatch, tmp_path)
(selected / ".managed").write_text("nixos", encoding="utf-8")
other = tmp_path / "other"
pre = other / "cache" / "scratch"
pre.mkdir(parents=True)
os.chmod(pre, 0o750)
assert stat.S_IMODE(os.stat(get_scratch_dir(other, prune=False)).st_mode) == 0o700
def test_canonical_container_signal_without_env_override_keeps_operator_mode(self, tmp_path, monkeypatch):
# Podman/containerd/K8s runtimes often don't export HERMES_CONTAINER; the canonical
# _detect_container breadth (not the narrower legacy signal set) must skip the chmod.
self._isolate_env(monkeypatch, tmp_path)
monkeypatch.setattr("hermes_constants._detect_container", lambda: True)
pre = tmp_path / "cache" / "scratch"
pre.mkdir(parents=True)
os.chmod(pre, 0o750)
scratch = get_scratch_dir(tmp_path, prune=False)
assert stat.S_IMODE(os.stat(scratch).st_mode) == 0o750
def test_repeated_calls_do_not_strip_filesystem_supported_mode_bits(self, tmp_path, monkeypatch):
self._isolate_env(monkeypatch, tmp_path)
probe = tmp_path / "mode-probe"
probe.mkdir()
expected = self._native_mode_after_chmod(probe)
monkeypatch.setenv("HERMES_HOME_MODE", "2770")
first = get_scratch_dir(tmp_path, prune=False)
second = get_scratch_dir(tmp_path, prune=False)
assert first == second
assert stat.S_IMODE(os.stat(second).st_mode) == expected
def test_container_keeps_operator_mode_but_honors_explicit(self, tmp_path, monkeypatch):
self._isolate_env(monkeypatch, tmp_path)
monkeypatch.setenv("HERMES_CONTAINER", "1")
pre = tmp_path / "cache" / "scratch"
pre.mkdir(parents=True)
os.chmod(pre, 0o750)
assert stat.S_IMODE(os.stat(get_scratch_dir(tmp_path, prune=False)).st_mode) == 0o750
probe = tmp_path / "mode-probe"
probe.mkdir()
expected = self._native_mode_after_chmod(probe)
monkeypatch.setenv("HERMES_HOME_MODE", "2770")
assert stat.S_IMODE(os.stat(get_scratch_dir(tmp_path, prune=False)).st_mode) == expected
def test_hermes_uid_gid_applied_to_scratch(self, tmp_path, monkeypatch):
self._isolate_env(monkeypatch, tmp_path)
monkeypatch.setenv("HERMES_UID", "1000")
monkeypatch.setenv("HERMES_GID", "911")
with patch.object(os, "chown") as mock_chown:
get_scratch_dir(tmp_path, prune=False)
mock_chown.assert_called_once_with(tmp_path / "cache" / "scratch", 1000, 911)
@pytest.mark.platforms("posix") # POSIX file modes
def test_secure_file_skips_chmod_on_canonical_container_signal(tmp_path, monkeypatch):
"""_secure_file skips on the same canonical container signal that apply_secure_dir_policy /
get_scratch_dir already honor (one policy implementation in hermes_constants)."""
from hermes_cli import config
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
for var in ("HERMES_MANAGED", "HERMES_CONTAINER", "HERMES_SKIP_CHMOD"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setattr("hermes_constants._detect_container", lambda: True)
f = tmp_path / "config.yaml"
f.write_text("", encoding="utf-8")
os.chmod(f, 0o640)
config._secure_file(f)
assert stat.S_IMODE(os.stat(f).st_mode) == 0o640
def test_prune_reaps_process_living_in_idle_entry_and_spares_live_tree(tmp_path):
"""A process whose cwd is inside an idle entry is gone by the time the entry is
(a lane's headless browsers survived for days with a deleted cwd); one living in a
tree that is still being written is not touched."""
import subprocess
scratch = get_scratch_dir(tmp_path, prune=False)
idle, live = scratch / "idle-lane" / "wt", scratch / "live-lane" / "wt"
idle.mkdir(parents=True)
live.mkdir(parents=True)
ancient = time.time() - 30 * 3600
for path in (idle.parent, idle, live.parent, live):
os.utime(path, (ancient, ancient))
(live / "log").write_text("still writing", encoding="utf-8")
sleeper = [sys.executable, "-c", "import time; time.sleep(60)"]
doomed = subprocess.Popen(sleeper, cwd=str(idle), stdin=subprocess.DEVNULL)
spared = subprocess.Popen(sleeper, cwd=str(live), stdin=subprocess.DEVNULL)
try:
assert prune_scratch_dir(scratch) == 1
assert doomed.wait(timeout=10) is not None
assert spared.poll() is None
assert not idle.parent.exists() and live.exists()
finally:
for proc in (doomed, spared):
if proc.poll() is None:
proc.kill()
proc.wait(timeout=10)
def test_prune_releases_git_worktree_registration_of_idle_entry(tmp_path):
"""Deleting a scratch entry that held a linked worktree leaves the repo with no
dangling registration (10 sat in one repo's ``git worktree list`` after cleanup)."""
import subprocess
def git(*args, cwd):
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True,
stdin=subprocess.DEVNULL, env={**os.environ, "GIT_CONFIG_GLOBAL": os.devnull,
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@x",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@x"})
repo = tmp_path / "repo"
repo.mkdir()
git("init", "-q", cwd=repo)
(repo / "f").write_text("x", encoding="utf-8")
git("add", "f", cwd=repo)
git("commit", "-q", "-m", "init", cwd=repo)
scratch = get_scratch_dir(tmp_path, prune=False)
tree = scratch / "lane" / "abwt"
tree.parent.mkdir()
git("worktree", "add", "-q", "--detach", str(tree), cwd=repo)
ancient = time.time() - 30 * 3600
for dirpath, dirnames, filenames in os.walk(tree.parent):
for name in dirnames + filenames:
os.utime(os.path.join(dirpath, name), (ancient, ancient), follow_symlinks=False)
os.utime(tree.parent, (ancient, ancient))
assert prune_scratch_dir(scratch) == 1
listing = subprocess.run(["git", "worktree", "list", "--porcelain"], cwd=repo, capture_output=True,
text=True, stdin=subprocess.DEVNULL, check=True).stdout
assert str(tree) not in listing and not tree.exists()