Files
hermes-agent/agent/session_client_env.py
unsupportedpastels 306efddd90 docs(tools): say what toolchain_environ drops for a remote client's session
The docstring still called a remote client's session unchanged. Since
fa99680a2b6b it drops the gateway's prompt and provider behaviour
values and keeps only the host ones.

Refs #134889 (T3)
Refs #139523
2026-10-16 17:49:54 -04:00

171 lines
9.4 KiB
Python

"""A local session's client environment: the allowlisted values a shell-launched client (classic CLI,
Ink TUI) sends as ``session.create``'s ``client_env``. Main's ``hermes`` process read them from its own
shell; the one gateway now runs every session, so they are frozen with the session's policy and bound
for its turns only, never written to the gateway's ``os.environ``. Desktop sends none: its sessions
keep the gateway's environment, and so does a session created before ``client_env`` existed.
Plain names and string values only. Behaviour names are read through :func:`session_env`; the
toolchain names (``PATH``, an activated venv, nvm/pyenv/conda) reach that session's tool subprocesses
through :func:`toolchain_environ`. Values naming this host (toolchain paths, the prefill file, the
project-plugins check of the client's folder) apply only when the gateway proved the client is on its
host (``local``); otherwise the session keeps the gateway's and is told so. Values are never logged.
"""
from __future__ import annotations
import json
import os
from contextlib import contextmanager
from contextvars import ContextVar
from tools.code_execution_env import is_secret_env_name
# What main's CLI read per process and the gateway otherwise reads daemon-wide.
BEHAVIOUR_ENV = ('HERMES_EPHEMERAL_SYSTEM_PROMPT', 'HERMES_PREFILL_MESSAGES_FILE', 'HERMES_INFERENCE_PROVIDER',
'HERMES_ENABLE_PROJECT_PLUGINS')
# Behaviour values that name the client's host: a path, or the opt-in that inspects the client's folder.
HOST_BEHAVIOUR = ('HERMES_PREFILL_MESSAGES_FILE', 'HERMES_ENABLE_PROJECT_PLUGINS')
TOOL_ENV = ('PATH', 'VIRTUAL_ENV', 'GOPATH', 'GOROOT', 'CARGO_HOME', 'RUSTUP_HOME', 'JAVA_HOME')
TOOL_ENV_PREFIXES = ('CONDA_', 'NODE_', 'NVM_', 'PYENV_')
# ``terminal.client_env_passthrough`` names more non-secret toolchain variables for a profile.
CONFIG_KEY = 'client_env_passthrough'
# The session-start notice of a session whose client the gateway could not place on its own host.
NOT_LOCAL_WARNING = ("this session uses the gateway's own PATH, toolchain and prefill file: the client's environment "
'was not applied because the client is not attached to this gateway locally.')
_MAX_NAMES = 256
_MAX_BYTES = 256 * 1024
# (env, local) for the session executing here; None outside a shell client's session.
_BOUND: ContextVar = ContextVar('session_client_env', default=None)
def configured_names(config) -> frozenset:
"""The profile's extra tool names (``terminal.client_env_passthrough``)."""
terminal = config.get('terminal') if isinstance(config, dict) else None
raw = terminal.get(CONFIG_KEY) if isinstance(terminal, dict) else None
return frozenset(name.strip() for name in raw if isinstance(name, str) and name.strip()) if isinstance(raw, list) else frozenset()
def _travels(name: str, secret=None) -> bool:
"""A toolchain name may travel unless it is a credential by execute_code's secret policy (even when
listed: ``NODE_AUTH_TOKEN`` matches ``NODE_``; secrets reach tools through ``terminal.env_passthrough``)
or a Hermes control name (a delegated child's markers, Kanban pins). ``secret``: a session's frozen
copy of that policy's substrings."""
hidden = is_secret_env_name(name) if secret is None else any(s in name.upper() for s in secret)
return not hidden and not name.startswith('HERMES_')
def client_env_rules(config, *, local=True) -> dict:
"""The normalisation rules (plain data) a session's map is filtered by, frozen with it: an exact retry
is judged by these, never by the profile's allowlist or the secret policy as they are later. A client
not proven ``local`` keeps only the values that name nothing on this host (prompt, provider request)."""
from tools.code_execution_env import _SECRET_SUBSTRINGS
return {'behaviour': [n for n in BEHAVIOUR_ENV if local or n not in HOST_BEHAVIOUR],
'names': sorted(set(TOOL_ENV) | configured_names(config)) if local else [],
'prefixes': list(TOOL_ENV_PREFIXES) if local else [], 'secret': list(_SECRET_SUBSTRINGS)}
def _kept(name: str, rules: dict) -> bool:
return name in rules['behaviour'] or (_travels(name, rules['secret']) and (
name in rules['names'] or name.startswith(tuple(rules['prefixes']))))
def collect_client_env(environ=None, config=None) -> dict[str, str]:
"""Client side: this shell's allowlisted values (the profile's extra names included)."""
environ = os.environ if environ is None else environ
rules = client_env_rules(config)
return {name: value for name, value in environ.items() if isinstance(value, str) and _kept(name, rules)}
def normalise_client_env(value, rules) -> dict[str, str]:
"""The validated map filtered by ``rules`` (``None``: unfiltered, for a map frozen before rules were).
Raises ``ValueError`` for anything but a bounded flat string-to-string map."""
if (not isinstance(value, dict) or len(value) > _MAX_NAMES
or any(not isinstance(k, str) or not isinstance(v, str) for k, v in value.items())
or sum(len(k) + len(v) for k, v in value.items()) > _MAX_BYTES):
raise ValueError('client_env must be a map of variable names to string values')
return {name: value[name] for name in sorted(value) if rules is None or _kept(name, rules)}
def accept_client_env(value, config, *, local=True) -> dict[str, str]:
"""Gateway side: the map filtered by the session profile's own current rules."""
return normalise_client_env(value, client_env_rules(config, local=local))
def retry_matches(committed: dict, request: dict) -> bool:
"""Whether a retried ``session.create`` (``request``: its params) carries the client environment the
committed session (``committed``: its stored policy) froze. The complete retried map, normalised by
the rules frozen with the session, must equal the stored one: an added name those rules keep, a
missing stored name or a changed value is a different request; one they drop never counts. Staging
and the transactional commit both judge by this. A session committed without a map (Desktop, one
from before the field) still matches only a retry that sends none."""
stored = committed.get('client_env_json')
if stored is None or 'client_env' not in request:
return stored is None and 'client_env' not in request
rules = committed.get('client_env_rules_json')
try:
sent = normalise_client_env(request['client_env'], None if rules is None else json.loads(rules))
except ValueError:
return False
return sent == json.loads(stored)
@contextmanager
def client_env_scope(env, *, local=True, rules=None):
"""Bind one session's client environment (``None``: the gateway's own) for this context; ``local``
is the gateway's own proof, frozen in the session's policy, that the client runs on this host;
``rules`` the normalisation rules frozen with it (``client_env_rules``; ``None``: the built-ins)."""
token = _BOUND.set(None if env is None else (env, bool(local), rules))
try:
yield
finally:
_BOUND.reset(token)
def bound_client_env():
bound = _BOUND.get()
return None if bound is None else bound[0]
def session_env(name: str, default: str = '') -> str:
"""A behaviour value for the session executing here: its client's (an unexported name is unset,
as it was for main's process), else the gateway's own environment, which a client not attached
locally also keeps for the values that name this host."""
bound = _BOUND.get()
if bound is None or (name in HOST_BEHAVIOUR and not bound[1]):
return os.getenv(name, default)
return bound[0].get(name, default)
def tool_env() -> dict[str, str]:
"""The toolchain values this session's tool subprocesses start with (empty when unbound or remote)."""
bound = _BOUND.get()
if bound is None or not bound[1]:
return {}
return {name: value for name, value in bound[0].items() if name not in BEHAVIOUR_ENV and _travels(name)}
def toolchain_environ(base=None) -> dict[str, str]:
"""The environment this session's tool subprocesses start from (``base``: the gateway's). Unbound
(messaging, cron, Desktop): unchanged. Every client's session drops the gateway's own behaviour
values it reads from its client (:func:`session_env`), so tools never see the daemon's ephemeral
prompt or provider request in place of the session's. A client not attached locally keeps the
host values (``HOST_BEHAVIOUR``) and the rest of the gateway's environment. A local shell client's
session: its toolchain replaces the gateway's, and every toolchain name it did not export (built-in,
or configured for its profile when the session was created) is unset, as in main's process: one
client's venv or GEM_HOME never reaches another's session through the daemon it started. PATH is
the exception: a client that sent none runs with the gateway's."""
env = dict(os.environ if base is None else base)
bound = _BOUND.get()
if bound is None:
return env
for name in BEHAVIOUR_ENV:
if bound[1] or name not in HOST_BEHAVIOUR:
env.pop(name, None)
if not bound[1]:
return env
rules = bound[2] or {'names': TOOL_ENV, 'prefixes': TOOL_ENV_PREFIXES, 'secret': None}
names = {name for name in rules['names'] if _travels(name, rules['secret'])}
prefixes = tuple(rules['prefixes'])
kept = {k: v for k, v in env.items() if k == 'PATH' or not (k in names or k.startswith(prefixes))}
return kept | tool_env()