Files
hermes-agent/tools/file_tools_paths.py
Sora-bluesky 10ee9819f9 fix(checkpoints): do not feed container paths into host checkpoint storage
With a container terminal backend (docker, singularity, modal, daytona,
vercel_sandbox, container plugins) file-tool paths keep container semantics,
but the checkpoint hook handed them to the host-side CheckpointManager: a path
that does not exist on the host produced a useless snapshot attempt, one that
happens to exist on the host snapshotted the wrong tree, the destructive
terminal branch did the same with the container cwd, and the post-write ledger
hashed the container path on the host so safe restore could trust unrelated
host content. Every failure was swallowed, so a docker user saw "No checkpoints
found for /home/admin" with nothing behind it.

Classify the task's backend the way the file tools do (_uses_container_paths)
and, for container-backed tasks, take no checkpoint and record no ledger entry;
/rollback prints the reason and refuses diff and restore for that session (a
host checkpoint that predates it belongs to another tree), and the
rollback.restore RPC returns the same reason as a failed restore. The refusal
classifies the session's configured backend directly (in the gateway under the
session's own identity and profile scope, as a turn binds them), so it holds
before the first mutation of the session. Local and ssh backends are untouched. This stops the
false protection; it does not add rollback support for containers (translating
bind mounts is a separate contract).

Tests: eight cases in tests/agent/test_tool_executor_checkpoint_paths.py through
the production classifier (a fake docker environment registered for the task, or
the configured backend): missing host path, colliding host tree (POSIX),
destructive terminal command, post-write ledger on a real host file, /rollback
and rollback.restore refusal in a fresh session, the local session still
restoring, and unchanged local behavior. Five fail on main on Windows, where the
collision case is skipped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 97a5709e4980c8f85a5a640e6e5e11fe8f94affa)
2026-09-18 10:38:08 -07:00

210 lines
8.9 KiB
Python

"""Path resolution for the file tools: task-aware base dir, ``~`` expansion, workspace-divergence warning.
Core invariant: the base directory anchoring 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/sentinel ``TERMINAL_CWD`` would silently anchor
edits to the agent process cwd, e.g. the main repo during a worktree session).
"""
import os
import posixpath
import sys
from pathlib import Path, PurePosixPath
# ``TERMINAL_CWD`` values that mean "not configured" ("." from a stale config;
# "auto"/"cwd" are wizard placeholders). gateway/run.py sanitizes the same set.
_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 (``get_subprocess_home``) so
gateway/cron runs, whose process HOME may differ, agree with interactive CLI sessions.
This mirrors ``hermes_constants.get_subprocess_home()`` so that ``~`` resolves consistently regardless
of whether the tool runs interactively or inside a gateway-driven cron job (#48552).
"""
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()
hint = next((h for h in _ENV_CLASS_NAME_HINTS if h in name), None)
stamped = getattr(env, "_hermes_backend_name", None)
if hint or (isinstance(stamped, str) and stamped):
return hint or stamped
return str(_get_env_config().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 container_backend_for_task(task_id: str = "default") -> str | None:
"""The task's backend name when its file paths belong to a container, else None."""
return _terminal_env_type_for_task(task_id) if _uses_container_paths(task_id) else None
def _normalize_without_host_deref(path: str | Path | PurePosixPath) -> PurePosixPath:
"""Normalize path syntax without following host symlinks: container paths are
meaningful inside the sandbox, and a host-side ``/workspace`` symlink must not rewrite them."""
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 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)
return expanded if os.path.isabs(expanded) else None
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 scope."""
# See #68559.
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 overrides to the shared ``"default"``
env, but the cwd value stays keyed by the raw session id.
"""
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 (per-session, so one session's
``cd`` never leaks into another); (2) a registered raw-keyed cwd override
(TUI/Desktop/ACP); (3) a sentinel-free absolute ``$TERMINAL_CWD``.
"""
try:
from tools.terminal_tool import get_session_cwd
recorded = get_session_cwd(task_id)
except Exception:
recorded = None
return recorded or _registered_task_cwd_override(task_id) or _configured_terminal_cwd()
def _host_text(text: str, container_paths: bool) -> str:
"""Expand ``~``; on host backends also translate Git Bash ``/c/Users/...`` drive
paths before Path sees them. Container/WSL Linux paths are never rewritten."""
if not container_paths:
from tools.environments.local import _msys_to_windows_path
text = _msys_to_windows_path(text)
return _expand_tilde(text)
def _anchor(text: str, base, container_paths: bool) -> Path | PurePosixPath:
"""Return *text* as an absolute, normalized path, joining it onto ``base()`` when
relative. Container: pure-posix, no host deref. Host: resolve() (win32: ntpath normpath)."""
if container_paths:
if not posixpath.isabs(text):
text = posixpath.join(str(base()), text)
return _normalize_without_host_deref(text)
if sys.platform == "win32":
import ntpath
if not ntpath.isabs(text):
text = ntpath.join(str(base()), text)
return Path(ntpath.normpath(text))
p = Path(text)
if not p.is_absolute():
p = Path(base()) / p
return p.resolve()
def _resolve_base_dir(
task_id: str = "default", *, container_paths: bool | None = None) -> Path | PurePosixPath:
"""Return the ABSOLUTE base directory for resolving relative paths:
``_authoritative_workspace_root``, else the process cwd as a last resort."""
root = _authoritative_workspace_root(task_id)
if container_paths is None:
container_paths = _uses_container_paths(task_id)
# A backend's relative cwd is anchored to the process cwd once, here.
return _anchor(_host_text(root or os.getcwd(), container_paths), os.getcwd, container_paths)
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)."""
container_paths = _uses_container_paths(task_id)
return _anchor(_host_text(filepath, container_paths),
lambda: _resolve_base_dir(task_id, container_paths=container_paths), container_paths)
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 (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 under the root."""
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()
if resolved.is_relative_to(root):
return None
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