Merge branch 'simp/r3-16-D' into simp/r3-16
This commit is contained in:
+13
-25
@@ -10,45 +10,33 @@ __release_date__ = "2026.8.31"
|
||||
def _ensure_utf8():
|
||||
"""Force UTF-8 stdout/stderr to prevent UnicodeEncodeError crashes.
|
||||
|
||||
The CLI prints box-drawing characters (┌│├└─) and the ⚕ glyph in the setup wizard, doctor, and
|
||||
status banners. Encoding those under a non-UTF-8 codec raises an unhandled UnicodeEncodeError
|
||||
that crashes the command before it can even start — e.g. `hermes setup` on a fresh Pi.
|
||||
The CLI prints box-drawing characters and the ⚕ glyph in the setup wizard, doctor, and status
|
||||
banners; under a non-UTF-8 codec that raises before the command can even start (e.g.
|
||||
`hermes setup` on a fresh Pi).
|
||||
"""
|
||||
repaired = False
|
||||
|
||||
for stream_name in ("stdout", "stderr"):
|
||||
stream = getattr(sys, stream_name, None)
|
||||
if stream is None:
|
||||
continue
|
||||
try:
|
||||
encoding = (getattr(stream, "encoding", "") or "").lower().replace("-", "")
|
||||
if encoding == "utf8":
|
||||
if (getattr(stream, "encoding", "") or "").lower().replace("-", "") == "utf8":
|
||||
continue
|
||||
|
||||
# Preferred: reconfigure the existing TextIOWrapper in place. This
|
||||
# preserves object identity so any code already holding a reference
|
||||
# to the old sys.stdout benefits from the repair too.
|
||||
# Preferred: reconfigure in place, preserving object identity so code already holding
|
||||
# a reference to the old sys.stdout benefits from the repair too.
|
||||
reconfigure = getattr(stream, "reconfigure", None)
|
||||
if callable(reconfigure):
|
||||
reconfigure(encoding="utf-8", errors="replace")
|
||||
repaired = True
|
||||
continue
|
||||
|
||||
# Fallback: reopen the underlying file descriptor as UTF-8. Used
|
||||
# for streams that don't expose reconfigure() (e.g. some wrapped
|
||||
# or replaced streams). closefd=False keeps the original fd open.
|
||||
new_stream = open(
|
||||
stream.fileno(), "w", encoding="utf-8",
|
||||
errors="replace", buffering=1, closefd=False,
|
||||
)
|
||||
setattr(sys, stream_name, new_stream)
|
||||
else:
|
||||
# No reconfigure(): reopen the fd as UTF-8 (closefd=False keeps the original fd open).
|
||||
new_stream = open(stream.fileno(), "w", encoding="utf-8", errors="replace",
|
||||
buffering=1, closefd=False)
|
||||
setattr(sys, stream_name, new_stream)
|
||||
repaired = True
|
||||
except (AttributeError, OSError, ValueError):
|
||||
pass
|
||||
|
||||
# Only nudge child processes toward UTF-8 when we actually detected a
|
||||
# non-UTF-8 locale. On a healthy UTF-8 host children inherit UTF-8 from the
|
||||
# locale already, so leave the environment untouched (minimal footprint).
|
||||
# Only nudge child processes toward UTF-8 when a non-UTF-8 locale was actually detected; on a
|
||||
# healthy UTF-8 host children inherit it from the locale already.
|
||||
if repaired:
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||
|
||||
+20
-46
@@ -6,11 +6,12 @@ import os
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
from contextlib import suppress
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
|
||||
|
||||
def normalize_archive_parts(member_name: str) -> list[str]:
|
||||
"""Return safe path parts for an archive member, or raise.
|
||||
"""Return safe path parts for an archive member, or raise ``ValueError``.
|
||||
|
||||
Rejects absolute paths (POSIX and Windows, including drive letters), empty names, and any ``..``
|
||||
component. Backslashes are folded to ``/`` first so a Windows-authored archive can't smuggle a
|
||||
@@ -19,17 +20,9 @@ def normalize_archive_parts(member_name: str) -> list[str]:
|
||||
normalized_name = member_name.replace("\\", "/")
|
||||
posix_path = PurePosixPath(normalized_name)
|
||||
windows_path = PureWindowsPath(member_name)
|
||||
|
||||
if (
|
||||
not normalized_name
|
||||
or posix_path.is_absolute()
|
||||
or windows_path.is_absolute()
|
||||
or windows_path.drive
|
||||
):
|
||||
raise ValueError(f"Unsafe archive member path: {member_name}")
|
||||
|
||||
parts = [part for part in posix_path.parts if part not in {"", "."}]
|
||||
if not parts or any(part == ".." for part in parts):
|
||||
if (not normalized_name or posix_path.is_absolute() or windows_path.is_absolute()
|
||||
or windows_path.drive or not parts or ".." in parts):
|
||||
raise ValueError(f"Unsafe archive member path: {member_name}")
|
||||
return parts
|
||||
|
||||
@@ -37,24 +30,19 @@ def normalize_archive_parts(member_name: str) -> list[str]:
|
||||
def make_targz(base: str, root_dir: str, base_dir: str) -> str:
|
||||
"""Create ``<base>.tar.gz`` of ``root_dir/base_dir`` in GNU tar format.
|
||||
|
||||
Writes to a sibling temp file and renames onto ``archive_path`` only after the archive is fully
|
||||
written.
|
||||
Writes to a sibling temp file and renames onto the archive path only after it is fully written.
|
||||
"""
|
||||
archive_path = f"{base}.tar.gz"
|
||||
dest_dir = os.path.dirname(archive_path) or "."
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
dir=dest_dir, prefix=".archive_", suffix=".tar.gz.tmp"
|
||||
)
|
||||
fd, tmp_path = tempfile.mkstemp(dir=dest_dir, prefix=".archive_", suffix=".tar.gz.tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
with tarfile.open(fileobj=f, mode="w:gz", format=tarfile.GNU_FORMAT) as tf:
|
||||
tf.add(str(Path(root_dir) / base_dir), arcname=base_dir)
|
||||
with os.fdopen(fd, "wb") as f, \
|
||||
tarfile.open(fileobj=f, mode="w:gz", format=tarfile.GNU_FORMAT) as tf:
|
||||
tf.add(str(Path(root_dir) / base_dir), arcname=base_dir)
|
||||
os.replace(tmp_path, archive_path)
|
||||
except BaseException:
|
||||
try:
|
||||
with suppress(OSError):
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
return archive_path
|
||||
|
||||
@@ -68,54 +56,40 @@ def safe_extract_targz(archive: Path, destination: Path) -> None:
|
||||
"""
|
||||
with tarfile.open(archive, "r:gz") as tf:
|
||||
for member in tf.getmembers():
|
||||
parts = normalize_archive_parts(member.name)
|
||||
target = destination.joinpath(*parts)
|
||||
|
||||
target = destination.joinpath(*normalize_archive_parts(member.name))
|
||||
if member.isdir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
|
||||
if not member.isfile():
|
||||
raise ValueError(
|
||||
f"Unsupported archive member type: {member.name}"
|
||||
)
|
||||
|
||||
raise ValueError(f"Unsupported archive member type: {member.name}")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
extracted = tf.extractfile(member)
|
||||
if extracted is None:
|
||||
raise ValueError(f"Cannot read archive member: {member.name}")
|
||||
|
||||
with extracted, open(target, "wb") as dst:
|
||||
shutil.copyfileobj(extracted, dst)
|
||||
|
||||
try:
|
||||
with suppress(OSError):
|
||||
os.chmod(target, member.mode & 0o777)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def archive_root_dirs(archive: Path) -> set[str]:
|
||||
"""Return the archive's top-level directory names.
|
||||
|
||||
Transfer archives carry exactly one root directory, which names the thing being imported.
|
||||
Inspecting the archive before extraction lets the caller resolve the target name (and refuse a
|
||||
malformed archive) without first mutating a live tree.
|
||||
Inspecting before extraction lets the caller resolve the target name (and refuse a malformed
|
||||
archive) without first mutating a live tree.
|
||||
"""
|
||||
with tarfile.open(archive, "r:gz") as tf:
|
||||
return {
|
||||
parts[0]
|
||||
for member in tf.getmembers()
|
||||
for parts in [normalize_archive_parts(member.name)]
|
||||
if len(parts) > 1 or member.isdir()
|
||||
}
|
||||
return {parts[0] for member in tf.getmembers()
|
||||
for parts in [normalize_archive_parts(member.name)]
|
||||
if len(parts) > 1 or member.isdir()}
|
||||
|
||||
|
||||
def copy_regular_files(src: Path, dst: Path) -> int:
|
||||
"""Copy the regular files under ``src`` into ``dst``, skipping symlinks.
|
||||
"""Copy the regular files under ``src`` into ``dst``, skipping symlinks; return the count.
|
||||
|
||||
Used on the *export* side so a symlink planted in an attachments or logs tree can't pull an
|
||||
arbitrary file into the archive. Returns the number of files copied; a missing ``src`` copies
|
||||
nothing.
|
||||
arbitrary file into the archive. A missing ``src`` copies nothing.
|
||||
"""
|
||||
if not src.is_dir():
|
||||
return 0
|
||||
|
||||
+462
-1122
File diff suppressed because it is too large
Load Diff
+37
-73
@@ -1,47 +1,41 @@
|
||||
"""``!<command>`` shell mode for the interactive CLI.
|
||||
|
||||
Typing ``!git status`` at the composer runs the command directly in the session's working directory.
|
||||
The model is never invoked: no user message, no assistant message, no tool result enters the
|
||||
conversation history, so a bang command costs zero tokens and cannot perturb role alternation or the
|
||||
prompt cache.
|
||||
|
||||
CLI-only by design: gateway/API/cron sessions have their own shells and no composer, so
|
||||
:func:`bang_shell_enabled` gates the feature off there.
|
||||
``!git status`` at the composer runs directly in the session's working directory. The model is
|
||||
never invoked — no user/assistant message or tool result enters history — so a bang command costs
|
||||
zero tokens and cannot perturb role alternation or the prompt cache. CLI-only by design:
|
||||
gateway/API/cron sessions have their own shells and no composer (:func:`bang_shell_enabled`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from contextlib import suppress
|
||||
from typing import Optional
|
||||
|
||||
USAGE_HINT = "Usage: !<command> — run a shell command without spending a model turn (e.g. !git status)"
|
||||
|
||||
# Bang commands are interactive convenience, not agent work. Keep the ceiling
|
||||
# well under the terminal tool's foreground cap: a user watching output can
|
||||
# Ctrl+C, and an accidental `!sleep 999` should not wedge the composer.
|
||||
# Interactive convenience, not agent work: keep the ceiling well under the terminal tool's foreground
|
||||
# cap so an accidental `!sleep 999` cannot wedge the composer (the user can Ctrl+C anyway).
|
||||
DEFAULT_TIMEOUT = 120
|
||||
|
||||
|
||||
def is_bang_command(text: Optional[str]) -> bool:
|
||||
"""Return True when *text* is a ``!`` shell-mode submission.
|
||||
"""True when *text* is a ``!`` shell-mode submission.
|
||||
|
||||
Only a leading ``!`` (after surrounding whitespace) counts. A line that merely *contains* ``!``
|
||||
mid-text (``fix the bug!``, ``echo hi!``) is an ordinary prompt and must reach the agent
|
||||
untouched.
|
||||
Only a leading ``!`` (after surrounding whitespace) counts; ``fix the bug!`` is an ordinary
|
||||
prompt and must reach the agent untouched.
|
||||
"""
|
||||
return isinstance(text, str) and text.strip().startswith("!")
|
||||
|
||||
|
||||
def parse_bang_command(text: str) -> str:
|
||||
"""Return the shell command inside a bang submission (``""`` when bare).
|
||||
"""The shell command inside a bang submission (``""`` when bare).
|
||||
|
||||
``! ls -la`` -> ``ls -la``; ``!!`` -> ``!`` -- a literal second bang is part of the command
|
||||
(e.g. history expansion for the user's shell), not a Hermes token.
|
||||
``! ls -la`` -> ``ls -la``; ``!!`` -> ``!`` — a literal second bang belongs to the user's shell
|
||||
(history expansion), not to Hermes.
|
||||
"""
|
||||
if not is_bang_command(text):
|
||||
return ""
|
||||
return text.strip()[1:].strip()
|
||||
return text.strip()[1:].strip() if is_bang_command(text) else ""
|
||||
|
||||
|
||||
def bang_shell_enabled() -> bool:
|
||||
@@ -57,23 +51,18 @@ def bang_shell_enabled() -> bool:
|
||||
def env_var_enabled(name, default=""): # type: ignore[misc]
|
||||
return str(os.getenv(name, default)).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
return not (
|
||||
env_var_enabled("HERMES_GATEWAY_SESSION")
|
||||
or env_var_enabled("HERMES_CRON_SESSION")
|
||||
or (os.getenv("HERMES_SESSION_PLATFORM") or "").strip()
|
||||
)
|
||||
return not (env_var_enabled("HERMES_GATEWAY_SESSION") or env_var_enabled("HERMES_CRON_SESSION")
|
||||
or (os.getenv("HERMES_SESSION_PLATFORM") or "").strip())
|
||||
|
||||
|
||||
def resolve_bang_cwd(session_key: Optional[str] = None) -> Optional[str]:
|
||||
"""Return the directory a bang command should run in.
|
||||
"""The directory a bang command should run in.
|
||||
|
||||
Mirrors the terminal tool's resolution order so ``!pwd`` matches where the agent's own commands
|
||||
land: the session's recorded ``cd`` state first (``terminal_tool.get_session_cwd``, updated
|
||||
after every agent command), then the configured ``TERMINAL_CWD``/backend default.
|
||||
Mirrors the terminal tool's order so ``!pwd`` matches the agent's own commands: the session's
|
||||
recorded ``cd`` state first, then the configured ``TERMINAL_CWD``/backend default.
|
||||
"""
|
||||
try:
|
||||
from tools.terminal_tool import _get_env_config, get_session_cwd
|
||||
|
||||
return get_session_cwd(session_key) or (_get_env_config() or {}).get("cwd") or None
|
||||
except Exception:
|
||||
return None
|
||||
@@ -82,78 +71,57 @@ def resolve_bang_cwd(session_key: Optional[str] = None) -> Optional[str]:
|
||||
def check_bang_approval(command: str) -> dict:
|
||||
"""Run *command* through the terminal tool's approval gate.
|
||||
|
||||
Reuses ``tools.terminal_tool._check_all_guards`` — the exact function ``terminal_tool()`` calls
|
||||
before executing anything — so the hardline blocklist, user deny rules, tirith findings, and the
|
||||
interactive dangerous-command prompt all apply to user-typed bang commands too.
|
||||
|
||||
Returns the gate's decision dict (``{"approved": bool, "message": ...}``). Falls back to
|
||||
*approved* only when the gate itself cannot be imported, which would mean a broken install
|
||||
rather than a policy decision.
|
||||
Reuses ``tools.terminal_tool._check_all_guards`` — exactly what ``terminal_tool()`` calls — so the
|
||||
hardline blocklist, user deny rules, tirith findings, and the dangerous-command prompt all apply
|
||||
to user-typed bang commands too. Returns the gate's ``{"approved": bool, "message": ...}``;
|
||||
falls back to *approved* only when the gate itself cannot be imported (a broken install, not a
|
||||
policy decision).
|
||||
"""
|
||||
try:
|
||||
from tools.terminal_tool import _check_all_guards
|
||||
except Exception:
|
||||
return {"approved": True, "message": None}
|
||||
|
||||
# env_type mirrors the terminal tool: bang commands always run locally in
|
||||
# the CLI process, never inside a remote/sandbox backend.
|
||||
# Bang commands always run locally in the CLI process, never inside a remote/sandbox backend.
|
||||
return _check_all_guards(command, "local", has_host_access=False)
|
||||
|
||||
|
||||
def _bang_env() -> dict:
|
||||
"""Environment for a bang command, with Hermes-managed secrets filtered.
|
||||
"""Environment for a bang command with Hermes-managed secrets filtered.
|
||||
|
||||
The CLI process holds every provider API key in ``os.environ``; a bang command is user-typed
|
||||
but may still run a third-party script, so reuse the sanitizer ``quick_commands`` and the
|
||||
local terminal backend use rather than hand the whole keyring to an arbitrary subprocess.
|
||||
The CLI process holds every provider API key; a user-typed command may still run a third-party
|
||||
script, so reuse the sanitizer ``quick_commands`` and the local terminal backend use.
|
||||
"""
|
||||
try:
|
||||
from tools.environments.local import _sanitize_subprocess_env
|
||||
|
||||
return _sanitize_subprocess_env(os.environ.copy())
|
||||
except Exception:
|
||||
return os.environ.copy()
|
||||
|
||||
|
||||
def run_bang_command(
|
||||
command: str,
|
||||
*,
|
||||
cwd: Optional[str] = None,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
writer=None,
|
||||
) -> int:
|
||||
"""Execute *command* and stream its output, returning the exit code.
|
||||
def run_bang_command(command: str, *, cwd: Optional[str] = None, timeout: int = DEFAULT_TIMEOUT, writer=None) -> int:
|
||||
"""Execute *command*, streaming merged stdout/stderr through *writer* (default ``print``); return the exit code.
|
||||
|
||||
stdout and stderr are merged and written through *writer* (defaults to ``print``) as they
|
||||
arrive, so long-running commands show progress instead of buffering to the end. Nothing is
|
||||
returned to a caller for insertion into conversation history — the output exists only on the
|
||||
user's terminal.
|
||||
Output exists only on the user's terminal — nothing is returned for insertion into history.
|
||||
"""
|
||||
emit = writer or (lambda line: print(line, end="" if line.endswith("\n") else "\n"))
|
||||
|
||||
run_cwd = os.path.expanduser(cwd) if cwd else None
|
||||
if run_cwd and not os.path.isdir(run_cwd):
|
||||
run_cwd = None
|
||||
|
||||
try:
|
||||
from hermes_cli._subprocess_compat import windows_hide_flags
|
||||
|
||||
creationflags = windows_hide_flags()
|
||||
except Exception:
|
||||
creationflags = 0
|
||||
|
||||
try:
|
||||
# shell=True is intentional and matches quick_commands: this is a
|
||||
# command the human typed into their own composer, not model output.
|
||||
# shell=True is intentional (matches quick_commands): the human typed this, not the model.
|
||||
proc = subprocess.Popen(
|
||||
command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, encoding="utf-8", errors="replace",
|
||||
cwd=run_cwd, env=_bang_env(), creationflags=creationflags,
|
||||
)
|
||||
command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
encoding="utf-8", errors="replace", cwd=run_cwd, env=_bang_env(),
|
||||
creationflags=creationflags)
|
||||
except Exception as exc:
|
||||
emit(f"!: failed to run command: {exc}")
|
||||
return 127
|
||||
|
||||
try:
|
||||
if proc.stdout is not None:
|
||||
for line in proc.stdout:
|
||||
@@ -169,11 +137,7 @@ def run_bang_command(
|
||||
emit("!: interrupted")
|
||||
return 130
|
||||
finally:
|
||||
try:
|
||||
if proc.stdout is not None:
|
||||
if proc.stdout is not None:
|
||||
with suppress(Exception):
|
||||
proc.stdout.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return int(proc.returncode or 0)
|
||||
|
||||
|
||||
+245
-430
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user