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)
210 lines
8.9 KiB
Python
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
|