213 lines
9.3 KiB
Python
213 lines
9.3 KiB
Python
"""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, overriding the correct per-command Popen env. 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.
|
|
_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`` filter is unsafe: bash 3.2 prints a value containing a newline
|
|
as a multi-line ``declare -x`` block, so continuation lines (attacker text
|
|
smuggled via e.g. HERMES_SESSION_CHAT_NAME) 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 attached to a pipeline
|
|
segment would expand it inside that segment's subshell, inconsistently with
|
|
the parent that expands the follow-up ``mv``.
|
|
"""
|
|
# ${!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(shlex.quote(name) for name in sorted(safe_names))
|
|
if extra_unset:
|
|
extra_unset = f" {extra_unset}"
|
|
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. The
|
|
temp name must be unique per concurrent writer: ``$$`` 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, so secrets are not
|
|
exposed through process arguments or logs.
|
|
"""
|
|
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.append(f"{present}=${{{name}+x}}")
|
|
save.append(f"{value}=${{{name}-}}")
|
|
restore.append(
|
|
f'if [ "${present}" = x ]; then export {name}="${value}"; '
|
|
f'else unset {name}; fi'
|
|
)
|
|
restore.append(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 from
|
|
the Hermes process); ``${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.extend(restore)
|
|
parts.append(
|
|
'export AI_AGENT="${AI_AGENT:-hermes-agent}" '
|
|
'HERMES_AGENT="${HERMES_AGENT:-true}"'
|
|
)
|
|
parts.append('export GIT_PAGER="${GIT_PAGER:-cat}" PAGER="${PAGER:-cat}"')
|
|
# ``--`` keeps hyphen-prefixed directory names from being parsed as options.
|
|
parts.append(f"builtin cd -- {quoted_cwd} || exit 126")
|
|
parts.append(f"eval '{escaped}'")
|
|
parts.append("__hermes_ec=$?")
|
|
parts.append("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.append(_cwd_marker_printf(cwd_marker))
|
|
parts.append("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:]
|