mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-08-28 05:00:04 -04:00
feb5736dfb
Closes #5757 `DEEPAGENTS_HOME` now selects the dcode user profile and trust root at launch while preserving `~/.deepagents` as the default. --- This makes the configured home an immutable, normalized path captured before dotenv loading and shared by the client, server, reloads, and child processes. A central path snapshot separates profile data, installation-owned resources, and project configuration so cwd changes and mutable environment state cannot move the trust root. Absolute profiles also remain usable when the launch user's home cannot be resolved; optional home-based integrations are skipped in that case. Profile resolution rejects ambiguous or unsafe roots, including relative and `~user` forms, filesystem and launch-home aliases, dangling symlinks, non-directories, and unreadable or unsearchable directories. The installer applies the same validation as Python and changes ownership only for exact leaves it creates. MCP discovery now retains explicit user or project provenance. Only the exact configured user `.mcp.json` receives user-level trust, and filesystem aliases or collisions fail closed to project scope. This prevents project dotenv files, ancestor homes, checkout-contained profiles, and case or symlink aliases from self-approving project MCP servers. Install and update locks and managed ripgrep prefer installation-scoped locations, with profile-scoped fallbacks when the shared locations are unusable. A fallback ripgrep is checksum-verified and exposed through a process-private `PATH` shim so profile-controlled sibling executables never enter subprocess lookup. Runtime consumers, prompts, bundled skills, UI messages, diagnostics, and token permission hints use the effective configured paths, and failed write-probe cleanup is surfaced without repeated warnings. <details> <summary>Test plan</summary> - Added deterministic path, dotenv, cwd, reload, client/server, missing-home, and subprocess regressions. - Added root-validation and installer parity coverage, including symlink, permission, ownership, and write-probe cases. - Added MCP provenance, filesystem-identity, trust-classification, and project self-approval security regressions. - Added shared-lock, managed-ripgrep fallback, checksum, private-`PATH`-shim, and optional-ripgrep regressions. - Added prompt, bundled-skill, diagnostics, and MCP token-path consumer regressions. - Current-head validation is covered by the `deepagents-code` lint job and Python 3.12–3.14 test matrix. </details> --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
426 lines
16 KiB
Python
426 lines
16 KiB
Python
"""Storage paths for offloaded conversation history."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import stat
|
|
import tempfile
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path, PurePath
|
|
|
|
from deepagents_code._paths import get_deepagents_home
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_FALLBACK_ARTIFACTS_ROOT = "/dcode-artifacts-fallback"
|
|
|
|
_SECONDS_PER_DAY = 86_400
|
|
|
|
CONVERSATION_HISTORY_DIRNAME = "conversation_history"
|
|
"""Subdirectory of the offload root that holds per-thread conversation archives.
|
|
|
|
Lives directly under `~/.deepagents/` in local mode. The `/agent` picker
|
|
excludes this reserved name in addition to requiring an `AGENTS.md` marker.
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _ArtifactsStorage:
|
|
"""Agent-visible artifacts root and optional routed large-result directory."""
|
|
|
|
root: str
|
|
large_results_dir: Path | None = None
|
|
|
|
|
|
def _filesystem_tool_path(path: PurePath) -> str:
|
|
"""Represent an absolute host path in the filesystem tool path format.
|
|
|
|
Drive-qualified paths are rejected by the SDK's virtual path validation. The
|
|
Windows extended-length form keeps the drive while starting with the `/`
|
|
required by filesystem tools; `pathlib` and Windows APIs still resolve it to
|
|
the same host directory.
|
|
|
|
Args:
|
|
path: Absolute host path to represent.
|
|
|
|
Returns:
|
|
A forward-slash path accepted by the filesystem tool contract.
|
|
"""
|
|
normalized = path.as_posix()
|
|
if path.drive and not path.drive.startswith("\\\\"):
|
|
return f"//?/{normalized}"
|
|
return normalized
|
|
|
|
|
|
_EPHEMERAL_OFFLOAD_STORAGE = False
|
|
"""Whether the most recent `_offload_fallback_root` fell back to temp storage."""
|
|
|
|
_UNIQUE_OFFLOAD_FALLBACK_ROOT: Path | None = None
|
|
"""Private random fallback root that cannot be reconstructed on a later call."""
|
|
|
|
|
|
def offload_storage_is_ephemeral() -> bool:
|
|
"""Return whether offload history is routed to non-persistent storage.
|
|
|
|
`True` when the persistent `~/.deepagents` location was unwritable and the
|
|
most recent `_offload_fallback_root` fell back to a temporary directory that
|
|
may not survive a restart. Only meaningful in local mode, where
|
|
`_offload_fallback_root` runs in the same process as the UI; in
|
|
server/sandbox mode persistence is owned by the server backend and this flag
|
|
stays `False` client-side.
|
|
|
|
Returns:
|
|
`True` if the local offload root is a temporary, non-persistent
|
|
directory; `False` when it is the persistent per-user location (or was
|
|
never resolved in this process).
|
|
"""
|
|
return _EPHEMERAL_OFFLOAD_STORAGE
|
|
|
|
|
|
def _harden_dir(path: Path) -> None:
|
|
"""Create `path` if needed and restrict it to the current user.
|
|
|
|
Only ever call this on directories owned by this process's storage (a temp
|
|
dir or a dedicated subdirectory), never on the shared `~/.deepagents` config
|
|
root.
|
|
|
|
Args:
|
|
path: Directory to create and harden to `0o700`.
|
|
|
|
Raises:
|
|
OSError: If the path exists but is not a directory, or the directory
|
|
cannot be created or its mode changed (e.g. a read-only mount).
|
|
PermissionError: If the existing directory is owned by another local user.
|
|
"""
|
|
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
info = path.lstat()
|
|
if not stat.S_ISDIR(info.st_mode):
|
|
msg = f"Path is not a directory: {path}"
|
|
raise OSError(msg)
|
|
getuid = getattr(os, "getuid", None)
|
|
if getuid is not None and info.st_uid != getuid():
|
|
msg = f"Directory is owned by another user: {path}"
|
|
raise PermissionError(msg)
|
|
# `mkdir(mode=...)` does not tighten an existing directory. These directories
|
|
# can hold conversation data and offloaded tool results, so they must remain
|
|
# inaccessible to other local accounts regardless of the process umask.
|
|
path.chmod(0o700)
|
|
|
|
|
|
def _probe_writable(path: Path) -> None:
|
|
"""Confirm `path` accepts new files (catches read-only mounts).
|
|
|
|
Creating the directory is insufficient when it already exists on a read-only
|
|
mount; a temporary file proves writes can succeed.
|
|
|
|
Args:
|
|
path: Directory to probe.
|
|
"""
|
|
with tempfile.NamedTemporaryFile(dir=path, prefix=".write-test-"):
|
|
pass
|
|
|
|
|
|
def _artifacts_root() -> _ArtifactsStorage:
|
|
"""Return storage configuration for offloaded artifacts.
|
|
|
|
The normal path is a stable, hardened host directory that filesystem tools
|
|
and shell commands can use directly. If that predictable directory is
|
|
unusable, large results use a private unique directory behind a stable virtual
|
|
root. Keeping the virtual root stable lets conversation archive paths persisted
|
|
in thread state continue matching their dedicated route after a restart.
|
|
|
|
Returns:
|
|
The agent-visible artifacts root and an optional directory to which large
|
|
results must be routed.
|
|
"""
|
|
getuid = getattr(os, "getuid", None)
|
|
suffix = str(getuid()) if getuid is not None else str(os.getpid())
|
|
temp_root = Path(tempfile.gettempdir())
|
|
root = temp_root / f"dcode-artifacts-{suffix}"
|
|
try:
|
|
_harden_dir(root)
|
|
_probe_writable(root)
|
|
except (OSError, RuntimeError):
|
|
logger.warning(
|
|
"Predictable per-user artifacts directory is unavailable; routing "
|
|
"large results from a stable virtual prefix to private temporary storage",
|
|
exc_info=True,
|
|
)
|
|
unique = Path(
|
|
tempfile.mkdtemp(prefix=f"dcode-artifacts-{suffix}-", dir=temp_root)
|
|
)
|
|
_harden_dir(unique)
|
|
_probe_writable(unique)
|
|
return _ArtifactsStorage(
|
|
root=_FALLBACK_ARTIFACTS_ROOT,
|
|
large_results_dir=unique,
|
|
)
|
|
return _ArtifactsStorage(root=_filesystem_tool_path(root))
|
|
|
|
|
|
def _offload_fallback_root() -> Path:
|
|
"""Return a writable base directory for offloaded conversation history.
|
|
|
|
Prefers the persistent profile root (`DEEPAGENTS_HOME`, default
|
|
`~/.deepagents`) so offloaded history survives across sessions and is easy
|
|
to locate; falls back to a private temporary directory when that root
|
|
cannot be created or written. The profile root itself is resolved at launch
|
|
and cannot fail here, so an unwritable or unmounted configured root is now
|
|
the only way to reach the fallback -- and history then does not persist,
|
|
which `_EPHEMERAL_OFFLOAD_STORAGE` records so callers can say so. This is
|
|
the live root for the local-mode `conversation_history` backend in
|
|
`agent.py`.
|
|
|
|
Archives always live in the `conversation_history` subdirectory of the
|
|
returned root. The `0o700` hardening therefore targets that subdirectory,
|
|
never the shared profile root -- which also houses `config.toml`,
|
|
`hooks.json`, `.env`, and `.state/`, whose permissions this must not
|
|
disturb. A temporary fallback root is created solely for offload, so the
|
|
whole directory is hardened in that case.
|
|
|
|
Note: the `S_ISDIR` check below (which uses `lstat`, deliberately not
|
|
following the link) guards the paths it is applied to -- the
|
|
`conversation_history` subdirectory and, in the fallback case, the temp
|
|
root -- not the profile root itself, which is created with a plain `mkdir`.
|
|
So a `conversation_history` (or temp root) that is itself a symlink is
|
|
rejected, whereas a symlinked profile root pointing at a directory the
|
|
current user owns is followed transparently and archives persist normally.
|
|
(A dangling profile-root symlink still falls through to temporary storage,
|
|
but via `mkdir` raising, not via this check.)
|
|
|
|
Returns:
|
|
A directory whose `conversation_history` subdirectory is private and
|
|
writable.
|
|
"""
|
|
|
|
def _prepare_user_dir() -> Path:
|
|
base = get_deepagents_home()
|
|
# Ensure the shared config root exists and is usable, but leave its
|
|
# permissions untouched -- hardening belongs on the archive subdir only.
|
|
base.mkdir(parents=True, exist_ok=True)
|
|
archive_dir = base / CONVERSATION_HISTORY_DIRNAME
|
|
_harden_dir(archive_dir)
|
|
_probe_writable(archive_dir)
|
|
return base
|
|
|
|
def _prepare_temp_dir(path: Path) -> Path:
|
|
# A temp dir is created solely for offload and is not shared config, so
|
|
# hardening the whole directory (which protects its archive subdir) is
|
|
# both safe and necessary in world-writable temp locations.
|
|
_harden_dir(path)
|
|
_probe_writable(path)
|
|
return path
|
|
|
|
global _EPHEMERAL_OFFLOAD_STORAGE, _UNIQUE_OFFLOAD_FALLBACK_ROOT # noqa: PLW0603
|
|
if _UNIQUE_OFFLOAD_FALLBACK_ROOT is not None:
|
|
# Unlike the persistent and predictable temp paths, a directory created
|
|
# by `mkdtemp` cannot be derived again. Keep returning the root already
|
|
# used by the archive backend so cleanup reaches the same files.
|
|
_EPHEMERAL_OFFLOAD_STORAGE = True
|
|
return _UNIQUE_OFFLOAD_FALLBACK_ROOT
|
|
try:
|
|
root = _prepare_user_dir()
|
|
except (RuntimeError, OSError):
|
|
logger.warning(
|
|
"User data directory is not writable; falling back to temporary "
|
|
"offload storage, which may not persist across restarts",
|
|
exc_info=True,
|
|
)
|
|
else:
|
|
_EPHEMERAL_OFFLOAD_STORAGE = False
|
|
return root
|
|
# Only reached on the fallback path: every root produced below is temporary
|
|
# and may not survive a restart.
|
|
_EPHEMERAL_OFFLOAD_STORAGE = True
|
|
getuid = getattr(os, "getuid", None)
|
|
suffix = str(getuid()) if getuid is not None else str(os.getpid())
|
|
temp_root = Path(tempfile.gettempdir())
|
|
path = temp_root / f"deepagents-{suffix}"
|
|
try:
|
|
return _prepare_temp_dir(path)
|
|
except (OSError, RuntimeError):
|
|
logger.warning(
|
|
"Per-user temporary offload directory is unavailable; creating "
|
|
"a private unique directory",
|
|
exc_info=True,
|
|
)
|
|
unique = Path(tempfile.mkdtemp(prefix=f"deepagents-{suffix}-", dir=temp_root))
|
|
_UNIQUE_OFFLOAD_FALLBACK_ROOT = _prepare_temp_dir(unique)
|
|
return _UNIQUE_OFFLOAD_FALLBACK_ROOT
|
|
|
|
|
|
def _history_retention_days() -> int:
|
|
"""Resolve the conversation-history retention window through the manifest.
|
|
|
|
Uses the canonical `history.retention_days` option, so managed config, the
|
|
`DEEPAGENTS_CODE_HISTORY_RETENTION_DAYS` env var, and `config.toml` all
|
|
apply with their normal precedence, and `dcode config` reports the same
|
|
value the sweep acts on. A malformed or negative value is rejected by the
|
|
resolver and falls through to the next source, ending at the default.
|
|
|
|
Returns:
|
|
The resolved non-negative retention window in days; `0` disables the
|
|
sweep.
|
|
"""
|
|
from deepagents_code.config_manifest import (
|
|
HISTORY_RETENTION_DAYS_DEFAULT,
|
|
_emit_ranked_diagnostics,
|
|
_resolve_option,
|
|
get_option,
|
|
)
|
|
|
|
option = get_option("history.retention_days")
|
|
if option is None:
|
|
return HISTORY_RETENTION_DAYS_DEFAULT
|
|
resolved = _resolve_option(option)
|
|
_emit_ranked_diagnostics(option, resolved)
|
|
value = resolved.value
|
|
if type(value) is int and value >= 0:
|
|
return value
|
|
return HISTORY_RETENTION_DAYS_DEFAULT
|
|
|
|
|
|
def _delete_expired_archive(candidate: Path, archive_dir: Path, cutoff: float) -> bool:
|
|
"""Delete one expired regular markdown archive.
|
|
|
|
The expiry check and `unlink` are serialized against a concurrent archive
|
|
refresh: the summarization middleware refreshes an archive by path
|
|
(read-append-rewrite), and on POSIX an `unlink` racing that rewrite would
|
|
orphan the open descriptor and silently drop the newly written history.
|
|
Opening the candidate and re-checking expiry with `fstat` on the live
|
|
descriptor immediately before `unlink` closes that window: a rewrite that
|
|
already refreshed the mtime is observed and the archive is kept, and a
|
|
rewrite that starts after the `fstat` still lands in the inode the writer
|
|
has open.
|
|
|
|
Args:
|
|
candidate: Direct child of the archive directory to inspect.
|
|
archive_dir: Directory that candidates must remain inside.
|
|
cutoff: Oldest mtime to retain, as Unix seconds.
|
|
|
|
Returns:
|
|
Whether the candidate was deleted.
|
|
"""
|
|
if candidate.parent != archive_dir or candidate.suffix != ".md":
|
|
return False
|
|
try:
|
|
with candidate.open("rb") as archive_file:
|
|
info = os.fstat(archive_file.fileno())
|
|
if not stat.S_ISREG(info.st_mode) or info.st_mtime >= cutoff:
|
|
return False
|
|
candidate.unlink()
|
|
except FileNotFoundError:
|
|
return False
|
|
except OSError:
|
|
logger.warning(
|
|
"Failed to sweep offloaded history archive %s", candidate, exc_info=True
|
|
)
|
|
return False
|
|
return True
|
|
|
|
|
|
def _sweep_offloaded_history(retention_days: int) -> int:
|
|
"""Delete expired archives after retention config has been validated.
|
|
|
|
Args:
|
|
retention_days: Number of days of archives to retain.
|
|
|
|
Returns:
|
|
The number of archives deleted.
|
|
"""
|
|
try:
|
|
archive_dir = _offload_fallback_root() / CONVERSATION_HISTORY_DIRNAME
|
|
candidates = list(archive_dir.iterdir())
|
|
except (OSError, RuntimeError):
|
|
logger.warning(
|
|
"Could not resolve offloaded history archives for sweep", exc_info=True
|
|
)
|
|
return 0
|
|
cutoff = time.time() - retention_days * _SECONDS_PER_DAY
|
|
return sum(
|
|
_delete_expired_archive(candidate, archive_dir, cutoff)
|
|
for candidate in candidates
|
|
)
|
|
|
|
|
|
def sweep_offloaded_history() -> int:
|
|
"""Delete conversation-history archives older than the configured retention.
|
|
|
|
Returns:
|
|
The number of archive files deleted. Failures return zero or omit the
|
|
affected file from the count rather than raising.
|
|
"""
|
|
try:
|
|
retention_days = _history_retention_days()
|
|
if retention_days == 0:
|
|
return 0
|
|
return _sweep_offloaded_history(retention_days)
|
|
except Exception:
|
|
logger.warning("Unexpected failure sweeping offloaded history", exc_info=True)
|
|
return 0
|
|
|
|
|
|
def delete_offloaded_history(thread_id: str) -> bool:
|
|
"""Remove a thread's offloaded conversation-history archive.
|
|
|
|
Deletes the per-thread markdown file written by the local-mode
|
|
`conversation_history` backend (`{root}/conversation_history/{thread_id}.md`),
|
|
resolving `root` with `_offload_fallback_root` so the persistent
|
|
`~/.deepagents` location and any temporary fallback are both covered.
|
|
|
|
Best-effort: filesystem failures are logged and swallowed rather than
|
|
raised, so a failed cleanup never blocks thread deletion. Resolving the
|
|
offload root is not side-effect-free -- it creates (and hardens) the
|
|
`conversation_history` directory and writes a short-lived probe file -- so a
|
|
call for a thread that has no archive still touches the filesystem before
|
|
returning `False`.
|
|
|
|
In server/sandbox mode the archive lives on the sandbox backend rather than
|
|
the local `~/.deepagents` directory, so there is no local archive to remove.
|
|
|
|
Args:
|
|
thread_id: Thread whose offloaded history should be removed.
|
|
|
|
Returns:
|
|
`True` only if an archive file was removed. `False` in every other case:
|
|
an empty or rejected `thread_id`, an unresolvable offload root, a missing
|
|
archive, or an `unlink` failure.
|
|
"""
|
|
if not thread_id:
|
|
return False
|
|
try:
|
|
archive_dir = _offload_fallback_root() / CONVERSATION_HISTORY_DIRNAME
|
|
except (OSError, RuntimeError):
|
|
logger.warning(
|
|
"Could not resolve offload root to clean history for thread %s",
|
|
thread_id,
|
|
exc_info=True,
|
|
)
|
|
return False
|
|
archive_path = archive_dir / f"{thread_id}.md"
|
|
# Guard against a crafted thread id escaping the archive directory. Thread
|
|
# ids are system-generated UUID7 strings, so a rejection here means either a
|
|
# crafted input or a bug emitting malformed ids -- both worth a trace, and
|
|
# both distinct from the benign "no archive exists" path below.
|
|
if archive_path.parent != archive_dir:
|
|
logger.warning(
|
|
"Refusing to delete offloaded history for suspicious thread id %r",
|
|
thread_id,
|
|
)
|
|
return False
|
|
try:
|
|
archive_path.unlink()
|
|
except FileNotFoundError:
|
|
return False
|
|
except OSError:
|
|
logger.warning(
|
|
"Failed to delete offloaded conversation history for thread %s",
|
|
thread_id,
|
|
exc_info=True,
|
|
)
|
|
return False
|
|
logger.debug("Deleted offloaded conversation history for thread %s", thread_id)
|
|
return True
|