Compare commits

..
Author SHA1 Message Date
Teknium bdbc4086b6 feat(titles): support language-aware title generation
Make auxiliary title prompts match the user language by default, with an optional pinned `auxiliary.title_generation.language` config.
2026-06-13 06:52:13 -07:00
248 changed files with 1042 additions and 18130 deletions
+16 -83
View File
@@ -11,20 +11,8 @@ on:
- 'optional-skills/**'
- '.github/workflows/deploy-site.yml'
workflow_dispatch:
inputs:
skills_index_run_id:
description: 'Optional Build Skills Index run ID whose skills-index artifact should be deployed'
required: false
type: string
rebuild_skills_index:
description: 'Force a fresh multi-source crawl instead of reusing the latest healthy index'
required: false
default: false
type: boolean
permissions:
contents: read
actions: read
pages: write
id-token: write
@@ -67,81 +55,26 @@ jobs:
- name: Install PyYAML for skill extraction
run: pip install pyyaml==6.0.2 httpx==0.28.1
- name: Prepare skills index (unified multi-source catalog)
- name: Build skills index (unified multi-source catalog)
env:
GH_TOKEN: ${{ github.token }}
GITHUB_TOKEN: ${{ github.token }}
SKILLS_INDEX_RUN_ID: ${{ github.event.inputs.skills_index_run_id || '' }}
REBUILD_SKILLS_INDEX: ${{ github.event.inputs.rebuild_skills_index || 'false' }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# The unified external catalog is expensive to crawl and can burn
# through the repository installation's GitHub API quota when several
# docs deploys land close together. Normal docs deploys therefore
# reuse the latest healthy catalog: first the artifact from a
# scheduled skills-index run, then the currently live index. Only a
# manual force rebuild does a fresh crawl here.
# Rebuild the unified catalog. The file is gitignored, so a fresh
# checkout starts without it and we want the freshest crawl in
# every deploy.
#
# If we do crawl, the build remains fatal. build_skills_index.py runs
# the health check BEFORE writing and exits non-zero on source
# collapse, keeping the last good Pages deployment live instead of
# publishing a degenerate catalog.
set -euo pipefail
INDEX_PATH="website/static/api/skills-index.json"
mkdir -p "$(dirname "$INDEX_PATH")"
validate_index() {
python3 - "$INDEX_PATH" <<'PY'
import json
import sys
from pathlib import Path
path = Path(sys.argv[1])
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
print(f"invalid skills index JSON: {exc}", file=sys.stderr)
sys.exit(1)
skills = data.get("skills")
if not isinstance(skills, list) or len(skills) < 1500:
count = len(skills) if isinstance(skills, list) else "missing"
print(f"skills index too small: {count}", file=sys.stderr)
sys.exit(1)
print(f"skills index ready: {len(skills)} skills")
PY
}
if [ "$REBUILD_SKILLS_INDEX" = "true" ]; then
python3 scripts/build_skills_index.py
validate_index
exit 0
fi
if [ -n "$SKILLS_INDEX_RUN_ID" ]; then
tmpdir="$(mktemp -d)"
echo "Downloading skills-index artifact from run $SKILLS_INDEX_RUN_ID"
if gh run download "$SKILLS_INDEX_RUN_ID" --name skills-index --dir "$tmpdir"; then
candidate="$(find "$tmpdir" -name skills-index.json -type f | head -n 1 || true)"
if [ -n "$candidate" ]; then
cp "$candidate" "$INDEX_PATH"
if validate_index; then
exit 0
fi
fi
fi
echo "::warning::Could not use skills-index artifact from run $SKILLS_INDEX_RUN_ID; trying live index"
fi
echo "Downloading currently live skills index"
if curl -fsSL --retry 3 --retry-delay 5 \
"https://hermes-agent.nousresearch.com/docs/api/skills-index.json" \
-o "$INDEX_PATH" && validate_index; then
exit 0
fi
echo "::warning::Live skills index unavailable or unhealthy; falling back to a fresh crawl"
rm -f "$INDEX_PATH"
# This MUST be fatal. build_skills_index.py runs a health check and
# exits non-zero WITHOUT writing the output file when a source
# collapses (e.g. a GitHub API rate limit zeroes the github /
# claude-marketplace / well-known taps all at once). Letting the
# deploy continue would either (a) ship a degenerate index missing
# whole hubs — the June 2026 regression where OpenAI/Anthropic/
# HuggingFace/NVIDIA tabs vanished — or (b) fall through to a
# local-only catalog. Failing here keeps the last good deployment
# live (GitHub Pages serves the previous build) instead of
# publishing a broken catalog. Re-run the workflow once the
# transient rate limit clears.
python3 scripts/build_skills_index.py
validate_index
- name: Extract skill metadata for dashboard
run: python3 website/scripts/extract-skills.py
+1 -1
View File
@@ -53,4 +53,4 @@ jobs:
- name: Trigger Deploy Site workflow
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh workflow run deploy-site.yml --repo ${{ github.repository }} -f skills_index_run_id=${{ github.run_id }}
run: gh workflow run deploy-site.yml --repo ${{ github.repository }}
-57
View File
@@ -29,8 +29,6 @@ jobs:
scan: ${{ steps.filter.outputs.scan }}
# True when pyproject.toml changed in this PR
deps: ${{ steps.filter.outputs.deps }}
# True when the curated MCP catalog / bundled MCP manifests changed.
mcp_catalog: ${{ steps.filter.outputs.mcp_catalog }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -56,14 +54,6 @@ jobs:
else
echo "deps=false" >> "$GITHUB_OUTPUT"
fi
MCP_CATALOG_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \
'optional-mcps/**' \
'hermes_cli/mcp_catalog.py' || true)
if [ -n "$MCP_CATALOG_FILES" ]; then
echo "mcp_catalog=true" >> "$GITHUB_OUTPUT"
else
echo "mcp_catalog=false" >> "$GITHUB_OUTPUT"
fi
scan:
name: Scan PR for critical supply chain risks
@@ -278,50 +268,3 @@ jobs:
runs-on: ubuntu-latest
steps:
- run: echo "No pyproject.toml changes, skipping dependency bounds check."
mcp-catalog-review:
name: MCP catalog security review
needs: changes
if: needs.changes.outputs.mcp_catalog == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Require explicit MCP catalog review label
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true)
if echo "$LABELS" | grep -Fxq 'mcp-catalog-reviewed'; then
echo "MCP catalog review label present."
exit 0
fi
BODY="## ⚠️ MCP catalog security review required
This PR changes the bundled MCP catalog or MCP catalog installer code. MCP entries can define local commands that users later install into \`mcp_servers\`, so this needs explicit maintainer review before merge.
A maintainer should verify:
- any new/changed \`optional-mcps/**/manifest.yaml\` command and args are expected,
- stdio transports do not use shell+egress/exfiltration payloads,
- git install refs are pinned and bootstrap commands are minimal,
- requested env vars/secrets match the upstream MCP's documented needs.
After review, add the \`mcp-catalog-reviewed\` label and re-run this check."
gh pr comment "$PR" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
echo "::error::MCP catalog changes require the mcp-catalog-reviewed label."
exit 1
mcp-catalog-review-gate:
name: MCP catalog security review
needs: changes
if: always() && needs.changes.outputs.mcp_catalog != 'true'
runs-on: ubuntu-latest
steps:
- run: echo "No MCP catalog changes, skipping MCP catalog security review."
-3
View File
@@ -900,9 +900,6 @@ def init_agent(
agent.api_key = client_kwargs.get("api_key", "")
agent.base_url = client_kwargs.get("base_url", agent.base_url)
try:
from agent.ssl_guard import verify_ca_bundle_with_fallback
verify_ca_bundle_with_fallback()
agent.client = agent._create_openai_client(client_kwargs, reason="agent_init", shared=True)
if not agent.quiet_mode:
print(f"🤖 AI Agent initialized with model: {agent.model}")
+1 -9
View File
@@ -881,8 +881,6 @@ def try_recover_primary_transport(
def drop_thinking_only_and_merge_users(
messages: List[Dict[str, Any]],
*,
drop_codex_reasoning_items: bool = True,
) -> List[Dict[str, Any]]:
"""Drop thinking-only assistant turns; merge any adjacent user messages left behind.
@@ -904,13 +902,7 @@ def drop_thinking_only_and_merge_users(
return messages
# Pass 1: drop thinking-only assistant turns.
kept = [
m for m in messages
if not _ra().AIAgent._is_thinking_only_assistant(
m,
drop_codex_reasoning_items=drop_codex_reasoning_items,
)
]
kept = [m for m in messages if not _ra().AIAgent._is_thinking_only_assistant(m)]
dropped = len(messages) - len(kept)
if dropped == 0:
return messages
-3
View File
@@ -751,9 +751,6 @@ def build_anthropic_client(
from httpx import Timeout
normalized_base_url = _normalize_base_url_text(base_url)
if normalized_base_url:
import re as _re
normalized_base_url = _re.sub(r"/v1/?$", "", normalized_base_url.rstrip("/"))
_read_timeout = timeout if (isinstance(timeout, (int, float)) and timeout > 0) else 900.0
kwargs = {
"timeout": Timeout(timeout=float(_read_timeout), connect=10.0),
+2 -3
View File
@@ -1144,8 +1144,7 @@ def _endpoint_speaks_anthropic_messages(base_url: str) -> bool:
normalized = (base_url or "").strip().lower().rstrip("/")
if not normalized:
return False
path = urlparse(normalized).path.rstrip("/")
if path.endswith("/anthropic") or path.endswith("/anthropic/v1"):
if normalized.endswith("/anthropic"):
return True
hostname = base_url_hostname(normalized)
if hostname == "api.anthropic.com":
@@ -5005,7 +5004,7 @@ def _build_call_kwargs(
# Provider-specific extra_body
merged_extra = dict(extra_body or {})
if provider == "nous":
if provider == "nous" or auxiliary_is_nous:
merged_extra.setdefault("tags", []).extend(_nous_portal_tags())
if merged_extra:
kwargs["extra_body"] = merged_extra
+4 -7
View File
@@ -935,14 +935,11 @@ def build_converse_kwargs(
if system_prompt:
kwargs["system"] = system_prompt
from agent.anthropic_adapter import _forbids_sampling_params
if temperature is not None:
kwargs["inferenceConfig"]["temperature"] = temperature
if not _forbids_sampling_params(model):
if temperature is not None:
kwargs["inferenceConfig"]["temperature"] = temperature
if top_p is not None:
kwargs["inferenceConfig"]["topP"] = top_p
if top_p is not None:
kwargs["inferenceConfig"]["topP"] = top_p
if stop_sequences:
kwargs["inferenceConfig"]["stopSequences"] = stop_sequences
+1 -5
View File
@@ -1081,7 +1081,6 @@ def _normalize_codex_response(
message_items_raw: List[Dict[str, Any]] = []
tool_calls: List[Any] = []
has_incomplete_items = response_status in {"queued", "in_progress", "incomplete"}
saw_streaming_or_item_incomplete = response_status in {"queued", "in_progress"}
saw_commentary_phase = False
saw_final_answer_phase = False
saw_reasoning_item = False
@@ -1096,7 +1095,6 @@ def _normalize_codex_response(
if item_status in {"queued", "in_progress", "incomplete"}:
has_incomplete_items = True
saw_streaming_or_item_incomplete = True
if item_type == "message":
item_phase = getattr(item, "phase", None)
@@ -1254,9 +1252,7 @@ def _normalize_codex_response(
finish_reason = "tool_calls"
elif leaked_tool_call_text:
finish_reason = "incomplete"
elif saw_streaming_or_item_incomplete:
finish_reason = "incomplete"
elif (has_incomplete_items or saw_commentary_phase) and not saw_final_answer_phase:
elif has_incomplete_items or (saw_commentary_phase and not saw_final_answer_phase):
finish_reason = "incomplete"
elif (reasoning_items_raw or reasoning_parts or saw_reasoning_item) and not final_text:
# Response contains only reasoning (encrypted thinking state and/or
+12 -26
View File
@@ -40,16 +40,6 @@ from agent.model_metadata import estimate_request_tokens_rough
logger = logging.getLogger(__name__)
# Stable marker the gateway matches on to re-tag the auto-compaction lifecycle
# status as ``kind="compacting"`` (tui_gateway/server.py::_status_update), so
# drivers like the desktop app can show an explicit "Summarizing…" indicator
# instead of the transcript appearing to silently reset. Keep the marker phrase
# intact if you reword COMPACTION_STATUS.
COMPACTION_STATUS_MARKER = "Compacting context"
COMPACTION_STATUS = (
f"🗜️ {COMPACTION_STATUS_MARKER} — summarizing earlier conversation so I can continue..."
)
def _compression_lock_holder(agent: Any) -> str:
"""Build a unique holder id for the lock: pid:tid:agent-instance:uuid.
@@ -334,7 +324,9 @@ def compress_context(
f"{approx_tokens:,}" if approx_tokens else "unknown", agent.model,
focus_topic,
)
agent._emit_status(COMPACTION_STATUS)
agent._emit_status(
"🗜️ Compacting context — summarizing earlier conversation so I can continue..."
)
# ── Compression lock ────────────────────────────────────────────────
# Atomic, state.db-backed lock per session_id. Without this, two
@@ -639,11 +631,7 @@ def compress_context(
return compressed, new_system_prompt
def try_shrink_image_parts_in_messages(
api_messages: list,
*,
max_dimension: int = 8000,
) -> bool:
def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
"""Re-encode all native image parts at a smaller size to recover from
image-too-large errors (Anthropic 5 MB, unknown other providers).
@@ -654,8 +642,7 @@ def try_shrink_image_parts_in_messages(
Strategy: look for ``image_url`` / ``input_image`` parts carrying a
``data:image/...;base64,...`` payload. For each one whose encoded
size exceeds 4 MB (a safe target that slides under Anthropic's 5 MB
ceiling with header overhead) or whose longest side exceeds
``max_dimension``, write the base64 to a tempfile, call
ceiling with header overhead), write the base64 to a tempfile, call
``vision_tools._resize_image_for_vision`` to produce a smaller data
URL, and substitute it in place.
@@ -677,9 +664,10 @@ def try_shrink_image_parts_in_messages(
# after a confirmed provider rejection, so the alternative is failure.
target_bytes = 4 * 1024 * 1024
# Anthropic enforces an 8000px per-side dimension cap independently of
# the 5 MB byte cap. In many-image requests, the provider can report a
# lower cap (observed: 2000px). The caller passes that parsed ceiling
# when the rejection includes it.
# the 5 MB byte cap. A tall screenshot can be well under 5 MB yet far
# over 8000px (e.g. 1200×12000 at 0.06 MB). We check pixel dimensions
# even when the byte budget is fine.
max_dimension = 8000
changed_count = 0
# Track parts that are over the target but could NOT be shrunk under it.
# If any survive, retrying is pointless — the same oversized payload will
@@ -696,9 +684,9 @@ def try_shrink_image_parts_in_messages(
# Check both byte size AND pixel dimensions.
needs_shrink = len(url) > target_bytes # over byte budget
if not needs_shrink:
# Even if bytes are fine, check pixel dimensions against the
# provider's reported per-side cap. A screenshot can be tiny in
# bytes yet too large in pixels.
# Even if bytes are fine, check pixel dimensions against
# Anthropic's 8000px cap. A tall image can be tiny in bytes
# yet huge in pixels.
try:
import base64 as _b64_dim
header_d, _, data_d = url.partition(",")
@@ -807,8 +795,6 @@ def try_shrink_image_parts_in_messages(
__all__ = [
"COMPACTION_STATUS",
"COMPACTION_STATUS_MARKER",
"check_compression_model_feasibility",
"replay_compression_warning",
"compress_context",
+14 -183
View File
@@ -71,35 +71,6 @@ logger = logging.getLogger(__name__)
INTERRUPT_WAITING_FOR_MODEL_PREFIX = "Operation interrupted: waiting for model response ("
def _image_error_max_dimension(error: Exception) -> Optional[int]:
"""Extract a provider-reported image dimension ceiling, if present."""
parts = []
for value in (
error,
getattr(error, "message", None),
getattr(error, "body", None),
):
if value:
try:
parts.append(str(value))
except Exception:
pass
text = " ".join(parts).lower()
if "image" not in text or "dimension" not in text or "max allowed size" not in text:
return None
match = re.search(r"max allowed size(?:\s+for [^:]+)?:\s*(\d{3,5})\s*pixels?", text)
if not match:
return None
try:
max_dimension = int(match.group(1))
except ValueError:
return None
if 512 <= max_dimension <= 8000:
return max_dimension
return None
def _ollama_context_limit_error(agent: Any, request_tokens: int) -> Optional[str]:
"""Return a user-facing error when Ollama is loaded with too little context."""
if not getattr(agent, "tools", None):
@@ -397,42 +368,6 @@ def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List
)
# Shared recovery hint appended to every content-policy refusal message. Both
# the HTTP-200 refusal path (``finish_reason=content_filter``) and the
# exception path (a provider moderation error classified as
# ``content_policy_blocked``) end with the same actionable next steps, so they
# share one trailer to keep the guidance from drifting between the two sites.
_CONTENT_POLICY_RECOVERY_HINT = (
"Try rephrasing the request, narrowing the context, or "
"adding a fallback provider with `hermes fallback add`."
)
def _content_policy_blocked_result(
messages: List[Dict],
api_call_count: int,
*,
final_response: str,
error_detail: str,
) -> Dict[str, Any]:
"""Build the terminal turn result for a content-policy block.
A content-policy refusal is deterministic for the unchanged prompt, so the
turn ends here (no retry). Both the HTTP-200 refusal handler and the
exception-path handler return the identical shape — a failed, non-completed
turn carrying the user-facing message and a ``content_policy_blocked:``
prefixed error — so they funnel through this one builder.
"""
return {
"final_response": final_response,
"messages": messages,
"api_calls": api_call_count,
"completed": False,
"failed": True,
"error": f"content_policy_blocked: {error_detail}",
}
def run_conversation(
agent,
user_message: str,
@@ -772,10 +707,7 @@ def run_conversation(
# a thinking-only turn. Runs on the per-call copy only — the
# stored conversation history keeps the reasoning block for the
# UI transcript and session persistence.
api_messages = agent._drop_thinking_only_and_merge_users(
api_messages,
drop_codex_reasoning_items=agent.api_mode != "codex_responses",
)
api_messages = agent._drop_thinking_only_and_merge_users(api_messages)
# Normalize message whitespace and tool-call JSON for consistent
# prefix matching. Ensures bit-perfect prefixes across turns,
@@ -1384,106 +1316,6 @@ def run_conversation(
)
finish_reason = "length"
# ── Content-policy refusal (HTTP 200) ──────────────────
# The model — or the provider's safety system — returned a
# *successful* response whose stop/finish reason is a refusal:
# Anthropic ``stop_reason="refusal"`` → ``content_filter``;
# OpenAI / portal ``finish_reason="content_filter"`` or a
# populated ``message.refusal`` (mapped in the chat_completions
# transport); Bedrock ``guardrail_intervened``. The content is
# typically empty, so without this branch the response falls
# through to the empty-response / invalid-response retry loops
# and is mis-surfaced as "rate limited" / "no content after
# retries" — burning paid attempts reproducing a deterministic
# refusal. Surface it clearly and stop. Mirrors the
# exception-based ``content_policy_blocked`` recovery: try a
# configured fallback once, otherwise return the refusal.
if finish_reason == "content_filter":
_refusal_transport = agent._get_transport()
if agent.api_mode == "anthropic_messages":
_refusal_result = _refusal_transport.normalize_response(
response, strip_tool_prefix=agent._is_anthropic_oauth
)
else:
_refusal_result = _refusal_transport.normalize_response(response)
_refusal_text = (getattr(_refusal_result, "content", None) or "").strip()
# Some refusals carry the explanation only in the reasoning
# channel; fall back to it so the user sees *something*.
if not _refusal_text:
_refusal_text = (agent._extract_reasoning(_refusal_result) or "").strip()
agent._invoke_api_request_error_hook(
task_id=effective_task_id,
turn_id=turn_id,
api_request_id=api_request_id,
api_call_count=api_call_count,
api_start_time=api_start_time,
api_kwargs=api_kwargs,
error_type="ContentPolicyBlocked",
error_message=_refusal_text or "model declined to respond (content_filter)",
status_code=None,
retry_count=retry_count,
max_retries=max_retries,
retryable=False,
reason=FailoverReason.content_policy_blocked.value,
)
if thinking_spinner:
thinking_spinner.stop("")
thinking_spinner = None
if agent.thinking_callback:
agent.thinking_callback("")
# Deterministic for the unchanged prompt — never retry.
# Try a configured fallback once (a different model may not
# refuse); otherwise surface the refusal terminally.
if agent._has_pending_fallback():
agent._buffer_status(
"⚠️ Model declined to respond (safety refusal) — trying fallback..."
)
if agent._try_activate_fallback():
retry_count = 0
compression_attempts = 0
_retry.primary_recovery_attempted = False
continue
agent._flush_status_buffer()
_refusal_log = (
_refusal_text[:500] + "..."
if len(_refusal_text) > 500
else _refusal_text
)
logger.warning(
"%sModel declined to respond (finish_reason=content_filter). "
"model=%s provider=%s refusal=%s",
agent.log_prefix, agent.model, agent.provider,
_refusal_log or "(no text)",
)
agent._emit_status(
"⚠️ The model declined to respond to this request (safety refusal)."
)
_refusal_detail = (
f"Model's explanation: {_refusal_text}"
if _refusal_text
else "The model returned no explanation."
)
_refusal_response = (
"⚠️ The model declined to respond to this request "
"(safety refusal — not a Hermes/gateway failure).\n\n"
f"{_refusal_detail}\n\n"
f"{_CONTENT_POLICY_RECOVERY_HINT}"
)
agent._cleanup_task_resources(effective_task_id)
agent._persist_session(messages, conversation_history)
return _content_policy_blocked_result(
messages,
api_call_count,
final_response=_refusal_response,
error_detail=_refusal_text or "model declined (content_filter)",
)
if finish_reason == "length":
if getattr(response, "id", "") == PARTIAL_STREAM_STUB_ID:
agent._vprint(
@@ -2235,11 +2067,7 @@ def run_conversation(
and not _retry.image_shrink_retry_attempted
):
_retry.image_shrink_retry_attempted = True
image_max_dimension = _image_error_max_dimension(api_error) or 8000
if agent._try_shrink_image_parts_in_messages(
api_messages,
max_dimension=image_max_dimension,
):
if agent._try_shrink_image_parts_in_messages(api_messages):
agent._vprint(
f"{agent.log_prefix}📐 Image(s) exceeded provider size limit — "
f"shrank and retrying...",
@@ -3255,17 +3083,20 @@ def run_conversation(
if classified.reason == FailoverReason.content_policy_blocked:
_summary = agent._summarize_api_error(api_error)
_policy_response = (
"⚠️ The model provider's safety filter blocked this request "
"(not a Hermes/gateway failure).\n\n"
f"⚠️ The model provider's safety filter blocked this request "
f"(not a Hermes/gateway failure).\n\n"
f"Provider message: {_summary}\n\n"
f"{_CONTENT_POLICY_RECOVERY_HINT}"
)
return _content_policy_blocked_result(
messages,
api_call_count,
final_response=_policy_response,
error_detail=_summary,
f"Try rephrasing the request, narrowing the context, or "
f"adding a fallback provider with `hermes fallback add`."
)
return {
"final_response": _policy_response,
"messages": messages,
"api_calls": api_call_count,
"completed": False,
"failed": True,
"error": f"content_policy_blocked: {_summary}",
}
return {
"final_response": None,
"messages": messages,
+11 -4
View File
@@ -70,6 +70,16 @@ def _resolve_args() -> list[str]:
def _resolve_home_dir() -> str:
"""Return a stable HOME for child ACP processes."""
try:
from hermes_constants import get_subprocess_home
profile_home = get_subprocess_home()
if profile_home:
return profile_home
except Exception:
pass
home = os.environ.get("HOME", "").strip()
if home:
return home
@@ -95,10 +105,7 @@ def _resolve_home_dir() -> str:
def _build_subprocess_env() -> dict[str, str]:
env = os.environ.copy()
home = _resolve_home_dir()
env["HOME"] = home
from hermes_constants import apply_subprocess_home_env
apply_subprocess_home_env(env)
env["HOME"] = _resolve_home_dir()
return env
-3
View File
@@ -1,3 +0,0 @@
class SSLConfigurationError(Exception):
"""Raised when SSL/TLS certificate bundle configuration fails."""
pass
+17
View File
@@ -46,6 +46,11 @@ def build_write_denied_paths(home: str) -> set[str]:
# Top-level Anthropic PKCE credential store remains sensitive even
# when a profile is active; default/non-profile sessions still read it.
str(hermes_root / ".anthropic_oauth.json"),
os.path.join(home, ".bashrc"),
os.path.join(home, ".zshrc"),
os.path.join(home, ".profile"),
os.path.join(home, ".bash_profile"),
os.path.join(home, ".zprofile"),
os.path.join(home, ".netrc"),
os.path.join(home, ".pgpass"),
os.path.join(home, ".npmrc"),
@@ -99,6 +104,12 @@ def is_write_denied(path: str) -> bool:
if resolved.startswith(prefix):
return True
# Hermes control-plane files: block both the ACTIVE profile's view
# (hermes_home) AND the global root view. Without the root pass, a
# profile-mode session leaves <root>/auth.json + <root>/config.yaml
# writable — letting a prompt-injected write_file overwrite the global
# files that every profile inherits from (same shape as #15981).
control_file_names = ("auth.json", "config.yaml", "webhook_subscriptions.json")
mcp_tokens_dir_name = "mcp-tokens"
hermes_dirs = []
@@ -111,6 +122,12 @@ def is_write_denied(path: str) -> bool:
continue
for base_real in hermes_dirs:
for name in control_file_names:
try:
if resolved == os.path.realpath(os.path.join(base_real, name)):
return True
except Exception:
continue
try:
mcp_real = os.path.realpath(os.path.join(base_real, mcp_tokens_dir_name))
if resolved == mcp_real or resolved.startswith(mcp_real + os.sep):
-11
View File
@@ -41,16 +41,6 @@ DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
GEMINI_DEFAULT_MAX_OUTPUT_TOKENS = 65535
def bare_gemini_model_id(model: str) -> str:
"""Strip Gemini's own provider prefix from an aggregator-style model id."""
name = (model or "").strip()
lowered = name.lower()
for prefix in ("google/", "gemini/"):
if lowered.startswith(prefix):
return name[len(prefix):].strip() or name
return name
def is_native_gemini_base_url(base_url: str) -> bool:
"""Return True when the endpoint speaks Gemini's native REST API."""
normalized = str(base_url or "").strip().rstrip("/").lower()
@@ -924,7 +914,6 @@ class GeminiNativeClient:
thinking_config=thinking_config,
)
model = bare_gemini_model_id(model)
if stream:
return self._stream_completion(model=model, request=request, timeout=timeout)
+2 -75
View File
@@ -5,7 +5,6 @@ and run_agent.py for pre-flight context checks.
"""
import ipaddress
import json
import logging
import os
import re
@@ -17,7 +16,7 @@ from urllib.parse import urlparse
import requests
import yaml
from utils import atomic_json_write, base_url_host_matches, base_url_hostname
from utils import base_url_host_matches, base_url_hostname
from hermes_constants import OPENROUTER_MODELS_URL
@@ -112,57 +111,6 @@ _endpoint_model_metadata_cache: Dict[str, Dict[str, Dict[str, Any]]] = {}
_endpoint_model_metadata_cache_time: Dict[str, float] = {}
_ENDPOINT_MODEL_CACHE_TTL = 300
def _get_model_metadata_cache_path() -> Path:
"""Return path to the OpenRouter model metadata disk cache."""
from hermes_constants import get_hermes_home
return get_hermes_home() / "cache" / "openrouter_model_metadata.json"
def _model_metadata_disk_cache_age_seconds() -> Optional[float]:
"""Return disk-cache age in seconds, or None if freshness is unknown."""
try:
cache_path = _get_model_metadata_cache_path()
if not cache_path.exists():
return None
age = time.time() - cache_path.stat().st_mtime
if age < 0:
return None
return age
except Exception:
return None
def _load_model_metadata_disk_cache() -> Dict[str, Dict[str, Any]]:
"""Load processed OpenRouter metadata cache from disk."""
try:
cache_path = _get_model_metadata_cache_path()
with cache_path.open("r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
return {}
return {
str(key): value
for key, value in data.items()
if isinstance(value, dict)
}
except Exception as e:
logger.debug("Failed to load OpenRouter model metadata disk cache: %s", e)
return {}
def _save_model_metadata_disk_cache(data: Dict[str, Dict[str, Any]]) -> None:
"""Save processed OpenRouter metadata cache to disk atomically."""
try:
atomic_json_write(
_get_model_metadata_cache_path(),
data,
indent=0,
separators=(",", ":"),
)
except Exception as e:
logger.debug("Failed to save OpenRouter model metadata disk cache: %s", e)
# Descending tiers for context length probing when the model is unknown.
# We start at 256K (covers GPT-5.x, many current large-context models) and
# step down on context-length errors until one works. Tier[0] is also the
@@ -679,15 +627,6 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any
if not force_refresh and _model_metadata_cache and (time.time() - _model_metadata_cache_time) < _MODEL_CACHE_TTL:
return _model_metadata_cache
if not force_refresh:
disk_age = _model_metadata_disk_cache_age_seconds()
if disk_age is not None and disk_age < _MODEL_CACHE_TTL:
disk_cache = _load_model_metadata_disk_cache()
if disk_cache:
_model_metadata_cache = disk_cache
_model_metadata_cache_time = time.time() - disk_age
return _model_metadata_cache
try:
response = requests.get(OPENROUTER_MODELS_URL, timeout=10, verify=_resolve_requests_verify())
response.raise_for_status()
@@ -709,24 +648,12 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any
_model_metadata_cache = cache
_model_metadata_cache_time = time.time()
_save_model_metadata_disk_cache(cache)
logger.debug("Fetched metadata for %s models from OpenRouter", len(cache))
return cache
except Exception as e:
logger.warning(f"Failed to fetch model metadata from OpenRouter: {e}")
if _model_metadata_cache:
return _model_metadata_cache
disk_cache = _load_model_metadata_disk_cache()
if disk_cache:
_model_metadata_cache = disk_cache
disk_age = _model_metadata_disk_cache_age_seconds()
if disk_age is not None:
_model_metadata_cache_time = time.time() - min(disk_age, _MODEL_CACHE_TTL)
else:
_model_metadata_cache_time = time.time() - _MODEL_CACHE_TTL + 1
return _model_metadata_cache
return {}
return _model_metadata_cache or {}
def fetch_endpoint_model_metadata(
+8 -14
View File
@@ -511,19 +511,13 @@ PLATFORM_HINTS = {
"Standard Markdown is automatically converted to Telegram formatting. "
"Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, "
"`inline code`, ```code blocks```, [links](url), and ## headers. "
"Telegram now supports rich Markdown, so lean into it: whenever it "
"makes the answer clearer or easier to scan, actively reach for real "
"Markdown tables (pipe `| col | col |` syntax), bullet and numbered "
"lists, task lists (`- [ ]` / `- [x]`), headings, nested blockquotes, "
"collapsible details, footnotes/references, math/formulas (`$...$`, "
"`$$...$$`), underline, subscript/superscript, marked (highlighted) "
"text, and anchors. Default to structured formatting over dense "
"paragraphs for any comparison, set of steps, key/value summary, or "
"tabular data. Prefer real Markdown tables and task lists over "
"hand-built bullet substitutes when presenting structured data; these "
"degrade gracefully (tables become readable bullet groups) when rich "
"rendering is unavailable, but advanced constructs like math and "
"collapsible details may render as plain source text in that case. "
"Telegram supports rich Markdown, so when it improves clarity you may "
"use headings, tables (pipe `| col | col |` syntax), task lists "
"(`- [ ]` / `- [x]`), nested blockquotes, collapsible details, "
"footnotes/references, math/formulas (`$...$`, `$$...$$`), underline, "
"subscript/superscript, marked (highlighted) text, and anchors. Prefer "
"real Markdown tables and task lists over hand-built bullet substitutes "
"when presenting structured data. "
"You can send media files natively: to deliver a file to the user, "
"include MEDIA:/absolute/path/to/file in your response. Images "
"(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice "
@@ -1164,7 +1158,7 @@ def build_skills_system_prompt(
or get_session_env("HERMES_SESSION_PLATFORM")
or ""
)
disabled = get_disabled_skill_names(_platform_hint or None)
disabled = get_disabled_skill_names()
cache_key = (
str(skills_dir.resolve()),
tuple(str(d) for d in external_dirs),
-8
View File
@@ -1,8 +0,0 @@
"""Egress proxy integrations.
Currently ships an iron-proxy (ironsh/iron-proxy) wrapper that intercepts
outbound traffic from remote terminal sandboxes and swaps proxy tokens
for real upstream credentials at the network edge.
Design notes live in :mod:`agent.proxy_sources.iron_proxy`.
"""
File diff suppressed because it is too large Load Diff
+27 -56
View File
@@ -272,65 +272,27 @@ def skill_matches_environment(frontmatter: Dict[str, Any]) -> bool:
# ── Disabled skills ───────────────────────────────────────────────────────
_RAW_CONFIG_CACHE: Dict[Tuple[str, int, int], Dict[str, Any]] = {}
def _raw_config_cache_clear() -> None:
"""Test hook — drop the shared raw config cache."""
_RAW_CONFIG_CACHE.clear()
def _load_raw_config() -> Dict[str, Any]:
"""Read config.yaml with a shared mtime+size keyed cache.
This module intentionally avoids importing ``hermes_cli.config`` on the
skill prompt/build path. A tiny local cache gives the same repeated-read
win without pulling the heavier CLI config stack into startup.
"""
config_path = get_config_path()
if not config_path.exists():
return {}
try:
stat = config_path.stat()
cache_key = (str(config_path), stat.st_mtime_ns, stat.st_size)
except OSError:
cache_key = None
if cache_key is not None:
cached = _RAW_CONFIG_CACHE.get(cache_key)
if cached is not None:
return cached
try:
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
except Exception as e:
logger.debug("Could not read skill config %s: %s", config_path, e)
return {}
if not isinstance(parsed, dict):
return {}
if cache_key is not None:
_RAW_CONFIG_CACHE.clear()
_RAW_CONFIG_CACHE[cache_key] = parsed
return parsed
def get_disabled_skill_names(platform: str | None = None) -> Set[str]:
"""Read disabled skill names from config.yaml.
Args:
platform: Explicit platform name (e.g. ``"telegram"``). When
*None*, resolves from ``HERMES_PLATFORM`` or
``HERMES_SESSION_PLATFORM`` env vars. Returns the global
disabled list, unioned with the platform-specific list when a
platform is resolved (a globally-disabled skill stays disabled
on every platform).
``HERMES_SESSION_PLATFORM`` env vars. Falls back to the
global disabled list when no platform is determined.
Reads the config file directly (no CLI config imports) to stay
lightweight.
"""
parsed = _load_raw_config()
if not parsed:
config_path = get_config_path()
if not config_path.exists():
return set()
try:
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
except Exception as e:
logger.debug("Could not read skill config %s: %s", config_path, e)
return set()
if not isinstance(parsed, dict):
return set()
skills_cfg = parsed.get("skills")
@@ -343,14 +305,13 @@ def get_disabled_skill_names(platform: str | None = None) -> Set[str]:
or os.getenv("HERMES_PLATFORM")
or get_session_env("HERMES_SESSION_PLATFORM")
)
global_disabled = _normalize_string_set(skills_cfg.get("disabled"))
if resolved_platform:
platform_disabled = (skills_cfg.get("platform_disabled") or {}).get(
resolved_platform
)
if platform_disabled is not None:
return global_disabled | _normalize_string_set(platform_disabled)
return global_disabled
return _normalize_string_set(platform_disabled)
return _normalize_string_set(skills_cfg.get("disabled"))
def _normalize_string_set(values) -> Set[str]:
@@ -375,7 +336,6 @@ _EXTERNAL_DIRS_CACHE: Dict[Tuple[str, int], List[Path]] = {}
def _external_dirs_cache_clear() -> None:
"""Test hook — drop the in-process cache."""
_EXTERNAL_DIRS_CACHE.clear()
_raw_config_cache_clear()
def get_external_skills_dirs() -> List[Path]:
@@ -408,8 +368,11 @@ def get_external_skills_dirs() -> List[Path]:
# Return a copy so callers can't mutate the cached list.
return list(cached)
parsed = _load_raw_config()
if not parsed:
try:
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
except Exception:
return []
if not isinstance(parsed, dict):
return []
skills_cfg = parsed.get("skills")
@@ -621,7 +584,15 @@ def resolve_skill_config_values(
current values (or the declared default if the key isn't set).
Path values are expanded via ``os.path.expanduser``.
"""
config = _load_raw_config()
config_path = get_config_path()
config: Dict[str, Any] = {}
if config_path.exists():
try:
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
if isinstance(parsed, dict):
config = parsed
except Exception:
pass
resolved: Dict[str, Any] = {}
for var in config_vars:
-94
View File
@@ -1,94 +0,0 @@
"""Preventive SSL CA certificate checks for Hermes Agent.
This module catches broken CA bundle paths before OpenAI/httpx turns them into
opaque ``FileNotFoundError: [Errno 2] No such file or directory`` failures.
"""
from __future__ import annotations
import logging
import os
import ssl
from pathlib import Path
from agent.errors import SSLConfigurationError
logger = logging.getLogger(__name__)
_CA_BUNDLE_ENV_VARS = (
"HERMES_CA_BUNDLE",
"SSL_CERT_FILE",
"REQUESTS_CA_BUNDLE",
"CURL_CA_BUNDLE",
)
_SKIP_VALUES = {"1", "true", "yes", "on"}
def _skip_ssl_guard_enabled() -> bool:
return os.getenv("HERMES_SKIP_SSL_GUARD", "").strip().lower() in _SKIP_VALUES
def _repair_hint() -> str:
return (
"Repair: python -m pip install --force-reinstall certifi openai httpx\n"
"If you configured a custom corporate CA bundle, fix or unset the "
"broken CA bundle environment variable."
)
def _ssl_err(message: str) -> SSLConfigurationError:
"""Create a consistent, user-actionable SSL configuration error."""
return SSLConfigurationError(f"{message}\n{_repair_hint()}")
def _validate_bundle_path(label: str, value: str, *, require_substantial: bool = False) -> None:
path = Path(value).expanduser()
if not path.exists():
raise _ssl_err(f"{label} points to a missing CA bundle: {value}")
if not path.is_file():
raise _ssl_err(f"{label} does not point to a CA bundle file: {value}")
if require_substantial and path.stat().st_size < 1024:
raise _ssl_err(f"{label} at {value} appears corrupted (too small)")
try:
ctx = ssl.create_default_context(cafile=str(path))
except Exception as exc:
raise _ssl_err(f"{label} CA bundle at {value} cannot be loaded: {exc}") from exc
if not ctx.get_ca_certs():
raise _ssl_err(f"{label} CA bundle at {value} did not load any certificates")
def verify_ca_bundle() -> None:
"""Verify configured and bundled CA certificates are present and loadable.
Raises:
SSLConfigurationError: If an explicit CA-bundle environment variable
points at a bad path, or if certifi's bundled ``cacert.pem`` is
missing/corrupt.
"""
if _skip_ssl_guard_enabled():
logger.debug("SSL CA bundle guard skipped via HERMES_SKIP_SSL_GUARD")
return
for env_var in _CA_BUNDLE_ENV_VARS:
value = os.getenv(env_var)
if value:
_validate_bundle_path(env_var, value)
try:
import certifi
except Exception as exc:
raise _ssl_err(f"certifi is not importable: {exc}") from exc
ca_bundle = str(certifi.where())
_validate_bundle_path("certifi", ca_bundle, require_substantial=True)
def verify_ca_bundle_with_fallback() -> None:
"""Backward-compatible wrapper for older call sites.
The old PR name mentioned a platform fallback, but allowing startup with a
broken certifi bundle still leaves httpx/OpenAI and requests call sites
failing later. Keep the wrapper name but enforce the same check.
"""
verify_ca_bundle()
+26 -1
View File
@@ -22,9 +22,31 @@ TitleCallback = Callable[[str], None]
_TITLE_PROMPT = (
"Generate a short, descriptive title (3-7 words) for a conversation that starts with the "
"following exchange. The title should capture the main topic or intent. "
"Write the title in the same language the user is writing in. "
"Return ONLY the title text, nothing else. No quotes, no punctuation at the end, no prefixes."
)
_TITLE_PROMPT_PINNED_LANGUAGE = (
"Generate a short, descriptive title (3-7 words) for a conversation that starts with the "
"following exchange. The title should capture the main topic or intent. "
"Write the title in {language}. "
"Return ONLY the title text, nothing else. No quotes, no punctuation at the end, no prefixes."
)
def _title_language() -> str:
"""Return configured title language, or empty string to match the user."""
try:
from hermes_cli.config import load_config
return str(
((load_config() or {}).get("auxiliary") or {})
.get("title_generation", {})
.get("language", "")
).strip()
except Exception:
return ""
def generate_title(
user_message: str,
@@ -48,8 +70,11 @@ def generate_title(
user_snippet = user_message[:500] if user_message else ""
assistant_snippet = assistant_response[:500] if assistant_response else ""
language = _title_language()
prompt = _TITLE_PROMPT_PINNED_LANGUAGE.format(language=language) if language else _TITLE_PROMPT
messages = [
{"role": "system", "content": _TITLE_PROMPT},
{"role": "system", "content": prompt},
{"role": "user", "content": f"User: {user_snippet}\n\nAssistant: {assistant_snippet}"},
]
+5 -16
View File
@@ -186,21 +186,10 @@ class AnthropicTransport(ProviderTransport):
def validate_response(self, response: Any) -> bool:
"""Check Anthropic response structure is valid.
An empty content list is legitimate for terminal stop reasons that
carry no text payload:
- ``end_turn`` the model's canonical "nothing more to add" after a
tool turn that already delivered the user-facing text.
- ``refusal`` the model declined to respond (Claude 4.5+). The
Messages API returns an empty ``content`` list with this stop
reason. Treating it as invalid sends a deterministic refusal into
the invalid-response retry loop, which reproduces the refusal on
every attempt and surfaces a misleading "rate limited / invalid
response" error instead of the refusal. ``normalize_response`` maps
``refusal`` ``content_filter`` so the agent loop's refusal handler
can surface it.
Treating either as invalid falsely retries a completed response.
An empty content list is legitimate when ``stop_reason == "end_turn"``
the model's canonical way of signalling "nothing more to add" after
a tool turn that already delivered the user-facing text. Treating it
as invalid falsely retries a completed response.
"""
if response is None:
return False
@@ -208,7 +197,7 @@ class AnthropicTransport(ProviderTransport):
if not isinstance(content_blocks, list):
return False
if not content_blocks:
return getattr(response, "stop_reason", None) in {"end_turn", "refusal"}
return getattr(response, "stop_reason", None) == "end_turn"
return True
def extract_cache_stats(self, response: Any) -> Optional[Dict[str, int]]:
+1 -35
View File
@@ -664,42 +664,8 @@ class ChatCompletionsTransport(ProviderTransport):
if rd:
provider_data["reasoning_details"] = rd
# OpenAI structured-refusal field. When a model declines, the SDK
# populates ``message.refusal`` with the explanation and leaves
# ``content`` empty. OpenAI-compatible proxies that front Anthropic /
# Bedrock (e.g. Nous Portal) surface a Claude refusal this way — or via
# ``finish_reason="content_filter"`` — instead of the native
# ``stop_reason="refusal"``. Without capturing it the refusal looks
# like an empty response, so the agent loop retries a deterministic
# refusal three times and gives up with "no content after retries".
# Promote it to content + a ``content_filter`` finish reason so the
# loop's refusal handler surfaces it clearly and stops. ``refusal`` is
# ``None`` for normal responses, so this is a no-op in the common case.
content = msg.content
refusal = getattr(msg, "refusal", None)
if refusal is None and hasattr(msg, "model_extra"):
_msg_extra = getattr(msg, "model_extra", None) or {}
if isinstance(_msg_extra, dict):
refusal = _msg_extra.get("refusal")
if isinstance(refusal, str) and refusal.strip():
# Record the refusal explanation regardless — it's useful provider
# metadata even when the model also returned a usable payload.
provider_data["refusal"] = refusal
_has_text = isinstance(content, str) and content.strip()
_has_tool_calls = bool(tool_calls)
# Only promote to a terminal ``content_filter`` when the refusal is
# the *sole* payload — no visible text and no tool calls. A response
# that carries real content (or tool calls) alongside a refusal note
# is a normal, usable turn: surfacing it as a failed safety refusal
# would discard the model's actual work. In the empty-payload case,
# adopt the refusal as content so the loop has something to show.
if not _has_text and not _has_tool_calls:
content = refusal
if finish_reason in (None, "stop"):
finish_reason = "content_filter"
return NormalizedResponse(
content=content,
content=msg.content,
tool_calls=tool_calls,
finish_reason=finish_reason,
reasoning=reasoning,
+16 -4
View File
@@ -218,10 +218,22 @@ class ResponsesApiTransport(ProviderTransport):
kwargs.pop("timeout", None)
if is_codex_backend:
# chatgpt.com/backend-api/codex rejects body-level
# ``extra_headers`` with HTTP 400. Correlation/cache routing for
# this backend must not be sent through the Responses payload.
kwargs.pop("extra_headers", None)
prompt_cache_key = kwargs.get("prompt_cache_key")
cache_scope_id = str(prompt_cache_key or session_id or "").strip()
if cache_scope_id:
existing_extra_headers = kwargs.get("extra_headers")
merged_extra_headers: Dict[str, str] = {}
if isinstance(existing_extra_headers, dict):
merged_extra_headers.update(
{
str(key): str(value)
for key, value in existing_extra_headers.items()
if key and value is not None
}
)
merged_extra_headers["session_id"] = cache_scope_id
merged_extra_headers["x-client-request-id"] = cache_scope_id
kwargs["extra_headers"] = merged_extra_headers
max_tokens = params.get("max_tokens")
if max_tokens is not None and not is_codex_backend:
-11
View File
@@ -67,16 +67,6 @@ function buildDesktopBackendPath({
)
}
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) } = {}) {
if (!hermesHome) return hermesHome
const resolved = pathModule.resolve(String(hermesHome))
const parent = pathModule.dirname(resolved)
if (pathModule.basename(parent).toLowerCase() === 'profiles') {
return pathModule.dirname(parent)
}
return resolved
}
function buildDesktopBackendEnv({
hermesHome,
pythonPathEntries = [],
@@ -107,6 +97,5 @@ module.exports = {
buildDesktopBackendEnv,
buildDesktopBackendPath,
delimiterForPlatform,
normalizeHermesHomeRoot,
pathEnvKey
}
@@ -7,7 +7,6 @@ const {
appendUniquePathEntries,
buildDesktopBackendEnv,
buildDesktopBackendPath,
normalizeHermesHomeRoot,
pathEnvKey
} = require('./backend-env.cjs')
@@ -67,21 +66,6 @@ test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () =
assert.ok(env.PATH.includes('/opt/homebrew/bin'))
})
test('normalizeHermesHomeRoot maps profile homes back to the global Hermes root', () => {
assert.equal(
normalizeHermesHomeRoot('/Users/test/.hermes/profiles/oracle', { pathModule: path.posix }),
'/Users/test/.hermes'
)
assert.equal(
normalizeHermesHomeRoot('C:\\Users\\test\\AppData\\Local\\hermes\\profiles\\oracle', { pathModule: path.win32 }),
'C:\\Users\\test\\AppData\\Local\\hermes'
)
assert.equal(
normalizeHermesHomeRoot('/Users/test/.hermes', { pathModule: path.posix }),
'/Users/test/.hermes'
)
})
test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => {
const env = buildDesktopBackendEnv({
hermesHome: 'C:\\Users\\test\\AppData\\Local\\hermes',
+5 -24
View File
@@ -38,7 +38,7 @@ const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
const { waitForDashboardPort } = require('./backend-ready.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
const { buildDesktopBackendEnv } = require('./backend-env.cjs')
const { readDirForIpc } = require('./fs-read-dir.cjs')
const { gitRootForIpc } = require('./git-root.cjs')
const { worktreesForIpc } = require('./git-worktrees.cjs')
@@ -240,7 +240,7 @@ if (INSTALL_STAMP) {
// HERMES_HOME beneath the throwaway userData dir so a fresh-install run never
// touches the user's real ~/.hermes / %LOCALAPPDATA%\hermes.
function resolveHermesHome() {
if (process.env.HERMES_HOME) return normalizeHermesHomeRoot(process.env.HERMES_HOME)
if (process.env.HERMES_HOME) return path.resolve(process.env.HERMES_HOME)
if (USER_DATA_OVERRIDE) return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home')
if (IS_WINDOWS && process.env.LOCALAPPDATA) {
const localappdata = path.join(process.env.LOCALAPPDATA, 'hermes')
@@ -5609,30 +5609,11 @@ ipcMain.handle('hermes:api', async (_event, request) => {
ipcMain.handle('hermes:notify', (_event, payload) => {
if (!Notification.isSupported()) return false
// Action buttons render only on signed macOS builds; elsewhere they're dropped
// and the body click still works.
const actions = Array.isArray(payload?.actions) ? payload.actions : []
const notification = new Notification({
new Notification({
title: payload?.title || 'Hermes',
body: payload?.body || '',
silent: Boolean(payload?.silent),
actions: actions.map(action => ({ type: 'button', text: String(action?.text || '') }))
})
notification.on('click', () => {
if (!mainWindow || mainWindow.isDestroyed()) return
focusWindow(mainWindow)
if (payload?.sessionId) {
mainWindow.webContents.send('hermes:focus-session', payload.sessionId)
}
})
notification.on('action', (_actionEvent, index) => {
if (!mainWindow || mainWindow.isDestroyed()) return
const action = actions[index]
if (action?.id) {
mainWindow.webContents.send('hermes:notification-action', { sessionId: payload?.sessionId, actionId: action.id })
}
})
notification.show()
silent: Boolean(payload?.silent)
}).show()
return true
})
-10
View File
@@ -94,16 +94,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
ipcRenderer.on('hermes:window-state-changed', listener)
return () => ipcRenderer.removeListener('hermes:window-state-changed', listener)
},
onFocusSession: callback => {
const listener = (_event, sessionId) => callback(sessionId)
ipcRenderer.on('hermes:focus-session', listener)
return () => ipcRenderer.removeListener('hermes:focus-session', listener)
},
onNotificationAction: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:notification-action', listener)
return () => ipcRenderer.removeListener('hermes:notification-action', listener)
},
onPreviewFileChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:preview-file-changed', listener)
+45 -122
View File
@@ -85,8 +85,6 @@ import {
import { QueuePanel } from './queue-panel'
import {
composerPlainText,
deleteSelectionInEditor,
insertPlainTextAtCaret,
normalizeComposerEditorDom,
placeCaretEnd,
refChipElement,
@@ -137,12 +135,6 @@ function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind {
return 'command'
}
/** A `/` query is at its arg stage once it's past the command name. */
const slashArgStage = (query: string) => query.includes(' ')
/** The `/command` token of a slash query (`personality x` → `/personality`). */
const slashCommandToken = (query: string) => `/${query.split(/\s+/, 1)[0]?.toLowerCase() ?? ''}`
interface QueueEditState {
attachments: ComposerAttachment[]
draft: string
@@ -540,6 +532,48 @@ export function ChatBar({
})
}, [])
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
if (imageBlobs.length > 0) {
event.preventDefault()
if (onAttachImageBlob) {
triggerHaptic('selection')
for (const blob of imageBlobs) {
void onAttachImageBlob(blob)
}
}
return
}
// Trim surrounding whitespace so a copy that dragged along leading/trailing
// blank lines (common when selecting from terminals, code blocks, web pages)
// doesn't dump multiline padding into the composer. Internal newlines are
// preserved — only the edges are cleaned up.
const pastedText = event.clipboardData.getData('text').trim()
if (!pastedText) {
event.preventDefault()
return
}
if (DATA_IMAGE_URL_RE.test(pastedText)) {
event.preventDefault()
return
}
event.preventDefault()
document.execCommand('insertText', false, pastedText)
const nextDraft = composerPlainText(event.currentTarget)
draftRef.current = nextDraft
aui.composer().setText(nextDraft)
}
const [trigger, setTrigger] = useState<TriggerState | null>(null)
const [triggerActive, setTriggerActive] = useState(0)
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
@@ -576,15 +610,7 @@ export function ChatBar({
}
const before = textBeforeCaret(editor)
const found = detectTrigger(before ?? composerPlainText(editor))
// The arg-stage popover is only useful for commands with an options screen.
// For a no-arg command it would dead-end on "No matches", so drop it — the
// directive is already complete.
const detected =
found?.kind === '/' && slashArgStage(found.query) && !desktopSlashCommandTakesArgs(slashCommandToken(found.query))
? null
: found
const detected = detectTrigger(before ?? composerPlainText(editor))
setTrigger(detected)
@@ -624,46 +650,6 @@ export function ChatBar({
flushEditorToDraft(event.currentTarget)
}
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
if (imageBlobs.length > 0) {
event.preventDefault()
if (onAttachImageBlob) {
triggerHaptic('selection')
for (const blob of imageBlobs) {
void onAttachImageBlob(blob)
}
}
return
}
// Trim surrounding whitespace so a copy that dragged along leading/trailing
// blank lines (common when selecting from terminals, code blocks, web pages)
// doesn't dump multiline padding into the composer. Internal newlines are
// preserved — only the edges are cleaned up.
const pastedText = event.clipboardData.getData('text').trim()
if (!pastedText) {
event.preventDefault()
return
}
if (DATA_IMAGE_URL_RE.test(pastedText)) {
event.preventDefault()
return
}
event.preventDefault()
insertPlainTextAtCaret(event.currentTarget, pastedText)
flushEditorToDraft(event.currentTarget)
}
const triggerAdapter: Unstable_TriggerAdapter | null =
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
@@ -679,12 +665,6 @@ export function ChatBar({
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
// Suppress the "No matches" empty state once a slash command is past its name:
// a no-arg command has nothing to offer, and a fully-typed arg commits on
// Space/Tab — neither should dead-end on a popover.
const argStageEmpty =
trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length
const closeTrigger = () => {
setTrigger(null)
setTriggerItems([])
@@ -695,25 +675,6 @@ export function ChatBar({
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
}, [triggerItems.length])
// Commit the literally-typed `/command arg` as a directive chip — used when
// the completion list is empty because the arg is already fully typed (the
// backend completer drops exact matches). Reuses the chip path via a
// synthetic item whose serialized form is the verbatim text.
const commitTypedSlashDirective = () => {
if (trigger?.kind !== '/') {
return
}
const text = `/${trigger.query.trimEnd()}`
replaceTriggerWithChip({
id: text,
type: 'slash',
label: text.slice(1),
metadata: { command: slashCommandToken(trigger.query), display: text, meta: '', group: '', action: '', rawText: text }
})
}
const replaceTriggerWithChip = (item: Unstable_TriggerItem) => {
const editor = editorRef.current
@@ -832,18 +793,6 @@ export function ChatBar({
return
}
// Non-collapsed Backspace/Delete: native selection-delete is ~O(n²) on large
// drafts (Ctrl+A → Delete froze ~1.3s). Collapsed carets fall through.
if (
(event.key === 'Backspace' || event.key === 'Delete') &&
deleteSelectionInEditor(event.currentTarget)
) {
event.preventDefault()
flushEditorToDraft(event.currentTarget)
return
}
// Cmd/Ctrl+Shift+K drains the next queued message. Plain Cmd/Ctrl+K is
// reserved for the global command palette.
if ((event.metaKey || event.ctrlKey) && !event.altKey && event.shiftKey && event.key.toLowerCase() === 'k') {
@@ -873,15 +822,7 @@ export function ChatBar({
return
}
// Enter / Tab / Space all accept the highlighted item: a no-arg command
// commits its directive chip, an arg-taking command expands to its
// options step, and an arg option commits the full `/cmd arg` chip. Space
// is slash-only (an `@` mention takes a literal space) and gated to a
// non-empty query so a bare `/ ` still types a space.
const acceptOnSpace = event.key === ' ' && trigger.kind === '/' && Boolean(trigger.query.trim())
const accept = event.key === 'Enter' || event.key === 'Tab' || acceptOnSpace
if (accept) {
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault()
triggerKeyConsumedRef.current = true
const item = triggerItems[triggerActive]
@@ -902,24 +843,6 @@ export function ChatBar({
}
}
// Arg stage with nothing left to suggest — a fully-typed arg the backend
// completer no longer echoes (it drops the exact match), e.g.
// `/personality creative`. Space/Tab still commit what's typed as a single
// directive chip; Enter falls through to submit (send it as-is).
if (
trigger?.kind === '/' &&
!triggerItems.length &&
(event.key === ' ' || event.key === 'Tab') &&
slashArgStage(trigger.query) &&
trigger.query.trim()
) {
event.preventDefault()
triggerKeyConsumedRef.current = true
commitTypedSlashDirective()
return
}
// ArrowUp/ArrowDown navigate, in priority order: the queue (edit entries in
// place) then sent-message history. The history ring is derived from live
// session messages each press — single source of truth, no mirror.
@@ -1842,7 +1765,7 @@ export function ChatBar({
ref={composerRef}
>
{showHelpHint && <HelpHint />}
{trigger && !argStageEmpty && (
{trigger && (
<ComposerTriggerPopover
activeIndex={triggerActive}
items={triggerItems}
@@ -3,24 +3,12 @@ import { describe, expect, it } from 'vitest'
import { insertInlineRefsIntoEditor } from './inline-refs'
import {
composerPlainText,
deleteSelectionInEditor,
insertPlainTextAtCaret,
normalizeComposerEditorDom,
refChipElement,
renderComposerContents,
RICH_INPUT_SLOT
} from './rich-editor'
const caretIn = (editor: HTMLElement) => {
const range = document.createRange()
const selection = window.getSelection()!
range.selectNodeContents(editor)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
}
describe('renderComposerContents', () => {
it('renders refs and raw text without interpreting user text as HTML', () => {
const editor = document.createElement('div')
@@ -71,64 +59,3 @@ describe('insertInlineRefsIntoEditor', () => {
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ')
})
})
describe('insertPlainTextAtCaret', () => {
it('inserts multiline text as text nodes + br', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
insertPlainTextAtCaret(editor, 'one\ntwo\nthree')
expect(editor.querySelectorAll('br').length).toBe(2)
expect(composerPlainText(editor)).toBe('one\ntwo\nthree')
editor.remove()
})
it('replaces the selected span', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = 'abXYef'
document.body.append(editor)
const text = editor.firstChild!
const selection = window.getSelection()!
const range = document.createRange()
range.setStart(text, 2)
range.setEnd(text, 4)
selection.removeAllRanges()
selection.addRange(range)
insertPlainTextAtCaret(editor, 'cd')
expect(composerPlainText(editor)).toBe('abcdef')
editor.remove()
})
})
describe('deleteSelectionInEditor', () => {
it('clears a non-collapsed range and leaves a collapsed caret', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = 'hello world'
document.body.append(editor)
const selection = window.getSelection()!
const range = document.createRange()
range.selectNodeContents(editor)
selection.removeAllRanges()
selection.addRange(range)
expect(deleteSelectionInEditor(editor)).toBe(true)
expect(composerPlainText(editor)).toBe('')
expect(selection.getRangeAt(0).collapsed).toBe(true)
expect(deleteSelectionInEditor(editor)).toBe(false)
editor.remove()
})
})
@@ -132,63 +132,6 @@ export function renderComposerContents(target: HTMLElement, text: string) {
appendComposerContents(target, text)
}
/** Caret range when the selection lives inside `editor`; else null. */
function composerSelectionRange(editor: HTMLElement) {
const selection = window.getSelection()
const range = selection?.rangeCount ? selection.getRangeAt(0) : null
if (!selection || !range || !editor.contains(range.commonAncestorContainer)) {
return null
}
return { range, selection }
}
/** Insert plain text at the caret (replacing any selection). Pastes use this
* instead of `execCommand('insertText')` Chromium's editing pipeline is
* ~O(n²) on large multiline blobs. */
export function insertPlainTextAtCaret(editor: HTMLElement, text: string) {
const hit = composerSelectionRange(editor)
const fragment = document.createDocumentFragment()
appendTextWithBreaks(fragment, text)
const tail = fragment.lastChild
if (hit) {
hit.range.deleteContents()
hit.range.insertNode(fragment)
} else {
editor.append(fragment)
}
if (tail) {
const caret = document.createRange()
caret.setStartAfter(tail)
caret.collapse(true)
const selection = hit?.selection ?? window.getSelection()
selection?.removeAllRanges()
selection?.addRange(caret)
}
}
/** Remove a non-collapsed selection in-editor. Skips collapsed carets so word/
* line delete (Opt/Cmd+Backspace) stays native. Returns whether anything ran. */
export function deleteSelectionInEditor(editor: HTMLElement) {
const hit = composerSelectionRange(editor)
if (!hit || hit.range.collapsed) {
return false
}
hit.range.deleteContents()
hit.range.collapse(true)
hit.selection.removeAllRanges()
hit.selection.addRange(hit.range)
return true
}
/** Serialize a draft string into chip-HTML for the contenteditable surface. */
export function composerHtml(text: string) {
let cursor = 0
@@ -1,67 +0,0 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
import { $activeSessionId } from '@/store/session'
import { onScrollToBottomRequest, resetThreadScroll, setThreadAtBottom } from '@/store/thread-scroll'
import { ScrollToBottomButton } from './scroll-to-bottom-button'
function pendingApproval() {
$activeSessionId.set('sess-1')
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
}
afterEach(() => {
cleanup()
clearAllPrompts()
resetThreadScroll()
$activeSessionId.set(null)
})
// `getByRole('button')` excludes aria-hidden nodes, so "queryByRole null" is the
// control's hidden (parked-at-bottom) state.
describe('ScrollToBottomButton', () => {
it('stays hidden while parked at the bottom', () => {
render(<ScrollToBottomButton />)
expect(screen.queryByRole('button')).toBeNull()
})
it('is a plain jump-to-bottom control when scrolled up with no approval', () => {
setThreadAtBottom(false)
render(<ScrollToBottomButton />)
expect(screen.getByRole('button', { name: 'Scroll to bottom' })).toBeTruthy()
expect(screen.queryByText('Approval needed')).toBeNull()
})
it('morphs into the approval pill when scrolled up with a pending approval', () => {
pendingApproval()
setThreadAtBottom(false)
render(<ScrollToBottomButton />)
expect(screen.getByRole('button', { name: 'Approval needed' })).toBeTruthy()
expect(screen.getByText('Approval needed')).toBeTruthy()
})
it('does not morph while a pending approval is still in view (at bottom)', () => {
pendingApproval()
render(<ScrollToBottomButton />)
// Parked at bottom → control hidden, so it can't claim "approval needed".
expect(screen.queryByRole('button')).toBeNull()
})
it('re-arms sticky-bottom on click', () => {
const handler = vi.fn()
const stop = onScrollToBottomRequest(handler)
setThreadAtBottom(false)
render(<ScrollToBottomButton />)
fireEvent.click(screen.getByRole('button'))
expect(handler).toHaveBeenCalledTimes(1)
stop()
})
})
@@ -5,7 +5,6 @@ import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $approvalRequest } from '@/store/prompts'
import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-scroll'
/**
@@ -16,13 +15,6 @@ import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-
* / background cards. Visible only while the user has scrolled meaningfully
* away from the bottom; clicking re-arms sticky-bottom and pins the viewport.
*
* When the turn is BLOCKED on an approval, this same control morphs into an
* "Approval needed" pill the only response surface is the inline Run/Reject
* bar on the parked tool row, which is always the bottom-most content, so the
* existing scroll-to-bottom action lands the user right on it. One control, no
* collision, no second scroll path (native scrollIntoView would scroll
* overflow:hidden ancestors that can't scroll back and wreck the layout).
*
* Enter/exit motion lives in styles.css under `.thread-jump-button` a
* directional scale (contract in from 1.1, contract out to 0.9) keyed off
* `data-state`. `idle` (never-shown) stays silent so it can't flash on mount;
@@ -31,11 +23,6 @@ import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-
export function ScrollToBottomButton() {
const { t } = useI18n()
const visible = useStore($threadJumpButtonVisible)
const request = useStore($approvalRequest)
// Scrolled away while an approval is pending → the inline Run/Reject bar is
// below the fold. Relabel so the user knows the session needs them, not just
// that there's more to read.
const approval = visible && Boolean(request)
const hasShownRef = useRef(false)
if (visible) {
@@ -43,17 +30,15 @@ export function ScrollToBottomButton() {
}
const state = visible ? 'in' : hasShownRef.current ? 'out' : 'idle'
const label = approval ? t.assistant.approval.jumpToApproval : t.assistant.thread.scrollToBottom
return (
<button
aria-hidden={!visible}
aria-label={label}
aria-label={t.assistant.thread.scrollToBottom}
className={cn(
'thread-jump-button absolute left-1/2 z-20 grid place-items-center backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
approval
? 'h-8 grid-flow-col gap-1.5 rounded-full border border-primary/40 bg-(--composer-fill) px-3 text-primary hover:bg-primary/10'
: 'size-8 rounded-full border border-border/65 bg-(--composer-fill) text-muted-foreground hover:text-foreground',
'thread-jump-button absolute left-1/2 z-20 grid size-8 place-items-center rounded-full',
'border border-border/65 bg-(--composer-fill) text-muted-foreground hover:text-foreground',
'backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
!visible && 'pointer-events-none'
)}
data-state={state}
@@ -67,8 +52,7 @@ export function ScrollToBottomButton() {
tabIndex={visible ? 0 : -1}
type="button"
>
<Codicon name="arrow-down" size={approval ? '0.875rem' : '1rem'} />
{approval && <span className="text-xs font-medium">{label}</span>}
<Codicon name="arrow-down" size="1rem" />
</button>
)
}
@@ -284,7 +284,6 @@ export function ProfileRail() {
selectProfile(name)
}}
open={createOpen}
profiles={profiles}
/>
<RenameProfileDialog
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import { CopyButton } from '@/components/ui/copy-button'
import { writeClipboardText } from '@/components/ui/copy-button'
import {
Dialog,
DialogContent,
@@ -49,17 +49,26 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
const r = t.sidebar.row
const [renameOpen, setRenameOpen] = useState(false)
const pinItem: ItemSpec = {
disabled: !onPin,
icon: 'pin',
label: pinned ? r.unpin : r.pin,
onSelect: () => {
triggerHaptic('selection')
onPin?.()
}
}
const items: ItemSpec[] = [
{
disabled: !onPin,
icon: 'pin',
label: pinned ? r.unpin : r.pin,
onSelect: () => {
triggerHaptic('selection')
onPin?.()
}
},
{
disabled: !sessionId,
icon: 'copy',
label: r.copyId,
onSelect: event => {
event.preventDefault()
triggerHaptic('selection')
void writeClipboardText(sessionId).catch(err => notifyError(err, r.copyIdFailed))
}
},
...(canOpenSessionWindow()
? [
{
@@ -113,28 +122,13 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
}
]
const renderMenuItem = (Item: MenuItem, { className, disabled, icon, label, onSelect, variant }: ItemSpec) => (
<Item className={className} disabled={disabled} key={label} onSelect={onSelect} variant={variant}>
<Codicon name={icon} size="0.875rem" />
<span>{label}</span>
</Item>
)
const renderItems = (Item: MenuItem) => (
<>
{renderMenuItem(Item, pinItem)}
<CopyButton
appearance={Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
disabled={!sessionId}
errorMessage={r.copyIdFailed}
key={r.copyId}
label={r.copyId}
onCopyError={err => notifyError(err, r.copyIdFailed)}
text={sessionId}
/>
{items.map(spec => renderMenuItem(Item, spec))}
</>
)
const renderItems = (Item: MenuItem) =>
items.map(({ className, disabled, icon, label, onSelect, variant }) => (
<Item className={className} disabled={disabled} key={label} onSelect={onSelect} variant={variant}>
<Codicon name={icon} size="0.875rem" />
<span>{label}</span>
</Item>
))
const renameDialog = (
<RenameSessionDialog
@@ -154,7 +154,7 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{
},
{
icon: KeyRound,
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens', 'egress', 'iron proxy', 'sandbox proxy'],
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens'],
labelKey: 'providerApiKeys',
tab: 'providers&pview=keys'
},
@@ -167,7 +167,7 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{
},
{
icon: Settings2,
keywords: ['gateway', 'proxy', 'server', 'webhook', 'env', 'egress proxy', 'iron proxy'],
keywords: ['gateway', 'proxy', 'server', 'webhook', 'env'],
labelKey: 'keysSettings',
tab: 'keys&kview=settings'
},
@@ -37,7 +37,6 @@ import {
SIDEBAR_SESSIONS_PAGE_SIZE,
unpinSession
} from '../store/layout'
import { respondToApprovalAction } from '../store/native-notifications'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
import {
$activeGatewayProfile,
@@ -270,26 +269,6 @@ export function DesktopController() {
}
}, [])
// Notification click: the main process already focused the window; jump to its session.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => {
if (sessionId) {
navigate(sessionRoute(sessionId))
}
})
return () => unsubscribe?.()
}, [navigate])
// Notification action button (Approve/Reject) — resolve in place, no navigation.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onNotificationAction?.(({ actionId, sessionId }) => {
void respondToApprovalAction(sessionId ?? null, actionId)
})
return () => unsubscribe?.()
}, [])
// hermes:// deep links (e.g. a docs "Send to App" button for an automation blueprint).
// Build the equivalent /blueprint slash command from the payload and drop
// it into the composer — the user reviews/edits, then sends; the agent (or
+1 -1
View File
@@ -527,7 +527,7 @@ const PLATFORM_INTRO: Record<string, string> = {
wecom_callback:
'Set up a WeCom self-built app, expose its callback URL, and provide the corp ID, secret, agent ID, and AES key.',
weixin:
'Run `hermes gateway setup`, select Weixin, then scan and confirm the QR code with a personal WeChat account. Hermes connects through Tencent\'s iLink Bot API and saves the credentials.',
'Sign in to the WeChat Official Account platform, copy the AppID and Token, and point the message callback URL at Hermes.',
qqbot: 'Register an app on the QQ Open Platform (q.qq.com) and copy the App ID and Client Secret.',
api_server:
'Expose Hermes as an OpenAI-compatible API. Set an auth key, then point Open WebUI / LobeChat / etc. at the host:port.',
@@ -2,15 +2,14 @@ import { useEffect, useState } from 'react'
import { ActionStatus } from '@/components/ui/action-status'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { createProfile, updateProfileSoul } from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { ProfileInfo } from '@/types/hermes'
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
@@ -24,18 +23,16 @@ export function isValidProfileName(name: string): boolean {
export function CreateProfileDialog({
onClose,
onCreated,
open,
profiles = []
open
}: {
onClose: () => void
onCreated?: (name: string) => Promise<void> | void
open: boolean
profiles?: ProfileInfo[]
}) {
const { t } = useI18n()
const p = t.profiles
const [name, setName] = useState('')
const [cloneFrom, setCloneFrom] = useState<null | string>('default')
const [cloneFromDefault, setCloneFromDefault] = useState(true)
const [soul, setSoul] = useState('')
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
const [error, setError] = useState<null | string>(null)
@@ -46,7 +43,7 @@ export function CreateProfileDialog({
}
setName('')
setCloneFrom('default')
setCloneFromDefault(true)
setSoul('')
setError(null)
setStatus('idle')
@@ -69,7 +66,7 @@ export function CreateProfileDialog({
setError(null)
try {
await createProfile({ name: trimmed, clone_from: cloneFrom })
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
if (soul.trim()) {
await updateProfileSoul(trimmed, soul)
@@ -110,25 +107,17 @@ export function CreateProfileDialog({
</p>
</div>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-clone-from">
{p.cloneFrom}
</label>
<Select onValueChange={value => setCloneFrom(value === '__none__' ? null : value)} value={cloneFrom ?? '__none__'}>
<SelectTrigger className="h-9 rounded-md" id="new-profile-clone-from">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{p.cloneFromNone}</SelectItem>
{profiles.map(profile => (
<SelectItem key={profile.name} value={profile.name}>
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{p.cloneFromDesc}</p>
</div>
<label className="flex cursor-pointer select-none items-start gap-2.5 px-0.5 py-1">
<Checkbox
checked={cloneFromDefault}
className="mt-0.5 shrink-0"
onCheckedChange={checked => setCloneFromDefault(checked === true)}
/>
<span className="grid gap-0.5 leading-snug">
<span className="text-sm font-medium">{p.cloneFromDefault}</span>
<span className="text-xs text-muted-foreground">{p.cloneFromDefaultDesc}</span>
</span>
</label>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-soul">
@@ -138,7 +127,7 @@ export function CreateProfileDialog({
className="min-h-28 font-mono text-xs leading-5"
id="new-profile-soul"
onChange={event => setSoul(event.target.value)}
placeholder={p.soulPlaceholder(cloneFrom ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
placeholder={p.soulPlaceholder(cloneFromDefault ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
value={soul}
/>
</div>
+20 -31
View File
@@ -12,7 +12,6 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
createProfile,
@@ -83,14 +82,14 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
}, [profiles, selectedName])
const handleCreate = useCallback(
async (name: string, cloneFrom: null | string) => {
async (name: string, cloneFromDefault: boolean) => {
const trimmed = name.trim()
if (!isValidProfileName(trimmed)) {
throw new Error(p.nameHint)
}
await createProfile({ name: trimmed, clone_from: cloneFrom })
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
notify({ kind: 'success', title: p.created, message: trimmed })
setSelectedName(trimmed)
await refresh()
@@ -181,9 +180,8 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
<CreateProfileDialog
onClose={() => setCreateOpen(false)}
onCreate={async (name, cloneFrom) => handleCreate(name, cloneFrom)}
onCreate={async (name, cloneFromDefault) => handleCreate(name, cloneFromDefault)}
open={createOpen}
profiles={profiles ?? []}
/>
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
@@ -455,18 +453,16 @@ function SoulEditor({ profileName }: { profileName: string }) {
function CreateProfileDialog({
onClose,
onCreate,
open,
profiles
open
}: {
onClose: () => void
onCreate: (name: string, cloneFrom: null | string) => Promise<void>
onCreate: (name: string, cloneFromDefault: boolean) => Promise<void>
open: boolean
profiles: ProfileInfo[]
}) {
const { t } = useI18n()
const p = t.profiles
const [name, setName] = useState('')
const [cloneFrom, setCloneFrom] = useState<null | string>('default')
const [cloneFromDefault, setCloneFromDefault] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
@@ -476,7 +472,7 @@ function CreateProfileDialog({
}
setName('')
setCloneFrom('default')
setCloneFromDefault(true)
setError(null)
setSaving(false)
}, [open])
@@ -497,7 +493,7 @@ function CreateProfileDialog({
setError(null)
try {
await onCreate(trimmed, cloneFrom)
await onCreate(trimmed, cloneFromDefault)
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : p.failedCreate)
@@ -532,25 +528,18 @@ function CreateProfileDialog({
</p>
</div>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-clone-from">
{p.cloneFrom}
</label>
<Select onValueChange={value => setCloneFrom(value === '__none__' ? null : value)} value={cloneFrom ?? '__none__'}>
<SelectTrigger className="h-9 rounded-md" id="new-profile-clone-from">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{p.cloneFromNone}</SelectItem>
{profiles.map(profile => (
<SelectItem key={profile.name} value={profile.name}>
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{p.cloneFromDesc}</p>
</div>
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border/40 bg-background/50 px-3 py-2 text-sm">
<input
checked={cloneFromDefault}
className="size-4 accent-primary"
onChange={event => setCloneFromDefault(event.target.checked)}
type="checkbox"
/>
<span>
<span className="font-medium">{p.cloneFromDefault}</span>
<span className="ml-2 text-xs text-muted-foreground">{p.cloneFromDefaultDesc}</span>
</span>
</label>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
@@ -2,7 +2,6 @@ import type { QueryClient } from '@tanstack/react-query'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
import { translateNow } from '@/i18n'
import {
appendAssistantTextPart,
appendReasoningPart,
@@ -16,7 +15,6 @@ import {
upsertToolPart
} from '@/lib/chat-messages'
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
import { playCompletionSound } from '@/lib/completion-sound'
import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
import {
dedupeGeneratedImageEchoesInParts,
@@ -27,10 +25,8 @@ import { triggerHaptic } from '@/lib/haptics'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { parseTodos } from '@/lib/todos'
import { setClarifyRequest } from '@/store/clarify'
import { setSessionCompacting } from '@/store/compaction'
import { refreshBackgroundProcesses } from '@/store/composer-status'
import { $gateway } from '@/store/gateway'
import { dispatchNativeNotification } from '@/store/native-notifications'
import { notify } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
@@ -334,8 +330,6 @@ export function useMessageStream({
const flushHandleRef = useRef<number | null>(null)
const lastFlushAtRef = useRef<number>(0)
const nativeSubagentSessionsRef = useRef<Set<string>>(new Set())
// Turns that auto-compacted: skip post-turn hydrate so live scrollback survives.
const compactedTurnRef = useRef<Set<string>>(new Set())
const flushQueuedDeltas = useCallback(
(sessionId?: string) => {
@@ -642,22 +636,18 @@ export function useMessageStream({
void refreshSessions().catch(() => undefined)
if (compactedTurnRef.current.delete(sessionId)) {
shouldHydrate = false
}
if (shouldHydrate) {
void hydrateFromStoredSession(3, completedState.storedSessionId, sessionId)
}
dispatchNativeNotification({
body: text.slice(0, 140) || translateNow('notifications.native.turnDoneBody'),
kind: 'turnDone',
sessionId,
title: translateNow('notifications.native.turnDoneTitle')
})
if (document.hidden && sessionId === activeSessionIdRef.current) {
void window.hermesDesktop?.notify({
title: 'Hermes finished',
body: text.slice(0, 140) || 'The response is ready.'
})
}
},
[hydrateFromStoredSession, refreshSessions, updateSessionState]
[activeSessionIdRef, hydrateFromStoredSession, refreshSessions, updateSessionState]
)
const failAssistantMessage = useCallback(
@@ -832,8 +822,6 @@ export function useMessageStream({
flushQueuedDeltas(sessionId)
clearSessionSubagents(sessionId)
setSessionCompacting(sessionId, false)
compactedTurnRef.current.delete(sessionId)
nativeSubagentSessionsRef.current.delete(sessionId)
if (isActiveEvent) {
@@ -879,11 +867,12 @@ export function useMessageStream({
// session so a background turn finishing can't wipe the active chat's
// prompt, and vice versa.
clearAllPrompts(sessionId)
setSessionCompacting(sessionId, false)
flushQueuedDeltas(sessionId)
playCompletionSound()
if (isActiveEvent) {
triggerHaptic('streamDone')
}
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
completeAssistantMessage(sessionId, finalText)
@@ -914,7 +903,10 @@ export function useMessageStream({
// terminal/process tool calls are the only things that spawn or reap
// background processes — sync the composer status stack right after.
if (!sessionInterrupted(sessionId) && (payload?.name === 'terminal' || payload?.name === 'process')) {
if (
!sessionInterrupted(sessionId) &&
(payload?.name === 'terminal' || payload?.name === 'process')
) {
void refreshBackgroundProcesses(sessionId)
}
}
@@ -966,13 +958,6 @@ export function useMessageStream({
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
dispatchNativeNotification({
body: question,
kind: 'input',
sessionId,
title: translateNow('notifications.native.inputTitle')
})
}
} else if (event.type === 'approval.request') {
// Dangerous-command / execute_code approval. The Python side is blocked
@@ -981,31 +966,17 @@ export function useMessageStream({
// Park it per-session (like clarify) so a *background* profile's turn can
// raise it and wait — the sidebar flags "needs input" and the inline bar
// surfaces once the user focuses that chat.
const command = typeof payload?.command === 'string' ? payload.command : ''
const description = typeof payload?.description === 'string' ? payload.description : 'dangerous command'
setApprovalRequest({
// false only when a tirith warning forbids it; backend omits the field otherwise.
allowPermanent: payload?.allow_permanent !== false,
command,
description,
command: typeof payload?.command === 'string' ? payload.command : '',
description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',
sessionId: sessionId ?? null
})
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
dispatchNativeNotification({
actions: [
{ id: 'approve', text: translateNow('notifications.native.approveAction') },
{ id: 'reject', text: translateNow('notifications.native.rejectAction') }
],
body: command || description,
kind: 'approval',
sessionId,
title: translateNow('notifications.native.approvalTitle')
})
} else if (event.type === 'sudo.request') {
// Sudo password capture (tools/terminal_tool.py). Blocked on
// sudo.respond {request_id, password}.
@@ -1017,13 +988,6 @@ export function useMessageStream({
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
dispatchNativeNotification({
body: translateNow('notifications.native.inputBody'),
kind: 'input',
sessionId,
title: translateNow('notifications.native.inputTitle')
})
}
} else if (event.type === 'secret.request') {
// Skill credential capture (tools/skills_tool.py). Blocked on
@@ -1031,26 +995,16 @@ export function useMessageStream({
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
if (requestId) {
const envVar = typeof payload?.env_var === 'string' ? payload.env_var : ''
const promptText = typeof payload?.prompt === 'string' ? payload.prompt : ''
setSecretRequest({
requestId,
envVar,
prompt: promptText,
envVar: typeof payload?.env_var === 'string' ? payload.env_var : '',
prompt: typeof payload?.prompt === 'string' ? payload.prompt : '',
sessionId: sessionId ?? null
})
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
dispatchNativeNotification({
body: promptText || envVar || translateNow('notifications.native.inputBody'),
kind: 'input',
sessionId,
title: translateNow('notifications.native.inputTitle')
})
}
} else if (event.type === 'terminal.read.request') {
// read_terminal tool: serialize the renderer's xterm buffer and answer
@@ -1068,12 +1022,9 @@ export function useMessageStream({
})
}
} else if (event.type === 'status.update') {
if (sessionId && payload?.kind === 'compacting') {
setSessionCompacting(sessionId, true)
compactedTurnRef.current.add(sessionId)
} else if (sessionId && payload?.kind === 'process') {
// The gateway's notification poller announces background process
// completions / watch matches here — re-sync the status stack.
// The gateway's notification poller announces background process
// completions / watch matches here — re-sync the status stack.
if (sessionId && payload?.kind === 'process') {
void refreshBackgroundProcesses(sessionId)
}
} else if (event.type === 'error') {
@@ -1085,17 +1036,8 @@ export function useMessageStream({
// the failed turn (same intent as the message.complete clear).
if (sessionId) {
clearAllPrompts(sessionId)
setSessionCompacting(sessionId, false)
compactedTurnRef.current.delete(sessionId)
}
dispatchNativeNotification({
body: errorMessage,
kind: 'turnError',
sessionId,
title: translateNow('notifications.native.turnErrorTitle')
})
if (looksLikeProviderSetup) {
requestDesktopOnboarding(errorMessage)
} else if (isActiveEvent) {
+1 -11
View File
@@ -5,7 +5,7 @@ import { Tip } from '@/components/ui/tooltip'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, Bell, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
import { Archive, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
@@ -20,7 +20,6 @@ import { SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings'
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
import { McpSettings } from './mcp-settings'
import { NotificationsSettings } from './notifications-settings'
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
import { SessionsSettings } from './sessions-settings'
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
@@ -31,7 +30,6 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
'gateway',
'keys',
'mcp',
'notifications',
'sessions',
'about'
]
@@ -103,12 +101,6 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
/>
)
})}
<OverlayNavItem
active={activeView === 'notifications'}
icon={Bell}
label={t.settings.nav.notifications}
onClick={() => setActiveView('notifications')}
/>
<div className="my-2 h-px bg-border/30" />
<OverlayNavItem
active={activeView === 'providers'}
@@ -233,8 +225,6 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<KeysSettings view={keysView} />
) : activeView === 'mcp' ? (
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
) : activeView === 'notifications' ? (
<NotificationsSettings />
) : (
<SessionsSettings />
)}
@@ -1,150 +0,0 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { useI18n } from '@/i18n'
import { COMPLETION_SOUND_VARIANTS, previewCompletionSound } from '@/lib/completion-sound'
import { triggerHaptic } from '@/lib/haptics'
import { Bell, Play } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $completionSoundVariantId, setCompletionSoundVariantId } from '@/store/completion-sound'
import {
$nativeNotifyPrefs,
NATIVE_NOTIFICATION_KINDS,
sendTestNativeNotification,
setNativeNotifyEnabled,
setNativeNotifyKind
} from '@/store/native-notifications'
import { notify } from '@/store/notifications'
import { CONTROL_TEXT } from './constants'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
const CAPTION = 'text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)'
function Caption({ children, className }: { children: ReactNode; className?: string }) {
return <p className={cn(CAPTION, className)}>{children}</p>
}
function ToggleRow(props: {
checked: boolean
description: string
disabled?: boolean
label: string
onChange: (on: boolean) => void
}) {
return (
<ListRow
action={
<Switch
aria-label={props.label}
checked={props.checked}
disabled={props.disabled}
onCheckedChange={on => {
triggerHaptic('selection')
props.onChange(on)
}}
/>
}
description={props.description}
title={props.label}
/>
)
}
export function NotificationsSettings() {
const { t } = useI18n()
const prefs = useStore($nativeNotifyPrefs)
const completionSoundVariantId = useStore($completionSoundVariantId)
const copy = t.settings.notifications
const runTest = async () => {
triggerHaptic('open')
const ok = await sendTestNativeNotification(copy.testTitle, copy.testBody)
notify({ kind: ok ? 'info' : 'error', message: ok ? copy.testSent : copy.testUnsupported })
}
return (
<SettingsContent>
<SectionHeading icon={Bell} title={copy.title} />
<Caption className="mb-2 leading-(--conversation-caption-line-height)">{copy.intro}</Caption>
<ToggleRow
checked={prefs.enabled}
description={copy.enableAllDesc}
label={copy.enableAll}
onChange={setNativeNotifyEnabled}
/>
<div className="my-1 h-px bg-border/30" />
{NATIVE_NOTIFICATION_KINDS.map(kind => (
<ToggleRow
checked={prefs.enabled && prefs.kinds[kind]}
description={copy.kinds[kind].description}
disabled={!prefs.enabled}
key={kind}
label={copy.kinds[kind].label}
onChange={on => setNativeNotifyKind(kind, on)}
/>
))}
<div className="my-1 h-px bg-border/30" />
<ListRow
action={
<div className="flex flex-wrap items-center justify-end gap-2">
<Select
onValueChange={value => {
const variantId = Number.parseInt(value, 10)
setCompletionSoundVariantId(variantId)
previewCompletionSound(variantId)
triggerHaptic('selection')
}}
value={String(completionSoundVariantId)}
>
<SelectTrigger className={cn('min-w-56', CONTROL_TEXT)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMPLETION_SOUND_VARIANTS.map(variant => (
<SelectItem key={variant.id} value={String(variant.id)}>
{variant.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
className="gap-1.5"
onClick={() => {
previewCompletionSound()
triggerHaptic('crisp')
}}
size="sm"
type="button"
variant="outline"
>
<Play className="size-3.5" />
{copy.completionSoundPreview}
</Button>
</div>
}
description={copy.completionSoundDesc}
title={copy.completionSoundTitle}
/>
<div className="mt-4 flex flex-col gap-2">
<Button className="self-start" onClick={() => void runTest()} size="sm" type="button" variant="outline">
<Bell />
{copy.test}
</Button>
<Caption>{copy.focusedHint}</Caption>
</div>
</SettingsContent>
)
}
@@ -1,100 +0,0 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OAuthProvider } from '@/types/hermes'
const listOAuthProviders = vi.fn()
const disconnectOAuthProvider = vi.fn()
const getEnvVars = vi.fn()
const startManualProviderOAuth = vi.fn()
const onboarding = atom({ manual: false })
vi.mock('@/hermes', () => ({
disconnectOAuthProvider: (providerId: string) => disconnectOAuthProvider(providerId),
getEnvVars: () => getEnvVars(),
listOAuthProviders: () => listOAuthProviders()
}))
vi.mock('@/store/onboarding', () => ({
$desktopOnboarding: onboarding,
startManualProviderOAuth: (providerId: string) => startManualProviderOAuth(providerId)
}))
function provider(id: string, loggedIn: boolean, patch: Partial<OAuthProvider> = {}): OAuthProvider {
return {
cli_command: `hermes auth add ${id}`,
disconnectable: true,
docs_url: '',
flow: 'device_code',
id,
name: id === 'nous' ? 'Nous Portal' : 'MiniMax',
status: {
logged_in: loggedIn
},
...patch
}
}
beforeEach(() => {
onboarding.set({ manual: false })
getEnvVars.mockResolvedValue({})
disconnectOAuthProvider.mockResolvedValue({ ok: true, provider: 'nous' })
listOAuthProviders.mockResolvedValue({
providers: [provider('nous', true), provider('minimax-oauth', false)]
})
vi.spyOn(window, 'confirm').mockReturnValue(true)
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.clearAllMocks()
})
async function renderProvidersSettings() {
const { ProvidersSettings } = await import('./providers-settings')
return render(<ProvidersSettings onViewChange={vi.fn()} view="accounts" />)
}
describe('ProvidersSettings', () => {
it('disconnects a connected provider account and refreshes the accounts list', async () => {
await renderProvidersSettings()
const remove = await screen.findByRole('button', { name: 'Remove Nous Portal' })
fireEvent.click(remove)
await waitFor(() => expect(disconnectOAuthProvider).toHaveBeenCalledWith('nous'))
expect(listOAuthProviders).toHaveBeenCalledTimes(2)
})
it('keeps provider selection separate from account removal', async () => {
await renderProvidersSettings()
fireEvent.click(await screen.findByText('Nous Portal'))
expect(startManualProviderOAuth).toHaveBeenCalledWith('nous')
expect(disconnectOAuthProvider).not.toHaveBeenCalled()
})
it('does not offer removal for externally managed providers', async () => {
listOAuthProviders.mockResolvedValue({
providers: [
provider('qwen-oauth', true, {
cli_command: 'hermes auth add qwen-oauth',
disconnect_hint: 'Use `hermes auth add qwen-oauth` or that provider\'s CLI to remove it.',
disconnectable: false,
flow: 'external',
name: 'Qwen (via Qwen CLI)'
})
]
})
await renderProvidersSettings()
expect(await screen.findByText('Qwen Code')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull()
expect(screen.getByText(/managed outside Hermes/)).toBeTruthy()
})
})
@@ -1,20 +1,18 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import {
FEATURED_ID,
FeaturedProviderRow,
KeyProviderRow,
ProviderRow,
providerTitle,
sortProviders
} from '@/components/desktop-onboarding-overlay'
import { Button } from '@/components/ui/button'
import { disconnectOAuthProvider, listOAuthProviders } from '@/hermes'
import { listOAuthProviders } from '@/hermes'
import { useI18n } from '@/i18n'
import { Check, ChevronDown, ChevronRight, KeyRound, Loader2, Terminal, Trash2 } from '@/lib/icons'
import { ChevronDown, KeyRound } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding'
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
@@ -87,17 +85,7 @@ function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGr
// Selecting a provider hands off to the shared onboarding overlay, which runs
// that provider's real sign-in flow; the key affordances open the API-key
// catalog below.
function OAuthPicker({
disconnecting,
onDisconnect,
onWantApiKey,
providers
}: {
disconnecting: null | string
onDisconnect: (provider: OAuthProvider) => void
onWantApiKey: () => void
providers: OAuthProvider[]
}) {
function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; providers: OAuthProvider[] }) {
const { t } = useI18n()
const p = t.settings.providers
const [showAll, setShowAll] = useState(false)
@@ -109,7 +97,7 @@ function OAuthPicker({
const select = (p: OAuthProvider) => startManualProviderOAuth(p.id)
const featured = ordered.find(p => p.id === FEATURED_ID && !p.status?.logged_in) ?? null
const featured = ordered.find(p => p.id === FEATURED_ID) ?? null
const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered
// Keep connected accounts grouped and always visible; only the unconnected
// providers hide behind the disclosure, so the page leads with what's set up.
@@ -142,13 +130,7 @@ function OAuthPicker({
{p.connected}
</p>
{connected.map(p => (
<ConnectedProviderRow
disconnecting={disconnecting === p.id}
key={p.id}
onDisconnect={onDisconnect}
onSelect={select}
provider={p}
/>
<ProviderRow key={p.id} onSelect={select} provider={p} />
))}
</>
)}
@@ -176,63 +158,6 @@ function OAuthPicker({
)
}
function ConnectedProviderRow({
disconnecting,
onDisconnect,
onSelect,
provider
}: {
disconnecting: boolean
onDisconnect: (provider: OAuthProvider) => void
onSelect: (provider: OAuthProvider) => void
provider: OAuthProvider
}) {
const { t } = useI18n()
const title = providerTitle(provider)
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
const canDisconnect = provider.disconnectable ?? provider.flow !== 'external'
const disconnectHint = provider.flow === 'external'
? t.settings.providers.removeExternal(title, provider.cli_command)
: t.settings.providers.removeKeyManaged(title)
return (
<div className="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[6px] transition-colors hover:bg-(--ui-control-hover-background)">
<button className="min-w-0 px-3 py-2.5 text-left" onClick={() => onSelect(provider)} type="button">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-[length:var(--conversation-text-font-size)] font-semibold">{title}</span>
<span className="inline-flex shrink-0 items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Check className="size-3" />
{t.settings.providers.connected}
</span>
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
{!canDisconnect && (
<p className="mt-0.5 truncate text-[0.68rem] leading-5 text-muted-foreground/70">
{disconnectHint}
</p>
)}
</button>
<div className="flex items-center gap-1 pr-2">
<Trail className="size-4 text-muted-foreground transition group-hover:text-foreground" />
{canDisconnect && (
<Button
aria-label={`${t.common.remove} ${title}`}
disabled={disconnecting}
onClick={() => onDisconnect(provider)}
size="icon-xs"
title={`${t.common.remove} ${title}`}
type="button"
variant="ghost"
>
{disconnecting ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
</Button>
)}
</div>
</div>
)
}
function NoProviderKeys() {
const { t } = useI18n()
@@ -248,26 +173,20 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
const { rowProps, vars } = useEnvCredentials()
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
const [openProvider, setOpenProvider] = useState<null | string>(null)
const [disconnecting, setDisconnecting] = useState<null | string>(null)
// The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we
// re-read connection state when the user finishes (or dismisses) a sign-in
// they launched from this page — otherwise the cards keep their stale status.
const onboardingActive = useStore($desktopOnboarding).manual
const refreshOAuthProviders = useCallback(async () => {
// OAuth providers are best-effort — a failure here just hides the panel.
const { providers } = await listOAuthProviders()
setOauthProviders(providers)
}, [])
useEffect(() => {
if (onboardingActive) {
return
}
let cancelled = false
// OAuth providers are best-effort — a failure here just hides the panel.
void (async () => {
if (onboardingActive) {
return
}
try {
const { providers } = await listOAuthProviders()
@@ -282,26 +201,6 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
return () => void (cancelled = true)
}, [onboardingActive])
async function handleDisconnect(provider: OAuthProvider) {
const name = providerTitle(provider)
if (!window.confirm(t.settings.providers.removeConfirm(name))) {
return
}
setDisconnecting(provider.id)
try {
await disconnectOAuthProvider(provider.id)
notify({ durationMs: 3_000, kind: 'success', title: t.settings.providers.removedTitle, message: t.settings.providers.removedMessage(name) })
await refreshOAuthProviders().catch(() => undefined)
} catch (err) {
notifyError(err, t.settings.providers.failedRemove(name))
} finally {
setDisconnecting(null)
}
}
if (!vars) {
return <LoadingState label={t.settings.providers.loading} />
}
@@ -338,12 +237,7 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
return (
<SettingsContent>
<OAuthPicker
disconnecting={disconnecting}
onDisconnect={provider => void handleDisconnect(provider)}
onWantApiKey={() => onViewChange('keys')}
providers={oauthProviders}
/>
<OAuthPicker onWantApiKey={() => onViewChange('keys')} providers={oauthProviders} />
</SettingsContent>
)
}
+1 -9
View File
@@ -4,15 +4,7 @@ import type { HermesGateway } from '@/hermes'
import type { IconComponent } from '@/lib/icons'
import type { EnvVarInfo } from '@/types/hermes'
export type SettingsView =
| 'about'
| 'gateway'
| 'keys'
| 'mcp'
| 'notifications'
| 'providers'
| 'sessions'
| `config:${string}`
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'providers' | 'sessions' | `config:${string}`
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
export interface SettingsPageProps {
@@ -2,7 +2,7 @@
import { type ToolCallMessagePartProps } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useState, type ComponentProps } from 'react'
import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useState } from 'react'
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
import { Button } from '@/components/ui/button'
@@ -36,30 +36,14 @@ function readClarifyArgs(args: unknown): ClarifyArgs {
}
// Choice and "Other" rows share a layout; only color/hover differs.
const OPTION_ROW_CLASS = 'flex w-full items-start gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors'
const CLARIFY_SHELL_CLASS =
'relative mb-3 mt-2 rounded-[0.5rem] border border-border/70 bg-card/40 text-sm shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]'
function ClarifyShell({
children,
className,
...props
}: ComponentProps<'div'>) {
return (
<div className={cn(CLARIFY_SHELL_CLASS, className)} data-slot="clarify-inline" {...props}>
<span aria-hidden className="arc-border" />
{children}
</div>
)
}
const OPTION_ROW_CLASS = 'flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors'
function RadioDot({ selected }: { selected: boolean }) {
return (
<span
aria-hidden
className={cn(
'mt-0.5 grid size-3.5 shrink-0 place-items-center rounded-full border transition-colors',
'grid size-3.5 shrink-0 place-items-center rounded-full border transition-colors',
selected ? 'border-primary' : 'border-muted-foreground/40'
)}
>
@@ -115,11 +99,9 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
// Race: tool.start fires a tick before clarify.request, so request_id
// arrives slightly after the tool block mounts. Hold the whole panel on a
// spinner until the gateway request is wired — showing disabled choices or
// a "loading question" stub is worse than a brief wait.
// arrives slightly after the tool block mounts. Show the question (from
// args) but disable submit until we have the request id from the gateway.
const ready = Boolean(matchingRequest?.requestId)
const loading = !ready && !submitting
const respond = useCallback(
async (answer: string) => {
@@ -156,11 +138,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
const handleTextareaKey = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.nativeEvent.isComposing) {
return
}
if (event.key === 'Enter' && !event.shiftKey) {
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
const trimmed = draft.trim()
@@ -184,20 +162,12 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
[draft, respond]
)
if (loading) {
return (
<ClarifyShell
aria-label={copy.loadingQuestion}
className="grid min-h-24 place-items-center px-3 py-6"
role="status"
>
<Loader2 aria-hidden className="size-5 animate-spin text-muted-foreground/80" />
</ClarifyShell>
)
}
return (
<ClarifyShell className="grid gap-6 px-3 py-2.5">
<div
className="relative mb-3 mt-2 grid gap-6 rounded-[0.5rem] border border-border/70 bg-card/40 px-3 py-2.5 text-sm shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]"
data-slot="clarify-inline"
>
<span aria-hidden className="arc-border" />
<div className="flex items-start gap-2.5">
<span
aria-hidden
@@ -205,7 +175,9 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
>
<HelpCircle className="size-3.5" />
</span>
<span className="flex-1 whitespace-pre-wrap font-medium leading-snug text-foreground">{question}</span>
<span className="flex-1 whitespace-pre-wrap font-medium leading-snug text-foreground">
{question || <em className="font-normal text-muted-foreground/70">{copy.loadingQuestion}</em>}
</span>
</div>
{!typing && hasChoices && (
@@ -218,7 +190,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
selectedChoice === choice && 'bg-accent/60'
)}
data-choice
disabled={submitting}
disabled={!ready || submitting}
key={`${index}-${choice}`}
onClick={() => {
setSelectedChoice(choice)
@@ -228,7 +200,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
>
<RadioDot selected={selectedChoice === choice} />
<span className="flex-1 wrap-anywhere">{choice}</span>
{selectedChoice === choice && <Check aria-hidden className="mt-0.5 size-4 shrink-0 text-primary" />}
{selectedChoice === choice && <Check aria-hidden className="size-4 shrink-0 text-primary" />}
</button>
))}
<button
@@ -259,9 +231,8 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
/>
<div className="flex items-center justify-between gap-2">
<span className="inline-flex items-center gap-1 text-[0.6875rem] text-muted-foreground/85">
<KbdCombo combo="enter" size="sm" />
<KbdCombo combo="shift+enter" size="sm" />
{t.composer.hotkeyDescs['composer.sendNewline']}
<KbdCombo combo="mod+enter" size="sm" />
{copy.shortcutSuffix}
</span>
<div className="flex items-center gap-1.5">
{hasChoices && (
@@ -278,10 +249,16 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
{copy.back}
</Button>
)}
<Button disabled={submitting} onClick={() => void respond('')} size="sm" type="button" variant="ghost">
<Button
disabled={!ready || submitting}
onClick={() => void respond('')}
size="sm"
type="button"
variant="ghost"
>
{copy.skip}
</Button>
<Button disabled={submitting || !draft.trim()} size="sm" type="submit">
<Button disabled={!ready || submitting || !draft.trim()} size="sm" type="submit">
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : copy.send}
</Button>
</div>
@@ -293,7 +270,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
<div className="flex justify-end">
<Button
className="-mr-2"
disabled={submitting}
disabled={!ready || submitting}
onClick={() => void respond('')}
size="xs"
type="button"
@@ -303,6 +280,6 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
</Button>
</div>
)}
</ClarifyShell>
</div>
)
}
@@ -96,7 +96,6 @@ import { extractPreviewTargets } from '@/lib/preview-targets'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import { $compactionActive } from '@/store/compaction'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
import { $connection } from '@/store/session'
@@ -274,7 +273,10 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
return pickPrimaryPreviewTarget(extractPreviewTargets(completedText))
}, [completedText])
const getMessageText = useCallback(() => messageContentText(messageRuntime.getState().content), [messageRuntime])
const getMessageText = useCallback(
() => messageContentText(messageRuntime.getState().content),
[messageRuntime]
)
const enterRef = useEnterAnimation(isRunning, `assistant-message:${messageId}`)
@@ -337,25 +339,13 @@ const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentProp
</div>
)
// Fixed label while auto-compaction runs — decoupled from backend status text.
const COMPACTION_LABEL = 'Summarizing thread'
const CompactionHint: FC = () => (
<span className="shimmer min-w-0 truncate text-muted-foreground/55">{COMPACTION_LABEL}</span>
)
const ResponseLoadingIndicator: FC = () => {
const { t } = useI18n()
const elapsed = useElapsedSeconds()
const compacting = useStore($compactionActive)
return (
<StatusRow
data-slot="aui_response-loading"
label={compacting ? COMPACTION_LABEL : t.assistant.thread.loadingResponse}
>
<StatusRow data-slot="aui_response-loading" label={t.assistant.thread.loadingResponse}>
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
{compacting && <CompactionHint />}
<ActivityTimerText seconds={elapsed} />
</StatusRow>
)
@@ -390,7 +380,6 @@ const StreamStallIndicator: FC = () => {
})
const [stalled, setStalled] = useState(false)
const compacting = useStore($compactionActive)
useEffect(() => {
setStalled(false)
@@ -399,21 +388,15 @@ const StreamStallIndicator: FC = () => {
return () => window.clearTimeout(id)
}, [activity])
const active = stalled || compacting
const elapsed = useElapsedSeconds(active)
const elapsed = useElapsedSeconds(stalled)
if (!active) {
if (!stalled) {
return null
}
return (
<StatusRow
className="mt-1.5"
data-slot="aui_stream-stall"
label={compacting ? COMPACTION_LABEL : 'Hermes is thinking'}
>
<StatusRow className="mt-1.5" data-slot="aui_stream-stall" label="Hermes is thinking">
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
{compacting && <CompactionHint />}
<ActivityTimerText seconds={elapsed} />
</StatusRow>
)
@@ -588,7 +571,10 @@ const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ te
return (
<MarkdownTextContent
containerClassName="text-xs leading-snug text-muted-foreground/85"
containerClassName={cn(
'text-xs leading-snug text-muted-foreground/85',
isRunning && 'shimmer text-muted-foreground/55'
)}
containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>}
isRunning={isRunning}
text={displayText}
@@ -180,7 +180,7 @@ const PROVIDER_DISPLAY: Record<string, { order: number; title: string }> = {
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
export const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99
export const sortProviders = (providers: OAuthProvider[]) =>
@@ -1,7 +1,6 @@
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { ContextMenuItem } from '@/components/ui/context-menu'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
@@ -10,7 +9,7 @@ import { Check, Copy, X } from '@/lib/icons'
import { cn } from '@/lib/utils'
type CopyPayload = string | (() => Promise<string> | string)
type CopyButtonAppearance = 'button' | 'icon' | 'inline' | 'menu-item' | 'context-menu-item' | 'tool-row'
type CopyButtonAppearance = 'button' | 'icon' | 'inline' | 'menu-item' | 'tool-row'
type CopyStatus = 'copied' | 'error' | 'idle'
const COPIED_RESET_MS = 1_500
@@ -160,11 +159,9 @@ export function CopyButton({
status === 'copied' ? t.common.copied : status === 'error' ? resolvedErrorMessage : (title ?? resolvedLabel)
const ariaLabel = status === 'idle' ? resolvedLabel : feedbackLabel
if (appearance === 'menu-item' || appearance === 'context-menu-item') {
const MenuItem = appearance === 'menu-item' ? DropdownMenuItem : ContextMenuItem
if (appearance === 'menu-item') {
return (
<MenuItem
<DropdownMenuItem
className={className}
disabled={disabled}
onSelect={event => {
@@ -173,7 +170,7 @@ export function CopyButton({
}}
>
{content}
</MenuItem>
</DropdownMenuItem>
)
}
-5
View File
@@ -88,8 +88,6 @@ declare global {
) => () => void
signalDeepLinkReady?: () => Promise<{ ok: boolean }>
onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void
onFocusSession?: (callback: (sessionId: string) => void) => () => void
onNotificationAction?: (callback: (payload: { actionId: string; sessionId?: string }) => void) => () => void
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
onPowerResume?: (callback: () => void) => () => void
@@ -415,9 +413,6 @@ export interface HermesNotification {
title?: string
body?: string
silent?: boolean
kind?: string
sessionId?: string
actions?: { id: string; text: string }[]
}
export interface HermesPreviewTarget {
-8
View File
@@ -393,14 +393,6 @@ export function listOAuthProviders(): Promise<OAuthProvidersResponse> {
})
}
export function disconnectOAuthProvider(providerId: string): Promise<{ ok: boolean; provider: string }> {
return window.hermesDesktop.api<{ ok: boolean; provider: string }>({
...profileScoped(),
path: `/api/providers/oauth/${encodeURIComponent(providerId)}`,
method: 'DELETE'
})
}
export function startOAuthLogin(providerId: string): Promise<OAuthStartResponse> {
return window.hermesDesktop.api<OAuthStartResponse>({
...profileScoped(),
+1 -62
View File
@@ -131,18 +131,6 @@ export const en: Translations = {
transcriptionUnavailable: 'Voice transcription is not available yet.',
tryRecordingAgain: 'Try recording again.',
unavailable: 'Voice unavailable'
},
native: {
approvalTitle: 'Approval needed',
approveAction: 'Approve',
rejectAction: 'Reject',
inputTitle: 'Input needed',
inputBody: 'Hermes is waiting for your response.',
turnDoneTitle: 'Hermes finished',
turnDoneBody: 'The response is ready.',
turnErrorTitle: 'Turn failed',
backgroundDoneTitle: 'Background task finished',
backgroundFailedTitle: 'Background task failed'
}
},
@@ -275,46 +263,7 @@ export const en: Translations = {
keysSettings: 'Settings',
mcp: 'MCP',
archivedChats: 'Archived Chats',
about: 'About',
notifications: 'Notifications'
},
notifications: {
title: 'Notifications',
intro:
'Native desktop notifications, separate from in-app toasts. These are device-local — each computer keeps its own settings.',
enableAll: 'Enable notifications',
enableAllDesc: 'Master switch. Turn this off to silence every notification below.',
focusedHint: 'Completion alerts only fire while Hermes is in the background.',
kinds: {
approval: {
label: 'Approval needed',
description: 'A command is waiting for you to approve or reject it.'
},
input: {
label: 'Input needed',
description: 'Hermes asked a question or needs a password or secret.'
},
turnDone: {
label: 'Response ready',
description: 'A turn finished while Hermes was in the background.'
},
turnError: {
label: 'Turn failed',
description: 'A turn ended with an error.'
},
backgroundDone: {
label: 'Background task finished',
description: 'A backgrounded terminal command completed.'
}
},
test: 'Send test notification',
testTitle: 'Hermes',
testBody: 'Notifications are working.',
testSent: 'Test sent. If nothing appears, check your OS notification permissions and Focus/Do Not Disturb.',
testUnsupported: 'This system does not support native notifications.',
completionSoundTitle: 'Completion Sound',
completionSoundDesc: 'Plays when an agent turn finishes. Pick a preset and preview it here.',
completionSoundPreview: 'Preview'
about: 'About'
},
sections: {
model: 'Model',
@@ -564,12 +513,6 @@ export const en: Translations = {
collapse: 'Collapse',
connectAnother: 'Connect another provider',
otherProviders: 'Other providers',
removeConfirm: provider => `Remove ${provider}?`,
removeExternal: (provider, command) => `${provider} is managed outside Hermes. Remove it with ${command}.`,
removeKeyManaged: provider => `${provider} is configured from an API key. Remove it from API Keys.`,
removedTitle: 'Account removed',
removedMessage: provider => `${provider} was removed.`,
failedRemove: provider => `Could not remove ${provider}`,
noProviderKeys: 'No provider API keys available.',
loading: 'Loading providers...'
},
@@ -960,9 +903,6 @@ export const en: Translations = {
deleting: 'Deleting...',
createDesc: 'Profiles are independent Hermes environments: separate config, skills, and SOUL.md.',
nameLabel: 'Name',
cloneFrom: 'Clone from',
cloneFromNone: 'None (blank)',
cloneFromDesc: 'Copies config, skills, and SOUL.md from the selected source profile.',
cloneFromDefault: 'Clone from default',
cloneFromDefaultDesc: 'Copy config, skills, and SOUL.md from your default profile.',
invalidName: hint => `Invalid name. ${hint}`,
@@ -1751,7 +1691,6 @@ export const en: Translations = {
moreOptions: 'More approval options',
allowSession: 'Allow this session',
alwaysAllowMenu: 'Always allow…',
jumpToApproval: 'Approval needed',
reject: 'Reject',
alwaysTitle: 'Always allow this command?',
alwaysDescription: pattern =>
+1 -63
View File
@@ -132,18 +132,6 @@ export const ja = defineLocale({
transcriptionUnavailable: '音声文字起こしはまだ利用できません。',
tryRecordingAgain: 'もう一度録音してください。',
unavailable: '音声は利用できません'
},
native: {
approvalTitle: '承認が必要です',
approveAction: '承認',
rejectAction: '拒否',
inputTitle: '入力が必要です',
inputBody: 'Hermes が応答を待っています。',
turnDoneTitle: 'Hermes が完了しました',
turnDoneBody: '応答の準備ができました。',
turnErrorTitle: 'ターンが失敗しました',
backgroundDoneTitle: 'バックグラウンドタスクが完了しました',
backgroundFailedTitle: 'バックグラウンドタスクが失敗しました'
}
},
@@ -189,47 +177,7 @@ export const ja = defineLocale({
keysSettings: '設定',
mcp: 'MCP',
archivedChats: 'アーカイブ済みチャット',
about: '情報',
notifications: '通知'
},
notifications: {
title: '通知',
intro:
'アプリ内トーストとは別の、ネイティブのデスクトップ通知です。設定は端末ごとに保存されます。',
enableAll: '通知を有効にする',
enableAllDesc: 'マスタースイッチ。オフにすると以下のすべての通知を無効にします。',
focusedHint: '完了通知は Hermes がバックグラウンドにあるときのみ表示されます。',
kinds: {
approval: {
label: '承認が必要',
description: 'コマンドが承認または拒否を待っています。'
},
input: {
label: '入力が必要',
description: 'Hermes が質問したか、パスワードやシークレットを必要としています。'
},
turnDone: {
label: '応答完了',
description: 'Hermes がバックグラウンドのときにターンが完了しました。'
},
turnError: {
label: 'ターン失敗',
description: 'ターンがエラーで終了しました。'
},
backgroundDone: {
label: 'バックグラウンドタスク完了',
description: 'バックグラウンドのターミナルコマンドが完了しました。'
}
},
test: 'テスト通知を送信',
testTitle: 'Hermes',
testBody: '通知は正常に動作しています。',
testSent:
'テストを送信しました。表示されない場合は、OS の通知許可と集中モード/おやすみモードを確認してください。',
testUnsupported: 'このシステムはネイティブ通知に対応していません。',
completionSoundTitle: '完了サウンド',
completionSoundDesc: 'エージェントのターン終了時に再生されます。プリセットを選んでここで試聴できます。',
completionSoundPreview: '試聴'
about: '情報'
},
sections: {
model: 'モデル',
@@ -694,12 +642,6 @@ export const ja = defineLocale({
collapse: '折りたたむ',
connectAnother: '別のプロバイダーを接続',
otherProviders: 'その他のプロバイダー',
removeConfirm: provider => `${provider} を削除しますか?`,
removeExternal: (provider, command) => `${provider} は Hermes の外部で管理されています。${command} で削除してください。`,
removeKeyManaged: provider => `${provider} は API キーで設定されています。API Keys から削除してください。`,
removedTitle: 'アカウントを削除しました',
removedMessage: provider => `${provider} を削除しました。`,
failedRemove: provider => `${provider} を削除できませんでした`,
noProviderKeys: '利用可能なプロバイダー API キーがありません。',
loading: 'プロバイダーを読み込み中...'
},
@@ -1099,9 +1041,6 @@ export const ja = defineLocale({
deleting: '削除中...',
createDesc: 'プロファイルは独立した Hermes 環境です:設定、スキル、SOUL.md が別々になります。',
nameLabel: '名前',
cloneFrom: '複製元',
cloneFromNone: 'なし(空)',
cloneFromDesc: '選択したプロファイルから設定、スキル、SOUL.md をコピーします。',
cloneFromDefault: 'デフォルトプロファイルから設定を複製',
cloneFromDefaultDesc: 'デフォルトプロファイルから設定、スキル、SOUL.md をコピーします。',
invalidName: hint => `無効なプロファイル名。${hint}`,
@@ -1892,7 +1831,6 @@ export const ja = defineLocale({
moreOptions: 'その他の承認オプション',
allowSession: 'このセッションで許可',
alwaysAllowMenu: '常に許可…',
jumpToApproval: '承認が必要',
reject: '拒否',
alwaysTitle: 'このコマンドを常に許可しますか?',
alwaysDescription: pattern =>
-44
View File
@@ -143,20 +143,6 @@ export interface Translations {
tryRecordingAgain: string
unavailable: string
}
// Native OS notification copy (titles + generic fallback bodies). Dynamic
// bodies (the agent's reply, a command, an error) are passed through raw.
native: {
approvalTitle: string
approveAction: string
rejectAction: string
inputTitle: string
inputBody: string
turnDoneTitle: string
turnDoneBody: string
turnErrorTitle: string
backgroundDoneTitle: string
backgroundFailedTitle: string
}
}
titlebar: {
@@ -216,26 +202,6 @@ export interface Translations {
mcp: string
archivedChats: string
about: string
notifications: string
}
notifications: {
title: string
intro: string
enableAll: string
enableAllDesc: string
focusedHint: string
kinds: Record<
'approval' | 'backgroundDone' | 'input' | 'turnDone' | 'turnError',
{ label: string; description: string }
>
test: string
testTitle: string
testBody: string
testSent: string
testUnsupported: string
completionSoundTitle: string
completionSoundDesc: string
completionSoundPreview: string
}
sections: Record<string, string>
searchPlaceholder: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string>
@@ -447,12 +413,6 @@ export interface Translations {
collapse: string
connectAnother: string
otherProviders: string
removeConfirm: (provider: string) => string
removeExternal: (provider: string, command: string) => string
removeKeyManaged: (provider: string) => string
removedTitle: string
removedMessage: (provider: string) => string
failedRemove: (provider: string) => string
noProviderKeys: string
loading: string
}
@@ -735,9 +695,6 @@ export interface Translations {
deleting: string
createDesc: string
nameLabel: string
cloneFrom: string
cloneFromNone: string
cloneFromDesc: string
cloneFromDefault: string
cloneFromDefaultDesc: string
invalidName: (hint: string) => string
@@ -1393,7 +1350,6 @@ export interface Translations {
moreOptions: string
allowSession: string
alwaysAllowMenu: string
jumpToApproval: string
reject: string
alwaysTitle: string
alwaysDescription: (pattern: string) => string
+1 -61
View File
@@ -127,18 +127,6 @@ export const zhHant = defineLocale({
transcriptionUnavailable: '語音轉寫暫不可用。',
tryRecordingAgain: '請再錄製一次。',
unavailable: '語音不可用'
},
native: {
approvalTitle: '需要核准',
approveAction: '核准',
rejectAction: '拒絕',
inputTitle: '需要輸入',
inputBody: 'Hermes 正在等待你的回應。',
turnDoneTitle: 'Hermes 已完成',
turnDoneBody: '回覆已就緒。',
turnErrorTitle: '本輪失敗',
backgroundDoneTitle: '背景工作已完成',
backgroundFailedTitle: '背景工作失敗'
}
},
@@ -184,45 +172,7 @@ export const zhHant = defineLocale({
keysSettings: '設定',
mcp: 'MCP',
archivedChats: '已封存聊天',
about: '關於',
notifications: '通知'
},
notifications: {
title: '通知',
intro: '原生桌面通知,與應用程式內提示不同。設定會依裝置保存,每台電腦各自獨立。',
enableAll: '啟用通知',
enableAllDesc: '總開關。關閉後會靜音下方所有通知。',
focusedHint: '完成提醒僅在 Hermes 位於背景時觸發。',
kinds: {
approval: {
label: '需要核准',
description: '有指令正在等待你核准或拒絕。'
},
input: {
label: '需要輸入',
description: 'Hermes 提出了問題,或需要密碼或密鑰。'
},
turnDone: {
label: '回覆就緒',
description: 'Hermes 在背景時完成了一輪對話。'
},
turnError: {
label: '本輪失敗',
description: '本輪以錯誤結束。'
},
backgroundDone: {
label: '背景工作完成',
description: '背景終端機指令已完成。'
}
},
test: '傳送測試通知',
testTitle: 'Hermes',
testBody: '通知運作正常。',
testSent: '測試已傳送。若沒有出現,請檢查系統通知權限與專注模式/勿擾模式。',
testUnsupported: '此系統不支援原生通知。',
completionSoundTitle: '完成提示音',
completionSoundDesc: '代理回合結束時播放。可在此選擇預設並預覽。',
completionSoundPreview: '預覽'
about: '關於'
},
sections: {
model: '模型',
@@ -671,12 +621,6 @@ export const zhHant = defineLocale({
collapse: '收合',
connectAnother: '連結其他提供方',
otherProviders: '其他提供方',
removeConfirm: provider => `移除 ${provider}`,
removeExternal: (provider, command) => `${provider} 由 Hermes 外部管理。請使用 ${command} 移除。`,
removeKeyManaged: provider => `${provider} 由 API 金鑰設定。請從 API Keys 中移除。`,
removedTitle: '帳號已移除',
removedMessage: provider => `${provider} 已移除。`,
failedRemove: provider => `無法移除 ${provider}`,
noProviderKeys: '沒有可用的提供方 API 金鑰。',
loading: '正在載入提供方...'
},
@@ -1055,9 +999,6 @@ export const zhHant = defineLocale({
deleting: '刪除中…',
createDesc: '設定檔是獨立的 Hermes 環境:各自擁有獨立的設定、技能和 SOUL.md。',
nameLabel: '名稱',
cloneFrom: '複製來源',
cloneFromNone: '無(空白)',
cloneFromDesc: '從選取的來源設定檔複製設定、技能和 SOUL.md。',
cloneFromDefault: '從預設設定檔複製設定',
cloneFromDefaultDesc: '從您的預設設定檔複製設定、技能和 SOUL.md。',
invalidName: hint => `設定檔名稱無效。${hint}`,
@@ -1834,7 +1775,6 @@ export const zhHant = defineLocale({
moreOptions: '更多核准選項',
allowSession: '允許本工作階段',
alwaysAllowMenu: '一律允許…',
jumpToApproval: '需要核准',
reject: '拒絕',
alwaysTitle: '一律允許此指令?',
alwaysDescription: pattern =>
+2 -63
View File
@@ -127,18 +127,6 @@ export const zh: Translations = {
transcriptionUnavailable: '语音转写暂不可用。',
tryRecordingAgain: '请再录一次。',
unavailable: '语音不可用'
},
native: {
approvalTitle: '需要批准',
approveAction: '批准',
rejectAction: '拒绝',
inputTitle: '需要输入',
inputBody: 'Hermes 正在等待你的回应。',
turnDoneTitle: 'Hermes 已完成',
turnDoneBody: '回复已就绪。',
turnErrorTitle: '本轮失败',
backgroundDoneTitle: '后台任务已完成',
backgroundFailedTitle: '后台任务失败'
}
},
@@ -271,45 +259,7 @@ export const zh: Translations = {
keysSettings: '设置',
mcp: 'MCP',
archivedChats: '已归档对话',
about: '关于',
notifications: '通知'
},
notifications: {
title: '通知',
intro: '原生桌面通知,区别于应用内提示。设置按设备保存,每台电脑各自独立。',
enableAll: '启用通知',
enableAllDesc: '总开关。关闭后将静音下方所有通知。',
focusedHint: '完成提醒仅在 Hermes 处于后台时触发。',
kinds: {
approval: {
label: '需要批准',
description: '有命令正在等待你批准或拒绝。'
},
input: {
label: '需要输入',
description: 'Hermes 提出了问题,或需要密码或密钥。'
},
turnDone: {
label: '回复就绪',
description: 'Hermes 在后台时完成了一轮对话。'
},
turnError: {
label: '本轮失败',
description: '本轮以错误结束。'
},
backgroundDone: {
label: '后台任务完成',
description: '后台终端命令已完成。'
}
},
test: '发送测试通知',
testTitle: 'Hermes',
testBody: '通知工作正常。',
testSent: '测试已发送。如果没有出现,请检查系统通知权限和专注模式/勿扰模式。',
testUnsupported: '此系统不支持原生通知。',
completionSoundTitle: '完成提示音',
completionSoundDesc: '智能体回合结束时播放。可在此选择预设并预览。',
completionSoundPreview: '预览'
about: '关于'
},
sections: {
model: '模型',
@@ -758,12 +708,6 @@ export const zh: Translations = {
collapse: '收起',
connectAnother: '连接其他提供方',
otherProviders: '其他提供方',
removeConfirm: provider => `移除 ${provider}`,
removeExternal: (provider, command) => `${provider} 由 Hermes 外部管理。请使用 ${command} 移除。`,
removeKeyManaged: provider => `${provider} 由 API 密钥配置。请从 API Keys 中移除。`,
removedTitle: '账号已移除',
removedMessage: provider => `${provider} 已移除。`,
failedRemove: provider => `无法移除 ${provider}`,
noProviderKeys: '没有可用的提供方 API 密钥。',
loading: '正在加载提供方...'
},
@@ -1093,8 +1037,7 @@ export const zh: Translations = {
feishu: '创建飞书 / Lark 应用,配置机器人能力,复制 App ID、App secret 和事件加密密钥。',
wecom: '在企业微信中添加群机器人,复制其 webhook key 作为 WECOM_BOT_ID。仅可发送——双向请用企业微信 (应用) 选项。',
wecom_callback: '设置一个企业微信自建应用,暴露其回调 URL,并提供 corp ID、secret、agent ID 和 AES key。',
weixin:
'运行 `hermes gateway setup`,选择 Weixin,然后使用个人微信账号扫描并确认二维码。Hermes 会通过腾讯 iLink Bot API 连接并保存凭据。',
weixin: '登录微信公众平台,复制 AppID 和 Token,并把消息回调 URL 指向 Hermes。',
qqbot: '在 QQ 开放平台 (q.qq.com) 注册一个应用,复制 App ID 和 Client Secret。',
api_server:
'把 Hermes 暴露为兼容 OpenAI 的 API。设置一个鉴权密钥,然后把 Open WebUI / LobeChat 等指向 host:port。',
@@ -1149,9 +1092,6 @@ export const zh: Translations = {
deleting: '删除中…',
createDesc: '配置档案是相互独立的 Hermes 环境:各自拥有独立的配置、技能和 SOUL.md。',
nameLabel: '名称',
cloneFrom: '克隆来源',
cloneFromNone: '无(空白)',
cloneFromDesc: '从选中的来源配置档案复制配置、技能和 SOUL.md。',
cloneFromDefault: '从默认档案克隆',
cloneFromDefaultDesc: '从你的默认配置档案复制配置、技能和 SOUL.md。',
invalidName: hint => `名称无效。${hint}`,
@@ -1931,7 +1871,6 @@ export const zh: Translations = {
moreOptions: '更多审批选项',
allowSession: '允许本会话',
alwaysAllowMenu: '始终允许…',
jumpToApproval: '需要审批',
reject: '拒绝',
alwaysTitle: '始终允许此命令?',
alwaysDescription: pattern =>
-519
View File
@@ -1,519 +0,0 @@
// Completion sound bank for agent turn-end cues.
// Fourteen curated presets for A/B in Settings → Appearance. Default is variant 1.
import { $completionSoundVariantId, resolveCompletionSoundVariantId } from '@/store/completion-sound'
import { $hapticsMuted } from '@/store/haptics'
type OscType = OscillatorType
let ctx: AudioContext | null = null
function getCtx(): AudioContext | null {
if (typeof window === 'undefined') {
return null
}
try {
if (!ctx) {
const Ctor = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (!Ctor) {
return null
}
ctx = new Ctor()
}
// Autoplay policies can leave the context suspended until a gesture; a
// resume() here recovers it once the user has interacted with the window.
if (ctx.state === 'suspended') {
void ctx.resume().catch(() => undefined)
}
return ctx
} catch {
return null
}
}
// One enveloped oscillator voice → master. Linear attack into an exponential
// decay keeps the tail smooth and avoids the click you get ramping to zero.
function voice(ac: AudioContext, master: GainNode, t0: number, spec: ToneSpec) {
const osc = ac.createOscillator()
const env = ac.createGain()
const start = t0 + (spec.start ?? 0)
const peak = spec.gain ?? 0.5
const attack = spec.attack ?? 0.006
const end = start + spec.dur
osc.type = spec.type ?? 'sine'
osc.frequency.setValueAtTime(spec.freq, start)
env.gain.setValueAtTime(0.0001, start)
env.gain.exponentialRampToValueAtTime(Math.max(peak, 0.0002), start + attack)
env.gain.exponentialRampToValueAtTime(0.0001, end)
osc.connect(env)
env.connect(master)
osc.start(start)
osc.stop(end + 0.02)
}
// Soft pluck: brief triangle strike with an upward glide into the bloom.
function pluckVoice(ac: AudioContext, master: GainNode, t0: number, spec: PluckSpec) {
const osc = ac.createOscillator()
const env = ac.createGain()
const start = t0 + (spec.start ?? 0)
const attack = spec.attack ?? 0.004
const glide = spec.glide ?? 0.16
const end = start + spec.decay
osc.type = 'triangle'
osc.frequency.setValueAtTime(spec.freqFrom, start)
osc.frequency.exponentialRampToValueAtTime(spec.freqTo, start + glide)
env.gain.setValueAtTime(0.0001, start)
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + attack)
env.gain.exponentialRampToValueAtTime(0.0001, end)
osc.connect(env)
env.connect(master)
osc.start(start)
osc.stop(end + 0.02)
}
// Slow-swell harmonic bloom — the dreamy tail after the pluck.
function bloomVoice(ac: AudioContext, master: GainNode, t0: number, spec: BloomSpec) {
const osc = ac.createOscillator()
const env = ac.createGain()
const start = t0 + (spec.start ?? 0)
const hold = spec.hold ?? 0.08
const end = start + spec.attack + hold + spec.decay
osc.type = spec.type ?? 'sine'
osc.frequency.setValueAtTime(spec.freq, start)
if (spec.freqTo) {
osc.frequency.exponentialRampToValueAtTime(spec.freqTo, start + spec.attack + hold * 0.6)
}
osc.detune.setValueAtTime(spec.detune ?? 0, start)
env.gain.setValueAtTime(0.0001, start)
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + spec.attack)
env.gain.setValueAtTime(Math.max(spec.gain, 0.0002), start + spec.attack + hold)
env.gain.exponentialRampToValueAtTime(0.0001, end)
osc.connect(env)
env.connect(master)
osc.start(start)
osc.stop(end + 0.02)
}
// One-shot white-noise source of a given length, the raw material for the
// bandpassed air/whoosh gestures below.
function noiseSource(ac: AudioContext, seconds: number): AudioBufferSourceNode {
const length = Math.floor(ac.sampleRate * seconds)
const buffer = ac.createBuffer(1, length, ac.sampleRate)
const data = buffer.getChannelData(0)
for (let i = 0; i < length; i += 1) {
data[i] = Math.random() * 2 - 1
}
const source = ac.createBufferSource()
source.buffer = buffer
return source
}
// A whisper of bandpassed noise for PS5-menu airiness.
function airPuff(ac: AudioContext, master: GainNode, t0: number, spec: AirPuffSpec) {
const source = noiseSource(ac, 0.12)
const filter = ac.createBiquadFilter()
const env = ac.createGain()
const start = t0 + (spec.start ?? 0)
const end = start + spec.decay
filter.type = 'bandpass'
filter.frequency.setValueAtTime(spec.freq, start)
filter.Q.setValueAtTime(spec.q ?? 1.2, start)
env.gain.setValueAtTime(0.0001, start)
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + 0.018)
env.gain.exponentialRampToValueAtTime(0.0001, end)
source.connect(filter)
filter.connect(env)
env.connect(master)
source.start(start)
source.stop(end + 0.02)
}
// Filtered noise sweep — soft send / whoosh gestures.
function whooshVoice(ac: AudioContext, master: GainNode, t0: number, spec: WhooshSpec) {
const source = noiseSource(ac, 0.4)
const filter = ac.createBiquadFilter()
const env = ac.createGain()
const start = t0 + (spec.start ?? 0)
const end = start + spec.decay
filter.type = 'bandpass'
filter.frequency.setValueAtTime(spec.freqFrom, start)
filter.frequency.exponentialRampToValueAtTime(spec.freqTo, end)
filter.Q.setValueAtTime(spec.q ?? 0.8, start)
env.gain.setValueAtTime(0.0001, start)
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + 0.03)
env.gain.exponentialRampToValueAtTime(0.0001, end)
source.connect(filter)
filter.connect(env)
env.connect(master)
source.start(start)
source.stop(end + 0.02)
}
// Pitch-sweep chirp — modem / sci-fi gestures.
function sweepVoice(ac: AudioContext, master: GainNode, t0: number, spec: SweepSpec) {
const osc = ac.createOscillator()
const env = ac.createGain()
const start = t0 + (spec.start ?? 0)
const attack = spec.attack ?? 0.003
const end = start + spec.decay
osc.type = spec.type ?? 'triangle'
osc.frequency.setValueAtTime(spec.freqFrom, start)
osc.frequency.exponentialRampToValueAtTime(spec.freqTo, end - 0.02)
env.gain.setValueAtTime(0.0001, start)
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + attack)
env.gain.exponentialRampToValueAtTime(0.0001, end)
osc.connect(env)
env.connect(master)
osc.start(start)
osc.stop(end + 0.02)
}
let reverbImpulse: AudioBuffer | null = null
// Subtle wet send so the chimes sit in a room rather than a tin can. The impulse
// is generated once and cached; each play gets a fresh, disposable convolver.
function makeReverb(ac: AudioContext): ConvolverNode {
if (!reverbImpulse) {
const seconds = 1.6
const length = Math.floor(ac.sampleRate * seconds)
reverbImpulse = ac.createBuffer(2, length, ac.sampleRate)
for (let channel = 0; channel < 2; channel += 1) {
const data = reverbImpulse.getChannelData(channel)
for (let i = 0; i < length; i += 1) {
// White noise with a steep exponential decay → smooth, short tail.
data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 2.6
}
}
}
const convolver = ac.createConvolver()
convolver.buffer = reverbImpulse
return convolver
}
export interface CompletionSoundVariant {
id: number
name: string
// `master` is warm (runs through low-pass + room tail).
play: (ac: AudioContext, master: GainNode, t0: number) => void
}
// Note frequencies (equal temperament). Everything lives in a low-mid register
// (C3C5) so the chimes feel warm and "appy" rather than bright and arcade-y.
const A2 = 110
const A3 = 220
const A4 = 440
const A5 = 880
const B5 = 987.77
const C3 = 130.81
const C4 = 261.63
const E4 = 329.63
const E5 = 659.25
const E6 = 1318.51
const G4 = 392
const G5 = 783.99
const C5 = 523.25
const C6 = 1046.5
export const COMPLETION_SOUND_VARIANTS: readonly CompletionSoundVariant[] = [
{
id: 1,
name: 'Two-note comfort',
play: (ac, master, t0) => {
voice(ac, master, t0, { freq: E4, dur: 0.22, gain: 0.05, attack: 0.03, type: 'sine' })
voice(ac, master, t0 + 0.08, { freq: C4, dur: 0.52, gain: 0.07, attack: 0.08, type: 'sine' })
voice(ac, master, t0 + 0.08, { freq: C3, dur: 0.46, gain: 0.02, attack: 0.1, type: 'sine' })
}
},
{
id: 2,
name: 'Glass ping',
play: (ac, master, t0) => {
voice(ac, master, t0, { freq: C6, dur: 0.55, gain: 0.032, attack: 0.002, type: 'sine' })
voice(ac, master, t0 + 0.01, { freq: E5, dur: 0.42, gain: 0.018, attack: 0.004, type: 'sine' })
airPuff(ac, master, t0, { freq: 3200, gain: 0.004, decay: 0.1, q: 1.4 })
}
},
{
id: 3,
name: 'Soft marimba',
play: (ac, master, t0) => {
pluckVoice(ac, master, t0, { freqFrom: E5, freqTo: G5, gain: 0.03, decay: 0.14, glide: 0.08 })
bloomVoice(ac, master, t0 + 0.04, { freq: C5, gain: 0.028, attack: 0.08, hold: 0.04, decay: 0.62 })
bloomVoice(ac, master, t0 + 0.06, { freq: G4, gain: 0.014, attack: 0.12, hold: 0.06, decay: 0.55 })
}
},
{
id: 4,
name: 'Tri-tone message',
play: (ac, master, t0) => {
voice(ac, master, t0, { freq: C6, dur: 0.14, gain: 0.045, attack: 0.004, type: 'sine' })
voice(ac, master, t0 + 0.1, { freq: A5, dur: 0.16, gain: 0.04, attack: 0.004, type: 'sine' })
voice(ac, master, t0 + 0.2, { freq: G5, dur: 0.22, gain: 0.035, attack: 0.006, type: 'sine' })
}
},
{
id: 5,
name: 'Airy whoosh',
play: (ac, master, t0) => {
whooshVoice(ac, master, t0, { freqFrom: 4200, freqTo: 900, gain: 0.022, decay: 0.28, q: 0.7 })
voice(ac, master, t0 + 0.12, { freq: A5, dur: 0.35, gain: 0.02, attack: 0.02, type: 'sine' })
}
},
{
id: 6,
name: 'Discovery cluster',
play: (ac, master, t0) => {
const clusterDetunes = [-14, -5, 0, 7, 12]
clusterDetunes.forEach((detune, i) => {
bloomVoice(ac, master, t0 + i * 0.03, {
freq: A3,
gain: 0.012,
attack: 0.38,
hold: 0.12,
decay: 1.05,
detune
})
})
bloomVoice(ac, master, t0 + 0.1, { freq: E4, gain: 0.008, attack: 0.45, hold: 0.08, decay: 0.9, detune: 3 })
}
},
{
id: 7,
name: 'Systems online',
play: (ac, master, t0) => {
voice(ac, master, t0, { freq: C5, dur: 0.16, gain: 0.04, attack: 0.006, type: 'sine' })
voice(ac, master, t0 + 0.09, { freq: G5, dur: 0.28, gain: 0.042, attack: 0.008, type: 'sine' })
voice(ac, master, t0 + 0.09, { freq: C4, dur: 0.24, gain: 0.012, attack: 0.01, type: 'sine' })
}
},
{
id: 8,
name: 'IBM terminal',
play: (ac, master, t0) => {
voice(ac, master, t0, { freq: B5, dur: 0.12, gain: 0.038, attack: 0.002, type: 'square' })
voice(ac, master, t0 + 0.14, { freq: E5, dur: 0.1, gain: 0.028, attack: 0.002, type: 'square' })
}
},
{
id: 9,
name: 'Modem chirp',
play: (ac, master, t0) => {
sweepVoice(ac, master, t0, { freqFrom: 320, freqTo: 2200, gain: 0.024, decay: 0.16, type: 'triangle' })
sweepVoice(ac, master, t0 + 0.1, { freqFrom: 480, freqTo: 1400, gain: 0.014, decay: 0.12, type: 'sine' })
}
},
{
id: 10,
name: 'Wind chimes',
play: (ac, master, t0) => {
const chimes = [G5, C6, E5, A5]
chimes.forEach((frequency, i) => {
voice(ac, master, t0 + i * 0.13, {
freq: frequency,
dur: 0.72,
gain: 0.028 - i * 0.003,
attack: 0.003,
type: 'sine'
})
})
}
},
{
id: 11,
name: 'Singing bowl',
play: (ac, master, t0) => {
bloomVoice(ac, master, t0, { freq: A3, gain: 0.022, attack: 0.58, hold: 0.16, decay: 1.35 })
bloomVoice(ac, master, t0 + 0.08, { freq: E4, gain: 0.01, attack: 0.62, hold: 0.12, decay: 1.2, detune: 4 })
bloomVoice(ac, master, t0 + 0.14, { freq: A4, gain: 0.006, attack: 0.68, hold: 0.08, decay: 1.05, detune: -3 })
}
},
{
id: 12,
name: 'Harp lift',
play: (ac, master, t0) => {
const notes = [C5, E5, G5, C6]
notes.forEach((frequency, i) => {
voice(ac, master, t0 + i * 0.075, {
freq: frequency,
dur: 0.38,
gain: 0.034 - i * 0.004,
attack: 0.012,
type: 'sine'
})
})
bloomVoice(ac, master, t0 + 0.2, { freq: C4, gain: 0.01, attack: 0.18, hold: 0.06, decay: 0.7 })
}
},
{
id: 13,
name: 'Sonar ping',
play: (ac, master, t0) => {
voice(ac, master, t0, { freq: A2, dur: 0.95, gain: 0.036, attack: 0.008, type: 'sine' })
voice(ac, master, t0 + 0.42, { freq: A3, dur: 0.55, gain: 0.014, attack: 0.01, type: 'sine' })
airPuff(ac, master, t0, { freq: 600, gain: 0.005, decay: 0.2, q: 0.5 })
}
},
{
id: 14,
name: 'Music box',
play: (ac, master, t0) => {
const notes = [E6, C6, G5, E5]
notes.forEach((frequency, i) => {
pluckVoice(ac, master, t0 + i * 0.09, {
freqFrom: frequency,
freqTo: frequency * 0.998,
gain: 0.02 - i * 0.002,
decay: 0.2,
glide: 0.06
})
})
}
}
] as const
function playVariant(variantId: number) {
const variant = COMPLETION_SOUND_VARIANTS.find(v => v.id === variantId)
if (!variant) {
return
}
const ac = getCtx()
if (!ac) {
return
}
// Signal path: voices → master → low-pass → (dry + reverb send) → out.
const master = ac.createGain()
const tone = ac.createBiquadFilter()
tone.type = 'lowpass'
tone.frequency.setValueAtTime(3800, ac.currentTime)
tone.Q.setValueAtTime(0.32, ac.currentTime)
master.gain.setValueAtTime(0.48, ac.currentTime)
master.connect(tone)
const dry = ac.createGain()
dry.gain.setValueAtTime(0.88, ac.currentTime)
tone.connect(dry)
dry.connect(ac.destination)
const reverb = makeReverb(ac)
const wet = ac.createGain()
wet.gain.setValueAtTime(0.34, ac.currentTime)
tone.connect(reverb)
reverb.connect(wet)
wet.connect(ac.destination)
variant.play(ac, master, ac.currentTime + 0.01)
}
// Audition the selected variant from settings. Bypasses the haptics mute toggle so
// sound design can be compared even when turn-end cues are silenced.
export function previewCompletionSound(variantId?: number) {
playVariant(resolveCompletionSoundVariantId(variantId ?? $completionSoundVariantId.get()))
}
// Plays the selected completion cue on any `message.complete`.
export function playCompletionSound() {
if ($hapticsMuted.get()) {
return
}
playVariant($completionSoundVariantId.get())
}
interface AirPuffSpec {
decay: number
freq: number
gain: number
q?: number
start?: number
}
interface BloomSpec {
attack: number
decay: number
detune?: number
freq: number
freqTo?: number
gain: number
hold?: number
start?: number
type?: OscType
}
interface PluckSpec {
attack?: number
decay: number
freqFrom: number
freqTo: number
gain: number
glide?: number
start?: number
}
interface SweepSpec {
attack?: number
decay: number
freqFrom: number
freqTo: number
gain: number
start?: number
type?: OscType
}
interface ToneSpec {
attack?: number
dur: number
freq: number
gain?: number
start?: number
type?: OscType
}
interface WhooshSpec {
decay: number
freqFrom: number
freqTo: number
gain: number
q?: number
start?: number
}
-2
View File
@@ -9,7 +9,6 @@ import {
IconAt as AtSign,
IconWaveSine as AudioLines,
IconChartBar as BarChart3,
IconBell as Bell,
IconBrain as Brain,
IconBug as Bug,
IconCheck as Check,
@@ -111,7 +110,6 @@ export {
AtSign,
AudioLines,
BarChart3,
Bell,
Brain,
Bug,
Check,
-53
View File
@@ -1,53 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $compactingSessions, $compactionActive, setSessionCompacting } from './compaction'
import { $activeSessionId } from './session'
describe('compaction store', () => {
beforeEach(() => {
$compactingSessions.set({})
$activeSessionId.set(null)
})
afterEach(() => {
$compactingSessions.set({})
$activeSessionId.set(null)
})
it('tracks compaction per session independently', () => {
setSessionCompacting('session-a', true)
setSessionCompacting('session-b', true)
expect($compactingSessions.get()).toEqual({ 'session-a': true, 'session-b': true })
})
it('exposes only the active session via the focus-scoped view', () => {
setSessionCompacting('session-a', true)
expect($compactionActive.get()).toBe(false)
$activeSessionId.set('session-a')
expect($compactionActive.get()).toBe(true)
$activeSessionId.set('session-b')
expect($compactionActive.get()).toBe(false)
})
it('clears a session without disturbing the others', () => {
setSessionCompacting('session-a', true)
setSessionCompacting('session-b', true)
setSessionCompacting('session-a', false)
expect($compactingSessions.get()).toEqual({ 'session-b': true })
})
it('is a no-op when clearing an unknown session', () => {
setSessionCompacting('session-a', true)
const before = $compactingSessions.get()
setSessionCompacting('session-missing', false)
expect($compactingSessions.get()).toBe(before)
})
})
-38
View File
@@ -1,38 +0,0 @@
import { atom, computed } from 'nanostores'
import { $activeSessionId } from './session'
// Per-session flag while auto-compaction runs mid-turn. Without it the
// transcript looks like it reset; per-session so a background chat can't
// clobber the foreground view.
const keyFor = (sessionId: string | null | undefined): string => sessionId ?? ''
export const $compactingSessions = atom<Record<string, true>>({})
export const $compactionActive = computed(
[$compactingSessions, $activeSessionId],
(sessions, activeId) => keyFor(activeId) in sessions
)
export function setSessionCompacting(sessionId: string | null | undefined, active: boolean): void {
const key = keyFor(sessionId)
const sessions = $compactingSessions.get()
if (active) {
if (key in sessions) {
return
}
$compactingSessions.set({ ...sessions, [key]: true })
return
}
if (!(key in sessions)) {
return
}
const next = { ...sessions }
delete next[key]
$compactingSessions.set(next)
}
@@ -1,32 +0,0 @@
import { atom } from 'nanostores'
import { persistString, storedString } from '@/lib/storage'
const STORAGE_KEY = 'hermes.desktop.completionSoundVariantId'
export const DEFAULT_COMPLETION_SOUND_VARIANT_ID = 1
// Range mirrors COMPLETION_SOUND_VARIANTS in lib/completion-sound.ts. Validating
// by range (not membership) keeps this store free of a dependency on the lib,
// which imports the atom back — a membership check would close that cycle.
const VARIANT_COUNT = 14
export function resolveCompletionSoundVariantId(variantId: number): number {
return Number.isInteger(variantId) && variantId >= 1 && variantId <= VARIANT_COUNT
? variantId
: DEFAULT_COMPLETION_SOUND_VARIANT_ID
}
function load(): number {
const stored = storedString(STORAGE_KEY)
return stored ? resolveCompletionSoundVariantId(Number.parseInt(stored, 10)) : DEFAULT_COMPLETION_SOUND_VARIANT_ID
}
export const $completionSoundVariantId = atom(load())
$completionSoundVariantId.subscribe(id => persistString(STORAGE_KEY, String(id)))
export function setCompletionSoundVariantId(variantId: number) {
$completionSoundVariantId.set(resolveCompletionSoundVariantId(variantId))
}
-20
View File
@@ -1,10 +1,8 @@
import { atom, computed } from 'nanostores'
import { translateNow } from '@/i18n'
import type { TodoItem, TodoStatus } from '@/lib/todos'
import { $gateway } from './gateway'
import { dispatchNativeNotification } from './native-notifications'
import { $subagentsBySession, type SubagentProgress } from './subagents'
import { $todosBySession } from './todos'
@@ -163,24 +161,6 @@ export function reconcileBackgroundProcesses(sid: string, procs: GatewayProcessE
const prev = $backgroundStatusBySession.get()[sid] ?? []
// running → exited since the last snapshot = a background process just finished.
const prevState = new Map(prev.map(item => [item.id, item.state]))
for (const [id, item] of fresh) {
if (item.state !== 'running' && prevState.get(id) === 'running') {
dispatchNativeNotification({
body: item.title,
kind: 'backgroundDone',
sessionId: sid,
title: translateNow(
item.state === 'failed'
? 'notifications.native.backgroundFailedTitle'
: 'notifications.native.backgroundDoneTitle'
)
})
}
}
const kept = prev.flatMap(old => {
const next = fresh.get(old.id)
fresh.delete(old.id)
@@ -1,192 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $gateway } from './gateway'
import {
dispatchNativeNotification,
NATIVE_NOTIFICATION_KINDS,
respondToApprovalAction,
sendTestNativeNotification,
setNativeNotifyEnabled,
setNativeNotifyKind
} from './native-notifications'
import { $approvalRequest, setApprovalRequest } from './prompts'
import { $activeSessionId, setActiveSessionId } from './session'
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const initialHermesDesktop = desktopWindow.hermesDesktop
const notify = vi.fn().mockResolvedValue(true)
function setWindowState({ focused = true, hidden = false }: { focused?: boolean; hidden?: boolean }) {
Object.defineProperty(document, 'hidden', { configurable: true, value: hidden })
Object.defineProperty(document, 'hasFocus', { configurable: true, value: () => focused })
}
let counter = 0
// Unique session id per call dodges the per-(kind,session) throttle so each
// assertion starts clean.
function freshSession(): string {
counter += 1
return `session-${counter}`
}
beforeEach(() => {
notify.mockClear()
desktopWindow.hermesDesktop = { notify } as unknown as Window['hermesDesktop']
setNativeNotifyEnabled(true)
for (const kind of NATIVE_NOTIFICATION_KINDS) {
setNativeNotifyKind(kind, true)
}
setActiveSessionId(null)
setWindowState({ focused: false, hidden: true })
})
afterEach(() => {
if (initialHermesDesktop) {
desktopWindow.hermesDesktop = initialHermesDesktop
} else {
delete desktopWindow.hermesDesktop
}
})
describe('dispatchNativeNotification focus gating', () => {
it('fires a completion notification for the active session when the window is hidden', () => {
const sessionId = freshSession()
setActiveSessionId(sessionId)
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
expect(notify).toHaveBeenCalledTimes(1)
})
it('fires a completion notification when the window is visible but unfocused (alt-tab)', () => {
const sessionId = freshSession()
setActiveSessionId(sessionId)
setWindowState({ focused: false, hidden: false })
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
expect(notify).toHaveBeenCalledTimes(1)
})
it('suppresses a completion notification when the window is focused', () => {
const sessionId = freshSession()
setActiveSessionId(sessionId)
setWindowState({ focused: true, hidden: false })
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
expect(notify).not.toHaveBeenCalled()
})
it('suppresses a completion notification for a non-active background session (no gateway spam)', () => {
setActiveSessionId('on-screen')
dispatchNativeNotification({ kind: 'turnDone', sessionId: 'busy-bot-session', title: 'done' })
expect(notify).not.toHaveBeenCalled()
})
it('fires an attention notification for an off-screen session even when focused', () => {
setWindowState({ focused: true, hidden: false })
setActiveSessionId('on-screen')
dispatchNativeNotification({ kind: 'approval', sessionId: 'background', title: 'approve' })
expect(notify).toHaveBeenCalledTimes(1)
})
it('suppresses an attention notification for the active session when focused', () => {
setWindowState({ focused: true, hidden: false })
setActiveSessionId('on-screen')
dispatchNativeNotification({ kind: 'approval', sessionId: 'on-screen', title: 'approve' })
expect(notify).not.toHaveBeenCalled()
})
})
describe('dispatchNativeNotification preferences', () => {
it('suppresses everything when the master switch is off', () => {
setNativeNotifyEnabled(false)
dispatchNativeNotification({ kind: 'approval', sessionId: freshSession(), title: 'approve' })
dispatchNativeNotification({ kind: 'turnDone', sessionId: freshSession(), title: 'done' })
expect(notify).not.toHaveBeenCalled()
})
it('suppresses only the disabled kind', () => {
const sessionId = freshSession()
setActiveSessionId(sessionId)
setNativeNotifyKind('turnDone', false)
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
expect(notify).not.toHaveBeenCalled()
dispatchNativeNotification({ kind: 'turnError', sessionId, title: 'boom' })
expect(notify).toHaveBeenCalledTimes(1)
})
it('forwards kind and sessionId to the bridge', () => {
setActiveSessionId('abc')
dispatchNativeNotification({ body: 'hi', kind: 'turnError', sessionId: 'abc', title: 'boom' })
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ body: 'hi', kind: 'turnError', sessionId: 'abc', title: 'boom' })
)
})
})
describe('dispatchNativeNotification throttle', () => {
it('collapses duplicate kind+session within the throttle window', () => {
const sessionId = freshSession()
setActiveSessionId(sessionId)
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done again' })
expect(notify).toHaveBeenCalledTimes(1)
})
})
describe('sendTestNativeNotification', () => {
it('fires regardless of focus or active session', () => {
setWindowState({ focused: true, hidden: false })
setActiveSessionId('on-screen')
sendTestNativeNotification('Hermes', 'works')
expect(notify).toHaveBeenCalledTimes(1)
})
})
describe('$activeSessionId wiring', () => {
it('reflects the setter used for gating', () => {
setActiveSessionId('xyz')
expect($activeSessionId.get()).toBe('xyz')
})
})
describe('respondToApprovalAction', () => {
const request = vi.fn().mockResolvedValue({ resolved: true })
beforeEach(() => {
request.mockClear()
$gateway.set({ request } as unknown as ReturnType<typeof $gateway.get>)
})
afterEach(() => {
$gateway.set(null)
})
it('approves via approval.respond {choice: "once"} and clears the prompt', async () => {
setActiveSessionId('bg')
setApprovalRequest({ command: 'rm -rf /', description: 'dangerous', sessionId: 'bg' })
await respondToApprovalAction('bg', 'approve')
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'once', session_id: 'bg' })
expect($approvalRequest.get()).toBeNull()
})
it('rejects via approval.respond {choice: "deny"}', async () => {
await respondToApprovalAction('bg', 'reject')
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'bg' })
})
it('ignores unknown action ids', async () => {
await respondToApprovalAction('bg', 'snooze')
expect(request).not.toHaveBeenCalled()
})
it('no-ops without a gateway', async () => {
$gateway.set(null)
await respondToApprovalAction('bg', 'approve')
expect(request).not.toHaveBeenCalled()
})
})
@@ -1,203 +0,0 @@
import { atom } from 'nanostores'
import { persistString, storedString } from '@/lib/storage'
import { $gateway } from './gateway'
import { clearApprovalRequest } from './prompts'
import { $activeSessionId } from './session'
// Native OS notifications (Electron `Notification`), separate from the in-app
// toast feed in `notifications.ts`. Each kind toggles independently.
export type NativeNotificationKind = 'approval' | 'backgroundDone' | 'input' | 'turnDone' | 'turnError'
export const NATIVE_NOTIFICATION_KINDS: readonly NativeNotificationKind[] = [
'approval',
'input',
'turnDone',
'turnError',
'backgroundDone'
]
// Blocking prompts — surface even while focused if they're for another session.
const ATTENTION_KINDS = new Set<NativeNotificationKind>(['approval', 'input'])
export interface NativeNotificationPrefs {
enabled: boolean
kinds: Record<NativeNotificationKind, boolean>
}
const STORAGE_KEY = 'hermes:native-notifications'
const DEFAULT_PREFS: NativeNotificationPrefs = {
enabled: true,
kinds: { approval: true, backgroundDone: true, input: true, turnDone: true, turnError: true }
}
function readPrefs(): NativeNotificationPrefs {
const raw = storedString(STORAGE_KEY)
if (!raw) {
return DEFAULT_PREFS
}
try {
const parsed = JSON.parse(raw) as Partial<NativeNotificationPrefs>
const kinds = { ...DEFAULT_PREFS.kinds }
for (const kind of NATIVE_NOTIFICATION_KINDS) {
const value = parsed.kinds?.[kind]
if (typeof value === 'boolean') {
kinds[kind] = value
}
}
return {
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULT_PREFS.enabled,
kinds
}
} catch {
return DEFAULT_PREFS
}
}
export const $nativeNotifyPrefs = atom<NativeNotificationPrefs>(readPrefs())
function writePrefs(next: NativeNotificationPrefs) {
$nativeNotifyPrefs.set(next)
persistString(STORAGE_KEY, JSON.stringify(next))
}
export function setNativeNotifyEnabled(enabled: boolean) {
writePrefs({ ...$nativeNotifyPrefs.get(), enabled })
}
export function setNativeNotifyKind(kind: NativeNotificationKind, on: boolean) {
const prev = $nativeNotifyPrefs.get()
writePrefs({ ...prev, kinds: { ...prev.kinds, [kind]: on } })
}
// De-dupe replayed events for the same kind+session. Self-evicting: entries
// older than the window are pruned on every dispatch, so the map can't grow.
const THROTTLE_MS = 1000
const lastFiredAt = new Map<string, number>()
function throttled(key: string, now: number): boolean {
for (const [k, at] of lastFiredAt) {
if (now - at >= THROTTLE_MS) {
lastFiredAt.delete(k)
}
}
if (lastFiredAt.has(key)) {
return true
}
lastFiredAt.set(key, now)
return false
}
// "Backgrounded" = the user isn't on Hermes. `document.hidden` only flips when
// minimized/occluded; an alt-tabbed window is visible-but-unfocused, so we also
// check `document.hasFocus()`.
function isBackgrounded(): boolean {
if (typeof document === 'undefined') {
return false
}
if (document.hidden) {
return true
}
return typeof document.hasFocus === 'function' && !document.hasFocus()
}
function shouldFire(kind: NativeNotificationKind, sessionId?: null | string): boolean {
// Attention kinds break through for an off-screen session even while focused.
if (ATTENTION_KINDS.has(kind)) {
return isBackgrounded() || (Boolean(sessionId) && sessionId !== $activeSessionId.get())
}
// Completion kinds: only the active session, only while away — so a busy
// gateway (messaging, kanban, cron) can't spam a toast per background session.
return isBackgrounded() && Boolean(sessionId) && sessionId === $activeSessionId.get()
}
export interface NativeNotificationAction {
id: string
text: string
}
export interface NativeNotificationInput {
kind: NativeNotificationKind
title: string
body?: string
sessionId?: null | string
silent?: boolean
actions?: NativeNotificationAction[]
}
export function dispatchNativeNotification(input: NativeNotificationInput): void {
const prefs = $nativeNotifyPrefs.get()
if (!prefs.enabled || !prefs.kinds[input.kind]) {
return
}
if (!shouldFire(input.kind, input.sessionId)) {
return
}
if (throttled(`${input.kind}:${input.sessionId ?? ''}`, Date.now())) {
return
}
void window.hermesDesktop?.notify({
actions: input.actions,
body: input.body,
kind: input.kind,
sessionId: input.sessionId ?? undefined,
silent: input.silent,
title: input.title
})
}
// Resolve a pending approval from a notification button, mirroring the in-app
// Run/Reject bar. Keyed by session id — a background approval has no local guard.
export async function respondToApprovalAction(sessionId: null | string, actionId: string): Promise<void> {
const choice = actionId === 'approve' ? 'once' : actionId === 'reject' ? 'deny' : null
if (!choice) {
return
}
const gateway = $gateway.get()
if (!gateway) {
return
}
try {
await gateway.request('approval.respond', { choice, session_id: sessionId ?? undefined })
clearApprovalRequest(sessionId)
} catch {
// Leave the prompt parked so the user can still resolve it in-app.
}
}
// Settings "send test" — bypasses gating. Returns whether the OS accepted it so
// the panel can flag a silent permission failure instead of looking dead.
export async function sendTestNativeNotification(title: string, body: string): Promise<boolean> {
const bridge = window.hermesDesktop
if (!bridge?.notify) {
return false
}
try {
return await bridge.notify({ body, kind: 'turnDone', title })
} catch {
return false
}
}
+1 -3
View File
@@ -47,8 +47,6 @@ export interface OAuthProviderStatus {
export interface OAuthProvider {
cli_command: string
disconnect_hint?: null | string
disconnectable?: boolean
docs_url: string
flow: 'device_code' | 'external' | 'loopback' | 'pkce'
id: string
@@ -472,7 +470,7 @@ export interface CronJobUpdates {
export interface ProfileCreatePayload {
clone_all?: boolean
clone_from?: null | string
clone_from?: string
clone_from_default?: boolean
name: string
no_skills?: boolean
-6
View File
@@ -182,11 +182,6 @@ terminal:
backend: "local"
cwd: "." # For local backend: "." = current directory. Ignored for remote backends unless a backend documents otherwise.
timeout: 180
# HOME policy for tool subprocesses:
# auto - default: host uses your real HOME; containers use HERMES_HOME/home
# real - force your real OS-user HOME
# profile - force HERMES_HOME/home for strict per-profile CLI config isolation
home_mode: "auto"
docker_mount_cwd_to_workspace: false # SECURITY: off by default. Opt in to mount the launch cwd into Docker /workspace.
lifetime_seconds: 300
# sudo_password: "hunter2" # Optional: pipe a sudo password via sudo -S. SECURITY WARNING: plaintext.
@@ -724,7 +719,6 @@ platform_toolsets:
# # allowed_chats: ["-1001234567890"]
# extra:
# disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages
# rich_messages: false # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2
#
# Discord-specific settings (config.yaml top-level, not under platforms:):
#
+1 -6
View File
@@ -394,7 +394,7 @@ def load_cli_config() -> Dict[str, Any]:
"terminal": {
"env_type": "local",
"cwd": ".", # "." is resolved to os.getcwd() at runtime
"home_mode": "auto",
"timeout": 60,
"lifetime_seconds": 300,
"docker_image": "nikolaik/python-nodejs:python3.11-nodejs20",
"docker_forward_env": [],
@@ -589,7 +589,6 @@ def load_cli_config() -> Dict[str, Any]:
"env_type": "TERMINAL_ENV",
"cwd": "TERMINAL_CWD",
"timeout": "TERMINAL_TIMEOUT",
"home_mode": "TERMINAL_HOME_MODE",
"lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
"docker_image": "TERMINAL_DOCKER_IMAGE",
"docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
@@ -7476,10 +7475,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._show_gateway_status()
elif canonical == "status":
self._show_session_status()
elif canonical == "egress":
from hermes_cli.proxy_cli import format_status_text
self._console_print(format_status_text(), highlight=False, markup=False)
elif canonical == "statusbar":
self._status_bar_visible = not self._status_bar_visible
state = "visible" if self._status_bar_visible else "hidden"
-2
View File
@@ -85,8 +85,6 @@ Extend `ProfileCreate` and the create endpoint — no new endpoints, no rewrite:
```python
class ProfileCreate(BaseModel):
name: str
clone_from: Optional[str] = None
# Backward compatibility for older dashboard/desktop clients.
clone_from_default: bool = False
clone_all: bool = False
no_skills: bool = False
-54
View File
@@ -1,54 +0,0 @@
# RCA: SSL CA cert bundle corruption after `hermes update`
**Status:** resolved by `fix(ssl): surface broken CA bundles before provider calls`
**Severity:** P2 — degrades the agent into opaque provider/client failures until the user repairs deps or CA configuration.
## Summary
A partial `hermes update`, interrupted venv repair, or stale CA-bundle environment variable can leave Python TLS configuration pointing at a missing, empty, or unloadable CA bundle. The first outbound HTTPS client creation or request can then fail with a raw `FileNotFoundError: [Errno 2] No such file or directory` or a low-level SSL error that does not name the broken CA path.
## Root cause
Hermes uses OpenAI/httpx and requests-based clients for provider calls, model metadata, gateway delivery, and web tools. Those clients inherit CA bundle settings from:
- `HERMES_CA_BUNDLE`
- `SSL_CERT_FILE`
- `REQUESTS_CA_BUNDLE`
- `CURL_CA_BUNDLE`
- the bundled `certifi` package's `cacert.pem`
When the venv is partially refreshed, or when one of those env vars points at a file that no longer exists, provider client construction can fail before Hermes has enough context to produce a useful message.
## Fix
`agent/ssl_guard.py` validates CA bundle configuration before the OpenAI-compatible provider client is created in `agent/agent_init.py`. It:
1. Checks explicit CA bundle env vars and reports the exact broken variable/path,
2. Verifies `certifi` is importable,
3. Verifies `certifi.where()` points at an existing file of plausible size,
4. Builds an `ssl.SSLContext` from each checked bundle,
5. Raises a typed `SSLConfigurationError` with a repair hint before httpx/OpenAI can raise a raw low-level error.
`hermes_cli doctor` exposes the same check under `SSL / CA Certificates`, so users can diagnose the problem without starting a model session.
## Recovery
When the guard fires during agent init, the user sees a message like:
```text
Failed to initialize OpenAI client: SSL_CERT_FILE points to a missing CA bundle: C:\path\to\missing\cacert.pem
Repair: python -m pip install --force-reinstall certifi openai httpx
If you configured a custom corporate CA bundle, fix or unset the broken CA bundle environment variable.
```
For a normal corrupted Hermes venv, reinstall the affected client dependencies:
```bash
python -m pip install --force-reinstall certifi openai httpx
```
For a custom/corporate CA setup, fix the env var so it points at a real PEM bundle, or unset it if Hermes should use the bundled `certifi` store.
## Environment escape hatch
Set `HERMES_SKIP_SSL_GUARD=1` to bypass the preflight check. This is intended only for sandboxed or managed-trust environments where the Python CA path looks unusual but downstream clients are known to work.
+27 -126
View File
@@ -37,12 +37,10 @@ class GatewayAuthorizationMixin:
Mirrors ``BasePlatformAdapter.enforces_own_access_policy``. Adapters
such as WeCom, Weixin, Yuanbao, QQBot, and WhatsApp evaluate their
documented ``dm_policy`` / ``group_policy`` / ``allow_from`` config before a
message is dispatched to the gateway. The flag alone is NOT "already
authorized": these adapters default to ``open``, which forwards every
sender, so ``_is_user_authorized`` only trusts the adapter when its
effective policy for the chat type is an actual ``allowlist`` restriction
(see that method). Defaults to ``False`` when the adapter is unknown or
doesn't expose the flag.
message is dispatched to the gateway, so a message that reaches
``_is_user_authorized`` has already been authorized by the adapter.
Defaults to ``False`` when the adapter is unknown or doesn't expose
the flag.
"""
if not platform:
return False
@@ -67,11 +65,10 @@ class GatewayAuthorizationMixin:
env var is not always bridged back into ``config.extra``) and falls
back to ``config.extra`` for bare runners built without a live adapter.
Used by ``_is_user_authorized`` to decide whether an own-policy adapter
actually restricted DM senders to a configured allowlist (trustworthy)
or merely forwarded everyone under ``dm_policy: open`` / for a pairing
handshake (not authorization). "Reached the gateway" only carries an
authorization signal in the ``allowlist`` case.
Used by ``_is_user_authorized`` to carve ``dm_policy: pairing`` out of
the adapter-trust shortcut: in pairing mode the adapter forwards the DM
so the gateway can run its pairing handshake, so "reached the gateway"
must not be read as "authorized".
"""
if not platform:
return ""
@@ -90,89 +87,6 @@ class GatewayAuthorizationMixin:
policy = extra.get("dm_policy")
return str(policy or "").strip().lower()
def _adapter_group_policy(self, platform: Optional[Platform]) -> str:
"""Best-effort read of an own-policy adapter's effective group policy.
Mirror of ``_adapter_dm_policy`` for group / forum / channel traffic:
returns the lowercased ``group_policy`` (``"open"`` / ``"allowlist"`` /
``"disabled"``) for *platform*, or ``""`` when unknown. Prefers the live
adapter's resolved ``_group_policy`` and falls back to ``config.extra``
for bare runners built without a live adapter.
Used by ``_is_user_authorized`` to decide whether an own-policy adapter
restricted group senders to a configured allowlist (trustworthy) or
forwarded the whole channel under ``group_policy: open`` (not
authorization).
"""
if not platform:
return ""
adapters = getattr(self, "adapters", None) or {}
adapter = adapters.get(platform)
policy = getattr(adapter, "_group_policy", None) if adapter is not None else None
if policy is None:
config = getattr(self, "config", None)
platform_cfg = (
config.platforms.get(platform)
if config is not None and hasattr(config, "platforms")
else None
)
extra = getattr(platform_cfg, "extra", None) if platform_cfg else None
if isinstance(extra, dict):
policy = extra.get("group_policy")
return str(policy or "").strip().lower()
def _adapter_group_has_sender_allowlist(
self,
platform: Optional[Platform],
chat_id: Optional[str],
) -> bool:
"""Whether a per-group sender allowlist gated this group message.
WeCom supports ``groups.<group_id>.allow_from`` on top of the top-level
``group_policy``. A group may be open at the chat level while still
restricting which senders inside that group can invoke Hermes. If such a
message reached the gateway, the adapter already checked that sender
allowlist, so it is a trustworthy intake decision rather than the
fail-open ``group_policy: open`` case.
"""
if not platform or not chat_id:
return False
adapters = getattr(self, "adapters", None) or {}
adapter = adapters.get(platform)
groups = getattr(adapter, "_groups", None) if adapter is not None else None
if groups is None:
config = getattr(self, "config", None)
platform_cfg = (
config.platforms.get(platform)
if config is not None and hasattr(config, "platforms")
else None
)
extra = getattr(platform_cfg, "extra", None) if platform_cfg else None
if isinstance(extra, dict):
groups = extra.get("groups")
if not isinstance(groups, dict):
return False
chat_id_str = str(chat_id)
group_cfg = groups.get(chat_id_str)
if not isinstance(group_cfg, dict):
lowered = chat_id_str.lower()
for key, value in groups.items():
if isinstance(key, str) and key.lower() == lowered and isinstance(value, dict):
group_cfg = value
break
if not isinstance(group_cfg, dict):
group_cfg = groups.get("*")
if not isinstance(group_cfg, dict):
return False
sender_allow = group_cfg.get("allow_from") or group_cfg.get("allowFrom")
if isinstance(sender_allow, str):
return bool(sender_allow.strip())
if isinstance(sender_allow, (list, tuple, set)):
return any(str(item).strip() for item in sender_allow)
return False
def _is_user_authorized(self, source: SessionSource) -> bool:
"""
Check if a user is authorized to use the bot.
@@ -323,40 +237,27 @@ class GatewayAuthorizationMixin:
global_allowlist = os.getenv("GATEWAY_ALLOWED_USERS", "").strip()
if not platform_allowlist and not group_user_allowlist and not group_chat_allowlist and not global_allowlist:
# No env allowlist configured. Adapters that own their own
# No env allowlists configured. Adapters that own their own
# config-driven access policy (dm_policy / group_policy /
# allow_from / group_allow_from) gate access at intake, so for those
# platforms we can honor the adapter's decision instead of the
# env-only default-deny below -- but ONLY when that decision was an
# actual allowlist restriction.
#
# The adapters default dm_policy / group_policy to "open", which
# forwards EVERY sender. Reading "reached the gateway" as
# authorization in that case would admit the whole external network
# with no operator-configured allowlist -- the fail-open SECURITY.md
# §2.6 forbids ("an allowlist is required for every enabled
# network-exposed adapter ... code paths that fail open when no
# allowlist is configured are code bugs"). "disabled" never
# forwards, and "pairing" forwards unpaired DMs only so the gateway
# can run its pairing handshake (the pairing-store check above
# already denied this sender). So trust the adapter only when its
# effective policy for THIS chat type is "allowlist"; for "open" /
# "pairing" / anything else, fall through to default-deny, where
# GATEWAY_ALLOW_ALL_USERS, the per-platform {PLATFORM}_ALLOW_ALL_USERS
# flag (checked above), and the pairing flow remain the explicit
# opt-ins to broader access. (#34515 follow-up: trusting "open" was a
# fail-open.)
# allow_from / group_allow_from) already gated this message at
# intake — it would not have reached the gateway otherwise — so
# honor that decision instead of falling through to the
# env-only default-deny below, which would silently break
# `dm_policy: open` and config-only allowlists. (#34515)
if self._adapter_enforces_own_access_policy(source.platform):
if source.chat_type in {"group", "forum", "channel"}:
effective_policy = self._adapter_group_policy(source.platform)
if self._adapter_group_has_sender_allowlist(
source.platform,
source.chat_id,
):
return True
else:
effective_policy = self._adapter_dm_policy(source.platform)
if effective_policy == "allowlist":
# Exception: `dm_policy: pairing` does NOT authorize at intake.
# The adapter forwards the DM precisely so the gateway can run
# its pairing handshake (issue a code, consult the pairing
# store). The pairing-store approval check above already ran and
# returned False for this sender, so blanket-trusting the
# adapter here would silently turn pairing mode into open
# access. Fall through to default-deny so the unpaired sender is
# offered a pairing code instead. (Pairing is DM-only; group
# traffic keeps the adapter-trust path.)
if not (
source.chat_type == "dm"
and self._adapter_dm_policy(source.platform) == "pairing"
):
return True
# No allowlists configured -- check global allow-all flag
return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
+4 -4
View File
@@ -417,9 +417,9 @@ class StreamingConfig:
# if the original preview has been visible for at least this many
# seconds, so the platform's visible timestamp reflects completion
# time instead of the preview creation time. Currently applied to
# Telegram only (other platforms ignore the setting). Default 0 disables
# the fresh-message replacement path; set >0 to opt in.
fresh_final_after_seconds: float = 0.0
# Telegram only (other platforms ignore the setting). Default 60s
# matches the OpenClaw rollout. Set to 0 to disable.
fresh_final_after_seconds: float = 60.0
def to_dict(self) -> Dict[str, Any]:
return {
@@ -446,7 +446,7 @@ class StreamingConfig:
),
cursor=data.get("cursor", DEFAULT_STREAMING_CURSOR),
fresh_final_after_seconds=_coerce_float(
data.get("fresh_final_after_seconds"), 0.0
data.get("fresh_final_after_seconds"), 60.0
),
)
+3 -9
View File
@@ -156,23 +156,18 @@ def _normalize_chat_content(
if isinstance(content, list):
parts: List[str] = []
total_len = 0
items = content[:MAX_CONTENT_LIST_SIZE] if len(content) > MAX_CONTENT_LIST_SIZE else content
for item in items:
if isinstance(item, str):
if item:
part = item[:MAX_NORMALIZED_TEXT_LENGTH]
parts.append(part)
total_len += len(part)
parts.append(item[:MAX_NORMALIZED_TEXT_LENGTH])
elif isinstance(item, dict):
item_type = str(item.get("type") or "").strip().lower()
if item_type in {"text", "input_text", "output_text"}:
text = item.get("text", "")
if text:
try:
part = str(text)[:MAX_NORMALIZED_TEXT_LENGTH]
parts.append(part)
total_len += len(part)
parts.append(str(text)[:MAX_NORMALIZED_TEXT_LENGTH])
except Exception:
pass
# Silently skip image_url / other non-text parts
@@ -180,9 +175,8 @@ def _normalize_chat_content(
nested = _normalize_chat_content(item, _max_depth=_max_depth, _depth=_depth + 1)
if nested:
parts.append(nested)
total_len += len(nested)
# Check accumulated size
if total_len >= MAX_NORMALIZED_TEXT_LENGTH:
if sum(len(p) for p in parts) >= MAX_NORMALIZED_TEXT_LENGTH:
break
result = "\n".join(parts)
return result[:MAX_NORMALIZED_TEXT_LENGTH] if len(result) > MAX_NORMALIZED_TEXT_LENGTH else result
+8 -56
View File
@@ -1128,11 +1128,8 @@ SUPPORTED_DOCUMENT_TYPES = {
".ini": "text/plain",
".cfg": "text/plain",
".zip": "application/zip",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".ppt": "application/vnd.ms-powerpoint",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".ts": "text/plain",
".py": "text/plain",
@@ -1916,21 +1913,16 @@ class BasePlatformAdapter(ABC):
enforce it at intake: a message is dropped inside the adapter and never
reaches the gateway unless it already passed that policy.
The gateway's env-based allowlist check runs *after* the adapter. When
no env allowlist is configured, the gateway consults this flag so it can
honor a config-only ``dm_policy: allowlist`` / ``allow_from`` (which the
adapter already enforced) instead of double-denying it. Crucially, the
flag alone is NOT "already authorized": these adapters default
``dm_policy`` / ``group_policy`` to ``"open"``, which forwards every
sender, so the gateway trusts the adapter only when its effective policy
for the chat type is an actual ``"allowlist"`` restriction never for
``"open"`` (that would be the network-exposed fail-open SECURITY.md §2.6
forbids). Open access still requires an explicit
``{PLATFORM}_ALLOW_ALL_USERS`` / ``GATEWAY_ALLOW_ALL_USERS`` opt-in.
The gateway's env-based allowlist check runs *after* the adapter, so for
these platforms a message arriving at ``_is_user_authorized`` has, by
definition, already been authorized by the adapter. Without this flag the
gateway would then deny it again (no env allowlist default deny),
silently breaking ``dm_policy: open`` and config-only allowlists.
Adapters that own their access policy override this to return ``True``.
Adapters that delegate access control to the gateway leave it ``False``
(the default).
The gateway treats that as "already authorized at intake" and skips the
env-allowlist default-deny. Adapters that delegate access control to the
gateway leave it ``False`` (the default).
"""
return False
@@ -1953,46 +1945,6 @@ class BasePlatformAdapter(ABC):
"""
return False
def prefers_fresh_final_streaming(
self,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> bool:
"""Whether the stream consumer should finalize a streamed reply by
sending a *fresh* final message (and deleting the preview) instead of
final-editing the preview.
Some adapters can send richer final messages than their current edit
implementation supports. Telegram is the motivating case: Hermes sends
final replies through ``sendRichMessage`` but still finalizes streamed
previews through its existing MarkdownV2 edit path until Bot API 10.1's
``rich_message`` edit parameter is wired directly. Such adapters
override this to ask the consumer to re-deliver the completed answer as
a new rich message and best-effort delete the stale preview, so the
final rendering matches the rich send path.
Default implementation returns False legacy platforms keep the
edit-in-place finalization path.
"""
return False
def streaming_overflow_limit(self) -> Optional[int]:
"""Max single-message length (in this adapter's ``message_len_fn``
units) the stream consumer may accumulate before it splits, when the
adapter can deliver a larger message than its legacy per-message limit.
Telegram Bot API 10.1 Rich Messages accept up to 32,768 chars in a
single ``sendRichMessage`` / ``sendRichMessageDraft``, far above the
4,096 MarkdownV2 limit. Adapters with such a richer send/draft path
override this so the consumer doesn't fragment a reply that fits one
rich message; the live edit preview is still bound by the platform's
edit limit, but the finalized reply (and DM draft preview) is delivered
whole.
Return ``None`` (default) to use ``MAX_MESSAGE_LENGTH``.
"""
return None
async def send_draft(
self,
chat_id: str,
+10 -108
View File
@@ -22,7 +22,6 @@ import logging
import os
import re
import smtplib
import socket
import ssl
import uuid
from email.header import decode_header
@@ -63,63 +62,6 @@ _AUTOMATED_HEADERS = {
# Gmail-safe max length per email body
MAX_MESSAGE_LENGTH = 50_000
SMTP_CONNECT_TIMEOUT = 30
def _create_ipv4_connection(
host: str,
port: int,
timeout: float,
source_address: Any = None,
) -> socket.socket:
"""Create a TCP connection using only IPv4 addresses.
This mirrors ``socket.create_connection`` but constrains DNS resolution to
``AF_INET``. It avoids mutating process-global socket functions, which
matters because email sends run in executor threads.
"""
last_error: OSError | None = None
for family, socktype, proto, _canonname, sockaddr in socket.getaddrinfo(
host, port, socket.AF_INET, socket.SOCK_STREAM
):
sock = socket.socket(family, socktype, proto)
sock.settimeout(timeout)
try:
if source_address:
sock.bind(source_address)
sock.connect(sockaddr)
return sock
except OSError as exc:
last_error = exc
sock.close()
if last_error is not None:
raise last_error
raise OSError(f"No IPv4 address found for {host}:{port}")
class _IPv4SMTP(smtplib.SMTP):
def _get_socket(self, host, port, timeout): # type: ignore[override]
return _create_ipv4_connection(
host,
port,
timeout,
source_address=self.source_address,
)
class _IPv4SMTP_SSL(smtplib.SMTP_SSL):
def _get_socket(self, host, port, timeout): # type: ignore[override]
raw_sock = _create_ipv4_connection(
host,
port,
timeout,
source_address=self.source_address,
)
return self.context.wrap_socket(
raw_sock,
server_hostname=getattr(self, "_host", host),
)
# Supported image extensions for inline detection
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
@@ -351,48 +293,6 @@ class EmailAdapter(BasePlatformAdapter):
# Fallback: just clear old entries if sort fails
self._seen_uids = set(list(self._seen_uids)[-self._seen_uids_max // 2:])
def _connect_smtp(self) -> smtplib.SMTP:
"""Create an SMTP connection, selecting the correct protocol for the port.
Port 465 uses implicit TLS (``SMTP_SSL``). All other ports use
``SMTP`` + ``STARTTLS``.
When the host resolves to an IPv6 address that is unreachable
(common on networks without IPv6 routing), the default connection can
hang until the socket timeout expires. We retry connection-level
failures through an IPv4-only socket path, without mutating global
resolver state. TLS verification errors are not retried.
Returns a connected SMTP object with TLS established callers
can proceed directly to ``login()``.
"""
ctx = ssl.create_default_context()
host = self._smtp_host
port = self._smtp_port
def _connect(*, ipv4_only: bool = False) -> smtplib.SMTP:
"""Attempt one SMTP connection."""
smtp_cls = _IPv4SMTP if ipv4_only else smtplib.SMTP
smtp_ssl_cls = _IPv4SMTP_SSL if ipv4_only else smtplib.SMTP_SSL
if port == 465:
return smtp_ssl_cls(host, port, timeout=SMTP_CONNECT_TIMEOUT, context=ctx)
smtp = smtp_cls(host, port, timeout=SMTP_CONNECT_TIMEOUT)
try:
smtp.starttls(context=ctx)
except Exception:
smtp.close()
raise
return smtp
try:
return _connect()
except (socket.timeout, TimeoutError, ConnectionError, OSError) as exc:
if isinstance(exc, ssl.SSLError):
raise
# Connection-level failure (may be unreachable IPv6).
# Retry with IPv4 only.
return _connect(ipv4_only=True)
async def connect(self) -> bool:
"""Connect to the IMAP server and start polling for new messages."""
try:
@@ -416,11 +316,10 @@ class EmailAdapter(BasePlatformAdapter):
try:
# Test SMTP connection
smtp = self._connect_smtp()
try:
smtp.login(self._address, self._password)
finally:
smtp.quit()
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
smtp.starttls(context=ssl.create_default_context())
smtp.login(self._address, self._password)
smtp.quit()
logger.info("[Email] SMTP connection test passed.")
except Exception as e:
logger.error("[Email] SMTP connection failed: %s", e)
@@ -656,8 +555,9 @@ class EmailAdapter(BasePlatformAdapter):
msg.attach(MIMEText(body, "plain", "utf-8"))
smtp = self._connect_smtp()
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
try:
smtp.starttls(context=ssl.create_default_context())
smtp.login(self._address, self._password)
smtp.send_message(msg)
finally:
@@ -777,8 +677,9 @@ class EmailAdapter(BasePlatformAdapter):
except Exception as e:
logger.warning("[Email] Failed to attach %s: %s", file_path, e)
smtp = self._connect_smtp()
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
try:
smtp.starttls(context=ssl.create_default_context())
smtp.login(self._address, self._password)
smtp.send_message(msg)
finally:
@@ -855,8 +756,9 @@ class EmailAdapter(BasePlatformAdapter):
part.add_header("Content-Disposition", f"attachment; filename={fname}")
msg.attach(part)
smtp = self._connect_smtp()
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
try:
smtp.starttls(context=ssl.create_default_context())
smtp.login(self._address, self._password)
smtp.send_message(msg)
finally:
+1 -1
View File
@@ -209,7 +209,7 @@ class _MatrixHtmlSanitizer(HTMLParser):
_ALLOWED_TAGS = {
"a", "b", "blockquote", "br", "code", "del", "em", "h1", "h2", "h3",
"h4", "h5", "h6", "hr", "i", "li", "ol", "p", "pre", "s", "strike",
"strong", "table", "tbody", "td", "th", "thead", "tr", "ul",
"strong", "ul",
}
_VOID_TAGS = {"br", "hr"}
+19 -152
View File
@@ -349,11 +349,8 @@ class TelegramAdapter(BasePlatformAdapter):
MAX_MESSAGE_LENGTH = 4096
supports_code_blocks = True # Telegram MarkdownV2 renders fenced code blocks
# Bot API 10.1 Rich Messages cap the raw markdown/html text at 32,768
# UTF-8 characters. Content above this is sent via the legacy chunking path.
RICH_MESSAGE_MAX_CHARS = 32768
# Backwards-compatible alias for tests/external callers that referenced the
# initial implementation name. The API limit is character-based, not bytes.
RICH_MESSAGE_MAX_BYTES = RICH_MESSAGE_MAX_CHARS
# UTF-8 bytes. Content above this is sent via the legacy chunking path.
RICH_MESSAGE_MAX_BYTES = 32768
# Threshold for detecting Telegram client-side message splits.
# When a chunk is near this limit, a continuation is almost certain.
_SPLIT_THRESHOLD = 4000
@@ -419,11 +416,8 @@ class TelegramAdapter(BasePlatformAdapter):
self._mention_patterns = self._compile_mention_patterns()
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False)
# Bot API 10.1 Rich Messages: when explicitly enabled, send final
# replies via sendRichMessage with the raw agent markdown so
# tables/task lists/etc. render natively. Disabled by default because
# several Telegram clients accept but render rich messages poorly.
self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", False)
# Bot API 10.1 Rich Messages: send final replies via sendRichMessage
# with the raw agent markdown so tables/task lists/etc. render natively.
# Latched off after a capability failure on sendRichMessage /
# sendRichMessageDraft (e.g. older python-telegram-bot without the
# endpoint) so later sends skip the doomed rich attempt entirely.
@@ -926,20 +920,19 @@ class TelegramAdapter(BasePlatformAdapter):
# the RAW agent markdown so richer constructs (tables, task lists,
# collapsible details, math, ...) render natively. The legacy MarkdownV2
# send() path stays as the fallback for unsupported/oversized content and
# older PTB/clients. Streaming edits stay on Hermes' existing MarkdownV2
# edit path for now; finalization can re-send as rich and delete the stale
# preview until rich_message edit support is wired directly.
# older PTB/clients. Streaming edits/drafts are intentionally untouched —
# Telegram exposes no rich-edit method.
# ------------------------------------------------------------------
def _content_fits_rich_limits(self, content: str) -> bool:
"""Cheap pre-check for the one hard rich limit we can count locally.
Only the 32,768 UTF-8 character text cap is enforced here. Other Bot API
Only the 32,768 UTF-8 byte text cap is enforced here. Other Bot API
rich limits (500 blocks, 16 nesting levels, 20 table columns, ...) are
not pre-counted; if exceeded Telegram returns a BadRequest, which
:meth:`_is_rich_fallback_error` classifies as permanent so the send
degrades to the legacy chunking path.
"""
return len(content) <= self.RICH_MESSAGE_MAX_CHARS
return len(content.encode("utf-8")) <= self.RICH_MESSAGE_MAX_BYTES
def _bot_supports_rich(self) -> bool:
"""True when the bound bot can issue raw ``sendRichMessage`` calls.
@@ -953,79 +946,15 @@ class TelegramAdapter(BasePlatformAdapter):
"""
return inspect.iscoroutinefunction(getattr(self._bot, "do_api_request", None))
_RICH_DETAILS_RE = re.compile(r"<details\b[^>]*>.*?</details>", re.IGNORECASE | re.DOTALL)
_RICH_MATH_IN_DETAILS_RE = re.compile(
r"(\$\$.*?\$\$|"
r"\\\[.*?\\\]|"
r"\\\(.*?\\\)|"
r"\\(?:sum|frac|alpha|beta|gamma|delta|theta|lambda|mu|pi|sigma|"
r"int|prod|sqrt|lim|infty|begin\{(?:equation|align|matrix|cases)\}))",
re.IGNORECASE | re.DOTALL,
)
def _has_telegram_desktop_details_math_crash_shape(self, content: str) -> bool:
"""Return True for rich-message details+math content that crashes TDesktop.
Telegram Desktop 6.9.1 can crash while rendering Bot API 10.1 rich
messages containing math inside a collapsible details block
(telegramdesktop/tdesktop#30808). The Bot API accepts the payload, so
Hermes must skip rich delivery up front and use the legacy MarkdownV2
path until affected Desktop clients age out.
"""
if not content:
return False
for details_block in self._RICH_DETAILS_RE.findall(content):
if self._RICH_MATH_IN_DETAILS_RE.search(details_block):
return True
return False
def _should_attempt_rich(
self, content: str, metadata: Optional[Dict[str, Any]] = None
) -> bool:
def _should_attempt_rich(self, content: str) -> bool:
return bool(
getattr(self, "_rich_messages_enabled", False)
and not getattr(self, "_rich_send_disabled", False)
and not (metadata or {}).get("expect_edits")
not getattr(self, "_rich_send_disabled", False)
and content
and content.strip()
and not self._has_telegram_desktop_details_math_crash_shape(content)
and self._content_fits_rich_limits(content)
and self._bot_supports_rich()
)
def prefers_fresh_final_streaming(
self, content: str, metadata: Optional[Dict[str, Any]] = None
) -> bool:
"""Whether to replace a streamed preview with a fresh rich final.
Keep this disabled for Telegram. The fresh-final path briefly shows two
copies of the final answer, then deletes the streaming preview after the
rich send succeeds. That is especially visible on clients that support
rich messages well, and it looks like duplicate delivery at the end of
every streamed turn. Until Telegram rich edits are wired directly, final
streamed replies should edit the existing preview in place.
"""
return False
def streaming_overflow_limit(self) -> Optional[int]:
"""Allow the stream consumer to accumulate up to the rich-message cap
before splitting, so a reply that fits one ``sendRichMessage`` /
``sendRichMessageDraft`` isn't fragmented at the 4,096 MarkdownV2 limit.
Gated on the same rich capability as the send path (minus the
content-length check raising that cap is the whole point): rich not
latched off and the bot exposes an async ``do_api_request``. Returns
``None`` ( legacy 4,096 limit) when rich isn't available, so non-rich
streams split exactly as before.
"""
if (
getattr(self, "_rich_messages_enabled", False)
and not getattr(self, "_rich_send_disabled", False)
and self._bot_supports_rich()
):
return self.RICH_MESSAGE_MAX_CHARS
return None
def _rich_message_payload(
self, content: str, *, skip_entity_detection: bool = False
) -> Dict[str, Any]:
@@ -1047,19 +976,16 @@ class TelegramAdapter(BasePlatformAdapter):
rejections (BadRequest from a parser/limit issue) are NOT capability
errors: the next message may be fine.
"""
name = exc.__class__.__name__.lower()
if name in {"endpointnotfound", "invalidtoken"}:
return True
if isinstance(exc, (AttributeError, TypeError, NotImplementedError)):
return True
if getattr(exc, "error_code", None) == 404:
return True
s = str(exc).lower()
if ("method" in s or "endpoint" in s) and (
"not found" in s or "does not exist" in s
):
if ("method" in s and "not found" in s) or "no such method" in s:
return True
return "no such method" in s
if "unsupported" in s or "not implemented" in s:
return True
return False
def _is_rich_fallback_error(self, exc: Exception) -> bool:
"""True ⇒ permanent/capability error ⇒ safe to fall back to legacy.
@@ -1071,10 +997,7 @@ class TelegramAdapter(BasePlatformAdapter):
"""
if self._is_bad_request_error(exc):
return True
if self._is_rich_capability_error(exc):
return True
s = str(exc).lower()
return "unsupported" in s or "not implemented" in s
return self._is_rich_capability_error(exc)
def _compute_single_send_routing(
self,
@@ -1147,8 +1070,6 @@ class TelegramAdapter(BasePlatformAdapter):
# which must not be sent as a stray field on the raw endpoint.
payload.update({k: v for k, v in thread_kwargs.items() if v is not None})
payload.update(self._notification_kwargs(metadata))
if getattr(self, "_disable_link_previews", False):
payload["link_preview_options"] = {"is_disabled": True}
if reply_to_id is not None:
# Spec: sendRichMessage takes reply_parameters (ReplyParameters
# object), NOT the legacy reply_to_message_id scalar. Unknown
@@ -1157,12 +1078,8 @@ class TelegramAdapter(BasePlatformAdapter):
payload["reply_parameters"] = {"message_id": reply_to_id}
try:
# Take the raw Bot API result (dict under real PTB). Passing
# return_type=Message would make PTB deserialize a Bot API 10.1
# response shape it does not fully model yet; a post-delivery parse
# error must not be mistaken for a sendable failure.
msg = await self._bot.do_api_request(
"sendRichMessage", api_kwargs=payload
"sendRichMessage", api_kwargs=payload, return_type=Message
)
except Exception as exc:
if self._is_rich_fallback_error(exc):
@@ -1199,7 +1116,7 @@ class TelegramAdapter(BasePlatformAdapter):
if isinstance(msg, dict):
message_id = msg.get("message_id")
if message_id is None:
message_id = (msg.get("result") or {}).get("message_id")
message_id = msg.get("result", {}).get("message_id")
else:
message_id = getattr(msg, "message_id", None)
return SendResult(
@@ -1209,12 +1126,10 @@ class TelegramAdapter(BasePlatformAdapter):
def _should_attempt_rich_draft(self, content: str) -> bool:
return bool(
getattr(self, "_rich_messages_enabled", False)
and not getattr(self, "_rich_send_disabled", False)
not getattr(self, "_rich_send_disabled", False)
and not getattr(self, "_rich_draft_disabled", False)
and content
and content.strip()
and not self._has_telegram_desktop_details_math_crash_shape(content)
and self._content_fits_rich_limits(content)
and self._bot_supports_rich()
)
@@ -2231,7 +2146,7 @@ class TelegramAdapter(BasePlatformAdapter):
# through to the legacy MarkdownV2 path on permanent/capability
# errors or DM-topic routing skips; returns directly on success or
# on a transient failure (which must NOT be legacy-resent).
if self._should_attempt_rich(content, metadata=metadata):
if self._should_attempt_rich(content):
rich_result = await self._try_send_rich(chat_id, content, reply_to, metadata)
if rich_result is not None:
if rich_result.success:
@@ -5553,52 +5468,6 @@ class TelegramAdapter(BasePlatformAdapter):
event.text = self._append_observed_note(event.text, cached.context_note())
logger.info("[Telegram] Cached observed group %s at %s", cached.kind, cached.path)
async def _cache_replied_media(self, msg: Any, event: MessageEvent) -> None:
"""Cache media from the message this turn replies to, if any."""
from gateway.platforms.base import cache_media_bytes
reply_msg = getattr(msg, "reply_to_message", None)
if reply_msg is None:
return
source, filename, mime, kind = self._observed_media_source(reply_msg)
if source is None:
return
max_bytes = getattr(self, "_max_doc_bytes", 20 * 1024 * 1024)
file_size = getattr(source, "file_size", None)
try:
size = int(file_size or 0)
except (TypeError, ValueError):
size = 0
if not (0 < size <= max_bytes):
return
try:
file_obj = await source.get_file()
data = bytes(await file_obj.download_as_bytearray())
if not filename:
filename = os.path.basename(getattr(file_obj, "file_path", "") or "")
cached = cache_media_bytes(data, filename=filename, mime_type=mime, default_kind=kind)
except Exception as exc:
logger.warning("[Telegram] Failed to cache replied-to media: %s", exc, exc_info=True)
return
if cached is None:
return
event.media_urls.append(cached.path)
event.media_types.append(cached.media_type)
if len(event.media_urls) == 1:
if cached.kind == "image":
event.message_type = MessageType.PHOTO
elif cached.kind == "video":
event.message_type = MessageType.VIDEO
event.text = self._append_observed_note(
event.text,
f"[Replied-to {cached.kind} '{cached.display_name}' saved at: {cached.path}]",
)
logger.info("[Telegram] Cached replied-to %s at %s", cached.kind, cached.path)
def _observed_media_source(self, msg: Message):
"""Return (telegram_file_source, filename, mime, default_kind) or Nones."""
if msg.photo:
@@ -5788,7 +5657,6 @@ class TelegramAdapter(BasePlatformAdapter):
event = self._build_message_event(msg, MessageType.TEXT, update_id=update.update_id)
event.text = self._clean_bot_trigger_text(event.text)
await self._cache_replied_media(msg, event)
event = self._apply_telegram_group_observe_attribution(event)
self._enqueue_text_event(event)
@@ -5803,7 +5671,6 @@ class TelegramAdapter(BasePlatformAdapter):
event = self._build_message_event(msg, MessageType.COMMAND, update_id=update.update_id)
event.text = self._clean_bot_trigger_text(event.text)
await self._cache_replied_media(msg, event)
event = self._apply_telegram_group_observe_attribution(event)
await self.handle_message(event)
+21 -58
View File
@@ -36,8 +36,7 @@ import logging
import re
import subprocess
import time
from collections import deque
from typing import Any, Deque, Dict, List, Optional
from typing import Any, Dict, List, Optional
try:
from aiohttp import web
@@ -68,7 +67,6 @@ DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 8644
_INSECURE_NO_AUTH = "INSECURE_NO_AUTH"
_DYNAMIC_ROUTES_FILENAME = "webhook_subscriptions.json"
_RATE_WINDOW_SECONDS = 60.0
# Hostnames/IP literals that only serve connections originating on the same
# machine. Anything else is treated as a public bind for safety-rail purposes.
@@ -124,7 +122,6 @@ class WebhookAdapter(BasePlatformAdapter):
# back to the "log" deliver type.
self._delivery_info: Dict[str, dict] = {}
self._delivery_info_created: Dict[str, float] = {}
self._delivery_info_order: Deque[tuple[float, str]] = deque()
# Reference to gateway runner for cross-platform delivery (set externally)
self.gateway_runner = None
@@ -133,10 +130,9 @@ class WebhookAdapter(BasePlatformAdapter):
# Prevents duplicate agent runs when webhook providers retry.
self._seen_deliveries: Dict[str, float] = {}
self._idempotency_ttl: int = 3600 # 1 hour
self._seen_deliveries_next_prune_at: float = 0.0
# Rate limiting: per-route timestamps in a fixed window.
self._rate_counts: Dict[str, Deque[float]] = {}
self._rate_counts: Dict[str, List[float]] = {}
self._rate_limit: int = int(config.extra.get("rate_limit", 30)) # per minute
# Body size limit (auth-before-body pattern)
@@ -275,57 +271,15 @@ class WebhookAdapter(BasePlatformAdapter):
on each POST so the dict size is bounded by ``rate_limit * TTL``
even if many webhooks fire and never receive a final response.
"""
if len(self._delivery_info_order) < len(self._delivery_info_created):
self._delivery_info_order = deque(
(created_at, key)
for key, created_at in sorted(
self._delivery_info_created.items(), key=lambda item: item[1]
)
)
cutoff = now - self._idempotency_ttl
while self._delivery_info_order and self._delivery_info_order[0][0] < cutoff:
created_at, key = self._delivery_info_order.popleft()
if self._delivery_info_created.get(key) != created_at:
continue
self._delivery_info.pop(key, None)
self._delivery_info_created.pop(key, None)
def _prune_seen_deliveries(self, now: float) -> None:
"""Occasionally prune expired delivery IDs without scanning every POST."""
if now < self._seen_deliveries_next_prune_at:
return
cutoff = now - self._idempotency_ttl
stale = [k for k, t in self._seen_deliveries.items() if t < cutoff]
stale = [
k
for k, t in self._delivery_info_created.items()
if t < cutoff
]
for k in stale:
self._seen_deliveries.pop(k, None)
self._seen_deliveries_next_prune_at = now + min(60.0, max(1.0, self._idempotency_ttl / 10))
def _record_rate_limit_hit(self, route_name: str, now: float) -> bool:
"""Return True if route is still within limit after recording this hit."""
window = self._rate_counts.get(route_name)
if not isinstance(window, deque):
new_window: Deque[float] = deque(window or ())
self._rate_counts[route_name] = new_window
window = new_window
cutoff = now - _RATE_WINDOW_SECONDS
while window and window[0] < cutoff:
window.popleft()
if len(window) >= self._rate_limit:
return False
window.append(now)
return True
def _record_delivery_id(self, delivery_id: str, now: float) -> bool:
"""Return True when this delivery should be processed."""
seen_at = self._seen_deliveries.get(delivery_id)
if seen_at is not None and now - seen_at < self._idempotency_ttl:
return False
if seen_at is not None:
self._seen_deliveries.pop(delivery_id, None)
self._seen_deliveries[delivery_id] = now
if len(self._seen_deliveries) > max(self._rate_limit * 2, 128):
self._prune_seen_deliveries(now)
return True
self._delivery_info.pop(k, None)
self._delivery_info_created.pop(k, None)
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
return {"name": chat_id, "type": "webhook"}
@@ -459,10 +413,13 @@ class WebhookAdapter(BasePlatformAdapter):
# ── Rate limiting (after auth) ───────────────────────────
now = time.time()
if not self._record_rate_limit_hit(route_name, now):
window = self._rate_counts.setdefault(route_name, [])
window[:] = [t for t in window if now - t < 60]
if len(window) >= self._rate_limit:
return web.json_response(
{"error": "Rate limit exceeded"}, status=429
)
window.append(now)
# Parse payload
try:
@@ -547,7 +504,13 @@ class WebhookAdapter(BasePlatformAdapter):
# ── Idempotency ─────────────────────────────────────────
# Skip duplicate deliveries (webhook retries).
now = time.time()
if not self._record_delivery_id(delivery_id, now):
# Prune expired entries
self._seen_deliveries = {
k: v
for k, v in self._seen_deliveries.items()
if now - v < self._idempotency_ttl
}
if delivery_id in self._seen_deliveries:
logger.info(
"[webhook] Skipping duplicate delivery %s", delivery_id
)
@@ -555,6 +518,7 @@ class WebhookAdapter(BasePlatformAdapter):
{"status": "duplicate", "delivery_id": delivery_id},
status=200,
)
self._seen_deliveries[delivery_id] = now
# ── Direct delivery mode (deliver_only) ─────────────────
# Skip the agent entirely — the rendered prompt IS the message we
@@ -630,7 +594,6 @@ class WebhookAdapter(BasePlatformAdapter):
}
self._delivery_info[session_chat_id] = deliver_config
self._delivery_info_created[session_chat_id] = now
self._delivery_info_order.append((now, session_chat_id))
self._prune_delivery_info(now)
# Build source and event
-53
View File
@@ -1,53 +0,0 @@
"""Gateway response filtering helpers.
These helpers operate at the gateway boundary: they decide whether a completed
agent turn should be delivered to the chat, not what should be persisted in the
conversation history.
"""
from __future__ import annotations
from typing import Any
# Canonical model-emitted control token for intentional silence.
SILENT_REPLY_TOKEN = "NO_REPLY"
# Exact whole-response markers that mean "the agent intentionally chose not to
# reply". Keep this list small and explicit; arbitrary empty output remains an
# error/empty-response path, not silence.
LIVE_GATEWAY_SILENT_MARKERS = frozenset({
"[SILENT]",
"SILENT",
"NO_REPLY",
"NO REPLY",
})
def _canonical_silence_candidate(text: str) -> str:
return " ".join(text.strip().upper().split())
def is_intentional_silence_response(response: Any) -> bool:
"""Return True only when ``response`` is exactly a silence marker.
Substantive prose that merely mentions ``NO_REPLY`` or ``[SILENT]`` must be
delivered normally. A blank response is also not silence; blank output is
handled by the empty-response failure path.
"""
if not isinstance(response, str):
return False
stripped = response.strip()
if not stripped:
return False
if len(stripped) > 64:
return False
return _canonical_silence_candidate(stripped) in LIVE_GATEWAY_SILENT_MARKERS
def is_intentional_silence_agent_result(agent_result: dict | None, response: Any) -> bool:
"""Silence markers suppress delivery only for successful agent turns."""
if not isinstance(agent_result, dict):
return False
if agent_result.get("failed"):
return False
return is_intentional_silence_response(response)
+30 -409
View File
@@ -677,15 +677,6 @@ def _build_gateway_agent_history(
clean_msg = {k: v for k, v in msg.items() if k not in {"timestamp", "observed"}}
agent_history.append(clean_msg)
elif content:
# Strip gateway-injected auto-continue notes that were persisted
# as part of user messages during interrupted turns. Keep the
# user's real text after the note, but never replay the recovery
# instruction itself — that is what caused infinite re-execution
# loops for interrupted long-running tools.
if role == "user":
content = _strip_auto_continue_noise(content)
if not content:
continue
# Simple text message - just need role and content.
if msg.get("mirror"):
mirror_src = msg.get("mirror_source", "another session")
@@ -693,10 +684,6 @@ def _build_gateway_agent_history(
entry = _build_replay_entry(role, content, msg)
agent_history.append(entry)
# Strip interrupted tool-call tails so the LLM doesn't re-execute
# tools that were killed mid-flight.
agent_history = _strip_interrupted_tool_tails(agent_history)
observed_context = "\n".join(observed_group_context).strip() or None
return agent_history, observed_context
@@ -762,99 +749,6 @@ _AUTO_APPEND_MEDIA_TOOL_NAMES = {
"image_generate",
}
# ---- helpers: detect interrupted tool tails & auto-continue noise ----------
def _is_interrupted_tool_result(content: Any) -> bool:
"""Return True if a tool result indicates the tool was interrupted."""
if not isinstance(content, str):
return False
lowered = content.lower()
if "[command interrupted]" in lowered:
return True
if "exit_code" in lowered and ("130" in lowered or "-1" in lowered):
return "interrupt" in lowered
return False
def _strip_interrupted_tool_tails(
agent_history: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Strip interrupted assistant→tool sequences from replay history.
Older interrupted gateway turns can be followed by a queued real user
message, so the interrupted assistant/tool block is not necessarily the
final tail by the time we rebuild replay history. Remove any contiguous
assistant(tool_calls) + tool-result block that contains an interrupted tool
result, while preserving successful tool-call sequences intact.
"""
if not agent_history:
return agent_history
cleaned: List[Dict[str, Any]] = []
i = 0
n = len(agent_history)
while i < n:
msg = agent_history[i]
if msg.get("role") == "assistant" and "tool_calls" in msg:
j = i + 1
tool_results: List[Dict[str, Any]] = []
while j < n and agent_history[j].get("role") == "tool":
tool_results.append(agent_history[j])
j += 1
if tool_results and any(
_is_interrupted_tool_result(m.get("content", ""))
for m in tool_results
):
logger.debug(
"Stripping interrupted assistant→tool replay block "
"(indices %d%d, tool_results=%d)",
i, j - 1, len(tool_results),
)
i = j
continue
if msg.get("role") == "tool" and _is_interrupted_tool_result(msg.get("content", "")):
logger.debug("Stripping orphan interrupted tool result from replay history")
i += 1
continue
cleaned.append(msg)
i += 1
return cleaned
_AUTO_CONTINUE_NOTE_PREFIX = "[System note: Your previous turn"
_AUTO_CONTINUE_FALLBACK_PREFIX = "[System note: A new message"
def _is_auto_continue_noise(content: Any) -> bool:
"""Return True if this user-message content is a gateway-injected
auto-continue note that should NOT be replayed as a real user turn."""
if not isinstance(content, str):
return False
return (
content.startswith(_AUTO_CONTINUE_NOTE_PREFIX)
or content.startswith(_AUTO_CONTINUE_FALLBACK_PREFIX)
)
def _strip_auto_continue_noise(content: Any) -> Any:
"""Remove persisted gateway auto-continue note prefix from user text.
Older gateway builds prepended the recovery note directly to the user
message, so the transcript row can contain both the synthetic note and
the user's real question. Strip one or more leading synthetic notes while
preserving any real text that follows.
"""
if not _is_auto_continue_noise(content):
return content
text = str(content)
while _is_auto_continue_noise(text):
end = text.find("]")
if end < 0:
return ""
text = text[end + 1 :].lstrip()
return text
# Tools in this set return their deliverable artifact as a JSON payload with a
# local-file path field rather than a literal ``MEDIA:`` tag (e.g. image_generate
# returns ``{"success": true, "image": "/abs/path.png"}``). The auto-append path
@@ -1127,7 +1021,6 @@ if _config_path.exists():
"backend": "TERMINAL_ENV",
"cwd": "TERMINAL_CWD",
"timeout": "TERMINAL_TIMEOUT",
"home_mode": "TERMINAL_HOME_MODE",
"lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
"docker_image": "TERMINAL_DOCKER_IMAGE",
"docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
@@ -2107,7 +2000,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_stop_task: Optional[asyncio.Task] = None
_session_model_overrides: Dict[str, Dict[str, str]] = {}
_session_reasoning_overrides: Dict[str, Dict[str, Any]] = {}
_startup_restore_in_progress: bool = False
def __init__(self, config: Optional[GatewayConfig] = None):
global _gateway_runner_ref
@@ -2188,13 +2080,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
self._pending_native_image_paths_by_session: Dict[str, List[str]] = {}
self._busy_ack_ts: Dict[str, float] = {} # last busy-ack timestamp per session (debounce)
self._session_run_generation: Dict[str, int] = {}
# Startup restore gate: while restart-interrupted sessions are being
# auto-resumed, real inbound messages are queued instead of competing
# with the synthetic resume turns for the same session. The queued
# events drain only after all startup resume tasks have finished.
self._startup_restore_in_progress = False
self._startup_restore_queue: List[MessageEvent] = []
self._startup_restore_tasks: List[asyncio.Task] = []
# LRU cache of live SessionSources keyed by session_key. Used by
# fallback routing paths (shutdown notifications, synthetic
# background-process events) when the persisted origin is missing
@@ -4610,94 +4495,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
{"restart_timeout", "shutdown_timeout", "restart_interrupted"}
)
async def _run_startup_resume_event(
self,
adapter: BasePlatformAdapter,
event: MessageEvent,
session_key: str,
) -> None:
"""Dispatch one synthetic startup resume and wait for its agent turn.
``BasePlatformAdapter.handle_message()`` returns after it installs the
adapter-level guard and spawns the background processing task. Startup
restore needs a stronger boundary: inbound messages must stay queued
until the resumed agent turn itself has finished, otherwise a user
message can race the restore turn immediately after ``handle_message``
returns.
"""
try:
await adapter.handle_message(event)
session_tasks = getattr(adapter, "_session_tasks", {})
task = session_tasks.get(session_key) if isinstance(session_tasks, dict) else None
if task is not None:
await asyncio.shield(task)
finally:
# _schedule_resume_pending_sessions pre-claims the runner slot
# before spawning this task. If adapter.handle_message raises
# before _handle_message takes ownership, release that pre-claim;
# otherwise the real run's normal cleanup owns the slot.
if self._running_agents.get(session_key) is _AGENT_PENDING_SENTINEL:
self._release_running_agent_state(session_key)
def _queue_startup_restore_event(self, event: MessageEvent) -> None:
queue = getattr(self, "_startup_restore_queue", None)
if queue is None:
queue = []
self._startup_restore_queue = queue
queue.append(event)
try:
source = event.source
logger.info(
"Queued inbound message during gateway startup restore: platform=%s chat=%s",
source.platform.value if source and source.platform else "unknown",
source.chat_id if source else "unknown",
)
except Exception:
pass
async def _drain_startup_restore_queue(self) -> int:
"""Replay inbound messages queued while startup auto-resume ran."""
drained = 0
queue = getattr(self, "_startup_restore_queue", None)
if queue is None:
return 0
while queue:
event = queue.pop(0)
source = getattr(event, "source", None)
adapter = self.adapters.get(source.platform) if source is not None else None
if adapter is None:
logger.debug(
"Dropping startup-restore queued message: adapter unavailable for %s",
getattr(getattr(source, "platform", None), "value", None),
)
continue
# Mark this replay so _handle_message does not queue it again while
# the restore gate remains closed for any fresh inbound arrivals.
try:
setattr(event, "_hermes_startup_restore_replay", True)
except Exception:
pass
await adapter.handle_message(event)
drained += 1
return drained
async def _finish_startup_restore(self) -> None:
"""Wait for startup auto-resume, then release and drain inbound queue."""
tasks = list(getattr(self, "_startup_restore_tasks", []) or [])
if tasks:
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, Exception):
logger.debug(
"startup auto-resume task failed",
exc_info=(type(result), result, result.__traceback__),
)
self._startup_restore_tasks = []
drained = await self._drain_startup_restore_queue()
self._startup_restore_in_progress = False
if drained:
logger.info("Drained %d inbound message(s) queued during startup restore", drained)
def _schedule_resume_pending_sessions(self, platform=None) -> int:
"""Auto-continue fresh restart-interrupted sessions after startup.
@@ -4759,14 +4556,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
)
continue
# Claim the session slot *before* spawning the task so that an
# inbound message arriving between task creation and the task's
# first await (where _process_message_background sets the real
# sentinel) sees the slot as occupied and queues behind it
# instead of spinning up a duplicate AIAgent (#45456).
self._running_agents[entry.session_key] = _AGENT_PENDING_SENTINEL
self._running_agents_ts[entry.session_key] = time.time()
# Empty-text internal event — the _is_resume_pending branch in
# _handle_message_with_agent prepends the proper reason-aware
# system note before the turn runs.
@@ -4776,17 +4565,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
source=source,
internal=True,
)
task = asyncio.create_task(
self._run_startup_resume_event(adapter, event, entry.session_key)
)
task = asyncio.create_task(adapter.handle_message(event))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
if getattr(self, "_startup_restore_in_progress", False):
tasks = getattr(self, "_startup_restore_tasks", None)
if tasks is None:
tasks = []
self._startup_restore_tasks = tasks
tasks.append(task)
scheduled += 1
if scheduled:
@@ -5049,15 +4830,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
except Exception as e:
logger.debug("Stuck-loop detection failed: %s", e)
# Serialize startup restore against inbound dispatch. Platform
# adapters can begin receiving messages as soon as they connect, but
# restart-interrupted sessions are not auto-resumed until all startup
# wiring below completes. Queue inbound messages until the resume
# pass runs and every synthetic resume turn has finished.
self._startup_restore_in_progress = True
self._startup_restore_queue = []
self._startup_restore_tasks = []
connected_count = 0
enabled_platform_count = 0
startup_nonretryable_errors: list[str] = []
@@ -5192,7 +4964,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
except Exception:
pass
self._request_clean_exit(reason)
self._startup_restore_in_progress = False
return True
if enabled_platform_count > 0:
if startup_retryable_errors:
@@ -5303,7 +5074,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# by the normal successful-turn path, so a failed auto-resume remains
# visible for manual recovery on the next user message.
self._schedule_resume_pending_sessions()
await self._finish_startup_restore()
# Drain any recovered process watchers (from crash recovery checkpoint)
try:
@@ -6606,14 +6376,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"""
source = event.source
if (
getattr(self, "_startup_restore_in_progress", False)
and not getattr(event, "internal", False)
and not getattr(event, "_hermes_startup_restore_replay", False)
):
self._queue_startup_restore_event(event)
return None
# Internal events (e.g. background-process completion notifications)
# are system-generated and must skip user authorization.
is_internal = bool(getattr(event, "internal", False))
@@ -6956,11 +6718,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if _cmd_def_inner and _cmd_def_inner.name == "restart":
return await self._handle_restart_command(event)
if _cmd_def_inner and _cmd_def_inner.name == "egress":
from hermes_cli.proxy_cli import format_status_text
return format_status_text()
# /stop must hard-kill the session when an agent is running.
# A soft interrupt (agent.interrupt()) doesn't help when the agent
# is truly hung — the executor thread is blocked and never checks
@@ -7412,11 +7169,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if canonical == "status":
return await self._handle_status_command(event)
if canonical == "egress":
from hermes_cli.proxy_cli import format_status_text
return format_status_text()
if canonical == "agents":
return await self._handle_agents_command(event)
@@ -8856,20 +8608,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
return None
response = agent_result.get("final_response") or ""
try:
from gateway.response_filters import is_intentional_silence_agent_result
_intentional_silence = is_intentional_silence_agent_result(
agent_result, response,
)
except Exception:
_intentional_silence = False
# Convert the agent's internal "(empty)" sentinel into a
# user-friendly message. "(empty)" means the model failed to
# produce visible content after exhausting all retries (nudge,
# prefill, empty-retry, fallback). Sending the raw sentinel
# looks like a bug; a short explanation is more helpful.
if response == "(empty)" and not _intentional_silence:
if response == "(empty)":
response = (
"⚠️ The model returned no response after processing tool "
"results. This can happen with some models — try again or "
@@ -8885,20 +8630,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_response_time, _api_calls, _resp_len,
)
# Re-baseline the cached agent's message_count snapshot now that
# this turn has completed and the agent has flushed its rows to
# the SessionDB. The cross-process coherence guard (#45966)
# snapshots the count at agent-BUILD time (before this turn's own
# writes) and never refreshes it on reuse — so without this, this
# process's own turn would grow the count and the next turn would
# see a mismatch and rebuild the agent every turn, destroying
# prompt caching. Refreshing here makes the guard fire only on a
# DIFFERENT process's writes. Uses the (possibly compaction-
# updated) live session_id. Fail-safe inside the helper.
self._refresh_agent_cache_message_count(
session_key, session_entry.session_id
)
# Successful turn — clear any stuck-loop counter for this session.
# This ensures the counter only accumulates across CONSECUTIVE
# restarts where the session was active (never completed).
@@ -8919,11 +8650,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# Normalize empty responses: surface errors, partial failures, and
# the case where agent did work but returned no text. Fix for #18765.
if not _intentional_silence:
response = _normalize_empty_agent_response(
agent_result, response, history_len=len(history),
)
response = _sanitize_gateway_final_response(source.platform, response)
response = _normalize_empty_agent_response(
agent_result, response, history_len=len(history),
)
response = _sanitize_gateway_final_response(source.platform, response)
# Ordering contract: the agent thread already updated the contextvar
# in conversation_compression.py; propagate to SessionEntry + _save().
@@ -8947,7 +8677,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
)
except Exception:
_show_reasoning_effective = getattr(self, "_show_reasoning", False)
if _show_reasoning_effective and response and not _intentional_silence:
if _show_reasoning_effective and response:
last_reasoning = agent_result.get("last_reasoning")
if last_reasoning:
# Collapse long reasoning to keep messages readable
@@ -8977,7 +8707,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
except Exception as _footer_err:
logger.debug("runtime_footer build failed: %s", _footer_err)
_footer_line = ""
if _footer_line and response and not agent_result.get("already_sent") and not _intentional_silence:
if _footer_line and response and not agent_result.get("already_sent"):
response = f"{response}\n\n{_footer_line}"
# Emit agent:end hook
@@ -9211,18 +8941,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
last_prompt_tokens=agent_result.get("last_prompt_tokens", 0),
)
# Intentional silence is a delivery decision, not a transcript
# mutation. The agent's [SILENT]/NO_REPLY assistant turn above is
# still persisted in session history so later turns keep normal
# user/assistant alternation; only the outbound chat delivery is
# suppressed.
if _intentional_silence:
logger.info(
"Suppressing intentional silence marker for session %s",
session_entry.session_id,
)
response = ""
# Auto voice reply: send TTS audio before the text response
_already_sent = bool(agent_result.get("already_sent"))
if self._should_send_voice_reply(event, response, agent_messages, already_sent=_already_sent):
@@ -12808,57 +12526,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if release_running_state:
self._release_running_agent_state(session_key)
def _refresh_agent_cache_message_count(
self, session_key: str, session_id: Optional[str]
) -> None:
"""Re-baseline a cached agent's stored message_count after THIS turn.
The cross-process coherence guard (#45966) compares the session's
on-disk ``message_count`` against the count snapshotted next to the
cached agent, and rebuilds the agent on a mismatch. But the snapshot
is taken at agent-BUILD time before this turn writes its own user +
assistant (+ tool) rows and the cache entry is never rewritten on a
reuse. So without this re-baseline, THIS process's own turn would
grow ``message_count`` and the very next turn would see a mismatch
and rebuild the agent every turn, for every conversation silently
destroying the per-conversation prompt caching the cache exists to
protect.
Call this once a turn has completed and the agent has flushed its
rows to the SessionDB. It snapshots the now-current count (which
includes this process's own writes) so the guard only fires when a
DIFFERENT process changes the transcript out from under us. The
``_sig`` is left untouched; only the count element is refreshed, and
only when the same agent is still cached (no rebuild/eviction raced
in between). Fail-safe: any DB error leaves the snapshot as-is, which
at worst costs one unnecessary rebuild on the next turn.
"""
if self._session_db is None or not session_id:
return
_cache_lock = getattr(self, "_agent_cache_lock", None)
_cache = getattr(self, "_agent_cache", None)
if not _cache_lock or _cache is None:
return
try:
_sess_row = self._session_db.get_session(session_id)
_live = _sess_row.get("message_count", 0) if _sess_row else None
except Exception:
return
if _live is None:
return
with _cache_lock:
cached = _cache.get(session_key)
# Only re-baseline a live 3-tuple entry; skip pending sentinels,
# legacy 2-tuples (they intentionally opt out of the guard), and
# the case where the entry was evicted/rebuilt mid-turn.
if (
isinstance(cached, tuple)
and len(cached) > 2
and cached[0] is not _AGENT_PENDING_SENTINEL
):
if cached[2] != _live:
_cache[session_key] = (cached[0], cached[1], _live)
def _evict_cached_agent(self, session_key: str) -> None:
"""Remove a cached agent for a session (called on /new, /model, etc).
@@ -14382,57 +14049,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
agent = None
_cache_lock = getattr(self, "_agent_cache_lock", None)
_cache = getattr(self, "_agent_cache", None)
# Detect cross-process writes: when another process (e.g. hermes
# dashboard) appends to the same session in the shared SessionDB,
# the cached agent's in-memory transcript becomes stale. Compare
# the session's current message_count against the count recorded
# when the agent was cached; on mismatch, invalidate the cache
# so a fresh agent re-reads from disk. (#45966)
_current_msg_count = None
if self._session_db is not None and session_id:
try:
_sess_row = self._session_db.get_session(session_id)
if _sess_row:
_current_msg_count = _sess_row.get("message_count", 0)
except Exception:
pass
if _cache_lock and _cache is not None:
with _cache_lock:
cached = _cache.get(session_key)
if cached and cached[1] == _sig:
# cached[2] is the message_count at cache time;
# stale when a second process appended rows.
_cached_mc = cached[2] if len(cached) > 2 else None
if (
_cached_mc is not None
and _current_msg_count is not None
and _current_msg_count != _cached_mc
):
# Cross-process write detected — discard stale
# agent so it rebuilds from fresh DB transcript.
logger.info(
"Agent cache invalidated for session %s: "
"message_count changed (%s -> %s), "
"possible cross-process write",
session_key, _cached_mc, _current_msg_count,
)
evicted = self._agent_cache.pop(session_key, None)
_ev_agent = evicted[0] if isinstance(evicted, tuple) and evicted else None
if _ev_agent and _ev_agent is not _AGENT_PENDING_SENTINEL:
self._cleanup_agent_resources(_ev_agent)
else:
agent = cached[0]
# Refresh LRU order so the cap enforcement evicts
# truly-oldest entries, not the one we just used.
if hasattr(_cache, "move_to_end"):
try:
_cache.move_to_end(session_key)
except KeyError:
pass
self._init_cached_agent_for_turn(agent, _interrupt_depth)
logger.debug("Reusing cached agent for session %s", session_key)
agent = cached[0]
# Refresh LRU order so the cap enforcement evicts
# truly-oldest entries, not the one we just used.
if hasattr(_cache, "move_to_end"):
try:
_cache.move_to_end(session_key)
except KeyError:
pass
self._init_cached_agent_for_turn(agent, _interrupt_depth)
logger.debug("Reusing cached agent for session %s", session_key)
if agent is None:
# Config changed or first message — create fresh agent
@@ -14470,7 +14100,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
)
if _cache_lock and _cache is not None:
with _cache_lock:
_cache[session_key] = (agent, _sig, _current_msg_count)
_cache[session_key] = (agent, _sig)
self._enforce_agent_cache_cap()
logger.debug("Created new agent for session %s (sig=%s)", session_key, _sig)
@@ -14784,11 +14414,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
except Exception as _e:
logger.error("Failed to send approval request: %s", _e)
# Keep real user text separate from API-only recovery guidance. If
# an auto-continue note is prepended below, persist the original
# message so stale guidance never replays as user-authored text.
_persist_user_message_override: Optional[Any] = None
# Prepend pending model switch note so the model knows about the switch
_pending_notes = getattr(self, '_pending_model_notes', {})
_msn = _pending_notes.pop(session_key, None) if session_key else None
@@ -14849,23 +14474,21 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if _reason == "shutdown_timeout"
else "a gateway interruption"
)
_persist_user_message_override = message
message = (
f"[System note: A new message has arrived. The previous turn "
f"was interrupted by {_reason_phrase}. "
f"Address the user's NEW message below FIRST. "
f"Do NOT re-execute old tool calls — skip any unfinished "
f"work from the conversation history and focus on what the "
f"user is asking now.]\n\n"
f"[System note: Your previous turn in this session was interrupted "
f"by {_reason_phrase}. The conversation history below is intact. "
f"If it contains unfinished tool result(s), process them first and "
f"summarize what was accomplished, then address the user's new "
f"message below.]\n\n"
+ message
)
elif _has_fresh_tool_tail:
_persist_user_message_override = message
message = (
"[System note: A new message has arrived. The conversation "
"history contains pending tool outputs from an interrupted turn. "
"IGNORE those pending results. Address the user's NEW message "
"below FIRST. Do NOT re-execute old tool calls from the history.]\n\n"
"[System note: Your previous turn was interrupted before you could "
"process the last tool result(s). The conversation history contains "
"tool outputs you haven't responded to yet. Please finish processing "
"those results and summarize what was accomplished, then address the "
"user's new message below.]\n\n"
+ message
)
@@ -14923,9 +14546,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"conversation_history": agent_history,
"task_id": session_id,
}
if _persist_user_message_override is not None:
_conversation_kwargs["persist_user_message"] = _persist_user_message_override
elif observed_group_context:
if observed_group_context:
_conversation_kwargs["persist_user_message"] = message
result = agent.run_conversation(_api_run_message, **_conversation_kwargs)
finally:
+22 -152
View File
@@ -143,13 +143,6 @@ class GatewayStreamConsumer:
# timestamps would be stale by completion time. Ported from
# openclaw/openclaw#72038.
self._message_created_ts: Optional[float] = None
# Every real preview message id the consumer has put on screen during
# this response (first send + any continuation messages from oversized
# edits/sends). The fresh-final path deletes all of them when it
# re-delivers the completed answer as a single (rich) message, so a
# reply that was split across the platform's edit limit while streaming
# doesn't leave stale fragments above the final message.
self._preview_message_ids: "set[str]" = set()
self._already_sent = False
self._edit_supported = True # Disabled when progressive edits are no longer usable
self._last_edit_time = 0.0
@@ -427,10 +420,7 @@ class GatewayStreamConsumer:
if isinstance(self.adapter, _BasePlatformAdapter)
else len
)
# Rich-capable adapters (Telegram rich messages) raise this above the
# legacy per-message limit so a reply that fits one rich send/draft
# isn't fragmented at 4096 while streaming. See _raw_message_limit.
_raw_limit = self._raw_message_limit()
_raw_limit = getattr(self.adapter, "MAX_MESSAGE_LENGTH", 4096)
_safe_limit = max(500, _raw_limit - _len_fn(self.cfg.cursor) - 100)
# Resolve native draft streaming once per run. When enabled the
@@ -599,20 +589,9 @@ class GatewayStreamConsumer:
if self._accumulated:
if self._fallback_final_send:
await self._send_fallback_final(self._accumulated)
elif self._final_response_sent:
# A finalize=True tick above already delivered the
# final answer via the adapter's fresh-final path
# (_try_fresh_final sent a fresh rich message and
# deleted the preview). Running a second finalize
# edit here would duplicate the message / re-delete,
# so just record delivery and stop.
self._final_content_delivered = True
elif (
current_update_visible
and (
not self._adapter_requires_finalize
or self._last_edit_overflowed
)
elif current_update_visible and (
not self._adapter_requires_finalize
or self._last_edit_overflowed
):
# Mid-stream edit above already delivered the
# final accumulated content. Skip the redundant
@@ -635,15 +614,6 @@ class GatewayStreamConsumer:
)
if self._final_response_sent:
self._final_content_delivered = True
elif self._fallback_final_send:
# The final edit attempt itself may be the one
# that exhausts flood-control strikes and
# promotes the consumer into fallback mode. Do
# not return to the gateway with a full-response
# fallback still pending; send only the unsent
# tail here so the normal gateway send path does
# not duplicate the visible prefix.
await self._send_fallback_final(self._accumulated)
elif not self._already_sent:
self._final_response_sent = await self._send_or_edit(self._accumulated)
if self._final_response_sent:
@@ -759,9 +729,6 @@ class GatewayStreamConsumer:
return reply_to_id
try:
meta = dict(self.metadata) if self.metadata else {}
# This chunk becomes the next edit target — adapters that support
# rich final sends (Telegram) must keep it on the editable path.
meta["expect_edits"] = True
result = await self.adapter.send(
chat_id=self.chat_id,
content=text,
@@ -770,7 +737,6 @@ class GatewayStreamConsumer:
)
if result.success and result.message_id:
self._message_id = str(result.message_id)
self._track_preview_ids_from_result(result)
self._already_sent = True
self._last_sent_text = text
# Fresh content bubble — close off any stale tool bubble
@@ -1148,76 +1114,6 @@ class GatewayStreamConsumer:
age = time.monotonic() - self._message_created_ts
return age >= threshold
def _raw_message_limit(self) -> int:
"""Per-message length budget (in the adapter's ``message_len_fn`` units)
before the consumer splits an overflowing reply.
Adapters with a richer send/draft path (e.g. Telegram rich messages)
can raise this above ``MAX_MESSAGE_LENGTH`` via
``streaming_overflow_limit`` so a reply that fits one rich message isn't
fragmented at the legacy edit limit. Falls back to
``MAX_MESSAGE_LENGTH`` (4096 default) for everyone else.
"""
base = getattr(self.adapter, "MAX_MESSAGE_LENGTH", 4096)
# isinstance gate: MagicMock adapters return mock objects (truthy, not
# ints) for arbitrary attribute access — keep them on the base limit.
if isinstance(self.adapter, _BasePlatformAdapter):
try:
cap = self.adapter.streaming_overflow_limit()
except Exception as e:
logger.debug("streaming_overflow_limit check failed: %s", e)
cap = None
if isinstance(cap, int) and cap > base:
return cap
return base
def _track_preview_id(self, message_id: Optional[str]) -> None:
"""Record a real preview message id for fresh-final cleanup."""
if message_id and message_id != "__no_edit__":
self._preview_message_ids.add(str(message_id))
def _track_preview_ids_from_result(self, result: Any) -> None:
"""Record every message id a send/edit result exposes: the primary id
plus any continuation ids from an oversized split
(``continuation_message_ids`` or ``raw_response['message_ids']``)."""
self._track_preview_id(getattr(result, "message_id", None))
for mid in (getattr(result, "continuation_message_ids", None) or ()):
self._track_preview_id(mid)
raw = getattr(result, "raw_response", None) or {}
if isinstance(raw, dict):
for mid in (raw.get("message_ids") or ()):
self._track_preview_id(mid)
def _adapter_prefers_fresh_final(self, text: str) -> bool:
"""Return True when the adapter would rather finalize a streamed reply
by sending a fresh message and deleting the preview than by editing the
preview in place e.g. Telegram, whose ``sendRichMessage`` send path
currently renders richer markdown than Hermes' MarkdownV2 edit path.
Returns False when there is no real preview to replace (no message id,
or the ``__no_edit__`` sentinel), when the adapter doesn't expose the
hook, or on any error (the consumer then keeps the edit-in-place path).
"""
if not self._message_id or self._message_id == "__no_edit__":
return False
fn = getattr(self.adapter, "prefers_fresh_final_streaming", None)
if fn is None:
return False
try:
try:
result = fn(text, metadata=self.metadata)
except TypeError:
# Adapter / test double whose hook doesn't accept the metadata
# keyword — fall back to the positional-only form.
result = fn(text)
except Exception as e:
logger.debug("prefers_fresh_final_streaming check failed: %s", e)
return False
# ``is True`` (not ``bool(...)``) so a MagicMock adapter's auto-child
# method — truthy by default in tests — does not wrongly enable the
# fresh-final path. Mirrors the REQUIRES_EDIT_FINALIZE gate in __init__.
return result is True
async def _try_fresh_final(self, text: str, *, is_turn_final: bool = True) -> bool:
"""Send ``text`` as a brand-new message (best-effort delete the old
preview) so the platform's visible timestamp reflects completion
@@ -1231,13 +1127,7 @@ class GatewayStreamConsumer:
Ported from openclaw/openclaw#72038.
"""
# Every preview message the user has seen for this response: the
# current one plus any continuation fragments tracked while streaming
# (an oversized reply split across the platform's edit limit). All of
# them are replaced by the single fresh message below.
stale_ids = set(self._preview_message_ids)
if self._message_id and self._message_id != "__no_edit__":
stale_ids.add(self._message_id)
old_message_id = self._message_id
try:
result = await self.adapter.send(
chat_id=self.chat_id,
@@ -1249,29 +1139,25 @@ class GatewayStreamConsumer:
return False
if not getattr(result, "success", False):
return False
# Successful fresh send — try to delete the stale preview so the
# user doesn't see the old edit-stuck message underneath. Cleanup
# is best-effort; platforms that don't implement ``delete_message``
# just leave the preview behind (still an acceptable outcome —
# the visible final timestamp is the important part).
if old_message_id and old_message_id != "__no_edit__":
delete_fn = getattr(self.adapter, "delete_message", None)
if delete_fn is not None:
try:
await delete_fn(self.chat_id, old_message_id)
except Exception as e:
logger.debug(
"Fresh-final preview cleanup failed (%s): %s",
old_message_id, e,
)
# Adopt the new message id as the current message so subsequent
# callers (e.g. overflow split loops, finalize retries) see a
# consistent state.
new_message_id = getattr(result, "message_id", None)
# Successful fresh send — try to delete the stale preview(s) so the
# user doesn't see the old edit-stuck message(s) underneath. Cleanup
# is best-effort; platforms that don't implement ``delete_message``
# just leave the preview behind (still an acceptable outcome — the
# visible final timestamp is the important part). Never delete the
# message we just sent.
delete_fn = getattr(self.adapter, "delete_message", None)
if delete_fn is not None:
for stale_id in stale_ids:
if not stale_id or stale_id == "__no_edit__" or stale_id == new_message_id:
continue
try:
await delete_fn(self.chat_id, stale_id)
except Exception as e:
logger.debug(
"Fresh-final preview cleanup failed (%s): %s",
stale_id, e,
)
self._preview_message_ids = set()
if new_message_id:
self._message_id = new_message_id
self._message_created_ts = time.monotonic()
@@ -1381,19 +1267,9 @@ class GatewayStreamConsumer:
# old preview follows. Ported from
# openclaw/openclaw#72038. Gated by config so the
# legacy edit-in-place path stays the default.
#
# Adapters can also opt in regardless of the time threshold
# via prefers_fresh_final_streaming (e.g. Telegram, whose
# send path renders richer markdown than its edit path):
# finalizing through edit would visibly downgrade a rich
# preview, so re-deliver as a fresh message + delete the
# preview instead.
if (
finalize
and (
self._should_send_fresh_final()
or self._adapter_prefers_fresh_final(text)
)
and self._should_send_fresh_final()
and await self._try_fresh_final(
text, is_turn_final=is_turn_final,
)
@@ -1407,9 +1283,6 @@ class GatewayStreamConsumer:
)
if result.success:
self._already_sent = True
# Record any continuation fragments an oversized edit
# split off, so fresh-final can clean them all up.
self._track_preview_ids_from_result(result)
# Adapter may have split-and-delivered an oversized
# edit across the original message + N continuations.
# When that happens, ``message_id`` is the LAST visible
@@ -1532,7 +1405,7 @@ class GatewayStreamConsumer:
chat_id=self.chat_id,
content=text,
reply_to=self._initial_reply_to_id,
metadata={**(self.metadata or {}), "expect_edits": True},
metadata=self.metadata,
)
if result.success:
if result.message_id:
@@ -1541,9 +1414,6 @@ class GatewayStreamConsumer:
# the user so fresh-final logic can detect stale
# preview timestamps on long-running responses.
self._message_created_ts = time.monotonic()
# Record this (and any continuation fragments from an
# oversized first send) for fresh-final cleanup.
self._track_preview_ids_from_result(result)
else:
self._edit_supported = False
self._already_sent = True
+1 -6
View File
@@ -110,8 +110,6 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("subgoal", "Add or manage extra criteria on the active goal", "Session",
args_hint="[text | remove N | clear]"),
CommandDef("status", "Show session info", "Session"),
CommandDef("egress", "Show Docker egress proxy status", "Session",
args_hint="[status]", subcommands=("status",)),
CommandDef("whoami", "Show your slash command access (admin / user)", "Info"),
CommandDef("profile", "Show active profile name and home directory", "Info"),
CommandDef("sethome", "Set this chat as the home channel", "Session",
@@ -533,7 +531,6 @@ _TELEGRAM_MENU_PRIORITY = (
"new",
"stop",
"status",
"egress",
"resume",
"sessions",
"model",
@@ -1056,9 +1053,7 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg")
# the telegram-parity test reads it so an entry here is a deliberate
# "Slack-via-/hermes" decision, not a silent clamp.
# - credits: the billing/top-up surface; reached via /hermes credits on Slack.
# - egress: Docker-only proxy status; reachable as /hermes egress on Slack
# while preserving /debug as a native Slack diagnostic command under the cap.
_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "egress"})
_SLACK_VIA_HERMES_ONLY = frozenset({"credits"})
def _sanitize_slack_name(raw: str) -> str:
+3 -105
View File
@@ -941,13 +941,6 @@ DEFAULT_CONFIG = {
# (terminal and execute_code). Skill-declared required_environment_variables
# are passed through automatically; this list is for non-skill use cases.
"env_passthrough": [],
# HOME handling for host tool subprocesses:
# auto — host keeps the real OS-user HOME; containers use
# HERMES_HOME/home for persistent state (default)
# real — force the real OS-user HOME
# profile — force HERMES_HOME/home when it exists (old strict
# per-profile CLI config isolation)
"home_mode": "auto",
# Extra files to source in the login shell when building the
# per-session environment snapshot. Use this when tools like nvm,
# pyenv, asdf, or custom PATH entries are registered by files that
@@ -1316,6 +1309,7 @@ DEFAULT_CONFIG = {
"api_key": "",
"timeout": 30,
"extra_body": {},
"language": "",
},
"tts_audio_tags": {
"provider": "auto",
@@ -1989,9 +1983,6 @@ DEFAULT_CONFIG = {
"reactions": False, # Add 👀/✅/❌ reactions to messages during processing
"channel_prompts": {}, # Per-chat/topic ephemeral system prompts (topics inherit from parent group)
"allowed_chats": "", # If set, bot ONLY responds in these group/supergroup chat IDs (whitelist)
"extra": {
"rich_messages": False, # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2
},
},
# Mattermost platform settings (gateway mode)
@@ -2327,7 +2318,7 @@ DEFAULT_CONFIG = {
# delivered as a fresh message if the preview has been visible at
# least this many seconds, so the platform timestamp reflects
# completion time. Telegram only; other platforms ignore it.
"fresh_final_after_seconds": 0.0,
"fresh_final_after_seconds": 60.0,
},
# Session storage — controls automatic cleanup of ~/.hermes/state.db.
@@ -2538,67 +2529,6 @@ DEFAULT_CONFIG = {
"paste_collapse_threshold_fallback": 5,
"paste_collapse_char_threshold": 2000,
# =========================================================================
# Egress credential-injection proxy (iron-proxy)
# =========================================================================
# When enabled, outbound traffic from remote terminal sandboxes (Docker
# today; Modal/SSH in follow-ups) is routed through a managed iron-proxy
# subprocess. The sandbox sees opaque proxy tokens; iron-proxy swaps in
# real API credentials at the egress boundary. Compromising the sandbox
# leaks tokens that only work from behind the proxy.
#
# Configure with `hermes egress setup`. Disabled by default — the rest of
# Hermes works exactly as before with `enabled: false`.
"proxy": {
# Master switch. When false, iron-proxy is never started, no docker
# mounts are added, no binaries are auto-installed — feature is a
# complete no-op.
"enabled": False,
# Tunnel listener port. Sandboxes get `HTTPS_PROXY=http://<host>:<port>`.
# 9090 is the default; collide-aware setup wizard can reassign.
"tunnel_port": 9090,
# Auto-download the pinned iron-proxy binary into ~/.hermes/bin/ on
# first use. When false, you must place `iron-proxy` on PATH yourself.
"auto_install": True,
# Where iron-proxy looks up the real upstream secrets at egress time.
# "env" — process env (default; what bitwarden integration
# already populates if you use it)
# "bitwarden" — refetch via `bws secret list` on each proxy restart;
# rotation in the Bitwarden web app propagates without
# touching .env (requires `secrets.bitwarden.enabled`).
"credential_source": "env",
# When true, the Docker backend refuses to start a sandbox if the
# proxy is enabled but not running. False = fall back to direct
# outbound with real credentials in the sandbox (the legacy posture).
"enforce_on_docker": True,
# When true, `hermes egress start` refuses to start if any provider
# env var is set that the proxy cannot strip (Anthropic native
# `x-api-key`, Azure OpenAI api-key, Gemini x-goog-api-key).
# These LLM-specific credentials would otherwise leak into the
# sandbox bypassing the proxy. Generic cloud creds (AWS_*,
# GOOGLE_APPLICATION_CREDENTIALS) are warned about but never
# block. Defaults to false because false positives (operator has
# the env set but doesn't actually use that provider) are common.
"fail_on_uncovered_providers": False,
# When credential_source is bitwarden but the BWS access token /
# project_id is missing OR the bws fetch returns no values for
# mapped providers, the daemon raises by default. Set this to
# True to opt back in to the legacy "silently fall back to host
# env" behaviour — useful for migrations where the operator wants
# to switch credential_source to bitwarden but hasn't fully wired
# BWS yet. Defaults to false (strict).
"allow_env_fallback": False,
# SSRF deny list applied to outbound traffic. Omit / leave empty
# to use the safe default: loopback, link-local (incl. cloud
# metadata IPs at 169.254.169.254), and RFC1918. Set to an
# explicit ``[]`` to opt out entirely (only sensible in hermetic
# tests that need to reach a loopback upstream).
"upstream_deny_cidrs": None,
# Extra allowed upstream hosts beyond the bundled defaults (which
# cover OpenRouter, OpenAI, Anthropic, Google, xAI, Mistral, Groq,
# Together, DeepSeek, Nous). Wildcards (`*.foo.com`) are supported.
"extra_allowed_hosts": [],
},
# Config schema version - bump this when adding new required fields
"_config_version": 29,
@@ -4182,7 +4112,7 @@ _KNOWN_ROOT_KEYS = {
"fallback_providers", "credential_pool_strategies", "toolsets",
"agent", "terminal", "display", "compression", "delegation",
"auxiliary", "custom_providers", "context", "memory", "gateway",
"sessions", "streaming", "updates", "mcp_servers",
"sessions", "streaming", "updates",
}
# Valid fields inside a custom_providers list entry
@@ -4890,38 +4820,6 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
if not quiet:
print(" ✓ Renamed write_mode → write_approval (boolean gate)")
# ── Post-migration: disable exfiltration-shaped MCP stdio entries ──
# Users can hand-edit mcp_servers, and older installs may already contain a
# malicious entry. Preserve the stanza for auditability but mark it
# disabled so the next startup will not spawn it. (#45620)
config = read_raw_config()
raw_mcp_servers = config.get("mcp_servers")
if isinstance(raw_mcp_servers, dict):
try:
from hermes_cli.mcp_security import validate_mcp_server_entry as _validate_mcp_server_entry
except Exception:
_validate_mcp_server_entry = None
if _validate_mcp_server_entry:
mcp_touched = False
for server_name, entry in raw_mcp_servers.items():
if not isinstance(entry, dict):
continue
issues = _validate_mcp_server_entry(server_name, entry)
if not issues:
continue
entry["enabled"] = False
mcp_touched = True
results["warnings"].append(
f"Disabled suspicious MCP server '{server_name}'"
)
if not quiet:
for issue in issues:
print(f"{issue}")
print(f" ⚠ Disabled MCP server '{server_name}' pending review")
if mcp_touched:
config["mcp_servers"] = raw_mcp_servers
save_config(config)
if current_ver < latest_ver and not quiet:
print(f"Config version: {current_ver}{latest_ver}")
+1 -45
View File
@@ -306,23 +306,6 @@ def _check_s6_supervision(issues: list[str]) -> None:
)
def check_certificates() -> None:
"""Verify the certifi CA bundle is loadable.
Surfaces the SSLConfigurationError user-friendly path before they hit
a wall of tracebacks on the first outbound HTTPS call.
"""
try:
from agent.ssl_guard import verify_ca_bundle_with_fallback
from agent.errors import SSLConfigurationError
verify_ca_bundle_with_fallback()
check_ok("SSL CA certificate bundle is valid")
except SSLConfigurationError as e:
check_fail("SSL CA certificate bundle is broken", str(e))
except Exception as e:
check_warn("SSL certificate check skipped", str(e))
def _check_gateway_service_linger(issues: list[str]) -> None:
"""Warn when a systemd user gateway service will stop after logout.
@@ -556,30 +539,6 @@ def run_doctor(args):
except Exception as e:
# Never let a bug in the advisory check block the rest of doctor.
check_warn(f"Security advisory check failed: {e}")
_section("MCP Server Security")
try:
from hermes_cli.config import load_config
from hermes_cli.mcp_security import validate_mcp_server_entry
servers = load_config().get("mcp_servers") or {}
suspicious = 0
if isinstance(servers, dict):
for name, entry in sorted(servers.items()):
if not isinstance(entry, dict):
continue
issues_found = validate_mcp_server_entry(name, entry)
if not issues_found:
continue
suspicious += 1
check_warn(f"MCP server '{name}' has suspicious stdio command", "; ".join(issues_found))
manual_issues.append(
f"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed."
)
if suspicious == 0:
check_ok("No suspicious MCP stdio commands")
except Exception as e:
check_warn(f"MCP security check failed: {e}")
_section("Python Environment")
py_version = sys.version_info
@@ -608,10 +567,7 @@ def run_doctor(args):
# Detect drift between pyproject.toml and hermes_cli/__init__.py versions
# (a git conflict resolution can silently revert one but not the other).
_check_version_consistency(issues)
_section("SSL / CA Certificates")
check_certificates()
_section("Required Packages")
required_packages = [
("openai", "OpenAI SDK"),
+6 -139
View File
@@ -1095,30 +1095,11 @@ def get_gateway_runtime_snapshot(system: bool = False) -> GatewayRuntimeSnapshot
# Other container runtimes (or containers built before Phase 2)
# still get the original "docker (foreground)" label.
try:
from hermes_cli.service_manager import detect_service_manager, get_service_manager
from hermes_cli.service_manager import detect_service_manager
if detect_service_manager() == "s6":
profile = _profile_suffix() or "default"
service_name = f"gateway-{profile}"
mgr = get_service_manager()
service_installed = False
service_running = False
try:
service_dir = getattr(mgr, "scandir", None)
if service_dir is not None:
service_installed = (service_dir / service_name).is_dir()
except Exception:
service_installed = False
if service_installed:
try:
service_running = bool(mgr.is_running(service_name))
except Exception:
service_running = False
return GatewayRuntimeSnapshot(
manager="s6 (container supervisor)",
service_installed=service_installed,
service_running=service_running,
gateway_pids=gateway_pids,
service_scope="s6",
)
except Exception:
pass # Fall through to the legacy label on any detection error.
@@ -1449,7 +1430,7 @@ def _profile_suffix() -> str:
return hashlib.sha256(str(home).encode()).hexdigest()[:8]
def _profile_arg(hermes_home: str | None = None, default_root: str | Path | None = None) -> str:
def _profile_arg(hermes_home: str | None = None) -> str:
"""Return ``--profile <name>`` only when HERMES_HOME is a named profile.
For ``~/.hermes/profiles/<name>``, returns ``"--profile <name>"``.
@@ -1459,16 +1440,12 @@ def _profile_arg(hermes_home: str | None = None, default_root: str | Path | None
hermes_home: Optional explicit HERMES_HOME path. Defaults to the current
``get_hermes_home()`` value. Should be passed when generating a
service definition for a different user (e.g. system service).
default_root: Optional Hermes root to compare against. Used when
generating a system service for another user from a sudo/root
process, where ``Path.home()`` and ``get_default_hermes_root()``
refer to root but the target profile lives under the service user.
"""
import re
from hermes_constants import get_default_hermes_root
home = Path(hermes_home or str(get_hermes_home())).resolve()
default = Path(default_root).resolve() if default_root else get_default_hermes_root().resolve()
default = get_default_hermes_root().resolve()
if home == default:
return ""
profiles_root = (default / "profiles").resolve()
@@ -1482,16 +1459,6 @@ def _profile_arg(hermes_home: str | None = None, default_root: str | Path | None
return ""
def _profile_arg_for_target_user(hermes_home: str, target_home_dir: str) -> str:
"""Return the profile arg for a system service running as another user."""
target_root = Path(target_home_dir) / ".hermes"
try:
Path(hermes_home).resolve().relative_to(target_root.resolve())
return _profile_arg(hermes_home, default_root=target_root)
except ValueError:
return _profile_arg(hermes_home)
def get_service_name() -> str:
"""Derive a systemd service name scoped to this HERMES_HOME.
@@ -2417,7 +2384,7 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
if system:
username, group_name, home_dir = _system_service_identity(run_as_user)
hermes_home = _hermes_home_for_target_user(home_dir)
profile_arg = _profile_arg_for_target_user(hermes_home, home_dir)
profile_arg = _profile_arg(hermes_home)
# Remap all paths that may resolve under the calling user's home
# (e.g. /root/) to the target user's home so the service can
# actually access them.
@@ -3795,101 +3762,6 @@ def _is_official_docker_checkout() -> bool:
)
def _running_under_gateway_supervisor() -> bool:
"""Return True when this process IS the gateway a service manager launched.
The conflict guard below must never fire on the service's own startup, or
it would wedge the unit into a respawn/refuse loop. Each supervisor exports
a reliable marker into the child's environment:
- systemd sets ``INVOCATION_ID`` for every unit it launches (the same
marker ``gateway/run.py`` already uses to pick the restart path).
- launchd sets ``XPC_SERVICE_NAME`` to the job label for jobs it spawns;
interactive shells inherit the sentinel ``"0"`` instead.
- the s6-overlay container longrun exports ``HERMES_S6_SUPERVISED_CHILD``.
"""
if os.environ.get("INVOCATION_ID"):
return True
if os.environ.get("HERMES_S6_SUPERVISED_CHILD"):
return True
xpc_service = os.environ.get("XPC_SERVICE_NAME", "")
if xpc_service and xpc_service != "0":
return True
return False
def _guard_supervised_gateway_conflict(force: bool = False) -> None:
"""Refuse a foreground gateway when a service manager already supervises one.
Running ``hermes gateway run [--replace]`` (or the manual-restart fallback)
from a shell on a systemd/launchd host spawns a second, long-lived
dispatcher that escapes the service cgroup, survives
``systemctl restart``, and becomes a silent concurrent writer on the shared
kanban DB the documented root cause of multi-writer SQLite WAL corruption
(issue #35240). Pass ``--force`` to start anyway.
"""
if force or _running_under_gateway_supervisor():
return
try:
snapshot = get_gateway_runtime_snapshot()
except Exception:
# Best-effort guard: a probe failure must never block a real startup.
logger.debug("Supervised-gateway conflict probe failed", exc_info=True)
return
if not (snapshot.service_installed and snapshot.service_running):
return
print_error(
f"A gateway is already running under {snapshot.manager} for this profile."
)
print(
" Starting another one from a shell leaves an orphan dispatcher that\n"
" escapes the service, survives restarts, and writes to the same kanban\n"
" DB concurrently — which can corrupt it. Restart the supervised gateway\n"
" instead:"
)
print()
print(" hermes gateway restart")
print()
print(
" Pass --force to start a foreground gateway anyway (not recommended\n"
" while the service is running)."
)
sys.exit(1)
def _guard_existing_gateway_process_conflict(replace: bool = False) -> None:
"""Refuse duplicate foreground startup before importing gateway.run.
``gateway.run`` performs the authoritative PID/lock check, but importing it
is expensive: it pulls in model_tools/plugin discovery first. On small
instances, a supervisor or dashboard loop repeatedly running bare
``hermes gateway run`` can burn memory/CPU just to fail with "already
running" after plugin discovery. This cheap PID-file preflight preserves the
same user-facing contract while avoiding that startup work without scanning
unrelated gateway processes from other HERMES_HOME roots.
"""
if replace or _running_under_gateway_supervisor():
return
try:
from gateway.status import get_running_pid
pid = get_running_pid()
except Exception:
logger.debug("Existing-gateway process probe failed", exc_info=True)
return
if pid is None:
return
print_error(
f"Another gateway instance is already running (PID {pid})."
)
print(" Use 'hermes gateway restart' to replace it,")
print(" or 'hermes gateway stop' first.")
print(" Or use 'hermes gateway run --replace' to auto-replace.")
sys.exit(1)
def _guard_official_docker_root_gateway() -> None:
"""Refuse gateway startup when the official Docker privilege drop was bypassed."""
if not hasattr(os, "geteuid") or os.geteuid() != 0:
@@ -3917,7 +3789,7 @@ def _guard_official_docker_root_gateway() -> None:
sys.exit(1)
def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False, force: bool = False):
def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
"""Run the gateway in foreground.
Args:
@@ -3926,12 +3798,8 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False, fo
replace: If True, kill any existing gateway instance before starting.
This prevents systemd restart loops when the old process
hasn't fully exited yet.
force: Skip the supervised-gateway conflict guard and start even when a
systemd/launchd service is already supervising this profile.
"""
_guard_official_docker_root_gateway()
_guard_supervised_gateway_conflict(force=force)
_guard_existing_gateway_process_conflict(replace=replace)
sys.path.insert(0, str(PROJECT_ROOT))
# Detached Windows gateway runs must ignore console-control broadcasts
@@ -6477,8 +6345,7 @@ def _gateway_command_inner(args):
verbose = getattr(args, "verbose", 0)
quiet = getattr(args, "quiet", False)
replace = getattr(args, "replace", False)
force = getattr(args, "force", False)
run_gateway(verbose, quiet=quiet, replace=replace, force=force)
run_gateway(verbose, quiet=quiet, replace=replace)
return
if subcmd == "setup":
+13 -278
View File
@@ -354,37 +354,6 @@ def _apply_profile_override() -> None:
return False
return True
def _resolve_sudo_user_profile_env(name: str) -> str | None:
"""Resolve `sudo hermes -p <name>` against the invoking user's home.
`_apply_profile_override()` runs before argparse, so `--run-as-user`
is not available yet. For sudo invocations, the best available signal
is SUDO_USER: root is only doing the privileged install/start action,
while the profile store normally belongs to the user who invoked sudo.
"""
if name == "default":
return None
if not hasattr(os, "geteuid") or os.geteuid() != 0:
return None
sudo_user = os.environ.get("SUDO_USER", "").strip()
if not sudo_user or sudo_user == "root":
return None
try:
import pwd
home = Path(pwd.getpwnam(sudo_user).pw_dir)
except Exception:
return None
candidate = home / ".hermes" / "profiles" / name
try:
if candidate.is_dir():
return str(candidate)
except OSError:
return None
return None
# 1. Check for explicit -p / --profile flag. Historically this worked even
# after the subcommand (`hermes chat -p coder`), so keep scanning broadly.
# The exception is command-argv passthrough regions such as `mcp add --args`.
@@ -472,12 +441,7 @@ def _apply_profile_override() -> None:
from hermes_cli.profiles import resolve_profile_env
hermes_home = resolve_profile_env(profile_name)
except FileNotFoundError as exc:
hermes_home = _resolve_sudo_user_profile_env(profile_name)
if not hermes_home:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
except ValueError as exc:
except (ValueError, FileNotFoundError) as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
except Exception as exc:
@@ -8066,182 +8030,6 @@ def _run_pre_update_backup(args) -> None:
print()
def _write_update_planned_stop_marker(profile_path: Path, pid: int) -> bool:
"""Write a planned-stop marker into a specific profile home."""
try:
from datetime import timezone
from gateway.status import _get_process_start_time
from utils import atomic_json_write
record = {
"target_pid": pid,
"target_start_time": _get_process_start_time(pid),
"stopper_pid": os.getpid(),
"written_at": datetime.now(timezone.utc).isoformat(),
}
atomic_json_write(
Path(profile_path) / ".gateway-planned-stop.json",
record,
indent=None,
separators=(",", ":"),
)
return True
except (OSError, PermissionError):
return False
def _wait_for_windows_update_gateway_exit(
pids: list[int], *, timeout: float
) -> set[int]:
"""Wait for the given gateway PIDs to exit, returning survivors."""
if not pids:
return set()
from gateway.status import _pid_exists
remaining = set(pids)
deadline = _time.monotonic() + max(timeout, 0.0)
while remaining and _time.monotonic() < deadline:
for pid in list(remaining):
try:
if not _pid_exists(pid):
remaining.discard(pid)
except Exception:
remaining.discard(pid)
if remaining:
_time.sleep(0.25)
survivors: set[int] = set()
for pid in remaining:
try:
if _pid_exists(pid):
survivors.add(pid)
except Exception:
pass
return survivors
def _pause_windows_gateways_for_update() -> dict | None:
"""Stop running Windows gateways before mutating the checkout or venv.
Windows scheduled/startup gateways run through pythonw.exe, so the generic
hermes.exe concurrent-instance guard does not see them. They still import
from the checkout and can keep files locked while ``git`` or ``uv`` updates
the install. Stop only PIDs that the gateway discovery code identifies.
"""
if not _is_windows():
return None
try:
from gateway.status import terminate_pid
from hermes_cli.gateway import (
_get_restart_drain_timeout,
find_gateway_pids,
find_profile_gateway_processes,
)
except Exception as exc:
logger.debug("Could not prepare Windows gateway pause for update: %s", exc)
return None
try:
running_pids = list(dict.fromkeys(find_gateway_pids(all_profiles=True)))
except Exception as exc:
logger.debug("Could not discover Windows gateway PIDs before update: %s", exc)
return None
if not running_pids:
return None
profile_processes = {}
try:
profile_processes = {
proc.pid: proc for proc in find_profile_gateway_processes()
}
except Exception as exc:
logger.debug("Could not map Windows gateway PIDs to profiles: %s", exc)
profiles: dict[str, int] = {}
mapped_pids = []
for pid in running_pids:
proc = profile_processes.get(pid)
if proc is None:
continue
profiles[str(proc.profile)] = int(pid)
mapped_pids.append(int(pid))
_write_update_planned_stop_marker(Path(proc.path), int(pid))
print("→ Stopping Windows gateway process(es) before updating Hermes...")
try:
drain_timeout = max(float(_get_restart_drain_timeout()), 1.0)
except Exception:
drain_timeout = 10.0
survivors = _wait_for_windows_update_gateway_exit(
mapped_pids,
timeout=drain_timeout,
)
unmapped_pids = [pid for pid in running_pids if pid not in profile_processes]
force_killed = []
for pid in sorted(set(survivors).union(unmapped_pids)):
try:
terminate_pid(int(pid), force=True)
force_killed.append(int(pid))
except (ProcessLookupError, PermissionError, OSError):
pass
if profiles:
print(f" ✓ Paused gateway profile(s): {', '.join(sorted(profiles))}")
if force_killed:
print(f" → Force-stopped {len(force_killed)} gateway process(es)")
if unmapped_pids:
print(
f" → Stopped {len(unmapped_pids)} gateway process(es) without profile mapping"
)
print(" Restart manually after update: hermes gateway run")
return {
"resume_needed": True,
"profiles": profiles,
"unmapped_pids": unmapped_pids,
}
def _resume_windows_gateways_after_update(token: dict | None) -> None:
"""Restart Windows profile gateways previously paused for update."""
if not token or not token.get("resume_needed"):
return
token["resume_needed"] = False
if not _is_windows():
return
profiles = token.get("profiles") or {}
if not profiles:
return
try:
from hermes_cli.gateway import launch_detached_profile_gateway_restart
except Exception as exc:
logger.debug("Could not load Windows gateway restart helper: %s", exc)
return
relaunched = []
for profile, old_pid in sorted(profiles.items()):
try:
if launch_detached_profile_gateway_restart(str(profile), int(old_pid)):
relaunched.append(str(profile))
except Exception as exc:
logger.debug(
"Could not restart Windows gateway profile %s after update: %s",
profile,
exc,
)
if relaunched:
print()
print(f" ✓ Restarting Windows gateway profile(s): {', '.join(relaunched)}")
def _discard_lockfile_churn(git_cmd, repo_root):
"""Restore tracked ``package-lock.json`` files that npm dirtied locally.
@@ -8444,15 +8232,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
# always roll back to the exact state they had before this update.
_run_pre_update_backup(args)
_windows_gateway_resume = _pause_windows_gateways_for_update()
if _windows_gateway_resume:
import atexit as _atexit
_atexit.register(
_resume_windows_gateways_after_update,
_windows_gateway_resume,
)
# Try git-based update first, fall back to ZIP download on Windows
# when git file I/O is broken (antivirus, NTFS filter drivers, etc.)
use_zip_update = False
@@ -8515,10 +8294,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
if use_zip_update:
# ZIP-based update for Windows when git is broken
try:
_update_via_zip(args)
finally:
_resume_windows_gateways_after_update(_windows_gateway_resume)
_update_via_zip(args)
return
# Fetch and pull
@@ -8655,7 +8431,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
check=False,
)
print("✓ Already up to date!")
_resume_windows_gateways_after_update(_windows_gateway_resume)
return
print(f"→ Found {commit_count} new commit(s)")
@@ -9852,8 +9627,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
except Exception as e:
logger.debug("Gateway restart during update failed: %s", e)
_resume_windows_gateways_after_update(_windows_gateway_resume)
# Warn if legacy Hermes gateway unit files are still installed.
# When both hermes.service (from a pre-rename install) and the
# current hermes-gateway.service are enabled, they SIGTERM-fight
@@ -10087,20 +9860,19 @@ def cmd_profile(args):
try:
clone_from = getattr(args, "clone_from", None)
clone_config = clone or clone_from is not None
profile_dir = create_profile(
name=name,
clone_from=clone_from,
clone_all=clone_all,
clone_config=clone_config,
clone_config=clone,
no_alias=no_alias,
no_skills=no_skills,
description=getattr(args, "description", None),
)
print(f"\nProfile '{name}' created at {profile_dir}")
if clone_config or clone_all:
if clone or clone_all:
source_label = (
getattr(args, "clone_from", None) or get_active_profile_name()
)
@@ -10114,8 +9886,8 @@ def cmd_profile(args):
f"Cloned config, .env, SOUL.md, and skills from {source_label}."
)
# Auto-clone Honcho config for the new profile (only with clone operations)
if clone_config or clone_all:
# Auto-clone Honcho config for the new profile (only with --clone/--clone-all)
if clone or clone_all:
try:
from plugins.memory.honcho.cli import clone_honcho_for_profile
@@ -10124,10 +9896,10 @@ def cmd_profile(args):
except Exception:
pass # Honcho plugin not installed or not configured
# Seed bundled skills for fresh profiles only. Clone operations
# already copied the source profile's skills, including any
# user-installed or intentionally removed skills.
if not (clone_config or clone_all):
# Seed bundled skills (skip if --clone-all already copied them, or
# if --no-skills was passed — in which case seed_profile_skills()
# honors the marker file and returns skipped_opt_out=True).
if not clone_all:
result = seed_profile_skills(profile_dir)
if result and result.get("skipped_opt_out"):
print(
@@ -10923,7 +10695,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
"acp", "auth", "backup", "bundles", "checkpoints", "claw", "completion",
"computer-use",
"config", "cron", "curator", "dashboard", "debug", "doctor",
"dump", "egress", "fallback", "gateway", "hooks", "import", "insights",
"dump", "fallback", "gateway", "hooks", "import", "insights",
"gui", "desktop", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate",
"model", "pairing", "plugins", "portal", "postinstall", "profile", "proxy",
"prompt-size",
@@ -11521,37 +11293,6 @@ def main():
secrets_parser.set_defaults(func=_dispatch_secrets)
# =========================================================================
# egress command — iron-proxy outbound credential-injection firewall
# =========================================================================
# NOTE: this is the OUTBOUND egress firewall (ironsh/iron-proxy).
# `hermes proxy` (defined elsewhere in this file) is a separate INBOUND
# OAuth-aggregator reverse proxy. Different direction, different purpose.
egress_parser = subparsers.add_parser(
"egress",
help="Manage the iron-proxy egress credential-injection firewall",
description=(
"Manage iron-proxy, the optional TLS-intercepting egress firewall "
"that swaps proxy tokens for real API credentials before outbound "
"requests leave a sandbox. Disabled by default. See: "
"https://hermes-agent.nousresearch.com/docs/user-guide/egress/iron-proxy"
),
)
from hermes_cli import proxy_cli as _proxy_cli
_proxy_cli.register_cli(egress_parser)
def _dispatch_egress(args): # noqa: ANN001
# The egress subparser uses dest='egress_command' to stay disjoint
# from the inbound OAuth ``hermes proxy`` subparser (dest='proxy_command').
sub = getattr(args, "egress_command", None)
if sub is not None and hasattr(args, "func") and args.func is not _dispatch_egress:
return args.func(args)
egress_parser.print_help()
return 0
egress_parser.set_defaults(func=_dispatch_egress)
# =========================================================================
# migrate command
# =========================================================================
@@ -12475,15 +12216,9 @@ def main():
cmd_chat(args)
return
# Execute the command. Propagate the handler's return code as the
# process exit code so subcommands that signal failure (e.g.
# ``hermes egress start`` refusing because of fail_on_uncovered_
# providers) actually exit non-zero. Handlers that return None
# are treated as success (exit 0).
# Execute the command
if hasattr(args, "func"):
rc = args.func(args)
if isinstance(rc, int) and rc != 0:
sys.exit(rc)
args.func(args)
else:
parser.print_help()
+3 -6
View File
@@ -730,12 +730,9 @@ def install_entry(entry: CatalogEntry, *, enable: bool = True) -> None:
server_cfg = _build_server_config(entry, install_dir)
server_cfg["enabled"] = enable
from hermes_cli.mcp_config import _save_mcp_server
if not _save_mcp_server(entry.name, server_cfg):
raise CatalogError(
f"catalog entry '{entry.name}' rejected: suspicious command/args configuration"
)
cfg = load_config()
cfg.setdefault("mcp_servers", {})[entry.name] = server_cfg
save_config(cfg)
# ── Probe + tool selection ──────────────────────────────────────────
_apply_tool_selection(entry, prior_selection=prior_selection)
+14 -36
View File
@@ -25,7 +25,6 @@ from hermes_cli.config import (
)
from hermes_cli.colors import Colors, color
from hermes_constants import display_hermes_home
from hermes_cli.mcp_security import validate_mcp_server_entry
from tools.mcp_tool import _ENV_VAR_PATTERN
logger = logging.getLogger(__name__)
@@ -85,23 +84,11 @@ def _get_mcp_servers(config: Optional[dict] = None) -> Dict[str, dict]:
return servers
def _save_mcp_server(name: str, server_config: dict) -> bool:
"""Add or update a server entry in config.yaml.
Returns False when a high-signal exfiltration-shaped stdio command is
rejected. MCP stdio servers are user-chosen local commands, so this blocks
shell+egress payloads rather than whitelisting command families.
"""
issues = validate_mcp_server_entry(name, server_config)
if issues:
for issue in issues:
_warning(issue)
_warning(f"Server '{name}' was NOT saved due to suspicious configuration.")
return False
def _save_mcp_server(name: str, server_config: dict):
"""Add or update a server entry in config.yaml."""
config = load_config()
config.setdefault("mcp_servers", {})[name] = server_config
save_config(config)
return True
def _remove_mcp_server(name: str) -> bool:
@@ -221,15 +208,11 @@ def _probe_single_server(
Returns list of ``(tool_name, description)`` tuples.
Raises on connection failure.
"""
issues = validate_mcp_server_entry(name, config)
if issues:
raise ValueError("; ".join(issues))
from tools.mcp_tool import (
_ensure_mcp_loop,
_run_on_mcp_loop,
_connect_server,
_stop_mcp_loop_if_idle,
_stop_mcp_loop,
)
config = _resolve_mcp_server_config(config)
@@ -257,7 +240,7 @@ def _probe_single_server(
except BaseException as exc:
raise _unwrap_exception_group(exc) from None
finally:
_stop_mcp_loop_if_idle()
_stop_mcp_loop()
return tools_found
@@ -356,12 +339,6 @@ def cmd_mcp_add(args):
if explicit_env:
server_config["env"] = explicit_env
issues = validate_mcp_server_entry(name, server_config)
if issues:
for issue in issues:
_warning(issue)
_warning(f"Server '{name}' was NOT saved due to suspicious configuration.")
return
# ── Authentication ────────────────────────────────────────────────
@@ -426,16 +403,16 @@ def cmd_mcp_add(args):
_error(f"Failed to connect: {exc}")
if _confirm("Save config anyway (you can test later)?", default=False):
server_config["enabled"] = False
if _save_mcp_server(name, server_config):
_success(f"Saved '{name}' to config (disabled)")
_info("Fix the issue, then: hermes mcp test " + name)
_save_mcp_server(name, server_config)
_success(f"Saved '{name}' to config (disabled)")
_info("Fix the issue, then: hermes mcp test " + name)
return
if not tools:
_warning("Server connected but reported no tools.")
if _confirm("Save config anyway?", default=True):
if _save_mcp_server(name, server_config):
_success(f"Saved '{name}' to config")
_save_mcp_server(name, server_config)
_success(f"Saved '{name}' to config")
return
# ── Tool selection ────────────────────────────────────────────────
@@ -492,10 +469,11 @@ def cmd_mcp_add(args):
# ── Save ──────────────────────────────────────────────────────────
server_config["enabled"] = True
if _save_mcp_server(name, server_config):
print()
_success(f"Saved '{name}' to {display_hermes_home()}/config.yaml ({tool_count}/{total} tools enabled)")
_info("Start a new session to use these tools.")
_save_mcp_server(name, server_config)
print()
_success(f"Saved '{name}' to {display_hermes_home()}/config.yaml ({tool_count}/{total} tools enabled)")
_info("Start a new session to use these tools.")
# ─── hermes mcp remove ───────────────────────────────────────────────────────
-96
View File
@@ -1,96 +0,0 @@
"""Security checks for user-configured MCP server entries.
MCP stdio transports intentionally support arbitrary local commands so users can
run custom servers. This module does not try to sandbox that capability. It only
blocks the high-signal exfiltration shape from #45620: a shell interpreter whose
inline script invokes network egress tooling.
"""
from __future__ import annotations
import os
import re
import shlex
from typing import Any
_SHELL_INTERPRETERS = frozenset({
"bash",
"sh",
"zsh",
"dash",
"fish",
"cmd",
"cmd.exe",
"powershell",
"powershell.exe",
"pwsh",
"pwsh.exe",
})
_EGRESS_PATTERN = re.compile(
r"(?<![\w.-])(?:curl|wget|nc|ncat|socat)(?![\w.-])"
r"|/dev/tcp/"
r"|\bInvoke-WebRequest\b"
r"|\bInvoke-RestMethod\b"
r"|\bSystem\.Net\.WebClient\b",
re.IGNORECASE,
)
_EXFIL_HINT_PATTERN = re.compile(
r"\.env\b|--data-binary|--data-raw|\b-X\s+POST\b|\bPOST\b|<\s*[^\s]+",
re.IGNORECASE,
)
def _command_basename(command: Any) -> str:
text = str(command or "").strip()
if not text:
return ""
try:
parts = shlex.split(text, posix=(os.name != "nt"))
except ValueError:
parts = text.split()
first = parts[0] if parts else text
return os.path.basename(first).lower()
def _inline_script(args: Any) -> str:
if args is None:
return ""
if isinstance(args, (list, tuple)):
return " ".join(str(item) for item in args)
return str(args)
def validate_mcp_server_entry(name: str, entry: dict[str, Any]) -> list[str]:
"""Return security warnings for an MCP server entry.
Empty return means the entry is not suspicious under the narrow #45620
exfiltration heuristic. This is intentionally not a whitelist: legitimate
local MCPs can still use custom commands, Python scripts, npx, uvx, etc.
"""
if not isinstance(entry, dict):
return []
command = entry.get("command")
basename = _command_basename(command)
if basename not in _SHELL_INTERPRETERS:
return []
script = _inline_script(entry.get("args"))
if not script:
return []
if not _EGRESS_PATTERN.search(script):
return []
issue = (
f"MCP server '{name}' uses shell interpreter '{command}' with network "
"egress in args"
)
if _EXFIL_HINT_PATTERN.search(script):
issue += " and exfiltration-shaped arguments"
return [issue]
def is_mcp_server_entry_suspicious(name: str, entry: dict[str, Any]) -> bool:
return bool(validate_mcp_server_entry(name, entry))
+1 -2
View File
@@ -84,6 +84,7 @@ _STRIP_VENDOR_ONLY_PROVIDERS: frozenset[str] = frozenset({
# Providers whose native naming is authoritative -- pass through unchanged.
_AUTHORITATIVE_NATIVE_PROVIDERS: frozenset[str] = frozenset({
"gemini",
"huggingface",
})
@@ -102,8 +103,6 @@ _MATCHING_PREFIX_STRIP_PROVIDERS: frozenset[str] = frozenset({
"arcee",
"ollama-cloud",
"custom",
"gemini",
"xai",
})
# Providers whose APIs require lowercase model IDs. Xiaomi's
+10 -57
View File
@@ -9,7 +9,6 @@ from __future__ import annotations
import json
import os
import urllib.parse
import urllib.request
import urllib.error
import time
@@ -1691,36 +1690,15 @@ def parse_model_input(raw: str, current_provider: str) -> tuple[str, str]:
def _get_custom_base_url() -> str:
"""Get the custom endpoint base_url from config.yaml."""
model_cfg = _get_model_config_dict()
return str(model_cfg.get("base_url", "")).strip()
def _get_model_config_dict() -> dict[str, Any]:
"""Return the main model config mapping, or an empty dict."""
try:
from hermes_cli.config import load_config
config = load_config()
model_cfg = config.get("model", {})
if isinstance(model_cfg, dict):
return model_cfg
return str(model_cfg.get("base_url", "")).strip()
except Exception:
pass
return {}
def _base_url_looks_like_anthropic_messages(base_url: str) -> bool:
normalized = str(base_url or "").strip().lower().rstrip("/")
if not normalized:
return False
path = urllib.parse.urlparse(normalized).path.rstrip("/")
return path.endswith("/anthropic") or path.endswith("/anthropic/v1")
def _anthropic_models_url(base_url: Optional[str] = None) -> str:
endpoint = str(base_url or "https://api.anthropic.com").strip().rstrip("/")
if endpoint.endswith("/v1"):
return endpoint + "/models"
return endpoint + "/v1/models"
return ""
def curated_models_for_provider(
@@ -2240,21 +2218,8 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
except Exception:
pass
if normalized == "anthropic":
model_cfg = _get_model_config_dict()
cfg_provider = normalize_provider(str(model_cfg.get("provider", "") or ""))
if cfg_provider == "anthropic":
cfg_base_url = str(model_cfg.get("base_url", "") or "").strip()
cfg_api_key = str(model_cfg.get("api_key", "") or "").strip()
else:
cfg_base_url = ""
cfg_api_key = ""
live = _fetch_anthropic_models(
base_url=cfg_base_url or None,
api_key=cfg_api_key or None,
)
live = _fetch_anthropic_models()
if live:
if cfg_base_url:
return live
# The live /v1/models dump lags newly-routed curated aliases
# (e.g. claude-fable-5, which is reachable on Anthropic before it
# is enumerated by the models endpoint). Surface curated entries
@@ -2323,16 +2288,13 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
if normalized == "custom":
base_url = _get_custom_base_url()
if base_url:
model_cfg = _get_model_config_dict()
# Try common API key env vars for custom endpoints
api_key = (
str(model_cfg.get("api_key", "") or "").strip()
or os.getenv("CUSTOM_API_KEY", "")
os.getenv("CUSTOM_API_KEY", "")
or os.getenv("OPENAI_API_KEY", "")
or os.getenv("OPENROUTER_API_KEY", "")
)
api_mode = "anthropic_messages" if _base_url_looks_like_anthropic_messages(base_url) else None
live = fetch_api_models(api_key, base_url, api_mode=api_mode)
live = fetch_api_models(api_key, base_url)
if live:
return live
# Bedrock uses live discovery keyed by the resolved AWS region so that
@@ -2581,24 +2543,18 @@ def clear_provider_models_cache(provider: Optional[str] = None) -> None:
pass
def _fetch_anthropic_models(
timeout: float = 5.0,
*,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
) -> Optional[list[str]]:
def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
"""Fetch available models from the Anthropic /v1/models endpoint.
Uses resolve_anthropic_token() to find credentials (env vars or
Claude Code auto-discovery) unless api_key is provided explicitly.
Returns sorted model IDs or None.
Claude Code auto-discovery). Returns sorted model IDs or None.
"""
try:
from agent.anthropic_adapter import resolve_anthropic_token, _is_oauth_token
except ImportError:
return None
token = (api_key or "").strip() or resolve_anthropic_token()
token = resolve_anthropic_token()
if not token:
return None
@@ -2613,7 +2569,7 @@ def _fetch_anthropic_models(
def _do_request(h: dict[str, str]):
req = urllib.request.Request(
_anthropic_models_url(base_url),
"https://api.anthropic.com/v1/models",
headers=h,
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
@@ -3803,10 +3759,7 @@ def validate_requested_model(
# tokens. (The api_mode=="anthropic_messages" branch below handles the
# Messages-API transport case separately.)
if normalized == "anthropic":
anthropic_models = _fetch_anthropic_models(
base_url=base_url or None,
api_key=api_key or None,
)
anthropic_models = _fetch_anthropic_models()
if anthropic_models is not None:
if requested_for_lookup in set(anthropic_models):
return {
-27
View File
@@ -135,20 +135,6 @@ def _sanitize_plugin_name(
return target
_GITHUB_BROWSER_SEGMENTS = {
"actions",
"blob",
"commit",
"commits",
"issues",
"pull",
"pulls",
"releases",
"tree",
"wiki",
}
def _resolve_git_url(identifier: str) -> tuple[str, Optional[str]]:
"""Turn an identifier into a cloneable Git URL and optional subdirectory.
@@ -160,8 +146,6 @@ def _resolve_git_url(identifier: str) -> tuple[str, Optional[str]]:
- Full URL: https://github.com/owner/repo.git
- Full URL: git@github.com:owner/repo.git
- Full URL: ssh://git@github.com/owner/repo.git
- Browser URL: https://github.com/owner/repo/tree/main/path
(https://github.com/owner/repo.git, "path")
- Shorthand: owner/repo https://github.com/owner/repo.git
- Shorthand w/ subdir: owner/repo/path/to/plugin
(https://github.com/owner/repo.git, "path/to/plugin")
@@ -177,17 +161,6 @@ def _resolve_git_url(identifier: str) -> tuple[str, Optional[str]]:
"""
# Already a URL.
if identifier.startswith(("https://", "http://", "git@", "ssh://", "file://")):
if identifier.startswith("https://github.com/"):
path = identifier[len("https://github.com/") :]
path = path.split("?", 1)[0].split("#", 1)[0].strip("/")
parts = path.split("/")
if len(parts) >= 3 and all(parts[:2]) and parts[2] in _GITHUB_BROWSER_SEGMENTS:
repo = parts[1].removesuffix(".git")
subdir = None
if parts[2] == "tree" and len(parts) >= 5:
subdir = "/".join(p for p in parts[4:] if p).strip("/") or None
return f"https://github.com/{parts[0]}/{repo}.git", subdir
# Explicit ``#subdir`` fragment — unambiguous for any scheme.
if "#" in identifier:
git_url, _, frag = identifier.partition("#")
+7 -9
View File
@@ -22,7 +22,6 @@ Usage::
import json
import os
import re
import shlex
import shutil
import stat
import subprocess
@@ -45,10 +44,10 @@ _PROFILE_DIRS = [
"plans",
"workspace",
"cron",
# Back-compat/Docker HOME for tool subprocesses. Host subprocesses keep
# the user's real HOME by default so normal CLI credentials remain visible;
# containers still use this directory for persistent HOME state.
# See hermes_constants.get_subprocess_home().
# Per-profile HOME for subprocesses: isolates system tool configs (git,
# ssh, gh, npm …) so credentials don't bleed between profiles. In Docker
# this also ensures tool configs land inside the persistent volume.
# See hermes_constants.get_subprocess_home() and issue #4426.
"home",
]
@@ -422,8 +421,7 @@ def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[P
else:
wrapper_path = wrapper_dir / canon
try:
hermes_exe = shutil.which("hermes") or "hermes"
wrapper_path.write_text(f'#!/bin/sh\nexec {shlex.quote(hermes_exe)} -p {profile} "$@"\n')
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {profile} "$@"\n')
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
return wrapper_path
except OSError as e:
@@ -786,9 +784,9 @@ def create_profile(
Path
The newly created profile directory.
"""
if no_skills and (clone_from is not None or clone_config or clone_all):
if no_skills and (clone_config or clone_all):
raise ValueError(
"--no-skills is mutually exclusive with --clone / --clone-from / --clone-all "
"--no-skills is mutually exclusive with --clone / --clone-all "
"(cloning explicitly copies skills from the source profile)."
)
canon = normalize_profile_name(name)
-747
View File
@@ -1,747 +0,0 @@
"""CLI handlers for ``hermes egress ...``.
Subcommands:
install download the pinned iron-proxy binary
setup interactive wizard: install binary, generate CA, mint tokens, write config
start launch the proxy as a managed subprocess
stop terminate the managed proxy
status show binary version + config presence + listen state + mappings
disable flip ``proxy.enabled`` to False (does not stop a running proxy)
config print the generated proxy.yaml path (for debugging / external review)
The top-level command is ``hermes egress``. Note that the inbound OAuth
reverse-proxy command (``hermes proxy``) lives elsewhere in
``hermes_cli/main.py`` different direction, different purpose.
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
from typing import List
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from agent.proxy_sources import iron_proxy as ip
from hermes_cli.config import load_config, save_config
# ---------------------------------------------------------------------------
# Argparse wiring — called from hermes_cli.main
# ---------------------------------------------------------------------------
def register_cli(parent_parser: argparse.ArgumentParser) -> None:
"""Attach the egress subcommand tree to a parent parser.
Called from ``hermes_cli.main`` as part of building the top-level
``hermes egress`` parser.
"""
# dest='egress_command' — keeps this subparser tree disjoint from the
# inbound OAuth ``hermes proxy`` subparser (which uses dest='proxy_command').
# No runtime collision today since they live in separate parser trees,
# but a future grep-and-refactor on ``proxy_command`` would otherwise
# hit both handlers.
sub = parent_parser.add_subparsers(dest="egress_command")
install = sub.add_parser(
"install",
help=f"Download iron-proxy binary (v{ip._IRON_PROXY_VERSION})",
)
install.add_argument(
"--force", action="store_true",
help="Re-download even if a managed copy already exists",
)
install.set_defaults(func=cmd_install)
setup = sub.add_parser(
"setup",
help="Interactive wizard: install + CA + mint tokens + write config",
)
setup.add_argument(
"--tunnel-port", type=int, default=None,
help=f"Override the tunnel port (default {ip._DEFAULT_TUNNEL_PORT})",
)
setup.add_argument(
"--from-bitwarden", action="store_true",
help="Treat secrets as managed by Bitwarden — discover provider keys "
"from secrets.bitwarden config instead of the current env. Fails "
"loudly if BW is unreachable rather than silently falling back.",
)
setup.add_argument(
"--no-bitwarden", action="store_true",
help="Explicitly switch credential_source back to env on re-setup "
"(only meaningful when the previous setup used --from-bitwarden).",
)
setup.add_argument(
"--rotate-tokens", action="store_true",
help="Mint fresh proxy tokens for every provider (default is to "
"preserve tokens for providers that already had one — avoids "
"401-ing already-running sandboxes on re-setup).",
)
setup.set_defaults(func=cmd_setup)
start = sub.add_parser("start", help="Start the managed iron-proxy")
start.set_defaults(func=cmd_start)
stop = sub.add_parser("stop", help="Stop the managed iron-proxy")
stop.set_defaults(func=cmd_stop)
status = sub.add_parser("status", help="Show proxy state and mappings")
status.add_argument(
"--show-tokens", action="store_true",
help="Print the proxy tokens (default: redacted prefix only). "
"Beware: tokens may persist in your shell history.",
)
status.set_defaults(func=cmd_status)
disable = sub.add_parser("disable", help="Turn off the proxy integration")
disable.set_defaults(func=cmd_disable)
cfg = sub.add_parser("config", help="Print the generated proxy.yaml path")
cfg.set_defaults(func=cmd_config)
# ---------------------------------------------------------------------------
# Handlers
# ---------------------------------------------------------------------------
def cmd_install(args: argparse.Namespace) -> int:
console = Console()
try:
binary = ip.install_iron_proxy(force=bool(args.force))
except Exception as exc: # noqa: BLE001 — top-level user-facing error funnel
console.print(f"[red]✗ install failed:[/red] {exc}")
console.print(
" Manual install: https://github.com/ironsh/iron-proxy/releases"
)
return 1
version = ip.iron_proxy_version(binary) or "(version unknown)"
console.print(f"[green]✓[/green] installed {binary} {version}")
return 0
def cmd_setup(args: argparse.Namespace) -> int:
console = Console()
console.print(Panel.fit(
"[bold]iron-proxy setup[/bold]\n\n"
"Routes outbound sandbox traffic through a local TLS-intercepting\n"
"proxy so prompt-injected agents never see real provider API keys.\n\n"
"[dim]Project: https://github.com/ironsh/iron-proxy (Apache-2.0)[/dim]",
border_style="cyan",
))
# ------------------------------------------------------------------ binary
console.print()
console.print("[bold]Step 1[/bold] Install the iron-proxy binary")
try:
binary = ip.find_iron_proxy(install_if_missing=False)
if binary is None:
console.print(" No iron-proxy on PATH — downloading…")
binary = ip.install_iron_proxy()
version = ip.iron_proxy_version(binary) or "(version unknown)"
console.print(f" [green]✓[/green] {binary} {version}")
except Exception as exc: # noqa: BLE001
console.print(f" [red]✗ install failed: {exc}[/red]")
return 1
# ------------------------------------------------------------------ CA
console.print()
console.print("[bold]Step 2[/bold] Generate a CA cert")
try:
ca_crt, ca_key = ip.ensure_ca_cert()
except Exception as exc: # noqa: BLE001
console.print(f" [red]✗ CA generation failed: {exc}[/red]")
return 1
console.print(f" [green]✓[/green] {ca_crt}")
# ------------------------------------------------------------------ mint
console.print()
console.print("[bold]Step 3[/bold] Mint proxy tokens for known providers")
available_env_names: List[str] = []
if args.from_bitwarden:
cfg = load_config()
bw_cfg = (cfg.get("secrets") or {}).get("bitwarden") or {}
if not bw_cfg.get("enabled"):
console.print(
" [red]✗ --from-bitwarden requested but "
"secrets.bitwarden.enabled is false.[/red]"
)
console.print(
" Run `hermes secrets bitwarden setup` first, or omit "
"--from-bitwarden."
)
return 1
try:
from agent.secret_sources import bitwarden as bw
access_token = os.environ.get(
bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN"), ""
).strip()
if not access_token:
console.print(
f" [red]✗ --from-bitwarden requested but "
f"{bw_cfg.get('access_token_env', 'BWS_ACCESS_TOKEN')} "
"is not set in the environment.[/red]"
)
return 1
secrets, _ = bw.fetch_bitwarden_secrets(
access_token=access_token,
project_id=bw_cfg.get("project_id", ""),
cache_ttl_seconds=0,
use_cache=False,
)
available_env_names = list(secrets.keys())
if not available_env_names:
console.print(
" [red]✗ Bitwarden returned an empty secrets list.[/red]\n"
" Check the project_id in secrets.bitwarden and the "
"BWS access-token's project scope."
)
return 1
console.print(
f" Pulled {len(available_env_names)} env names from Bitwarden."
)
except Exception as exc: # noqa: BLE001 — explicit user-facing error
console.print(
f" [red]✗ Could not enumerate Bitwarden secrets: {exc}[/red]"
)
console.print(
" Either fix the Bitwarden config and retry, or rerun setup "
"without --from-bitwarden (the proxy will read secrets from "
"the host process env at start time)."
)
return 1
discovered = ip.discover_provider_mappings(
available_env_names=available_env_names or None,
)
# Preserve tokens for providers we already had unless the operator
# explicitly requested rotation. This prevents re-running `hermes
# egress setup` from invalidating tokens baked into already-running
# sandboxes.
existing = ip.load_mappings()
rotate = bool(getattr(args, "rotate_tokens", False))
# P3 confirmation gate: --rotate-tokens invalidates every running
# sandbox's proxy tokens immediately. An accidental re-run (history
# scroll-back, tmux paste) is unrecoverable, so require explicit
# confirmation when there's something to actually rotate. Skipped
# when stdin isn't a tty (CI / non-interactive use), in which case
# the operator passed the flag deliberately.
if rotate and existing:
import sys as _sys
from datetime import datetime as _dt
if _sys.stdin.isatty():
console.print(
"[yellow]⚠[/yellow] --rotate-tokens will invalidate proxy "
"tokens in every running Hermes sandbox. They will start "
"401-ing against upstreams until restarted."
)
try:
ans = input("Type 'rotate' to confirm: ").strip().lower()
except EOFError:
ans = ""
if ans != "rotate":
console.print("[yellow]Cancelled.[/yellow]")
return 1
# Backup the existing mappings before we overwrite. The
# resulting ``.rotated-<unix>`` sibling is plain JSON and lets
# the operator manually recover tokens if they realise the
# rotation was a mistake.
try:
import shutil as _shutil
state_dir = ip._proxy_state_dir()
mappings_src = state_dir / "mappings.json"
if mappings_src.exists():
ts = _dt.now().strftime("%Y%m%dT%H%M%S")
backup = state_dir / f"mappings.json.rotated-{ts}"
_shutil.copy2(str(mappings_src), str(backup))
console.print(f" [dim]backup: {backup}[/dim]")
except OSError as exc:
console.print(
f" [yellow]Could not back up mappings before rotation: "
f"{exc}[/yellow]"
)
elif rotate and not existing:
console.print(
"[dim]Note: --rotate-tokens is a no-op on first-time setup "
"(no existing tokens to rotate).[/dim]"
)
mappings = ip.merge_mappings(
existing=existing,
discovered=discovered,
rotate=rotate,
)
if not mappings:
console.print(
" [yellow]No known provider API keys found in env/Bitwarden.[/yellow]"
)
console.print(
" Set at least one of these and rerun setup:"
)
for env_name in sorted(ip._BEARER_PROVIDERS):
console.print(f" - {env_name}")
return 1
# Warn the operator about providers we recognize but can't proxy
# (Anthropic native, AWS Bedrock, Azure OpenAI, etc). These still
# work — they just bypass the egress isolation.
uncovered = ip.discover_uncovered_providers(
available_env_names=available_env_names or None,
)
if uncovered:
console.print()
console.print(
" [yellow]⚠[/yellow] Detected provider env vars that the "
"proxy does not yet cover:"
)
for name in uncovered:
console.print(f" - {name}")
console.print(
" [dim]These providers use non-bearer auth (x-api-key, "
"SigV4, etc.) and will hold real credentials inside the "
"sandbox. Egress isolation is INCOMPLETE for these.[/dim]"
)
table = Table(show_header=True, header_style="bold")
table.add_column("Provider env", style="cyan")
table.add_column("Upstream hosts", style="dim")
table.add_column("Proxy token", style="green")
for m in mappings:
table.add_row(
m.real_env_name,
", ".join(m.upstream_hosts),
_redact_token(m.proxy_token),
)
console.print(table)
# ------------------------------------------------------------------ write
console.print()
console.print("[bold]Step 4[/bold] Write config and persist mappings")
cfg = load_config()
proxy_cfg = cfg.setdefault("proxy", {})
# ``args.tunnel_port`` is None when the flag was not given; ``0`` is
# invalid for a TCP listener so we treat it as an explicit refusal
# and surface a clear error rather than silently substituting the
# default.
if args.tunnel_port is not None:
if args.tunnel_port < 1 or args.tunnel_port > 65534:
console.print(
" [red]✗ --tunnel-port must be between 1 and 65534 "
"(the plain-HTTP listener uses port+1).[/red]"
)
return 1
tunnel_port = int(args.tunnel_port)
else:
tunnel_port = int(proxy_cfg.get("tunnel_port", ip._DEFAULT_TUNNEL_PORT))
proxy_cfg["tunnel_port"] = tunnel_port
extra_hosts = list(proxy_cfg.get("extra_allowed_hosts") or [])
allowed = list(ip._DEFAULT_ALLOWED_HOSTS) + [
h for h in extra_hosts if h not in ip._DEFAULT_ALLOWED_HOSTS
]
audit_log_path = ip._proxy_state_dir() / "audit.log"
# Pre-create the audit log with 0o600. On the pinned v0.39 the
# daemon does NOT write to this file (no ``log.audit_path`` field in
# its config schema) — it's reserved for the v0.40+ upgrade where
# per-request records start flowing. Because the file is
# non-load-bearing today, a pre-create failure (immutable parent,
# pre-existing foreign-owned file, full disk) is a WARNING, not a
# setup abort.
audit_log_ok = True
try:
ip.ensure_audit_log(audit_log_path)
except RuntimeError as exc:
audit_log_ok = False
console.print(f" [yellow]⚠ {exc}[/yellow]")
# Allow operator override of the deny list via
# ``proxy.upstream_deny_cidrs`` — but the default (None) gives a safe
# default-deny list (loopback, IMDS, RFC1918) that matches the docs
# promise.
deny_cidrs = proxy_cfg.get("upstream_deny_cidrs")
iron_cfg = ip.build_proxy_config(
mappings=mappings,
ca_cert=ca_crt,
ca_key=ca_key,
tunnel_port=tunnel_port,
audit_log=audit_log_path,
allowed_hosts=allowed,
upstream_deny_cidrs=deny_cidrs,
)
cfg_path = ip.write_proxy_config(iron_cfg)
mappings_path = ip.write_mappings(mappings)
console.print(f" [green]✓[/green] config: {cfg_path}")
console.print(f" [green]✓[/green] mappings: {mappings_path}")
if audit_log_ok:
console.print(
f" [green]✓[/green] audit log: {audit_log_path} "
f"[dim](reserved — not written by iron-proxy v0.39; "
f"per-request records land in iron-proxy.log)[/dim]"
)
# ------------------------------------------------------------------ enable
proxy_cfg["enabled"] = True
proxy_cfg.setdefault("auto_install", True)
proxy_cfg.setdefault("enforce_on_docker", True)
# CRITICAL: do NOT silently downgrade credential_source on re-run.
# If the operator previously configured `bitwarden` mode (e.g. for
# rotation), running `hermes egress setup` again WITHOUT
# --from-bitwarden must not rewrite credential_source to "env" —
# that silently breaks the Bitwarden rotation guarantee the docs
# make. Require an explicit --no-bitwarden to switch back.
existing_source = proxy_cfg.get("credential_source")
if args.from_bitwarden:
proxy_cfg["credential_source"] = "bitwarden"
elif getattr(args, "no_bitwarden", False):
proxy_cfg["credential_source"] = "env"
if existing_source == "bitwarden":
console.print(
"[yellow]Switched credential_source from bitwarden to env.[/yellow]"
)
elif existing_source == "bitwarden":
# Preserve the existing bitwarden mode. Surface the decision so
# the operator knows we kept it.
console.print(
"[dim]Keeping credential_source=bitwarden from existing config. "
"Pass --no-bitwarden to switch to env-based credentials.[/dim]"
)
else:
proxy_cfg["credential_source"] = "env"
proxy_cfg.setdefault("fail_on_uncovered_providers", False)
save_config(cfg)
live_status = ip.get_status()
if live_status.pid is not None:
ip.stop_proxy()
console.print(
" [yellow]⚠ stopped the running iron-proxy; config or tokens changed, "
"so restart it with `hermes egress start` before launching new "
"Docker sandboxes.[/yellow]"
)
console.print()
console.print(
"[green]✓ iron-proxy is configured.[/green] "
"Sandboxes will route outbound traffic through it."
)
console.print(
" Start: [cyan]hermes egress start[/cyan]\n"
" Status: [cyan]hermes egress status[/cyan]\n"
" Stop: [cyan]hermes egress stop[/cyan]\n"
" Disable: [cyan]hermes egress disable[/cyan]"
)
return 0
def cmd_start(args: argparse.Namespace) -> int:
console = Console()
cfg = load_config()
proxy_cfg = cfg.get("proxy") or {}
if not proxy_cfg.get("enabled"):
console.print(
"[yellow]proxy.enabled is false — run `hermes egress setup` "
"first.[/yellow]"
)
return 1
# If the operator opted in to Bitwarden-rotation semantics, refresh
# upstream secrets from BSM at startup. This is what delivers the
# rotation guarantee that distinguishes ``credential_source:
# bitwarden`` from ``credential_source: env``. Without it, rotating
# a key in the Bitwarden web app doesn't reach the proxy.
credential_source = proxy_cfg.get("credential_source", "env")
bw_cfg = (cfg.get("secrets") or {}).get("bitwarden")
refresh_bw = (
credential_source == "bitwarden"
and bw_cfg is not None
and bool(bw_cfg.get("enabled"))
)
# Silent-degrade guard: the operator explicitly chose
# ``credential_source: bitwarden``, but secrets.bitwarden has since
# been disabled or removed. Proceeding would quietly start on host
# env — exactly the bug class the BW mode is meant to defeat. Refuse
# unless the documented escape hatch is set.
if credential_source == "bitwarden" and not refresh_bw:
if bool(proxy_cfg.get("allow_env_fallback", False)):
console.print(
"[yellow]⚠ credential_source=bitwarden but "
"secrets.bitwarden is disabled or missing — falling back "
"to host-env secrets (allow_env_fallback=true). Rotated "
"Bitwarden keys will NOT propagate.[/yellow]"
)
else:
console.print(
"[red]✗ Refusing to start: proxy.credential_source is "
"'bitwarden' but secrets.bitwarden is disabled or "
"missing.[/red]"
)
console.print(
" Re-enable it (`secrets.bitwarden.enabled: true`), switch "
"back to env credentials with `hermes egress setup "
"--no-bitwarden`, or set `proxy.allow_env_fallback: true` "
"to opt into the host-env fallback."
)
return 1
# Pass the proxy-side allow_env_fallback opt-in through to
# start_proxy. This is a deliberate, documented escape hatch: when
# set, the daemon silently falls back to host env if BWS is
# unreachable, instead of raising. Default is strict (raise).
if refresh_bw and bw_cfg is not None:
bw_cfg = dict(bw_cfg)
bw_cfg["allow_env_fallback"] = bool(
proxy_cfg.get("allow_env_fallback", False)
)
# fail_on_uncovered_providers: when true, refuse to start if any
# LLM-specific non-bearer providers (Anthropic native, Azure OpenAI,
# Gemini) have env vars set in the host process — those would
# otherwise leak real credentials into the sandbox while bypassing
# the proxy. Only the strict LLM-specific subset blocks; generic
# cloud creds (AWS_*, GOOGLE_APPLICATION_CREDENTIALS) still surface
# as warnings via `discover_uncovered_providers` but don't block, to
# avoid tripping every operator with terraform / gcloud set up.
if bool(proxy_cfg.get("fail_on_uncovered_providers", False)):
blocked = ip.discover_blocked_providers()
if blocked:
console.print(
"[red]✗ Refusing to start: provider env vars present "
"that bypass the proxy:[/red]"
)
for name in blocked:
console.print(f" - {name}")
console.print(
" Set `proxy.fail_on_uncovered_providers: false` in "
"config.yaml to start anyway (sandbox will hold real "
"credentials for those providers)."
)
return 1
# stephenschoettler #1: when `credential_source: bitwarden`, the
# operator picked BWS specifically to get the rotation guarantee —
# silently falling back to parent-env at start_proxy time reintroduces
# exactly the bug class the BW mode is supposed to defeat (host env
# is stale / mismatched). Pre-check at the wizard layer so we fail
# loud with actionable error messages BEFORE start_proxy degrades.
if refresh_bw:
bw_access_env = (bw_cfg or {}).get("access_token_env", "BWS_ACCESS_TOKEN")
if not os.environ.get(bw_access_env, "").strip():
console.print(
f"[red]✗ Refusing to start: credential_source=bitwarden but "
f"{bw_access_env} is not set in the environment.[/red]"
)
console.print(
" Either export the access token, or run "
"`hermes egress setup --no-bitwarden` to switch back to "
"env-based credentials."
)
return 1
if not (bw_cfg or {}).get("project_id"):
console.print(
"[red]✗ Refusing to start: credential_source=bitwarden but "
"secrets.bitwarden.project_id is empty.[/red]"
)
console.print(
" Run `hermes secrets bitwarden setup` to configure the "
"project, or switch back via `hermes egress setup "
"--no-bitwarden`."
)
return 1
try:
status = ip.start_proxy(
install_if_missing=bool(proxy_cfg.get("auto_install", True)),
refresh_secrets_from_bitwarden=refresh_bw,
bitwarden_config=bw_cfg,
)
except Exception as exc: # noqa: BLE001 — top-level user-facing funnel
console.print(f"[red]✗ failed to start iron-proxy:[/red] {exc}")
return 1
if status.pid:
listening = (
"[green]listening[/green]"
if status.listening
else "[yellow]not yet listening[/yellow]"
)
console.print(
f"[green]✓[/green] iron-proxy running pid={status.pid} "
f"port={status.tunnel_port} {listening}"
)
else:
console.print("[red]✗ iron-proxy did not come up cleanly[/red]")
return 1
return 0
def cmd_stop(args: argparse.Namespace) -> int:
console = Console()
if ip.stop_proxy():
console.print("[green]✓[/green] iron-proxy stopped")
else:
console.print("[dim]iron-proxy was not running[/dim]")
return 0
def format_status_text(*, show_tokens: bool = False) -> str:
"""Plain-text egress status for slash commands, Dashboard, and Desktop."""
cfg = load_config()
proxy_cfg = cfg.get("proxy") or {}
status = ip.get_status()
def yn(value: bool) -> str:
return "yes" if value else "no"
lines = [
"Egress proxy status",
"",
f"Enabled: {yn(bool(proxy_cfg.get('enabled')))}",
f"Binary: {status.binary_path or '(missing)'}",
f"Binary version: {status.binary_version or '(unknown)'}",
f"Config: {status.config_path or '(not generated)'}",
f"CA cert: {status.ca_cert_path or '(not generated)'}",
f"Tunnel port: {status.tunnel_port}",
f"Process: pid {status.pid}" if status.pid else "Process: (stopped)",
f"Listening: {yn(status.listening)}",
f"Credential src: {proxy_cfg.get('credential_source', 'env')}",
f"Docker enforce: {yn(bool(proxy_cfg.get('enforce_on_docker', True)))}",
"Scope: Docker backend only in this release",
]
mappings = ip.load_mappings()
if mappings:
lines.extend(["", "Token mappings:"])
for m in mappings:
tok = m.proxy_token if show_tokens else _redact_token(m.proxy_token)
lines.append(f" - {m.real_env_name}: {tok} ({', '.join(m.upstream_hosts)})")
uncovered = ip.discover_uncovered_providers()
if uncovered:
lines.extend([
"",
"Uncovered providers (real credentials still visible inside the sandbox):",
])
for name in uncovered:
lines.append(f" - {name}")
if bool(proxy_cfg.get("enabled")) and not status.configured:
lines.extend(["", "Next: run `hermes egress setup` to mint tokens and write proxy.yaml."])
elif bool(proxy_cfg.get("enabled")) and not (status.pid and status.listening):
lines.extend(["", "Next: run `hermes egress start` before launching Docker sandboxes."])
return "\n".join(lines)
def cmd_status(args: argparse.Namespace) -> int:
console = Console()
cfg = load_config()
proxy_cfg = cfg.get("proxy") or {}
status = ip.get_status()
table = Table(show_header=False, box=None, padding=(0, 2))
table.add_column("", style="bold")
table.add_column("")
table.add_row("Enabled", _yn(bool(proxy_cfg.get("enabled"))))
table.add_row("Binary", str(status.binary_path or "[dim](missing)[/dim]"))
table.add_row("Binary version", status.binary_version or "[dim](unknown)[/dim]")
table.add_row("Config", str(status.config_path or "[dim](not generated)[/dim]"))
table.add_row("CA cert", str(status.ca_cert_path or "[dim](not generated)[/dim]"))
table.add_row("Tunnel port", str(status.tunnel_port))
table.add_row("Process", f"pid {status.pid}" if status.pid else "[dim](stopped)[/dim]")
table.add_row("Listening", _yn(status.listening))
table.add_row("Credential src", str(proxy_cfg.get("credential_source", "env")))
table.add_row("Docker enforce", _yn(bool(proxy_cfg.get("enforce_on_docker", True))))
console.print(table)
mappings = ip.load_mappings()
if mappings:
console.print()
console.print("[bold]Token mappings[/bold]")
m_table = Table(show_header=True, header_style="bold")
m_table.add_column("Real env", style="cyan")
m_table.add_column("Upstream", style="dim")
m_table.add_column("Proxy token", style="green")
for m in mappings:
tok = m.proxy_token if args.show_tokens else _redact_token(m.proxy_token)
m_table.add_row(m.real_env_name, ", ".join(m.upstream_hosts), tok)
console.print(m_table)
if args.show_tokens:
console.print(
"[yellow]⚠[/yellow] proxy tokens just printed in full — "
"they may persist in your shell history. Consider clearing "
"it after this command."
)
# Surface uncovered providers so the operator knows the isolation
# boundary is incomplete for those upstreams.
uncovered = ip.discover_uncovered_providers()
if uncovered:
console.print()
console.print(
"[yellow]Uncovered providers[/yellow] "
"(real credentials still visible inside the sandbox):"
)
for name in uncovered:
console.print(f" - {name}")
return 0
def cmd_disable(args: argparse.Namespace) -> int:
console = Console()
cfg = load_config()
proxy_cfg = cfg.setdefault("proxy", {})
if not proxy_cfg.get("enabled"):
console.print("[dim]proxy.enabled was already false.[/dim]")
return 0
proxy_cfg["enabled"] = False
save_config(cfg)
console.print("[green]✓[/green] proxy.enabled set to false")
# Use the public get_status() pid (which already incorporates the
# _pid_alive check) instead of reaching into ip._read_pid(). That
# private accessor only proves the pidfile is non-empty — a stale
# pidfile from a crashed previous run would fire the warning
# spuriously.
if ip.get_status().pid is not None:
console.print(
" iron-proxy is still running — stop it with "
"[cyan]hermes egress stop[/cyan] if you want it down too."
)
return 0
def cmd_config(args: argparse.Namespace) -> int:
console = Console()
status = ip.get_status()
if status.config_path is None:
console.print(
"[yellow](no config generated — run `hermes egress setup`)[/yellow]"
)
return 1
console.print(str(status.config_path))
return 0
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _yn(value: bool) -> str:
return "[green]yes[/green]" if value else "[dim]no[/dim]"
def _redact_token(token: str) -> str:
if len(token) < 16:
return token
return f"{token[:12]}{token[-4:]}"
+1 -3
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import logging
import os
import re
from urllib.parse import urlparse
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
@@ -94,8 +93,7 @@ def _detect_api_mode_for_url(base_url: str) -> Optional[str]:
return "codex_responses"
if hostname == "api.openai.com":
return "codex_responses"
path = urlparse(normalized).path.rstrip("/")
if path.endswith("/anthropic") or path.endswith("/anthropic/v1"):
if normalized.endswith("/anthropic"):
return "anthropic_messages"
if hostname == "api.kimi.com" and "/coding" in normalized:
return "anthropic_messages"
+3 -30
View File
@@ -1200,28 +1200,6 @@ def setup_terminal_backend(config: dict):
config["terminal"].setdefault(
"docker_image", "nikolaik/python-nodejs:python3.11-nodejs20"
)
print()
print_info("Docker sandboxes can be protected with the egress credential firewall.")
print_info(
"It routes sandbox traffic through iron-proxy so containers receive "
"proxy tokens instead of real API keys."
)
print_info(
" Docker only for now; Modal, SSH, Daytona, and Singularity are not wired yet."
)
if prompt_yes_no(" Enable egress firewall for Docker sandboxes?", False):
proxy_cfg = config.setdefault("proxy", {})
proxy_cfg["enabled"] = True
proxy_cfg.setdefault("enforce_on_docker", True)
print_success("Egress firewall enabled in config")
print_info(
"Run `hermes egress setup` then `hermes egress start` to mint "
"tokens and launch the proxy."
)
else:
print_info(
"Skipping egress firewall. You can enable it later with `hermes egress setup`."
)
elif selected_backend == "singularity":
print_success("Terminal backend: Singularity/Apptainer")
@@ -1677,20 +1655,15 @@ def _setup_telegram_auto_result():
profile_name: str | None = None
try:
profile_name = _profile_name_from_hermes_home(Path(get_hermes_home()))
hermes_home = str(get_hermes_home())
if "/profiles/" in hermes_home:
profile_name = hermes_home.rstrip("/").rsplit("/", 1)[-1]
except Exception:
pass
return auto_setup_telegram_bot_result(profile_name=profile_name)
def _profile_name_from_hermes_home(hermes_home) -> str | None:
"""Return the active profile name when HERMES_HOME is a profile dir."""
if hermes_home.parent.name == "profiles":
return hermes_home.name
return None
def _setup_telegram_auto() -> str | None:
"""Attempt automatic Telegram bot creation and return only the token."""
result = _setup_telegram_auto_result()
+2 -8
View File
@@ -25,13 +25,7 @@ PLATFORMS = {k: info.label for k, info in _PLATFORMS.items() if k != "api_server
# ─── Config Helpers ───────────────────────────────────────────────────────────
def get_disabled_skills(config: dict, platform: Optional[str] = None) -> Set[str]:
"""Return disabled skill names: the global list unioned with the
platform-specific list when a platform is given.
A globally-disabled skill stays disabled on every platform, so the
platform list adds to the global list rather than replacing it. This
mirrors ``agent.skill_utils.get_disabled_skill_names``.
"""
"""Return disabled skill names. Platform-specific list falls back to global."""
skills_cfg = config.get("skills", {})
global_disabled = set(skills_cfg.get("disabled", []))
if platform is None:
@@ -39,7 +33,7 @@ def get_disabled_skills(config: dict, platform: Optional[str] = None) -> Set[str
platform_disabled = cfg_get(skills_cfg, "platform_disabled", platform)
if platform_disabled is None:
return global_disabled
return global_disabled | set(platform_disabled)
return set(platform_disabled)
def save_disabled_skills(config: dict, disabled: Set[str], platform: Optional[str] = None):
-10
View File
@@ -60,16 +60,6 @@ def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callab
action="store_true",
help="Replace any existing gateway instance (useful for systemd)",
)
gateway_run.add_argument(
"--force",
action="store_true",
help=(
"Start a foreground gateway even when a systemd/launchd/s6 service "
"already supervises this profile. Without --force, the command "
"refuses because a second dispatcher escapes the service and can "
"corrupt shared gateway state."
),
)
gateway_run.add_argument(
"--no-supervise",
action="store_true",
+3 -3
View File
@@ -35,17 +35,17 @@ def build_profile_parser(subparsers, *, cmd_profile: Callable) -> None:
profile_create.add_argument(
"--clone",
action="store_true",
help="Copy config.yaml, .env, SOUL.md, and skills from active profile",
help="Copy config.yaml, .env, SOUL.md from active profile",
)
profile_create.add_argument(
"--clone-all",
action="store_true",
help="Full copy of active profile (all state, excluding per-profile history)",
help="Full copy of active profile (all state)",
)
profile_create.add_argument(
"--clone-from",
metavar="SOURCE",
help="Source profile to clone from; implies --clone unless --clone-all is set",
help="Source profile to clone from (default: active)",
)
profile_create.add_argument(
"--no-alias", action="store_true", help="Skip wrapper script creation"

Some files were not shown because too many files have changed in this diff Show More