Files
hermes-agent/tools/file_tools_paths.py
Teknium d4cec15b47 refactor(tools): first-wave simplification of tools/ (file ops split, lazy_deps, code_exec, approval, browser, delegate, mcp, skills, terminal, voice, media)
Behavior-neutral structural pass over tools/*: god-file extractions into
sibling modules (file_operations_common/lint/search, file_tools_paths/
read_tracking/write, code_execution_env/rpc, tool_search_catalog/names/
validation, tts_command_provider, ...), duplicate helper unification,
if/elif -> dispatch tables, dead-code removal, docstring compaction.
Tool schemas (get_tool_definitions) verified byte-identical to base.
2026-09-02 14:43:45 -07:00

271 lines
11 KiB
Python

"""Path resolution for the file tools: task-aware base dir, ``~`` expansion, workspace-divergence warning.
Companion to ``tools.file_tools`` (which re-imports every name here). The core
invariant: the base directory used to anchor relative paths is ALWAYS absolute
and derived from the task's terminal cwd, never from the process cwd unless no
other anchor exists. A relative or sentinel ``TERMINAL_CWD`` would otherwise
silently anchor edits to the agent process cwd (e.g. the main repo while a
worktree session is active).
"""
import os
import posixpath
import sys
from pathlib import Path, PurePosixPath
# ``TERMINAL_CWD`` values that mean "not configured", not a directory to resolve
# against ("." from a stale config; "auto"/"cwd" are setup-wizard placeholders).
# The gateway sanitizes the same set at import time (gateway/run.py).
_TERMINAL_CWD_SENTINELS = frozenset({"", ".", "./", "auto", "cwd"})
_CONTAINER_PATH_BACKENDS_FALLBACK = frozenset({"docker", "singularity", "modal", "daytona", "vercel_sandbox"})
# Backend name inferred from the live environment's class name (first match wins).
_ENV_CLASS_NAME_HINTS = ("local", "ssh", "docker", "singularity", "modal", "daytona")
def _expand_tilde(path: str) -> str:
"""Expand ``~`` using the effective profile home when available.
In-process file tools share the gateway process's HOME, which may differ
from the profile-specific HOME interactive CLI sessions use; mirroring
``hermes_constants.get_subprocess_home()`` keeps ``~`` consistent across
interactive and gateway-driven (cron) runs.
"""
if not path or "~" not in path:
return path
try:
from hermes_constants import get_subprocess_home
home = get_subprocess_home()
except Exception:
home = None
if home and (path == "~" or path.startswith("~/")):
return home if path == "~" else os.path.join(home, path[2:])
return os.path.expanduser(path)
def _terminal_env_type_for_task(task_id: str = "default") -> str:
"""Best-effort terminal backend type for path-resolution decisions."""
try:
from tools.terminal_tool import (
_active_environments,
_env_lock,
_get_env_config,
_resolve_container_task_id,
)
try:
container_key = _resolve_container_task_id(task_id)
except Exception:
container_key = task_id
with _env_lock:
env = _active_environments.get(container_key) or _active_environments.get(task_id)
if env is not None:
name = env.__class__.__name__.lower()
for hint in _ENV_CLASS_NAME_HINTS:
if hint in name:
return hint
stamped = getattr(env, "_hermes_backend_name", None)
if isinstance(stamped, str) and stamped:
return stamped
cfg = _get_env_config()
return str(cfg.get("env_type") or os.getenv("TERMINAL_ENV") or "local").lower()
except Exception:
return str(os.getenv("TERMINAL_ENV") or "local").lower()
def _uses_container_paths(task_id: str = "default") -> bool:
env_type = _terminal_env_type_for_task(task_id)
try:
from tools.terminal_tool import _is_container_backend
return _is_container_backend(env_type)
except Exception:
return env_type in _CONTAINER_PATH_BACKENDS_FALLBACK
def _normalize_without_host_deref(path: str | Path | PurePosixPath) -> PurePosixPath:
"""Normalize path syntax without following host symlinks.
Container paths are meaningful inside the sandbox; ``Path.resolve()`` on the
host could dereference a host-side symlink (e.g. ``/workspace``) and rewrite
the path before Docker sees it.
"""
return PurePosixPath(posixpath.normpath(str(path)))
def _sentinel_free_abs_cwd(raw: str | None) -> str | None:
"""Return *raw* expanded when it is a non-sentinel ABSOLUTE anchor, else ``None``.
A relative anchor is meaningless without knowing which cwd it is relative
to — exactly the ambiguity that misroutes worktree edits.
"""
raw = str(raw or "").strip()
if raw.lower() in _TERMINAL_CWD_SENTINELS:
return None
expanded = _expand_tilde(raw)
if not os.path.isabs(expanded):
return None
return expanded
def _configured_terminal_cwd() -> str | None:
"""Return ``$TERMINAL_CWD`` only when it names a real (absolute, non-sentinel) anchor.
Scope-aware: under gateway multiplexing the routed profile's cwd lives in
the per-turn terminal scope, not the process env.
"""
from agent.runtime_cwd import scope_terminal_cwd
return _sentinel_free_abs_cwd(scope_terminal_cwd() or None)
def _registered_task_cwd_override(task_id: str = "default") -> str | None:
"""Return a registered cwd override keyed by the RAW task id, when available.
``terminal_tool`` collapses CWD-only task overrides to the shared
``"default"`` environment (TUI/dashboard/ACP sessions share one sandbox),
but the cwd value itself stays keyed by the raw session id — so read the
raw override before falling back to the collapsed container key.
"""
try:
from tools.terminal_tool import resolve_task_overrides
overrides = resolve_task_overrides(task_id)
except Exception:
return None
return _sentinel_free_abs_cwd(overrides.get("cwd"))
def _authoritative_workspace_root(task_id: str = "default") -> str | None:
"""Best-effort absolute workspace root, or ``None`` when no reliable anchor exists.
Order: (1) the session's own cwd record (written on every completed terminal
command; per-session, so one session's ``cd`` never leaks into another);
(2) a registered raw-keyed task/session cwd override (TUI/Desktop/ACP);
(3) a sentinel-free absolute ``$TERMINAL_CWD`` (``-w`` sessions).
"""
try:
from tools.terminal_tool import get_session_cwd
recorded = get_session_cwd(task_id)
except Exception:
recorded = None
if recorded:
return recorded
registered = _registered_task_cwd_override(task_id)
if registered:
return registered
return _configured_terminal_cwd()
def _resolve_base_dir(
task_id: str = "default",
*,
container_paths: bool | None = None,
) -> Path | PurePosixPath:
"""Return the ABSOLUTE base directory for resolving relative paths.
Uses ``_authoritative_workspace_root`` (live cwd → registered override →
``$TERMINAL_CWD``), falling back to the process cwd only as a last resort.
Sentinel/relative ``TERMINAL_CWD`` values are rejected outright rather than
anchored to the process cwd, so the result never depends on where the
agent process happens to run.
"""
root = _authoritative_workspace_root(task_id)
if container_paths is None:
container_paths = _uses_container_paths(task_id)
base_text = _expand_tilde(root) if root else os.getcwd()
if container_paths:
if not posixpath.isabs(base_text):
base_text = posixpath.join(os.getcwd(), base_text)
return _normalize_without_host_deref(base_text)
# Git Bash ``pwd -P`` reports ``/c/Users/...``; translate before Path so
# relative file-tool paths don't anchor under a nonexistent ``\\c\\Users``.
from tools.environments.local import _msys_to_windows_path
base_text = _msys_to_windows_path(base_text)
if sys.platform == "win32":
import ntpath
if not ntpath.isabs(base_text):
base_text = ntpath.join(os.getcwd(), base_text)
return Path(ntpath.normpath(base_text))
base = Path(base_text)
if not base.is_absolute():
# A backend reporting a relative cwd is anchored to the process cwd
# once, here, so the result no longer depends on cwd at resolve().
base = Path(os.getcwd()) / base
return base.resolve()
def _resolve_path_for_task(filepath: str, task_id: str = "default") -> Path | PurePosixPath:
"""Resolve *filepath* against the task's absolute base directory.
Absolute inputs are returned resolved-but-unanchored. On native Windows,
Git Bash / MSYS drive paths (``/c/Users/...``) are translated first so
they aren't treated as relative ``\\c\\Users\\...`` under the process cwd;
container/WSL Linux paths are never rewritten.
"""
container_paths = _uses_container_paths(task_id)
if container_paths:
expanded = _expand_tilde(filepath)
if posixpath.isabs(expanded):
return _normalize_without_host_deref(expanded)
resolved = _resolve_base_dir(task_id, container_paths=True) / expanded
return _normalize_without_host_deref(resolved)
from tools.environments.local import _msys_to_windows_path
expanded = _expand_tilde(_msys_to_windows_path(filepath))
if sys.platform == "win32":
import ntpath
if ntpath.isabs(expanded):
return Path(ntpath.normpath(expanded))
joined = ntpath.join(str(_resolve_base_dir(task_id, container_paths=False)), expanded)
return Path(ntpath.normpath(joined))
p = Path(expanded)
if p.is_absolute():
return p.resolve()
resolved = _resolve_base_dir(task_id, container_paths=False) / p
return resolved.resolve()
# Back-compat alias (imported by agent.context_references and tests).
_resolve_path = _resolve_path_for_task
def _path_resolution_warning(filepath: str, resolved: Path, task_id: str = "default") -> str | None:
"""Warn when a RELATIVE path resolved OUTSIDE the task's workspace root.
Surfaces the worktree-cwd divergence the moment it matters — the edit is
about to land in a different checkout than the terminal's cwd. ``None`` for
absolute paths, an unknown root, or a path correctly under the root. Fires
on the very first write even before any ``cd`` populated the cwd registry.
"""
try:
if Path(_expand_tilde(filepath)).is_absolute():
return None
workspace_root = _authoritative_workspace_root(task_id)
if not workspace_root:
return None
if _uses_container_paths(task_id):
root = _normalize_without_host_deref(Path(_expand_tilde(workspace_root)))
else:
root = Path(_expand_tilde(workspace_root)).resolve()
try:
resolved.relative_to(root)
return None
except ValueError:
return (
f"Relative path {filepath!r} resolved to {str(resolved)!r}, which is "
f"OUTSIDE the active workspace ({str(root)!r}). The edit will land in "
f"a different directory than the terminal's cwd. If this is not "
f"intended (e.g. a git-worktree session writing into the main "
f"checkout), pass an absolute path under the workspace instead."
)
except Exception:
return None