One multiplexed gateway process serves every profile, but several per-turn reads still went through state frozen from the LAUNCH profile: - `_current_max_iterations` re-bridged `agent.max_turns`/`sessions.*` from the module constant `_hermes_home` into one process-wide HERMES_MAX_ITERATIONS, so every secondary ran with the default profile's turn budget. A routed turn (HERMES_HOME override) now resolves `agent.max_turns` from its own config. - `_refresh_fallback_model` read `_hermes_home/config.yaml` into one runner-wide slot, so secondaries fell back through the default's provider/model with their own keys. It now reads the active gateway home and keeps a last-known-good chain per home. - `_load_prefill_messages` resolved relative paths against the launch home. - `agent/auxiliary_client._AUTH_JSON_PATH` was an import-time constant, so a secondary's compression/title/vision calls authenticated to Nous with the default profile's token when it had no pool entry. Resolved per call via `hermes_cli.auth._auth_file_path()` (patched constant still wins in tests). - `gateway/hooks.HOOKS_DIR` was frozen at import and one `HookRegistry` was loaded outside any profile scope, so secondaries' `hooks/` never ran and the default profile's handlers received every profile's messages, responses and user ids. `HOOKS_DIR` now resolves per call (salvaged from #56508) and the runner holds one registry per served home, picked from the active scope at emit time and front-loaded under each secondary's startup scope. - Shell-hook subprocesses inherited the launch `os.environ` (default HERMES_HOME and the default profile's secrets). They now get the routed HERMES_HOME via `build_subprocess_env`, scrubbed under multiplexing, and the stdin payload carries `profile` so one script can tell which profile fired it. - Media-delivery policy (`gateway.strict`, `media_delivery_allow_dirs`, `trust_recent_files*`) was bridged once into env at startup and read from env per delivery; under a HERMES_HOME override the validator now reads the routed profile's config. Single-profile runs keep the env-bridge contract. Audit: /tmp/mux_audit F3, F4, F6 (auth.json half), F7, F12 (media). Live repro (temp HERMES_HOME A with profiles/B): before, B saw max_iterations 7, fallback A/fallback, TOKEN_A, A's hooks, strict=A; after, all B's values.
121 lines
6.1 KiB
Python
121 lines
6.1 KiB
Python
"""Deliver ``MEDIA:<path>`` files that live inside a remote terminal sandbox (#466).
|
|
|
|
``validate_media_delivery_path`` only accepts files on the gateway host. When the terminal backend
|
|
is ssh / daytona / vercel (any backend that reports its ``_remote_home``), the agent's artifact is
|
|
on another filesystem, so the tag was silently dropped. This module pulls the file through the
|
|
active environment's ``fetch_file`` into the document cache (already an allowlisted delivery root)
|
|
and hands back the host copy. Backends without a known remote home (docker outside its mounts,
|
|
modal, singularity) still get the conservative any-component denylist, so a ``/root/...`` artifact
|
|
there is not fetched — set ``_remote_home`` on the environment to opt in.
|
|
|
|
The remote path is screened against the same denylist as local deliveries BEFORE any bytes move,
|
|
and again after ``readlink -f`` (fail closed when it cannot resolve) — a remote fetch must never
|
|
become a bypass of the host denylist. Strict mode (``HERMES_MEDIA_DELIVERY_STRICT``) keeps its
|
|
pre-existing behaviour: nothing is fetched, since a fetched copy would land in an allowlisted root
|
|
and skip the recency gate strict mode exists for.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import posixpath
|
|
import re
|
|
import uuid
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Optional
|
|
|
|
from gateway.platforms.base import (
|
|
_MEDIA_DELIVERY_DENIED_HOME_SUBPATHS, _MEDIA_DELIVERY_DENIED_PREFIXES, _ROOT_CREDENTIAL_PATHS)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Telegram bot uploads cap at 50 MB; the other platforms are in the same range. Mirrors
|
|
# tools.image_source._MAX_INGEST_BYTES.
|
|
_FETCH_MAX_BYTES = 50 * 1024 * 1024
|
|
|
|
_DENIED_PREFIXES = tuple(PurePosixPath(p) for p in _MEDIA_DELIVERY_DENIED_PREFIXES)
|
|
# Credential dirs under the sandbox home plus the Hermes stores, which live at ``~/.hermes`` there.
|
|
_DENIED_HOME_RELATIVE = tuple(PurePosixPath(s) for s in _MEDIA_DELIVERY_DENIED_HOME_SUBPATHS) + tuple(
|
|
PurePosixPath(".hermes", *PurePosixPath(rel.replace(os.sep, "/")).parts) for rel in _ROOT_CREDENTIAL_PATHS)
|
|
|
|
|
|
def remote_path_is_denied(path: str, remote_home: Optional[str]) -> bool:
|
|
"""Pure string check (the remote fs can't be stat'd from here) applying the host denylist to a
|
|
sandbox path. Unknown home ⇒ home-relative entries match ANY path component (conservative)."""
|
|
target = PurePosixPath(posixpath.normpath(path))
|
|
if not target.is_absolute():
|
|
return True
|
|
home = PurePosixPath(posixpath.normpath(remote_home)) if remote_home else None
|
|
|
|
def _under(root: PurePosixPath) -> bool:
|
|
return target == root or root in target.parents
|
|
|
|
# The sandbox's own home may be a denied system prefix (/root); its credential subpaths are
|
|
# separate, more specific entries — same exception as _path_under_denied_prefix.
|
|
if any(_under(p) for p in _DENIED_PREFIXES if p != home):
|
|
return True
|
|
if home is not None:
|
|
return any(_under(home / rel) for rel in _DENIED_HOME_RELATIVE)
|
|
parts = target.parts
|
|
return any(parts[i:i + len(rel.parts)] == rel.parts
|
|
for rel in _DENIED_HOME_RELATIVE for i in range(len(parts) - len(rel.parts) + 1))
|
|
|
|
|
|
def _active_remote_env():
|
|
"""The live remote BaseEnvironment for the current session, or None (local backend / no env yet).
|
|
Keyed by the session id the turn registered its sandbox under (falls back to the session key)."""
|
|
from agent.prompt_builder import _REMOTE_TERMINAL_BACKENDS, _plugin_backend_is_remote
|
|
from gateway.platforms.base import _tenv
|
|
from gateway.session_context import get_session_env
|
|
from tools.terminal_tool_lifecycle import get_active_env
|
|
backend = _tenv("TERMINAL_ENV", "local").strip().lower()
|
|
if backend not in _REMOTE_TERMINAL_BACKENDS and not _plugin_backend_is_remote(backend):
|
|
return None
|
|
return get_active_env(get_session_env("HERMES_SESSION_ID") or get_session_env("HERMES_SESSION_KEY") or "default")
|
|
|
|
|
|
def fetch_remote_media(path: str) -> Optional[str]:
|
|
"""Host path of a validated copy of sandbox file ``path``, or None (never raises). Only fires
|
|
when a remote backend is active; the caller has already failed local validation."""
|
|
from gateway.media_policy import media_delivery_strict
|
|
if media_delivery_strict():
|
|
return None
|
|
env = _active_remote_env()
|
|
if env is None:
|
|
return None
|
|
from gateway.platforms.base import (
|
|
DOCUMENT_CACHE_DIR, _log_safe_path, _normalize_media_tag_path, validate_media_delivery_path)
|
|
from tools.environments.base import FileFetchError
|
|
|
|
remote_home = getattr(env, "_remote_home", None)
|
|
candidate = posixpath.normpath(_normalize_media_tag_path(str(path)) or "")
|
|
if candidate == "~" or candidate.startswith("~/"):
|
|
if not remote_home:
|
|
return None
|
|
candidate = posixpath.normpath(posixpath.join(remote_home, candidate[2:]))
|
|
if not candidate.startswith("/") or remote_path_is_denied(candidate, remote_home):
|
|
return None
|
|
try:
|
|
# ``[ -f ]`` in fetch_file follows symlinks, so the link TARGET is what gets screened;
|
|
# an unresolvable path fails closed rather than trusting the unresolved name.
|
|
resolved = env.fetch_realpath(candidate)
|
|
if resolved is None or remote_path_is_denied(resolved, remote_home):
|
|
return None
|
|
basename = re.sub(r"[^\w.\-]", "_", posixpath.basename(resolved)) or "file"
|
|
dest = Path(DOCUMENT_CACHE_DIR) / f"remote_{uuid.uuid4().hex[:12]}_{basename}"
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
env.fetch_file(resolved, dest, max_bytes=_FETCH_MAX_BYTES)
|
|
except FileFetchError as exc:
|
|
logger.warning("Remote media fetch of %s skipped: %s", _log_safe_path(candidate), exc)
|
|
return None
|
|
except Exception:
|
|
logger.warning("Remote media fetch of %s failed", _log_safe_path(candidate), exc_info=True)
|
|
return None
|
|
validated = validate_media_delivery_path(str(dest))
|
|
if not validated:
|
|
dest.unlink(missing_ok=True)
|
|
return None
|
|
logger.info("Fetched remote media %s from the %s sandbox", _log_safe_path(candidate), type(env).__name__)
|
|
return validated
|