Files
deepagents/libs/code/deepagents_code/offload.py
Mason Daugherty feb5736dfb feat(code): support DEEPAGENTS_HOME (#5773)
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>
2026-08-25 22:09:15 -04:00

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