File size: 10,334 Bytes
5655a42 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 | """Bash session-snapshot scripting for ``tools.environments.base``.
Pure string builders: the CWD marker, the ``export -p`` dump that strips
per-session vars, the ``init_session`` bootstrap, and the per-command wrapper.
No module state lives here; ``BaseEnvironment`` supplies quoting hooks.
"""
import re
import shlex
from typing import Iterable
# Bridged per-session vars (gateway.session_context._VAR_MAP) are injected fresh onto every
# command's process env and must NEVER persist in the shared bash snapshot: one long-lived
# backend serves many sessions, so a snapshot carrying the FIRST session's HERMES_SESSION_ID
# would make every LATER session source a foreign identity. Every bridged name starts with
# one of these prefixes (or is HERMES_UI_SESSION_ID); unit tests use this regex as the
# Python-side contract for the exclusion set.
# Per-session variables that the gateway bridges freshly onto every command's process environment (via
# tools/environments/local._inject_session_context_env, reading gateway.session_context._VAR_MAP). They must
# NEVER be persisted into the shared bash session snapshot: a single long-lived backend serves many
# concurrent sessions (the messaging gateway, TUI, desktop/web dashboard all collapse the terminal to one
# "default" environment), so ``export -p`` dumping the FIRST session's HERMES_SESSION_ID into the snapshot
# makes every LATER session ``source`` that stale value and see a FOREIGN session's identity — overriding
# the correct per-command Popen env (issue: cross-session HERMES_SESSION_ID leak via the shared snapshot).
# Stripping them from the snapshot is safe because they are re-injected on every command; a snapshot should
# only carry the user's own shell state (PATH, functions, exports they set), not Hermes' per-turn session
# identity. Used by unit tests as the Python-side contract for the exclusion set; the dump path unsets by
# name/prefix instead of grepping declare lines (see below / issue #71296).
_SNAPSHOT_EXCLUDED_ENV_REGEX = (
"^declare -x (HERMES_SESSION_|HERMES_UI_SESSION_ID|HERMES_CRON_AUTO_DELIVER_|"
"HERMES_CRON_SESSION|HERMES_BROWSER_CONTROL_)")
_SHELL_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# mktemp template suffix + the shell variable holding the allocated temp path.
_SNAP_TMP_SUFFIX = ".tmp.XXXXXXXXXX"
_SNAP_TMP = '"$__hermes_snap_tmp"'
def _cwd_marker(session_id: str) -> str:
return f"__HERMES_CWD_{session_id}__"
def _cwd_marker_printf(marker: str) -> str:
"""Emit the CWD marker on its own line (leading ``\\n`` guards against a
command whose output lacks a trailing newline; ``_split_cwd_marker`` strips it)."""
return f"printf '\\n{marker}%s{marker}\\n' \"$(pwd -P)\""
def _export_dump_excluding_session_vars(tmp_path: str, excluded_names: Iterable[str] = ()) -> str:
"""Shell snippet dumping ``export -p`` to *tmp_path* minus the per-session bridged vars (see
``_SNAPSHOT_EXCLUDED_ENV_REGEX``) and *excluded_names*. The vars are ``unset`` in a subshell
BEFORE ``export -p``: a line-based ``grep -vE`` is unsafe because bash 3.2 prints a value
containing a newline as a multi-line ``declare -x`` block, so smuggled continuation lines would
survive into the snapshot and execute on the next ``source``. ``|| true`` keeps the success
contract. The dump is a brace group with the redirection on the group: *tmp_path* is usually a
shell-variable expansion, and a redirect on a pipeline segment would expand it inside that
segment's subshell, inconsistently with the parent that expands the follow-up ``mv``.
``curl … | bash #`` smuggled into a Matrix room/display name via ``HERMES_SESSION_CHAT_NAME``) land in
the snapshot and execute on the next ``source`` (issue #71296). Unsetting first means ``export -p``
never emits those vars — including any continuation lines.
"""
# ${!PREFIX*} is bash 3.2+ name-prefix expansion; empty matches are ignored
# under 2>/dev/null. Caller names are quoted so malformed config can never
# become shell syntax (valid names stay unquoted by shlex.quote()).
safe_names = {name for name in excluded_names if isinstance(name, str) and name}
extra_unset = "".join(f" {shlex.quote(name)}" for name in sorted(safe_names))
return (
"{ ( unset ${!HERMES_SESSION_*} ${!HERMES_CRON_AUTO_DELIVER_*} "
"${!HERMES_BROWSER_CONTROL_*} "
# AI_AGENT / HERMES_AGENT are per-command attribution markers re-exported
# by every wrapper with ${VAR:-default} semantics; persisting them would
# let the FIRST command's value override a later outer-harness value.
"AI_AGENT HERMES_AGENT "
f"HERMES_UI_SESSION_ID{extra_unset} 2>/dev/null; "
"export -p; ) || true; } "
f"> {tmp_path}")
def _snapshot_bootstrap_script(
*, quoted_cwd: str, quoted_snap: str, snap_tmp_template: str, excluded_names: Iterable[str], cwd_marker: str,
) -> str:
"""Login-shell bootstrap that captures env/functions/aliases into the snapshot. Atomic publish:
assemble in a ``mktemp`` file, then ``mv`` over the final path so a concurrent ``source`` never
reads a half-written snapshot (``$$`` is the parent PID in ``&``-launched subshells and macOS
bash 3.2 lacks ``$BASHPID``, so only ``mktemp`` is portable). Functions are filtered by NAME via
``declare -F`` (a line-based ``declare -f | grep -v`` strips the header and leaves an orphaned
body that breaks every sourced command); the non-empty guard matters because bare ``declare -f``
dumps ALL functions. The trailing ``cd`` restores the configured cwd after profile scripts (e.g.
``cd ~``) so ``pwd -P`` reports terminal.cwd, not the profile's directory."""
return (
"umask 077\n"
f"__hermes_snap_tmp=$(mktemp {snap_tmp_template}) || exit 1\n"
f"{_export_dump_excluding_session_vars(_SNAP_TMP, excluded_names)}\n"
"__hermes_fns=$(declare -F | awk '{print $3}' | grep -vE '^_[^_]') || true\n"
f"[ -n \"$__hermes_fns\" ] && declare -f $__hermes_fns >> {_SNAP_TMP} 2>/dev/null || true\n"
f"alias -p >> {_SNAP_TMP}\n"
f"echo 'shopt -s expand_aliases' >> {_SNAP_TMP}\n"
f"echo 'set +e' >> {_SNAP_TMP}\n"
f"echo 'set +u' >> {_SNAP_TMP}\n"
# Publish only if assembly succeeded; otherwise drop the partial temp.
f"mv -f {_SNAP_TMP} {quoted_snap} || rm -f {_SNAP_TMP}\n"
f"builtin cd -- {quoted_cwd} 2>/dev/null || true\n"
f"{_cwd_marker_printf(cwd_marker)}\n")
def _passthrough_save_restore(names: Iterable[str]) -> tuple[list[str], list[str]]:
"""Shell lines that save profile-scoped passthrough vars before the snapshot is sourced
and restore (or unset) them afterwards — a shared snapshot may hold the previous
profile's value. Values stay in environment memory and never enter the command string."""
save: list[str] = []
restore: list[str] = []
for name in names:
marker = f"_HERMES_RUNTIME_PASSTHROUGH_{name}"
present, value = f"{marker}_PRESENT", f"{marker}_VALUE"
save += [f"{present}=${{{name}+x}}", f"{value}=${{{name}-}}"]
restore += [
f'if [ "${present}" = x ]; then export {name}="${value}"; else unset {name}; fi',
f"unset {present} {value}"]
return save, restore
def _wrap_command_script(
command: str, *, quoted_cwd: str, quoted_snap: str, snap_tmp_template: str,
passthrough_names: Iterable[str], snapshot_ready: bool, cwd_marker: str) -> str:
"""Per-command bash script: source snapshot, cd, run, re-dump env, emit CWD marker.
``source`` stdout goes to /dev/null because macOS bash 3.2 / some Homebrew builds echo
``declare -x`` lines when sourcing. AI_AGENT/HERMES_AGENT advertise the harness to remote
backends (whose env is not inherited); ``${VAR:-default}`` never clobbers an outer harness.
GIT_PAGER/PAGER=cat stop pager-happy tools hanging a PTY-backed command. The env re-dump
uses the same mktemp+mv atomic publish as the bootstrap and chains ``mv`` on the dump
succeeding so a failed dump never replaces a good snapshot. ``umask 077`` is applied after
the user's command so snapshot files (which may carry secrets) are private without
changing the command's umask.
"""
escaped = command.replace("'", "'\\''")
save, restore = _passthrough_save_restore(passthrough_names)
parts = list(save)
if snapshot_ready:
parts.append(f"source {quoted_snap} >/dev/null 2>&1 || true")
parts += restore
parts += [
'export AI_AGENT="${AI_AGENT:-hermes-agent}" HERMES_AGENT="${HERMES_AGENT:-true}"',
'export GIT_PAGER="${GIT_PAGER:-cat}" PAGER="${PAGER:-cat}"',
# ``--`` keeps hyphen-prefixed directory names from being parsed as options.
f"builtin cd -- {quoted_cwd} || exit 126",
f"eval '{escaped}'",
"__hermes_ec=$?",
"umask 077"]
if snapshot_ready:
parts.append(
f"__hermes_snap_tmp=$(mktemp {snap_tmp_template}) && "
f"{{ {_export_dump_excluding_session_vars(_SNAP_TMP, passthrough_names)} "
f"&& mv -f {_SNAP_TMP} {quoted_snap}; }} "
f"2>/dev/null || rm -f {_SNAP_TMP} 2>/dev/null || true")
parts += [_cwd_marker_printf(cwd_marker), "exit $__hermes_ec"]
return "\n".join(parts)
def _split_cwd_marker(output: str, marker: str) -> tuple[str | None, str] | None:
"""Locate the last ``marker<path>marker`` pair in *output*. Returns
``(cwd_path_or_None, output_without_marker_line)``, or ``None`` when no complete pair
exists. The stripped span runs from the ``\\n`` the wrapper injected before the marker
through the end of the marker line."""
last = output.rfind(marker)
if last == -1:
return None
search_start = max(0, last - 4096) # CWD path won't be >4KB
first = output.rfind(marker, search_start, last)
if first == -1 or first == last:
return None
cwd_path = output[first + len(marker) : last].strip() or None
line_start = output.rfind("\n", 0, first)
if line_start == -1:
line_start = first
line_end = output.find("\n", last + len(marker))
line_end = line_end + 1 if line_end != -1 else len(output)
return cwd_path, output[:line_start] + output[line_end:]
|