Compare commits
121
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb5649f2e8 | ||
|
|
e433c41014 | ||
|
|
4e6d05c6a5 | ||
|
|
a1f51feb72 | ||
|
|
6c34088a17 | ||
|
|
fc2b8b3d31 | ||
|
|
3bc4a2ff78 | ||
|
|
ce19fdb7ce | ||
|
|
7f245b0035 | ||
|
|
7bbe7024c2 | ||
|
|
7433d5f0eb | ||
|
|
1436793051 | ||
|
|
08d89e7aba | ||
|
|
2c174bce24 | ||
|
|
5191c1c2ce | ||
|
|
0f3670ba79 | ||
|
|
288f7026e3 | ||
|
|
efbe1635dd | ||
|
|
a27d7e68cc | ||
|
|
13a1bd0f83 | ||
|
|
0e22bf6439 | ||
|
|
972a9885ee | ||
|
|
9459057d7f | ||
|
|
cf7d5932f8 | ||
|
|
04d4471d79 | ||
|
|
5105c3651a | ||
|
|
293c04fef6 | ||
|
|
10bad2faf1 | ||
|
|
2b4873f7fb | ||
|
|
723c2331bd | ||
|
|
b00060ce54 | ||
|
|
0428945b5b | ||
|
|
afc8615509 | ||
|
|
89bdb1e546 | ||
|
|
7b9dc7cd0a | ||
|
|
d76a58bd15 | ||
|
|
1f5eef8093 | ||
|
|
9f33d673e9 | ||
|
|
d842155da1 | ||
|
|
4936a49a0c | ||
|
|
85e6232a07 | ||
|
|
81e42335a1 | ||
|
|
526a1e24b5 | ||
|
|
1eb13744b4 | ||
|
|
49dd91d682 | ||
|
|
715b691723 | ||
|
|
9cbb91abd3 | ||
|
|
c8ad2ca997 | ||
|
|
10bd01972b | ||
|
|
12c84d6c77 | ||
|
|
ab26541b9a | ||
|
|
bb46bf8ce4 | ||
|
|
4b5ba112ad | ||
|
|
cdf30a7ac6 | ||
|
|
b0288ae9b6 | ||
|
|
630a4ef03c | ||
|
|
b4ba3f5e3b | ||
|
|
8f278403d1 | ||
|
|
1b16c48170 | ||
|
|
e986e3fc68 | ||
|
|
12682d96b9 | ||
|
|
8d5d36d793 | ||
|
|
7aaae7acd0 | ||
|
|
73d1357747 | ||
|
|
af1995a838 | ||
|
|
dc90ca4e17 | ||
|
|
af5b526472 | ||
|
|
b42c5bf652 | ||
|
|
a218a0f156 | ||
|
|
1106879147 | ||
|
|
6b76284c77 | ||
|
|
4026f526d5 | ||
|
|
9a2b976326 | ||
|
|
3581131e7d | ||
|
|
bf8effad02 | ||
|
|
817f392311 | ||
|
|
2b67e96aec | ||
|
|
abd69b8117 | ||
|
|
da28d5d113 | ||
|
|
1fa761f8de | ||
|
|
069bfd6545 | ||
|
|
1d584a301e | ||
|
|
57c2a55be4 | ||
|
|
0a865e5948 | ||
|
|
c8e5f34f24 | ||
|
|
7d11fa4e9e | ||
|
|
7c0605bf22 | ||
|
|
819def44c7 | ||
|
|
08890d77e6 | ||
|
|
425e777f54 | ||
|
|
7be22e37e1 | ||
|
|
28902dc890 | ||
|
|
63097ee0d7 | ||
|
|
6e2fd955ca | ||
|
|
78c11d99e3 | ||
|
|
957a8ffa88 | ||
|
|
cc14b74718 | ||
|
|
9b5f7b63c6 | ||
|
|
d146b85173 | ||
|
|
28bf8fb47d | ||
|
|
3380563d94 | ||
|
|
ad7436a5d9 | ||
|
|
fc46354580 | ||
|
|
1185dfd773 | ||
|
|
f82cb48120 | ||
|
|
4fd9397ae3 | ||
|
|
45f9099e51 | ||
|
|
4373e802a1 | ||
|
|
d206e1f51d | ||
|
|
4f65d5509f | ||
|
|
e2c2e41137 | ||
|
|
42139c3cf0 | ||
|
|
bde66ef37a | ||
|
|
905ce58a12 | ||
|
|
ec108c625e | ||
|
|
906b1da57f | ||
|
|
fa4e87b253 | ||
|
|
4833acf046 | ||
|
|
128a6837b7 | ||
|
|
7a74492134 | ||
|
|
69ffb9cfd4 |
@@ -11,8 +11,20 @@ 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
|
||||
|
||||
@@ -55,26 +67,81 @@ jobs:
|
||||
- name: Install PyYAML for skill extraction
|
||||
run: pip install pyyaml==6.0.2 httpx==0.28.1
|
||||
|
||||
- name: Build skills index (unified multi-source catalog)
|
||||
- name: Prepare skills index (unified multi-source catalog)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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' }}
|
||||
run: |
|
||||
# Rebuild the unified catalog. The file is gitignored, so a fresh
|
||||
# checkout starts without it and we want the freshest crawl in
|
||||
# every deploy.
|
||||
# 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.
|
||||
#
|
||||
# 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.
|
||||
# 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"
|
||||
python3 scripts/build_skills_index.py
|
||||
validate_index
|
||||
|
||||
- name: Extract skill metadata for dashboard
|
||||
run: python3 website/scripts/extract-skills.py
|
||||
|
||||
@@ -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 }}
|
||||
run: gh workflow run deploy-site.yml --repo ${{ github.repository }} -f skills_index_run_id=${{ github.run_id }}
|
||||
|
||||
@@ -29,6 +29,8 @@ 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:
|
||||
@@ -54,6 +56,14 @@ 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
|
||||
@@ -268,3 +278,50 @@ 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."
|
||||
|
||||
@@ -900,6 +900,9 @@ 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}")
|
||||
|
||||
@@ -881,6 +881,8 @@ 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.
|
||||
|
||||
@@ -902,7 +904,13 @@ 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)]
|
||||
kept = [
|
||||
m for m in messages
|
||||
if not _ra().AIAgent._is_thinking_only_assistant(
|
||||
m,
|
||||
drop_codex_reasoning_items=drop_codex_reasoning_items,
|
||||
)
|
||||
]
|
||||
dropped = len(messages) - len(kept)
|
||||
if dropped == 0:
|
||||
return messages
|
||||
|
||||
@@ -751,6 +751,9 @@ 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),
|
||||
|
||||
@@ -1144,7 +1144,8 @@ def _endpoint_speaks_anthropic_messages(base_url: str) -> bool:
|
||||
normalized = (base_url or "").strip().lower().rstrip("/")
|
||||
if not normalized:
|
||||
return False
|
||||
if normalized.endswith("/anthropic"):
|
||||
path = urlparse(normalized).path.rstrip("/")
|
||||
if path.endswith("/anthropic") or path.endswith("/anthropic/v1"):
|
||||
return True
|
||||
hostname = base_url_hostname(normalized)
|
||||
if hostname == "api.anthropic.com":
|
||||
@@ -5004,7 +5005,7 @@ def _build_call_kwargs(
|
||||
|
||||
# Provider-specific extra_body
|
||||
merged_extra = dict(extra_body or {})
|
||||
if provider == "nous" or auxiliary_is_nous:
|
||||
if provider == "nous":
|
||||
merged_extra.setdefault("tags", []).extend(_nous_portal_tags())
|
||||
if merged_extra:
|
||||
kwargs["extra_body"] = merged_extra
|
||||
|
||||
@@ -935,11 +935,14 @@ def build_converse_kwargs(
|
||||
if system_prompt:
|
||||
kwargs["system"] = system_prompt
|
||||
|
||||
if temperature is not None:
|
||||
kwargs["inferenceConfig"]["temperature"] = temperature
|
||||
from agent.anthropic_adapter import _forbids_sampling_params
|
||||
|
||||
if top_p is not None:
|
||||
kwargs["inferenceConfig"]["topP"] = top_p
|
||||
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 stop_sequences:
|
||||
kwargs["inferenceConfig"]["stopSequences"] = stop_sequences
|
||||
|
||||
@@ -1081,6 +1081,7 @@ 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
|
||||
@@ -1095,6 +1096,7 @@ 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)
|
||||
@@ -1252,7 +1254,9 @@ def _normalize_codex_response(
|
||||
finish_reason = "tool_calls"
|
||||
elif leaked_tool_call_text:
|
||||
finish_reason = "incomplete"
|
||||
elif has_incomplete_items or (saw_commentary_phase and not saw_final_answer_phase):
|
||||
elif saw_streaming_or_item_incomplete:
|
||||
finish_reason = "incomplete"
|
||||
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
|
||||
|
||||
@@ -40,6 +40,16 @@ 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.
|
||||
@@ -324,9 +334,7 @@ def compress_context(
|
||||
f"{approx_tokens:,}" if approx_tokens else "unknown", agent.model,
|
||||
focus_topic,
|
||||
)
|
||||
agent._emit_status(
|
||||
"🗜️ Compacting context — summarizing earlier conversation so I can continue..."
|
||||
)
|
||||
agent._emit_status(COMPACTION_STATUS)
|
||||
|
||||
# ── Compression lock ────────────────────────────────────────────────
|
||||
# Atomic, state.db-backed lock per session_id. Without this, two
|
||||
@@ -631,7 +639,11 @@ def compress_context(
|
||||
return compressed, new_system_prompt
|
||||
|
||||
|
||||
def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
def try_shrink_image_parts_in_messages(
|
||||
api_messages: list,
|
||||
*,
|
||||
max_dimension: int = 8000,
|
||||
) -> bool:
|
||||
"""Re-encode all native image parts at a smaller size to recover from
|
||||
image-too-large errors (Anthropic 5 MB, unknown other providers).
|
||||
|
||||
@@ -642,7 +654,8 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
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), write the base64 to a tempfile, call
|
||||
ceiling with header overhead) or whose longest side exceeds
|
||||
``max_dimension``, write the base64 to a tempfile, call
|
||||
``vision_tools._resize_image_for_vision`` to produce a smaller data
|
||||
URL, and substitute it in place.
|
||||
|
||||
@@ -664,10 +677,9 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
# 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. 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
|
||||
# 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.
|
||||
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
|
||||
@@ -684,9 +696,9 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
# 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
|
||||
# Anthropic's 8000px cap. A tall image can be tiny in bytes
|
||||
# yet huge in pixels.
|
||||
# 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.
|
||||
try:
|
||||
import base64 as _b64_dim
|
||||
header_d, _, data_d = url.partition(",")
|
||||
@@ -795,6 +807,8 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
|
||||
|
||||
__all__ = [
|
||||
"COMPACTION_STATUS",
|
||||
"COMPACTION_STATUS_MARKER",
|
||||
"check_compression_model_feasibility",
|
||||
"replay_compression_warning",
|
||||
"compress_context",
|
||||
|
||||
+183
-14
@@ -71,6 +71,35 @@ 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):
|
||||
@@ -368,6 +397,42 @@ 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,
|
||||
@@ -707,7 +772,10 @@ 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)
|
||||
api_messages = agent._drop_thinking_only_and_merge_users(
|
||||
api_messages,
|
||||
drop_codex_reasoning_items=agent.api_mode != "codex_responses",
|
||||
)
|
||||
|
||||
# Normalize message whitespace and tool-call JSON for consistent
|
||||
# prefix matching. Ensures bit-perfect prefixes across turns,
|
||||
@@ -1316,6 +1384,106 @@ 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(
|
||||
@@ -2067,7 +2235,11 @@ def run_conversation(
|
||||
and not _retry.image_shrink_retry_attempted
|
||||
):
|
||||
_retry.image_shrink_retry_attempted = True
|
||||
if agent._try_shrink_image_parts_in_messages(api_messages):
|
||||
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,
|
||||
):
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}📐 Image(s) exceeded provider size limit — "
|
||||
f"shrank and retrying...",
|
||||
@@ -3083,20 +3255,17 @@ def run_conversation(
|
||||
if classified.reason == FailoverReason.content_policy_blocked:
|
||||
_summary = agent._summarize_api_error(api_error)
|
||||
_policy_response = (
|
||||
f"⚠️ The model provider's safety filter blocked this request "
|
||||
f"(not a Hermes/gateway failure).\n\n"
|
||||
"⚠️ The model provider's safety filter blocked this request "
|
||||
"(not a Hermes/gateway failure).\n\n"
|
||||
f"Provider message: {_summary}\n\n"
|
||||
f"Try rephrasing the request, narrowing the context, or "
|
||||
f"adding a fallback provider with `hermes fallback add`."
|
||||
f"{_CONTENT_POLICY_RECOVERY_HINT}"
|
||||
)
|
||||
return _content_policy_blocked_result(
|
||||
messages,
|
||||
api_call_count,
|
||||
final_response=_policy_response,
|
||||
error_detail=_summary,
|
||||
)
|
||||
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,
|
||||
|
||||
@@ -70,16 +70,6 @@ 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
|
||||
@@ -105,7 +95,10 @@ def _resolve_home_dir() -> str:
|
||||
|
||||
def _build_subprocess_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env["HOME"] = _resolve_home_dir()
|
||||
home = _resolve_home_dir()
|
||||
env["HOME"] = home
|
||||
from hermes_constants import apply_subprocess_home_env
|
||||
apply_subprocess_home_env(env)
|
||||
return env
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
class SSLConfigurationError(Exception):
|
||||
"""Raised when SSL/TLS certificate bundle configuration fails."""
|
||||
pass
|
||||
@@ -46,11 +46,6 @@ 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"),
|
||||
@@ -104,12 +99,6 @@ 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 = []
|
||||
@@ -122,12 +111,6 @@ 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):
|
||||
|
||||
@@ -41,6 +41,16 @@ 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()
|
||||
@@ -914,6 +924,7 @@ class GeminiNativeClient:
|
||||
thinking_config=thinking_config,
|
||||
)
|
||||
|
||||
model = bare_gemini_model_id(model)
|
||||
if stream:
|
||||
return self._stream_completion(model=model, request=request, timeout=timeout)
|
||||
|
||||
|
||||
+75
-2
@@ -5,6 +5,7 @@ and run_agent.py for pre-flight context checks.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -16,7 +17,7 @@ from urllib.parse import urlparse
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
from utils import base_url_host_matches, base_url_hostname
|
||||
from utils import atomic_json_write, base_url_host_matches, base_url_hostname
|
||||
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
|
||||
@@ -111,6 +112,57 @@ _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
|
||||
@@ -627,6 +679,15 @@ 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()
|
||||
@@ -648,12 +709,24 @@ 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}")
|
||||
return _model_metadata_cache or {}
|
||||
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 {}
|
||||
|
||||
|
||||
def fetch_endpoint_model_metadata(
|
||||
|
||||
+14
-8
@@ -511,13 +511,19 @@ PLATFORM_HINTS = {
|
||||
"Standard Markdown is automatically converted to Telegram formatting. "
|
||||
"Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, "
|
||||
"`inline code`, ```code blocks```, [links](url), and ## headers. "
|
||||
"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. "
|
||||
"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. "
|
||||
"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 "
|
||||
@@ -1158,7 +1164,7 @@ def build_skills_system_prompt(
|
||||
or get_session_env("HERMES_SESSION_PLATFORM")
|
||||
or ""
|
||||
)
|
||||
disabled = get_disabled_skill_names()
|
||||
disabled = get_disabled_skill_names(_platform_hint or None)
|
||||
cache_key = (
|
||||
str(skills_dir.resolve()),
|
||||
tuple(str(d) for d in external_dirs),
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""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
+56
-27
@@ -272,27 +272,65 @@ 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. Falls back to the
|
||||
global disabled list when no platform is determined.
|
||||
``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).
|
||||
|
||||
Reads the config file directly (no CLI config imports) to stay
|
||||
lightweight.
|
||||
"""
|
||||
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):
|
||||
parsed = _load_raw_config()
|
||||
if not parsed:
|
||||
return set()
|
||||
|
||||
skills_cfg = parsed.get("skills")
|
||||
@@ -305,13 +343,14 @@ 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 _normalize_string_set(platform_disabled)
|
||||
return _normalize_string_set(skills_cfg.get("disabled"))
|
||||
return global_disabled | _normalize_string_set(platform_disabled)
|
||||
return global_disabled
|
||||
|
||||
|
||||
def _normalize_string_set(values) -> Set[str]:
|
||||
@@ -336,6 +375,7 @@ _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]:
|
||||
@@ -368,11 +408,8 @@ def get_external_skills_dirs() -> List[Path]:
|
||||
# Return a copy so callers can't mutate the cached list.
|
||||
return list(cached)
|
||||
|
||||
try:
|
||||
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = _load_raw_config()
|
||||
if not parsed:
|
||||
return []
|
||||
|
||||
skills_cfg = parsed.get("skills")
|
||||
@@ -584,15 +621,7 @@ 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_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
|
||||
config = _load_raw_config()
|
||||
|
||||
resolved: Dict[str, Any] = {}
|
||||
for var in config_vars:
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""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()
|
||||
@@ -186,10 +186,21 @@ class AnthropicTransport(ProviderTransport):
|
||||
def validate_response(self, response: Any) -> bool:
|
||||
"""Check Anthropic response structure is valid.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
if response is None:
|
||||
return False
|
||||
@@ -197,7 +208,7 @@ class AnthropicTransport(ProviderTransport):
|
||||
if not isinstance(content_blocks, list):
|
||||
return False
|
||||
if not content_blocks:
|
||||
return getattr(response, "stop_reason", None) == "end_turn"
|
||||
return getattr(response, "stop_reason", None) in {"end_turn", "refusal"}
|
||||
return True
|
||||
|
||||
def extract_cache_stats(self, response: Any) -> Optional[Dict[str, int]]:
|
||||
|
||||
@@ -664,8 +664,42 @@ 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=msg.content,
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=finish_reason,
|
||||
reasoning=reasoning,
|
||||
|
||||
@@ -218,22 +218,10 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
kwargs.pop("timeout", None)
|
||||
|
||||
if is_codex_backend:
|
||||
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
|
||||
# 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)
|
||||
|
||||
max_tokens = params.get("max_tokens")
|
||||
if max_tokens is not None and not is_codex_backend:
|
||||
|
||||
@@ -67,6 +67,16 @@ 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 = [],
|
||||
@@ -97,5 +107,6 @@ module.exports = {
|
||||
buildDesktopBackendEnv,
|
||||
buildDesktopBackendPath,
|
||||
delimiterForPlatform,
|
||||
normalizeHermesHomeRoot,
|
||||
pathEnvKey
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
appendUniquePathEntries,
|
||||
buildDesktopBackendEnv,
|
||||
buildDesktopBackendPath,
|
||||
normalizeHermesHomeRoot,
|
||||
pathEnvKey
|
||||
} = require('./backend-env.cjs')
|
||||
|
||||
@@ -66,6 +67,21 @@ 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',
|
||||
|
||||
@@ -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 } = require('./backend-env.cjs')
|
||||
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = 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 path.resolve(process.env.HERMES_HOME)
|
||||
if (process.env.HERMES_HOME) return normalizeHermesHomeRoot(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,11 +5609,30 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
||||
|
||||
ipcMain.handle('hermes:notify', (_event, payload) => {
|
||||
if (!Notification.isSupported()) return false
|
||||
new Notification({
|
||||
// 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({
|
||||
title: payload?.title || 'Hermes',
|
||||
body: payload?.body || '',
|
||||
silent: Boolean(payload?.silent)
|
||||
}).show()
|
||||
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()
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -94,6 +94,16 @@ 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)
|
||||
|
||||
@@ -85,6 +85,8 @@ import {
|
||||
import { QueuePanel } from './queue-panel'
|
||||
import {
|
||||
composerPlainText,
|
||||
deleteSelectionInEditor,
|
||||
insertPlainTextAtCaret,
|
||||
normalizeComposerEditorDom,
|
||||
placeCaretEnd,
|
||||
refChipElement,
|
||||
@@ -135,6 +137,12 @@ 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
|
||||
@@ -532,48 +540,6 @@ 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[]>([])
|
||||
@@ -610,7 +576,15 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
const before = textBeforeCaret(editor)
|
||||
const detected = detectTrigger(before ?? composerPlainText(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
|
||||
|
||||
setTrigger(detected)
|
||||
|
||||
@@ -650,6 +624,46 @@ 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
|
||||
|
||||
@@ -665,6 +679,12 @@ 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([])
|
||||
@@ -675,6 +695,25 @@ 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
|
||||
|
||||
@@ -793,6 +832,18 @@ 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') {
|
||||
@@ -822,7 +873,15 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
// 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) {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
const item = triggerItems[triggerActive]
|
||||
@@ -843,6 +902,24 @@ 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.
|
||||
@@ -1765,7 +1842,7 @@ export function ChatBar({
|
||||
ref={composerRef}
|
||||
>
|
||||
{showHelpHint && <HelpHint />}
|
||||
{trigger && (
|
||||
{trigger && !argStageEmpty && (
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={triggerActive}
|
||||
items={triggerItems}
|
||||
|
||||
@@ -3,12 +3,24 @@ 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')
|
||||
@@ -59,3 +71,64 @@ 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,6 +132,63 @@ 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
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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,6 +5,7 @@ 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'
|
||||
|
||||
/**
|
||||
@@ -15,6 +16,13 @@ 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;
|
||||
@@ -23,6 +31,11 @@ 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) {
|
||||
@@ -30,15 +43,17 @@ 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={t.assistant.thread.scrollToBottom}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
'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)]',
|
||||
'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',
|
||||
!visible && 'pointer-events-none'
|
||||
)}
|
||||
data-state={state}
|
||||
@@ -52,7 +67,8 @@ export function ScrollToBottomButton() {
|
||||
tabIndex={visible ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="arrow-down" size="1rem" />
|
||||
<Codicon name="arrow-down" size={approval ? '0.875rem' : '1rem'} />
|
||||
{approval && <span className="text-xs font-medium">{label}</span>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -284,6 +284,7 @@ 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 { writeClipboardText } from '@/components/ui/copy-button'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -49,26 +49,17 @@ 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()
|
||||
? [
|
||||
{
|
||||
@@ -122,13 +113,28 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
}
|
||||
]
|
||||
|
||||
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 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 renameDialog = (
|
||||
<RenameSessionDialog
|
||||
|
||||
@@ -154,7 +154,7 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{
|
||||
},
|
||||
{
|
||||
icon: KeyRound,
|
||||
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens'],
|
||||
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens', 'egress', 'iron proxy', 'sandbox proxy'],
|
||||
labelKey: 'providerApiKeys',
|
||||
tab: 'providers&pview=keys'
|
||||
},
|
||||
@@ -167,7 +167,7 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{
|
||||
},
|
||||
{
|
||||
icon: Settings2,
|
||||
keywords: ['gateway', 'proxy', 'server', 'webhook', 'env'],
|
||||
keywords: ['gateway', 'proxy', 'server', 'webhook', 'env', 'egress proxy', 'iron proxy'],
|
||||
labelKey: 'keysSettings',
|
||||
tab: 'keys&kview=settings'
|
||||
},
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
SIDEBAR_SESSIONS_PAGE_SIZE,
|
||||
unpinSession
|
||||
} from '../store/layout'
|
||||
import { respondToApprovalAction } from '../store/native-notifications'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
@@ -269,6 +270,26 @@ 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
|
||||
|
||||
@@ -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:
|
||||
'Sign in to the WeChat Official Account platform, copy the AppID and Token, and point the message callback URL at Hermes.',
|
||||
'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.',
|
||||
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,14 +2,15 @@ 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}$/
|
||||
|
||||
@@ -23,16 +24,18 @@ export function isValidProfileName(name: string): boolean {
|
||||
export function CreateProfileDialog({
|
||||
onClose,
|
||||
onCreated,
|
||||
open
|
||||
open,
|
||||
profiles = []
|
||||
}: {
|
||||
onClose: () => void
|
||||
onCreated?: (name: string) => Promise<void> | void
|
||||
open: boolean
|
||||
profiles?: ProfileInfo[]
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
const [name, setName] = useState('')
|
||||
const [cloneFromDefault, setCloneFromDefault] = useState(true)
|
||||
const [cloneFrom, setCloneFrom] = useState<null | string>('default')
|
||||
const [soul, setSoul] = useState('')
|
||||
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
@@ -43,7 +46,7 @@ export function CreateProfileDialog({
|
||||
}
|
||||
|
||||
setName('')
|
||||
setCloneFromDefault(true)
|
||||
setCloneFrom('default')
|
||||
setSoul('')
|
||||
setError(null)
|
||||
setStatus('idle')
|
||||
@@ -66,7 +69,7 @@ export function CreateProfileDialog({
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
|
||||
await createProfile({ name: trimmed, clone_from: cloneFrom })
|
||||
|
||||
if (soul.trim()) {
|
||||
await updateProfileSoul(trimmed, soul)
|
||||
@@ -107,17 +110,25 @@ export function CreateProfileDialog({
|
||||
</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-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>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="new-profile-soul">
|
||||
@@ -127,7 +138,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(cloneFromDefault ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
|
||||
placeholder={p.soulPlaceholder(cloneFrom ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
|
||||
value={soul}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ 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,
|
||||
@@ -82,14 +83,14 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
}, [profiles, selectedName])
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (name: string, cloneFromDefault: boolean) => {
|
||||
async (name: string, cloneFrom: null | string) => {
|
||||
const trimmed = name.trim()
|
||||
|
||||
if (!isValidProfileName(trimmed)) {
|
||||
throw new Error(p.nameHint)
|
||||
}
|
||||
|
||||
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
|
||||
await createProfile({ name: trimmed, clone_from: cloneFrom })
|
||||
notify({ kind: 'success', title: p.created, message: trimmed })
|
||||
setSelectedName(trimmed)
|
||||
await refresh()
|
||||
@@ -180,8 +181,9 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
|
||||
<CreateProfileDialog
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreate={async (name, cloneFromDefault) => handleCreate(name, cloneFromDefault)}
|
||||
onCreate={async (name, cloneFrom) => handleCreate(name, cloneFrom)}
|
||||
open={createOpen}
|
||||
profiles={profiles ?? []}
|
||||
/>
|
||||
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
@@ -453,16 +455,18 @@ function SoulEditor({ profileName }: { profileName: string }) {
|
||||
function CreateProfileDialog({
|
||||
onClose,
|
||||
onCreate,
|
||||
open
|
||||
open,
|
||||
profiles
|
||||
}: {
|
||||
onClose: () => void
|
||||
onCreate: (name: string, cloneFromDefault: boolean) => Promise<void>
|
||||
onCreate: (name: string, cloneFrom: null | string) => Promise<void>
|
||||
open: boolean
|
||||
profiles: ProfileInfo[]
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
const [name, setName] = useState('')
|
||||
const [cloneFromDefault, setCloneFromDefault] = useState(true)
|
||||
const [cloneFrom, setCloneFrom] = useState<null | string>('default')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
@@ -472,7 +476,7 @@ function CreateProfileDialog({
|
||||
}
|
||||
|
||||
setName('')
|
||||
setCloneFromDefault(true)
|
||||
setCloneFrom('default')
|
||||
setError(null)
|
||||
setSaving(false)
|
||||
}, [open])
|
||||
@@ -493,7 +497,7 @@ function CreateProfileDialog({
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onCreate(trimmed, cloneFromDefault)
|
||||
await onCreate(trimmed, cloneFrom)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : p.failedCreate)
|
||||
@@ -528,18 +532,25 @@ function CreateProfileDialog({
|
||||
</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>
|
||||
<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>
|
||||
|
||||
{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,6 +2,7 @@ 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,
|
||||
@@ -15,6 +16,7 @@ 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,
|
||||
@@ -25,8 +27,10 @@ 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'
|
||||
@@ -330,6 +334,8 @@ 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) => {
|
||||
@@ -636,18 +642,22 @@ export function useMessageStream({
|
||||
|
||||
void refreshSessions().catch(() => undefined)
|
||||
|
||||
if (compactedTurnRef.current.delete(sessionId)) {
|
||||
shouldHydrate = false
|
||||
}
|
||||
|
||||
if (shouldHydrate) {
|
||||
void hydrateFromStoredSession(3, completedState.storedSessionId, sessionId)
|
||||
}
|
||||
|
||||
if (document.hidden && sessionId === activeSessionIdRef.current) {
|
||||
void window.hermesDesktop?.notify({
|
||||
title: 'Hermes finished',
|
||||
body: text.slice(0, 140) || 'The response is ready.'
|
||||
})
|
||||
}
|
||||
dispatchNativeNotification({
|
||||
body: text.slice(0, 140) || translateNow('notifications.native.turnDoneBody'),
|
||||
kind: 'turnDone',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.turnDoneTitle')
|
||||
})
|
||||
},
|
||||
[activeSessionIdRef, hydrateFromStoredSession, refreshSessions, updateSessionState]
|
||||
[hydrateFromStoredSession, refreshSessions, updateSessionState]
|
||||
)
|
||||
|
||||
const failAssistantMessage = useCallback(
|
||||
@@ -822,6 +832,8 @@ export function useMessageStream({
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
clearSessionSubagents(sessionId)
|
||||
setSessionCompacting(sessionId, false)
|
||||
compactedTurnRef.current.delete(sessionId)
|
||||
nativeSubagentSessionsRef.current.delete(sessionId)
|
||||
|
||||
if (isActiveEvent) {
|
||||
@@ -867,12 +879,11 @@ 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)
|
||||
|
||||
if (isActiveEvent) {
|
||||
triggerHaptic('streamDone')
|
||||
}
|
||||
playCompletionSound()
|
||||
|
||||
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
|
||||
completeAssistantMessage(sessionId, finalText)
|
||||
@@ -903,10 +914,7 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -958,6 +966,13 @@ 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
|
||||
@@ -966,17 +981,31 @@ 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: typeof payload?.command === 'string' ? payload.command : '',
|
||||
description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',
|
||||
command,
|
||||
description,
|
||||
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}.
|
||||
@@ -988,6 +1017,13 @@ 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
|
||||
@@ -995,16 +1031,26 @@ 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: typeof payload?.env_var === 'string' ? payload.env_var : '',
|
||||
prompt: typeof payload?.prompt === 'string' ? payload.prompt : '',
|
||||
envVar,
|
||||
prompt: promptText,
|
||||
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
|
||||
@@ -1022,9 +1068,12 @@ export function useMessageStream({
|
||||
})
|
||||
}
|
||||
} else if (event.type === 'status.update') {
|
||||
// The gateway's notification poller announces background process
|
||||
// completions / watch matches here — re-sync the status stack.
|
||||
if (sessionId && payload?.kind === 'process') {
|
||||
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.
|
||||
void refreshBackgroundProcesses(sessionId)
|
||||
}
|
||||
} else if (event.type === 'error') {
|
||||
@@ -1036,8 +1085,17 @@ 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) {
|
||||
|
||||
@@ -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, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
|
||||
import { Archive, Bell, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
@@ -20,6 +20,7 @@ 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'
|
||||
@@ -30,6 +31,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
'gateway',
|
||||
'keys',
|
||||
'mcp',
|
||||
'notifications',
|
||||
'sessions',
|
||||
'about'
|
||||
]
|
||||
@@ -101,6 +103,12 @@ 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'}
|
||||
@@ -225,6 +233,8 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
<KeysSettings view={keysView} />
|
||||
) : activeView === 'mcp' ? (
|
||||
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
|
||||
) : activeView === 'notifications' ? (
|
||||
<NotificationsSettings />
|
||||
) : (
|
||||
<SessionsSettings />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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,18 +1,20 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, 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 { listOAuthProviders } from '@/hermes'
|
||||
import { disconnectOAuthProvider, listOAuthProviders } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { ChevronDown, KeyRound } from '@/lib/icons'
|
||||
import { Check, ChevronDown, ChevronRight, KeyRound, Loader2, Terminal, Trash2 } 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'
|
||||
|
||||
@@ -85,7 +87,17 @@ 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({ onWantApiKey, providers }: { onWantApiKey: () => void; providers: OAuthProvider[] }) {
|
||||
function OAuthPicker({
|
||||
disconnecting,
|
||||
onDisconnect,
|
||||
onWantApiKey,
|
||||
providers
|
||||
}: {
|
||||
disconnecting: null | string
|
||||
onDisconnect: (provider: OAuthProvider) => void
|
||||
onWantApiKey: () => void
|
||||
providers: OAuthProvider[]
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.settings.providers
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
@@ -97,7 +109,7 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
|
||||
|
||||
const select = (p: OAuthProvider) => startManualProviderOAuth(p.id)
|
||||
|
||||
const featured = ordered.find(p => p.id === FEATURED_ID) ?? null
|
||||
const featured = ordered.find(p => p.id === FEATURED_ID && !p.status?.logged_in) ?? 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.
|
||||
@@ -130,7 +142,13 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
|
||||
{p.connected}
|
||||
</p>
|
||||
{connected.map(p => (
|
||||
<ProviderRow key={p.id} onSelect={select} provider={p} />
|
||||
<ConnectedProviderRow
|
||||
disconnecting={disconnecting === p.id}
|
||||
key={p.id}
|
||||
onDisconnect={onDisconnect}
|
||||
onSelect={select}
|
||||
provider={p}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
@@ -158,6 +176,63 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -173,20 +248,26 @@ 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
|
||||
|
||||
useEffect(() => {
|
||||
if (onboardingActive) {
|
||||
return
|
||||
}
|
||||
const refreshOAuthProviders = useCallback(async () => {
|
||||
// OAuth providers are best-effort — a failure here just hides the panel.
|
||||
const { providers } = await listOAuthProviders()
|
||||
setOauthProviders(providers)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
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()
|
||||
|
||||
@@ -201,6 +282,26 @@ 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} />
|
||||
}
|
||||
@@ -237,7 +338,12 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<OAuthPicker onWantApiKey={() => onViewChange('keys')} providers={oauthProviders} />
|
||||
<OAuthPicker
|
||||
disconnecting={disconnecting}
|
||||
onDisconnect={provider => void handleDisconnect(provider)}
|
||||
onWantApiKey={() => onViewChange('keys')}
|
||||
providers={oauthProviders}
|
||||
/>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,15 @@ import type { HermesGateway } from '@/hermes'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'providers' | 'sessions' | `config:${string}`
|
||||
export type SettingsView =
|
||||
| 'about'
|
||||
| 'gateway'
|
||||
| 'keys'
|
||||
| 'mcp'
|
||||
| 'notifications'
|
||||
| '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 } from 'react'
|
||||
import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useState, type ComponentProps } from 'react'
|
||||
|
||||
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -36,14 +36,30 @@ function readClarifyArgs(args: unknown): ClarifyArgs {
|
||||
}
|
||||
|
||||
// Choice and "Other" rows share a layout; only color/hover differs.
|
||||
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'
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioDot({ selected }: { selected: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'grid size-3.5 shrink-0 place-items-center rounded-full border transition-colors',
|
||||
'mt-0.5 grid size-3.5 shrink-0 place-items-center rounded-full border transition-colors',
|
||||
selected ? 'border-primary' : 'border-muted-foreground/40'
|
||||
)}
|
||||
>
|
||||
@@ -99,9 +115,11 @@ 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. Show the question (from
|
||||
// args) but disable submit until we have the request id from the gateway.
|
||||
// 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.
|
||||
const ready = Boolean(matchingRequest?.requestId)
|
||||
const loading = !ready && !submitting
|
||||
|
||||
const respond = useCallback(
|
||||
async (answer: string) => {
|
||||
@@ -138,7 +156,11 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
|
||||
const handleTextareaKey = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||||
if (event.nativeEvent.isComposing) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
const trimmed = draft.trim()
|
||||
|
||||
@@ -162,12 +184,20 @@ 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 (
|
||||
<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" />
|
||||
<ClarifyShell className="grid gap-6 px-3 py-2.5">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
@@ -175,9 +205,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
>
|
||||
<HelpCircle className="size-3.5" />
|
||||
</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>
|
||||
<span className="flex-1 whitespace-pre-wrap font-medium leading-snug text-foreground">{question}</span>
|
||||
</div>
|
||||
|
||||
{!typing && hasChoices && (
|
||||
@@ -190,7 +218,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
selectedChoice === choice && 'bg-accent/60'
|
||||
)}
|
||||
data-choice
|
||||
disabled={!ready || submitting}
|
||||
disabled={submitting}
|
||||
key={`${index}-${choice}`}
|
||||
onClick={() => {
|
||||
setSelectedChoice(choice)
|
||||
@@ -200,7 +228,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
>
|
||||
<RadioDot selected={selectedChoice === choice} />
|
||||
<span className="flex-1 wrap-anywhere">{choice}</span>
|
||||
{selectedChoice === choice && <Check aria-hidden className="size-4 shrink-0 text-primary" />}
|
||||
{selectedChoice === choice && <Check aria-hidden className="mt-0.5 size-4 shrink-0 text-primary" />}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
@@ -231,8 +259,9 @@ 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="mod+enter" size="sm" />
|
||||
{copy.shortcutSuffix}
|
||||
<KbdCombo combo="enter" size="sm" />
|
||||
<KbdCombo combo="shift+enter" size="sm" />
|
||||
{t.composer.hotkeyDescs['composer.sendNewline']}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasChoices && (
|
||||
@@ -249,16 +278,10 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
{copy.back}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={!ready || submitting}
|
||||
onClick={() => void respond('')}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Button disabled={submitting} onClick={() => void respond('')} size="sm" type="button" variant="ghost">
|
||||
{copy.skip}
|
||||
</Button>
|
||||
<Button disabled={!ready || submitting || !draft.trim()} size="sm" type="submit">
|
||||
<Button disabled={submitting || !draft.trim()} size="sm" type="submit">
|
||||
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : copy.send}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -270,7 +293,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="-mr-2"
|
||||
disabled={!ready || submitting}
|
||||
disabled={submitting}
|
||||
onClick={() => void respond('')}
|
||||
size="xs"
|
||||
type="button"
|
||||
@@ -280,6 +303,6 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ClarifyShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ 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'
|
||||
@@ -273,10 +274,7 @@ 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}`)
|
||||
|
||||
@@ -339,13 +337,25 @@ 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={t.assistant.thread.loadingResponse}>
|
||||
<StatusRow
|
||||
data-slot="aui_response-loading"
|
||||
label={compacting ? COMPACTION_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>
|
||||
)
|
||||
@@ -380,6 +390,7 @@ const StreamStallIndicator: FC = () => {
|
||||
})
|
||||
|
||||
const [stalled, setStalled] = useState(false)
|
||||
const compacting = useStore($compactionActive)
|
||||
|
||||
useEffect(() => {
|
||||
setStalled(false)
|
||||
@@ -388,15 +399,21 @@ const StreamStallIndicator: FC = () => {
|
||||
return () => window.clearTimeout(id)
|
||||
}, [activity])
|
||||
|
||||
const elapsed = useElapsedSeconds(stalled)
|
||||
const active = stalled || compacting
|
||||
const elapsed = useElapsedSeconds(active)
|
||||
|
||||
if (!stalled) {
|
||||
if (!active) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusRow className="mt-1.5" data-slot="aui_stream-stall" label="Hermes is thinking">
|
||||
<StatusRow
|
||||
className="mt-1.5"
|
||||
data-slot="aui_stream-stall"
|
||||
label={compacting ? COMPACTION_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>
|
||||
)
|
||||
@@ -571,10 +588,7 @@ const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ te
|
||||
|
||||
return (
|
||||
<MarkdownTextContent
|
||||
containerClassName={cn(
|
||||
'text-xs leading-snug text-muted-foreground/85',
|
||||
isRunning && 'shimmer text-muted-foreground/55'
|
||||
)}
|
||||
containerClassName="text-xs leading-snug text-muted-foreground/85"
|
||||
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(/^\/+/, '')}`
|
||||
|
||||
const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
|
||||
export 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,6 +1,7 @@
|
||||
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'
|
||||
@@ -9,7 +10,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' | 'tool-row'
|
||||
type CopyButtonAppearance = 'button' | 'icon' | 'inline' | 'menu-item' | 'context-menu-item' | 'tool-row'
|
||||
type CopyStatus = 'copied' | 'error' | 'idle'
|
||||
const COPIED_RESET_MS = 1_500
|
||||
|
||||
@@ -159,9 +160,11 @@ export function CopyButton({
|
||||
status === 'copied' ? t.common.copied : status === 'error' ? resolvedErrorMessage : (title ?? resolvedLabel)
|
||||
const ariaLabel = status === 'idle' ? resolvedLabel : feedbackLabel
|
||||
|
||||
if (appearance === 'menu-item') {
|
||||
if (appearance === 'menu-item' || appearance === 'context-menu-item') {
|
||||
const MenuItem = appearance === 'menu-item' ? DropdownMenuItem : ContextMenuItem
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
<MenuItem
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
onSelect={event => {
|
||||
@@ -170,7 +173,7 @@ export function CopyButton({
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DropdownMenuItem>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Vendored
+5
@@ -88,6 +88,8 @@ 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
|
||||
@@ -413,6 +415,9 @@ export interface HermesNotification {
|
||||
title?: string
|
||||
body?: string
|
||||
silent?: boolean
|
||||
kind?: string
|
||||
sessionId?: string
|
||||
actions?: { id: string; text: string }[]
|
||||
}
|
||||
|
||||
export interface HermesPreviewTarget {
|
||||
|
||||
@@ -393,6 +393,14 @@ 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(),
|
||||
|
||||
@@ -131,6 +131,18 @@ 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'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -263,7 +275,46 @@ export const en: Translations = {
|
||||
keysSettings: 'Settings',
|
||||
mcp: 'MCP',
|
||||
archivedChats: 'Archived Chats',
|
||||
about: 'About'
|
||||
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'
|
||||
},
|
||||
sections: {
|
||||
model: 'Model',
|
||||
@@ -513,6 +564,12 @@ 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...'
|
||||
},
|
||||
@@ -903,6 +960,9 @@ 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}`,
|
||||
@@ -1691,6 +1751,7 @@ 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 =>
|
||||
|
||||
@@ -132,6 +132,18 @@ export const ja = defineLocale({
|
||||
transcriptionUnavailable: '音声文字起こしはまだ利用できません。',
|
||||
tryRecordingAgain: 'もう一度録音してください。',
|
||||
unavailable: '音声は利用できません'
|
||||
},
|
||||
native: {
|
||||
approvalTitle: '承認が必要です',
|
||||
approveAction: '承認',
|
||||
rejectAction: '拒否',
|
||||
inputTitle: '入力が必要です',
|
||||
inputBody: 'Hermes が応答を待っています。',
|
||||
turnDoneTitle: 'Hermes が完了しました',
|
||||
turnDoneBody: '応答の準備ができました。',
|
||||
turnErrorTitle: 'ターンが失敗しました',
|
||||
backgroundDoneTitle: 'バックグラウンドタスクが完了しました',
|
||||
backgroundFailedTitle: 'バックグラウンドタスクが失敗しました'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -177,7 +189,47 @@ export const ja = defineLocale({
|
||||
keysSettings: '設定',
|
||||
mcp: 'MCP',
|
||||
archivedChats: 'アーカイブ済みチャット',
|
||||
about: '情報'
|
||||
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: '試聴'
|
||||
},
|
||||
sections: {
|
||||
model: 'モデル',
|
||||
@@ -642,6 +694,12 @@ 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: 'プロバイダーを読み込み中...'
|
||||
},
|
||||
@@ -1041,6 +1099,9 @@ export const ja = defineLocale({
|
||||
deleting: '削除中...',
|
||||
createDesc: 'プロファイルは独立した Hermes 環境です:設定、スキル、SOUL.md が別々になります。',
|
||||
nameLabel: '名前',
|
||||
cloneFrom: '複製元',
|
||||
cloneFromNone: 'なし(空)',
|
||||
cloneFromDesc: '選択したプロファイルから設定、スキル、SOUL.md をコピーします。',
|
||||
cloneFromDefault: 'デフォルトプロファイルから設定を複製',
|
||||
cloneFromDefaultDesc: 'デフォルトプロファイルから設定、スキル、SOUL.md をコピーします。',
|
||||
invalidName: hint => `無効なプロファイル名。${hint}`,
|
||||
@@ -1831,6 +1892,7 @@ export const ja = defineLocale({
|
||||
moreOptions: 'その他の承認オプション',
|
||||
allowSession: 'このセッションで許可',
|
||||
alwaysAllowMenu: '常に許可…',
|
||||
jumpToApproval: '承認が必要',
|
||||
reject: '拒否',
|
||||
alwaysTitle: 'このコマンドを常に許可しますか?',
|
||||
alwaysDescription: pattern =>
|
||||
|
||||
@@ -143,6 +143,20 @@ 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: {
|
||||
@@ -202,6 +216,26 @@ 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>
|
||||
@@ -413,6 +447,12 @@ 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
|
||||
}
|
||||
@@ -695,6 +735,9 @@ export interface Translations {
|
||||
deleting: string
|
||||
createDesc: string
|
||||
nameLabel: string
|
||||
cloneFrom: string
|
||||
cloneFromNone: string
|
||||
cloneFromDesc: string
|
||||
cloneFromDefault: string
|
||||
cloneFromDefaultDesc: string
|
||||
invalidName: (hint: string) => string
|
||||
@@ -1350,6 +1393,7 @@ export interface Translations {
|
||||
moreOptions: string
|
||||
allowSession: string
|
||||
alwaysAllowMenu: string
|
||||
jumpToApproval: string
|
||||
reject: string
|
||||
alwaysTitle: string
|
||||
alwaysDescription: (pattern: string) => string
|
||||
|
||||
@@ -127,6 +127,18 @@ export const zhHant = defineLocale({
|
||||
transcriptionUnavailable: '語音轉寫暫不可用。',
|
||||
tryRecordingAgain: '請再錄製一次。',
|
||||
unavailable: '語音不可用'
|
||||
},
|
||||
native: {
|
||||
approvalTitle: '需要核准',
|
||||
approveAction: '核准',
|
||||
rejectAction: '拒絕',
|
||||
inputTitle: '需要輸入',
|
||||
inputBody: 'Hermes 正在等待你的回應。',
|
||||
turnDoneTitle: 'Hermes 已完成',
|
||||
turnDoneBody: '回覆已就緒。',
|
||||
turnErrorTitle: '本輪失敗',
|
||||
backgroundDoneTitle: '背景工作已完成',
|
||||
backgroundFailedTitle: '背景工作失敗'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -172,7 +184,45 @@ export const zhHant = defineLocale({
|
||||
keysSettings: '設定',
|
||||
mcp: 'MCP',
|
||||
archivedChats: '已封存聊天',
|
||||
about: '關於'
|
||||
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: '預覽'
|
||||
},
|
||||
sections: {
|
||||
model: '模型',
|
||||
@@ -621,6 +671,12 @@ 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: '正在載入提供方...'
|
||||
},
|
||||
@@ -999,6 +1055,9 @@ export const zhHant = defineLocale({
|
||||
deleting: '刪除中…',
|
||||
createDesc: '設定檔是獨立的 Hermes 環境:各自擁有獨立的設定、技能和 SOUL.md。',
|
||||
nameLabel: '名稱',
|
||||
cloneFrom: '複製來源',
|
||||
cloneFromNone: '無(空白)',
|
||||
cloneFromDesc: '從選取的來源設定檔複製設定、技能和 SOUL.md。',
|
||||
cloneFromDefault: '從預設設定檔複製設定',
|
||||
cloneFromDefaultDesc: '從您的預設設定檔複製設定、技能和 SOUL.md。',
|
||||
invalidName: hint => `設定檔名稱無效。${hint}`,
|
||||
@@ -1775,6 +1834,7 @@ export const zhHant = defineLocale({
|
||||
moreOptions: '更多核准選項',
|
||||
allowSession: '允許本工作階段',
|
||||
alwaysAllowMenu: '一律允許…',
|
||||
jumpToApproval: '需要核准',
|
||||
reject: '拒絕',
|
||||
alwaysTitle: '一律允許此指令?',
|
||||
alwaysDescription: pattern =>
|
||||
|
||||
@@ -127,6 +127,18 @@ export const zh: Translations = {
|
||||
transcriptionUnavailable: '语音转写暂不可用。',
|
||||
tryRecordingAgain: '请再录一次。',
|
||||
unavailable: '语音不可用'
|
||||
},
|
||||
native: {
|
||||
approvalTitle: '需要批准',
|
||||
approveAction: '批准',
|
||||
rejectAction: '拒绝',
|
||||
inputTitle: '需要输入',
|
||||
inputBody: 'Hermes 正在等待你的回应。',
|
||||
turnDoneTitle: 'Hermes 已完成',
|
||||
turnDoneBody: '回复已就绪。',
|
||||
turnErrorTitle: '本轮失败',
|
||||
backgroundDoneTitle: '后台任务已完成',
|
||||
backgroundFailedTitle: '后台任务失败'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -259,7 +271,45 @@ export const zh: Translations = {
|
||||
keysSettings: '设置',
|
||||
mcp: 'MCP',
|
||||
archivedChats: '已归档对话',
|
||||
about: '关于'
|
||||
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: '预览'
|
||||
},
|
||||
sections: {
|
||||
model: '模型',
|
||||
@@ -708,6 +758,12 @@ 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: '正在加载提供方...'
|
||||
},
|
||||
@@ -1037,7 +1093,8 @@ 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: '登录微信公众平台,复制 AppID 和 Token,并把消息回调 URL 指向 Hermes。',
|
||||
weixin:
|
||||
'运行 `hermes gateway setup`,选择 Weixin,然后使用个人微信账号扫描并确认二维码。Hermes 会通过腾讯 iLink Bot API 连接并保存凭据。',
|
||||
qqbot: '在 QQ 开放平台 (q.qq.com) 注册一个应用,复制 App ID 和 Client Secret。',
|
||||
api_server:
|
||||
'把 Hermes 暴露为兼容 OpenAI 的 API。设置一个鉴权密钥,然后把 Open WebUI / LobeChat 等指向 host:port。',
|
||||
@@ -1092,6 +1149,9 @@ export const zh: Translations = {
|
||||
deleting: '删除中…',
|
||||
createDesc: '配置档案是相互独立的 Hermes 环境:各自拥有独立的配置、技能和 SOUL.md。',
|
||||
nameLabel: '名称',
|
||||
cloneFrom: '克隆来源',
|
||||
cloneFromNone: '无(空白)',
|
||||
cloneFromDesc: '从选中的来源配置档案复制配置、技能和 SOUL.md。',
|
||||
cloneFromDefault: '从默认档案克隆',
|
||||
cloneFromDefaultDesc: '从你的默认配置档案复制配置、技能和 SOUL.md。',
|
||||
invalidName: hint => `名称无效。${hint}`,
|
||||
@@ -1871,6 +1931,7 @@ export const zh: Translations = {
|
||||
moreOptions: '更多审批选项',
|
||||
allowSession: '允许本会话',
|
||||
alwaysAllowMenu: '始终允许…',
|
||||
jumpToApproval: '需要审批',
|
||||
reject: '拒绝',
|
||||
alwaysTitle: '始终允许此命令?',
|
||||
alwaysDescription: pattern =>
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
// 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
|
||||
// (C3–C5) 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
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IconAt as AtSign,
|
||||
IconWaveSine as AudioLines,
|
||||
IconChartBar as BarChart3,
|
||||
IconBell as Bell,
|
||||
IconBrain as Brain,
|
||||
IconBug as Bug,
|
||||
IconCheck as Check,
|
||||
@@ -110,6 +111,7 @@ export {
|
||||
AtSign,
|
||||
AudioLines,
|
||||
BarChart3,
|
||||
Bell,
|
||||
Brain,
|
||||
Bug,
|
||||
Check,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
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'
|
||||
|
||||
@@ -161,6 +163,24 @@ 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)
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,203 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,8 @@ 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
|
||||
@@ -470,7 +472,7 @@ export interface CronJobUpdates {
|
||||
|
||||
export interface ProfileCreatePayload {
|
||||
clone_all?: boolean
|
||||
clone_from?: string
|
||||
clone_from?: null | string
|
||||
clone_from_default?: boolean
|
||||
name: string
|
||||
no_skills?: boolean
|
||||
|
||||
@@ -182,6 +182,11 @@ 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.
|
||||
@@ -719,6 +724,7 @@ 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:):
|
||||
#
|
||||
|
||||
@@ -394,7 +394,7 @@ def load_cli_config() -> Dict[str, Any]:
|
||||
"terminal": {
|
||||
"env_type": "local",
|
||||
"cwd": ".", # "." is resolved to os.getcwd() at runtime
|
||||
"timeout": 60,
|
||||
"home_mode": "auto",
|
||||
"lifetime_seconds": 300,
|
||||
"docker_image": "nikolaik/python-nodejs:python3.11-nodejs20",
|
||||
"docker_forward_env": [],
|
||||
@@ -589,6 +589,7 @@ 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",
|
||||
@@ -7475,6 +7476,10 @@ 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"
|
||||
|
||||
@@ -85,6 +85,8 @@ 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
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# 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.
|
||||
+126
-27
@@ -37,10 +37,12 @@ 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, 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.
|
||||
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.
|
||||
"""
|
||||
if not platform:
|
||||
return False
|
||||
@@ -65,10 +67,11 @@ 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 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".
|
||||
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.
|
||||
"""
|
||||
if not platform:
|
||||
return ""
|
||||
@@ -87,6 +90,89 @@ 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.
|
||||
@@ -237,27 +323,40 @@ 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 allowlists configured. Adapters that own their own
|
||||
# No env allowlist configured. Adapters that own their own
|
||||
# config-driven access policy (dm_policy / group_policy /
|
||||
# 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)
|
||||
# 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.)
|
||||
if self._adapter_enforces_own_access_policy(source.platform):
|
||||
# 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"
|
||||
):
|
||||
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":
|
||||
return True
|
||||
# No allowlists configured -- check global allow-all flag
|
||||
return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
|
||||
|
||||
+4
-4
@@ -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 60s
|
||||
# matches the OpenClaw rollout. Set to 0 to disable.
|
||||
fresh_final_after_seconds: float = 60.0
|
||||
# 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
|
||||
|
||||
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"), 60.0
|
||||
data.get("fresh_final_after_seconds"), 0.0
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -156,18 +156,23 @@ 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:
|
||||
parts.append(item[:MAX_NORMALIZED_TEXT_LENGTH])
|
||||
part = item[:MAX_NORMALIZED_TEXT_LENGTH]
|
||||
parts.append(part)
|
||||
total_len += len(part)
|
||||
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:
|
||||
parts.append(str(text)[:MAX_NORMALIZED_TEXT_LENGTH])
|
||||
part = str(text)[:MAX_NORMALIZED_TEXT_LENGTH]
|
||||
parts.append(part)
|
||||
total_len += len(part)
|
||||
except Exception:
|
||||
pass
|
||||
# Silently skip image_url / other non-text parts
|
||||
@@ -175,8 +180,9 @@ 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 sum(len(p) for p in parts) >= MAX_NORMALIZED_TEXT_LENGTH:
|
||||
if total_len >= MAX_NORMALIZED_TEXT_LENGTH:
|
||||
break
|
||||
result = "\n".join(parts)
|
||||
return result[:MAX_NORMALIZED_TEXT_LENGTH] if len(result) > MAX_NORMALIZED_TEXT_LENGTH else result
|
||||
|
||||
@@ -1128,8 +1128,11 @@ 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",
|
||||
@@ -1913,16 +1916,21 @@ 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, 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.
|
||||
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.
|
||||
|
||||
Adapters that own their access policy override this to return ``True``.
|
||||
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).
|
||||
Adapters that delegate access control to the gateway leave it ``False``
|
||||
(the default).
|
||||
"""
|
||||
return False
|
||||
|
||||
@@ -1945,6 +1953,46 @@ 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,
|
||||
|
||||
+108
-10
@@ -22,6 +22,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import smtplib
|
||||
import socket
|
||||
import ssl
|
||||
import uuid
|
||||
from email.header import decode_header
|
||||
@@ -62,6 +63,63 @@ _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"}
|
||||
|
||||
@@ -293,6 +351,48 @@ 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:
|
||||
@@ -316,10 +416,11 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
|
||||
try:
|
||||
# Test SMTP connection
|
||||
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()
|
||||
smtp = self._connect_smtp()
|
||||
try:
|
||||
smtp.login(self._address, self._password)
|
||||
finally:
|
||||
smtp.quit()
|
||||
logger.info("[Email] SMTP connection test passed.")
|
||||
except Exception as e:
|
||||
logger.error("[Email] SMTP connection failed: %s", e)
|
||||
@@ -555,9 +656,8 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
|
||||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||
|
||||
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
|
||||
smtp = self._connect_smtp()
|
||||
try:
|
||||
smtp.starttls(context=ssl.create_default_context())
|
||||
smtp.login(self._address, self._password)
|
||||
smtp.send_message(msg)
|
||||
finally:
|
||||
@@ -677,9 +777,8 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
except Exception as e:
|
||||
logger.warning("[Email] Failed to attach %s: %s", file_path, e)
|
||||
|
||||
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
|
||||
smtp = self._connect_smtp()
|
||||
try:
|
||||
smtp.starttls(context=ssl.create_default_context())
|
||||
smtp.login(self._address, self._password)
|
||||
smtp.send_message(msg)
|
||||
finally:
|
||||
@@ -756,9 +855,8 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
part.add_header("Content-Disposition", f"attachment; filename={fname}")
|
||||
msg.attach(part)
|
||||
|
||||
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
|
||||
smtp = self._connect_smtp()
|
||||
try:
|
||||
smtp.starttls(context=ssl.create_default_context())
|
||||
smtp.login(self._address, self._password)
|
||||
smtp.send_message(msg)
|
||||
finally:
|
||||
|
||||
@@ -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", "ul",
|
||||
"strong", "table", "tbody", "td", "th", "thead", "tr", "ul",
|
||||
}
|
||||
_VOID_TAGS = {"br", "hr"}
|
||||
|
||||
|
||||
+152
-19
@@ -349,8 +349,11 @@ 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 bytes. Content above this is sent via the legacy chunking path.
|
||||
RICH_MESSAGE_MAX_BYTES = 32768
|
||||
# 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
|
||||
# Threshold for detecting Telegram client-side message splits.
|
||||
# When a chunk is near this limit, a continuation is almost certain.
|
||||
_SPLIT_THRESHOLD = 4000
|
||||
@@ -416,8 +419,11 @@ 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: send final replies via sendRichMessage
|
||||
# with the raw agent markdown so tables/task lists/etc. render natively.
|
||||
# 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)
|
||||
# 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.
|
||||
@@ -920,19 +926,20 @@ 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/drafts are intentionally untouched —
|
||||
# Telegram exposes no rich-edit method.
|
||||
# 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.
|
||||
# ------------------------------------------------------------------
|
||||
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 byte text cap is enforced here. Other Bot API
|
||||
Only the 32,768 UTF-8 character 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.encode("utf-8")) <= self.RICH_MESSAGE_MAX_BYTES
|
||||
return len(content) <= self.RICH_MESSAGE_MAX_CHARS
|
||||
|
||||
def _bot_supports_rich(self) -> bool:
|
||||
"""True when the bound bot can issue raw ``sendRichMessage`` calls.
|
||||
@@ -946,15 +953,79 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
return inspect.iscoroutinefunction(getattr(self._bot, "do_api_request", None))
|
||||
|
||||
def _should_attempt_rich(self, content: str) -> bool:
|
||||
_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:
|
||||
return bool(
|
||||
not getattr(self, "_rich_send_disabled", False)
|
||||
getattr(self, "_rich_messages_enabled", False)
|
||||
and not getattr(self, "_rich_send_disabled", False)
|
||||
and not (metadata or {}).get("expect_edits")
|
||||
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]:
|
||||
@@ -976,16 +1047,19 @@ 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 and "not found" in s) or "no such method" in s:
|
||||
if ("method" in s or "endpoint" in s) and (
|
||||
"not found" in s or "does not exist" in s
|
||||
):
|
||||
return True
|
||||
if "unsupported" in s or "not implemented" in s:
|
||||
return True
|
||||
return False
|
||||
return "no such method" in s
|
||||
|
||||
def _is_rich_fallback_error(self, exc: Exception) -> bool:
|
||||
"""True ⇒ permanent/capability error ⇒ safe to fall back to legacy.
|
||||
@@ -997,7 +1071,10 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
if self._is_bad_request_error(exc):
|
||||
return True
|
||||
return self._is_rich_capability_error(exc)
|
||||
if self._is_rich_capability_error(exc):
|
||||
return True
|
||||
s = str(exc).lower()
|
||||
return "unsupported" in s or "not implemented" in s
|
||||
|
||||
def _compute_single_send_routing(
|
||||
self,
|
||||
@@ -1070,6 +1147,8 @@ 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
|
||||
@@ -1078,8 +1157,12 @@ 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, return_type=Message
|
||||
"sendRichMessage", api_kwargs=payload
|
||||
)
|
||||
except Exception as exc:
|
||||
if self._is_rich_fallback_error(exc):
|
||||
@@ -1116,7 +1199,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
if isinstance(msg, dict):
|
||||
message_id = msg.get("message_id")
|
||||
if message_id is None:
|
||||
message_id = msg.get("result", {}).get("message_id")
|
||||
message_id = (msg.get("result") or {}).get("message_id")
|
||||
else:
|
||||
message_id = getattr(msg, "message_id", None)
|
||||
return SendResult(
|
||||
@@ -1126,10 +1209,12 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
def _should_attempt_rich_draft(self, content: str) -> bool:
|
||||
return bool(
|
||||
not getattr(self, "_rich_send_disabled", False)
|
||||
getattr(self, "_rich_messages_enabled", False)
|
||||
and 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()
|
||||
)
|
||||
@@ -2146,7 +2231,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):
|
||||
if self._should_attempt_rich(content, metadata=metadata):
|
||||
rich_result = await self._try_send_rich(chat_id, content, reply_to, metadata)
|
||||
if rich_result is not None:
|
||||
if rich_result.success:
|
||||
@@ -5468,6 +5553,52 @@ 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:
|
||||
@@ -5657,6 +5788,7 @@ 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)
|
||||
|
||||
@@ -5671,6 +5803,7 @@ 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)
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ import logging
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
from collections import deque
|
||||
from typing import Any, Deque, Dict, List, Optional
|
||||
|
||||
try:
|
||||
from aiohttp import web
|
||||
@@ -67,6 +68,7 @@ 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.
|
||||
@@ -122,6 +124,7 @@ 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
|
||||
@@ -130,9 +133,10 @@ 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, List[float]] = {}
|
||||
self._rate_counts: Dict[str, Deque[float]] = {}
|
||||
self._rate_limit: int = int(config.extra.get("rate_limit", 30)) # per minute
|
||||
|
||||
# Body size limit (auth-before-body pattern)
|
||||
@@ -271,15 +275,57 @@ 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
|
||||
stale = [
|
||||
k
|
||||
for k, t in self._delivery_info_created.items()
|
||||
if t < cutoff
|
||||
]
|
||||
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]
|
||||
for k in stale:
|
||||
self._delivery_info.pop(k, None)
|
||||
self._delivery_info_created.pop(k, None)
|
||||
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
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||
return {"name": chat_id, "type": "webhook"}
|
||||
@@ -413,13 +459,10 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
|
||||
# ── Rate limiting (after auth) ───────────────────────────
|
||||
now = time.time()
|
||||
window = self._rate_counts.setdefault(route_name, [])
|
||||
window[:] = [t for t in window if now - t < 60]
|
||||
if len(window) >= self._rate_limit:
|
||||
if not self._record_rate_limit_hit(route_name, now):
|
||||
return web.json_response(
|
||||
{"error": "Rate limit exceeded"}, status=429
|
||||
)
|
||||
window.append(now)
|
||||
|
||||
# Parse payload
|
||||
try:
|
||||
@@ -504,13 +547,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
# ── Idempotency ─────────────────────────────────────────
|
||||
# Skip duplicate deliveries (webhook retries).
|
||||
now = time.time()
|
||||
# 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:
|
||||
if not self._record_delivery_id(delivery_id, now):
|
||||
logger.info(
|
||||
"[webhook] Skipping duplicate delivery %s", delivery_id
|
||||
)
|
||||
@@ -518,7 +555,6 @@ 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
|
||||
@@ -594,6 +630,7 @@ 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
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""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)
|
||||
+409
-30
@@ -677,6 +677,15 @@ 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")
|
||||
@@ -684,6 +693,10 @@ 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
|
||||
|
||||
@@ -749,6 +762,99 @@ _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
|
||||
@@ -1021,6 +1127,7 @@ 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",
|
||||
@@ -2000,6 +2107,7 @@ 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
|
||||
@@ -2080,6 +2188,13 @@ 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
|
||||
@@ -4495,6 +4610,94 @@ 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.
|
||||
|
||||
@@ -4556,6 +4759,14 @@ 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.
|
||||
@@ -4565,9 +4776,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
source=source,
|
||||
internal=True,
|
||||
)
|
||||
task = asyncio.create_task(adapter.handle_message(event))
|
||||
task = asyncio.create_task(
|
||||
self._run_startup_resume_event(adapter, event, entry.session_key)
|
||||
)
|
||||
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:
|
||||
@@ -4830,6 +5049,15 @@ 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] = []
|
||||
@@ -4964,6 +5192,7 @@ 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:
|
||||
@@ -5074,6 +5303,7 @@ 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:
|
||||
@@ -6376,6 +6606,14 @@ 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))
|
||||
@@ -6718,6 +6956,11 @@ 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
|
||||
@@ -7169,6 +7412,11 @@ 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)
|
||||
|
||||
@@ -8608,13 +8856,20 @@ 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)":
|
||||
if response == "(empty)" and not _intentional_silence:
|
||||
response = (
|
||||
"⚠️ The model returned no response after processing tool "
|
||||
"results. This can happen with some models — try again or "
|
||||
@@ -8630,6 +8885,20 @@ 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).
|
||||
@@ -8650,10 +8919,11 @@ 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.
|
||||
response = _normalize_empty_agent_response(
|
||||
agent_result, response, history_len=len(history),
|
||||
)
|
||||
response = _sanitize_gateway_final_response(source.platform, response)
|
||||
if not _intentional_silence:
|
||||
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().
|
||||
@@ -8677,7 +8947,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
)
|
||||
except Exception:
|
||||
_show_reasoning_effective = getattr(self, "_show_reasoning", False)
|
||||
if _show_reasoning_effective and response:
|
||||
if _show_reasoning_effective and response and not _intentional_silence:
|
||||
last_reasoning = agent_result.get("last_reasoning")
|
||||
if last_reasoning:
|
||||
# Collapse long reasoning to keep messages readable
|
||||
@@ -8707,7 +8977,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"):
|
||||
if _footer_line and response and not agent_result.get("already_sent") and not _intentional_silence:
|
||||
response = f"{response}\n\n{_footer_line}"
|
||||
|
||||
# Emit agent:end hook
|
||||
@@ -8941,6 +9211,18 @@ 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):
|
||||
@@ -12526,6 +12808,57 @@ 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).
|
||||
|
||||
@@ -14049,20 +14382,57 @@ 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:
|
||||
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)
|
||||
# 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)
|
||||
|
||||
if agent is None:
|
||||
# Config changed or first message — create fresh agent
|
||||
@@ -14100,7 +14470,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
)
|
||||
if _cache_lock and _cache is not None:
|
||||
with _cache_lock:
|
||||
_cache[session_key] = (agent, _sig)
|
||||
_cache[session_key] = (agent, _sig, _current_msg_count)
|
||||
self._enforce_agent_cache_cap()
|
||||
logger.debug("Created new agent for session %s (sig=%s)", session_key, _sig)
|
||||
|
||||
@@ -14414,6 +14784,11 @@ 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
|
||||
@@ -14474,21 +14849,23 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if _reason == "shutdown_timeout"
|
||||
else "a gateway interruption"
|
||||
)
|
||||
_persist_user_message_override = message
|
||||
message = (
|
||||
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"
|
||||
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"
|
||||
+ message
|
||||
)
|
||||
elif _has_fresh_tool_tail:
|
||||
_persist_user_message_override = message
|
||||
message = (
|
||||
"[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"
|
||||
"[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"
|
||||
+ message
|
||||
)
|
||||
|
||||
@@ -14546,7 +14923,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
"conversation_history": agent_history,
|
||||
"task_id": session_id,
|
||||
}
|
||||
if observed_group_context:
|
||||
if _persist_user_message_override is not None:
|
||||
_conversation_kwargs["persist_user_message"] = _persist_user_message_override
|
||||
elif observed_group_context:
|
||||
_conversation_kwargs["persist_user_message"] = message
|
||||
result = agent.run_conversation(_api_run_message, **_conversation_kwargs)
|
||||
finally:
|
||||
|
||||
+152
-22
@@ -143,6 +143,13 @@ 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
|
||||
@@ -420,7 +427,10 @@ class GatewayStreamConsumer:
|
||||
if isinstance(self.adapter, _BasePlatformAdapter)
|
||||
else len
|
||||
)
|
||||
_raw_limit = getattr(self.adapter, "MAX_MESSAGE_LENGTH", 4096)
|
||||
# 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()
|
||||
_safe_limit = max(500, _raw_limit - _len_fn(self.cfg.cursor) - 100)
|
||||
|
||||
# Resolve native draft streaming once per run. When enabled the
|
||||
@@ -589,9 +599,20 @@ class GatewayStreamConsumer:
|
||||
if self._accumulated:
|
||||
if self._fallback_final_send:
|
||||
await self._send_fallback_final(self._accumulated)
|
||||
elif current_update_visible and (
|
||||
not self._adapter_requires_finalize
|
||||
or self._last_edit_overflowed
|
||||
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
|
||||
)
|
||||
):
|
||||
# Mid-stream edit above already delivered the
|
||||
# final accumulated content. Skip the redundant
|
||||
@@ -614,6 +635,15 @@ 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:
|
||||
@@ -729,6 +759,9 @@ 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,
|
||||
@@ -737,6 +770,7 @@ 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
|
||||
@@ -1114,6 +1148,76 @@ 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
|
||||
@@ -1127,7 +1231,13 @@ class GatewayStreamConsumer:
|
||||
|
||||
Ported from openclaw/openclaw#72038.
|
||||
"""
|
||||
old_message_id = self._message_id
|
||||
# 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)
|
||||
try:
|
||||
result = await self.adapter.send(
|
||||
chat_id=self.chat_id,
|
||||
@@ -1139,25 +1249,29 @@ 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()
|
||||
@@ -1267,9 +1381,19 @@ 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()
|
||||
and (
|
||||
self._should_send_fresh_final()
|
||||
or self._adapter_prefers_fresh_final(text)
|
||||
)
|
||||
and await self._try_fresh_final(
|
||||
text, is_turn_final=is_turn_final,
|
||||
)
|
||||
@@ -1283,6 +1407,9 @@ 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
|
||||
@@ -1405,7 +1532,7 @@ class GatewayStreamConsumer:
|
||||
chat_id=self.chat_id,
|
||||
content=text,
|
||||
reply_to=self._initial_reply_to_id,
|
||||
metadata=self.metadata,
|
||||
metadata={**(self.metadata or {}), "expect_edits": True},
|
||||
)
|
||||
if result.success:
|
||||
if result.message_id:
|
||||
@@ -1414,6 +1541,9 @@ 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
|
||||
|
||||
@@ -110,6 +110,8 @@ 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",
|
||||
@@ -531,6 +533,7 @@ _TELEGRAM_MENU_PRIORITY = (
|
||||
"new",
|
||||
"stop",
|
||||
"status",
|
||||
"egress",
|
||||
"resume",
|
||||
"sessions",
|
||||
"model",
|
||||
@@ -1053,7 +1056,9 @@ _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.
|
||||
_SLACK_VIA_HERMES_ONLY = frozenset({"credits"})
|
||||
# - 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"})
|
||||
|
||||
|
||||
def _sanitize_slack_name(raw: str) -> str:
|
||||
|
||||
+105
-2
@@ -941,6 +941,13 @@ 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
|
||||
@@ -1982,6 +1989,9 @@ 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)
|
||||
@@ -2317,7 +2327,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": 60.0,
|
||||
"fresh_final_after_seconds": 0.0,
|
||||
},
|
||||
|
||||
# Session storage — controls automatic cleanup of ~/.hermes/state.db.
|
||||
@@ -2528,6 +2538,67 @@ 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,
|
||||
@@ -4111,7 +4182,7 @@ _KNOWN_ROOT_KEYS = {
|
||||
"fallback_providers", "credential_pool_strategies", "toolsets",
|
||||
"agent", "terminal", "display", "compression", "delegation",
|
||||
"auxiliary", "custom_providers", "context", "memory", "gateway",
|
||||
"sessions", "streaming", "updates",
|
||||
"sessions", "streaming", "updates", "mcp_servers",
|
||||
}
|
||||
|
||||
# Valid fields inside a custom_providers list entry
|
||||
@@ -4819,6 +4890,38 @@ 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}")
|
||||
|
||||
|
||||
+45
-1
@@ -306,6 +306,23 @@ 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.
|
||||
|
||||
@@ -539,6 +556,30 @@ 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
|
||||
@@ -567,7 +608,10 @@ 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"),
|
||||
|
||||
+139
-6
@@ -1095,11 +1095,30 @@ 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
|
||||
from hermes_cli.service_manager import detect_service_manager, get_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.
|
||||
@@ -1430,7 +1449,7 @@ def _profile_suffix() -> str:
|
||||
return hashlib.sha256(str(home).encode()).hexdigest()[:8]
|
||||
|
||||
|
||||
def _profile_arg(hermes_home: str | None = None) -> str:
|
||||
def _profile_arg(hermes_home: str | None = None, default_root: str | Path | None = None) -> str:
|
||||
"""Return ``--profile <name>`` only when HERMES_HOME is a named profile.
|
||||
|
||||
For ``~/.hermes/profiles/<name>``, returns ``"--profile <name>"``.
|
||||
@@ -1440,12 +1459,16 @@ def _profile_arg(hermes_home: str | None = None) -> str:
|
||||
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 = get_default_hermes_root().resolve()
|
||||
default = Path(default_root).resolve() if default_root else get_default_hermes_root().resolve()
|
||||
if home == default:
|
||||
return ""
|
||||
profiles_root = (default / "profiles").resolve()
|
||||
@@ -1459,6 +1482,16 @@ def _profile_arg(hermes_home: str | None = None) -> str:
|
||||
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.
|
||||
|
||||
@@ -2384,7 +2417,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(hermes_home)
|
||||
profile_arg = _profile_arg_for_target_user(hermes_home, home_dir)
|
||||
# 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.
|
||||
@@ -3762,6 +3795,101 @@ 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:
|
||||
@@ -3789,7 +3917,7 @@ def _guard_official_docker_root_gateway() -> None:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
|
||||
def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False, force: bool = False):
|
||||
"""Run the gateway in foreground.
|
||||
|
||||
Args:
|
||||
@@ -3798,8 +3926,12 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
|
||||
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
|
||||
@@ -6345,7 +6477,8 @@ def _gateway_command_inner(args):
|
||||
verbose = getattr(args, "verbose", 0)
|
||||
quiet = getattr(args, "quiet", False)
|
||||
replace = getattr(args, "replace", False)
|
||||
run_gateway(verbose, quiet=quiet, replace=replace)
|
||||
force = getattr(args, "force", False)
|
||||
run_gateway(verbose, quiet=quiet, replace=replace, force=force)
|
||||
return
|
||||
|
||||
if subcmd == "setup":
|
||||
|
||||
+278
-13
@@ -354,6 +354,37 @@ 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`.
|
||||
@@ -441,7 +472,12 @@ def _apply_profile_override() -> None:
|
||||
from hermes_cli.profiles import resolve_profile_env
|
||||
|
||||
hermes_home = resolve_profile_env(profile_name)
|
||||
except (ValueError, FileNotFoundError) as exc:
|
||||
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:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as exc:
|
||||
@@ -8030,6 +8066,182 @@ 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.
|
||||
|
||||
@@ -8232,6 +8444,15 @@ 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
|
||||
@@ -8294,7 +8515,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
|
||||
if use_zip_update:
|
||||
# ZIP-based update for Windows when git is broken
|
||||
_update_via_zip(args)
|
||||
try:
|
||||
_update_via_zip(args)
|
||||
finally:
|
||||
_resume_windows_gateways_after_update(_windows_gateway_resume)
|
||||
return
|
||||
|
||||
# Fetch and pull
|
||||
@@ -8431,6 +8655,7 @@ 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)")
|
||||
@@ -9627,6 +9852,8 @@ 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
|
||||
@@ -9860,19 +10087,20 @@ 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,
|
||||
clone_config=clone_config,
|
||||
no_alias=no_alias,
|
||||
no_skills=no_skills,
|
||||
description=getattr(args, "description", None),
|
||||
)
|
||||
print(f"\nProfile '{name}' created at {profile_dir}")
|
||||
|
||||
if clone or clone_all:
|
||||
if clone_config or clone_all:
|
||||
source_label = (
|
||||
getattr(args, "clone_from", None) or get_active_profile_name()
|
||||
)
|
||||
@@ -9886,8 +10114,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/--clone-all)
|
||||
if clone or clone_all:
|
||||
# Auto-clone Honcho config for the new profile (only with clone operations)
|
||||
if clone_config or clone_all:
|
||||
try:
|
||||
from plugins.memory.honcho.cli import clone_honcho_for_profile
|
||||
|
||||
@@ -9896,10 +10124,10 @@ def cmd_profile(args):
|
||||
except Exception:
|
||||
pass # Honcho plugin not installed or not configured
|
||||
|
||||
# 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:
|
||||
# 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):
|
||||
result = seed_profile_skills(profile_dir)
|
||||
if result and result.get("skipped_opt_out"):
|
||||
print(
|
||||
@@ -10695,7 +10923,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
|
||||
"acp", "auth", "backup", "bundles", "checkpoints", "claw", "completion",
|
||||
"computer-use",
|
||||
"config", "cron", "curator", "dashboard", "debug", "doctor",
|
||||
"dump", "fallback", "gateway", "hooks", "import", "insights",
|
||||
"dump", "egress", "fallback", "gateway", "hooks", "import", "insights",
|
||||
"gui", "desktop", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate",
|
||||
"model", "pairing", "plugins", "portal", "postinstall", "profile", "proxy",
|
||||
"prompt-size",
|
||||
@@ -11293,6 +11521,37 @@ 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
|
||||
# =========================================================================
|
||||
@@ -12216,9 +12475,15 @@ def main():
|
||||
cmd_chat(args)
|
||||
return
|
||||
|
||||
# Execute the command
|
||||
# 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).
|
||||
if hasattr(args, "func"):
|
||||
args.func(args)
|
||||
rc = args.func(args)
|
||||
if isinstance(rc, int) and rc != 0:
|
||||
sys.exit(rc)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
@@ -730,9 +730,12 @@ def install_entry(entry: CatalogEntry, *, enable: bool = True) -> None:
|
||||
server_cfg = _build_server_config(entry, install_dir)
|
||||
server_cfg["enabled"] = enable
|
||||
|
||||
cfg = load_config()
|
||||
cfg.setdefault("mcp_servers", {})[entry.name] = server_cfg
|
||||
save_config(cfg)
|
||||
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"
|
||||
)
|
||||
|
||||
# ── Probe + tool selection ──────────────────────────────────────────
|
||||
_apply_tool_selection(entry, prior_selection=prior_selection)
|
||||
|
||||
+36
-14
@@ -25,6 +25,7 @@ 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__)
|
||||
@@ -84,11 +85,23 @@ def _get_mcp_servers(config: Optional[dict] = None) -> Dict[str, dict]:
|
||||
return servers
|
||||
|
||||
|
||||
def _save_mcp_server(name: str, server_config: dict):
|
||||
"""Add or update a server entry in config.yaml."""
|
||||
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
|
||||
config = load_config()
|
||||
config.setdefault("mcp_servers", {})[name] = server_config
|
||||
save_config(config)
|
||||
return True
|
||||
|
||||
|
||||
def _remove_mcp_server(name: str) -> bool:
|
||||
@@ -208,11 +221,15 @@ 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,
|
||||
_stop_mcp_loop_if_idle,
|
||||
)
|
||||
|
||||
config = _resolve_mcp_server_config(config)
|
||||
@@ -240,7 +257,7 @@ def _probe_single_server(
|
||||
except BaseException as exc:
|
||||
raise _unwrap_exception_group(exc) from None
|
||||
finally:
|
||||
_stop_mcp_loop()
|
||||
_stop_mcp_loop_if_idle()
|
||||
|
||||
return tools_found
|
||||
|
||||
@@ -339,6 +356,12 @@ 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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -403,16 +426,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
|
||||
_save_mcp_server(name, server_config)
|
||||
_success(f"Saved '{name}' to config (disabled)")
|
||||
_info("Fix the issue, then: hermes mcp test " + name)
|
||||
if _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):
|
||||
_save_mcp_server(name, server_config)
|
||||
_success(f"Saved '{name}' to config")
|
||||
if _save_mcp_server(name, server_config):
|
||||
_success(f"Saved '{name}' to config")
|
||||
return
|
||||
|
||||
# ── Tool selection ────────────────────────────────────────────────
|
||||
@@ -469,11 +492,10 @@ def cmd_mcp_add(args):
|
||||
# ── Save ──────────────────────────────────────────────────────────
|
||||
|
||||
server_config["enabled"] = True
|
||||
_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.")
|
||||
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.")
|
||||
|
||||
|
||||
# ─── hermes mcp remove ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""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))
|
||||
@@ -84,7 +84,6 @@ _STRIP_VENDOR_ONLY_PROVIDERS: frozenset[str] = frozenset({
|
||||
|
||||
# Providers whose native naming is authoritative -- pass through unchanged.
|
||||
_AUTHORITATIVE_NATIVE_PROVIDERS: frozenset[str] = frozenset({
|
||||
"gemini",
|
||||
"huggingface",
|
||||
})
|
||||
|
||||
@@ -103,6 +102,8 @@ _MATCHING_PREFIX_STRIP_PROVIDERS: frozenset[str] = frozenset({
|
||||
"arcee",
|
||||
"ollama-cloud",
|
||||
"custom",
|
||||
"gemini",
|
||||
"xai",
|
||||
})
|
||||
|
||||
# Providers whose APIs require lowercase model IDs. Xiaomi's
|
||||
|
||||
+57
-10
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import time
|
||||
@@ -1690,15 +1691,36 @@ 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 str(model_cfg.get("base_url", "")).strip()
|
||||
return model_cfg
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
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"
|
||||
|
||||
|
||||
def curated_models_for_provider(
|
||||
@@ -2218,8 +2240,21 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
|
||||
except Exception:
|
||||
pass
|
||||
if normalized == "anthropic":
|
||||
live = _fetch_anthropic_models()
|
||||
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,
|
||||
)
|
||||
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
|
||||
@@ -2288,13 +2323,16 @@ 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 = (
|
||||
os.getenv("CUSTOM_API_KEY", "")
|
||||
str(model_cfg.get("api_key", "") or "").strip()
|
||||
or os.getenv("CUSTOM_API_KEY", "")
|
||||
or os.getenv("OPENAI_API_KEY", "")
|
||||
or os.getenv("OPENROUTER_API_KEY", "")
|
||||
)
|
||||
live = fetch_api_models(api_key, base_url)
|
||||
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)
|
||||
if live:
|
||||
return live
|
||||
# Bedrock uses live discovery keyed by the resolved AWS region so that
|
||||
@@ -2543,18 +2581,24 @@ def clear_provider_models_cache(provider: Optional[str] = None) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
|
||||
def _fetch_anthropic_models(
|
||||
timeout: float = 5.0,
|
||||
*,
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
) -> 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). Returns sorted model IDs or None.
|
||||
Claude Code auto-discovery) unless api_key is provided explicitly.
|
||||
Returns sorted model IDs or None.
|
||||
"""
|
||||
try:
|
||||
from agent.anthropic_adapter import resolve_anthropic_token, _is_oauth_token
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
token = resolve_anthropic_token()
|
||||
token = (api_key or "").strip() or resolve_anthropic_token()
|
||||
if not token:
|
||||
return None
|
||||
|
||||
@@ -2569,7 +2613,7 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
|
||||
|
||||
def _do_request(h: dict[str, str]):
|
||||
req = urllib.request.Request(
|
||||
"https://api.anthropic.com/v1/models",
|
||||
_anthropic_models_url(base_url),
|
||||
headers=h,
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
@@ -3759,7 +3803,10 @@ 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()
|
||||
anthropic_models = _fetch_anthropic_models(
|
||||
base_url=base_url or None,
|
||||
api_key=api_key or None,
|
||||
)
|
||||
if anthropic_models is not None:
|
||||
if requested_for_lookup in set(anthropic_models):
|
||||
return {
|
||||
|
||||
@@ -135,6 +135,20 @@ 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.
|
||||
|
||||
@@ -146,6 +160,8 @@ 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")
|
||||
@@ -161,6 +177,17 @@ 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("#")
|
||||
|
||||
@@ -22,6 +22,7 @@ Usage::
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
@@ -44,10 +45,10 @@ _PROFILE_DIRS = [
|
||||
"plans",
|
||||
"workspace",
|
||||
"cron",
|
||||
# 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.
|
||||
# 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().
|
||||
"home",
|
||||
]
|
||||
|
||||
@@ -421,7 +422,8 @@ def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[P
|
||||
else:
|
||||
wrapper_path = wrapper_dir / canon
|
||||
try:
|
||||
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {profile} "$@"\n')
|
||||
hermes_exe = shutil.which("hermes") or "hermes"
|
||||
wrapper_path.write_text(f'#!/bin/sh\nexec {shlex.quote(hermes_exe)} -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:
|
||||
@@ -784,9 +786,9 @@ def create_profile(
|
||||
Path
|
||||
The newly created profile directory.
|
||||
"""
|
||||
if no_skills and (clone_config or clone_all):
|
||||
if no_skills and (clone_from is not None or clone_config or clone_all):
|
||||
raise ValueError(
|
||||
"--no-skills is mutually exclusive with --clone / --clone-all "
|
||||
"--no-skills is mutually exclusive with --clone / --clone-from / --clone-all "
|
||||
"(cloning explicitly copies skills from the source profile)."
|
||||
)
|
||||
canon = normalize_profile_name(name)
|
||||
|
||||
@@ -0,0 +1,747 @@
|
||||
"""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:]}"
|
||||
@@ -5,6 +5,7 @@ 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__)
|
||||
@@ -93,7 +94,8 @@ def _detect_api_mode_for_url(base_url: str) -> Optional[str]:
|
||||
return "codex_responses"
|
||||
if hostname == "api.openai.com":
|
||||
return "codex_responses"
|
||||
if normalized.endswith("/anthropic"):
|
||||
path = urlparse(normalized).path.rstrip("/")
|
||||
if path.endswith("/anthropic") or path.endswith("/anthropic/v1"):
|
||||
return "anthropic_messages"
|
||||
if hostname == "api.kimi.com" and "/coding" in normalized:
|
||||
return "anthropic_messages"
|
||||
|
||||
+30
-3
@@ -1200,6 +1200,28 @@ 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")
|
||||
@@ -1655,15 +1677,20 @@ def _setup_telegram_auto_result():
|
||||
|
||||
profile_name: str | None = None
|
||||
try:
|
||||
hermes_home = str(get_hermes_home())
|
||||
if "/profiles/" in hermes_home:
|
||||
profile_name = hermes_home.rstrip("/").rsplit("/", 1)[-1]
|
||||
profile_name = _profile_name_from_hermes_home(Path(get_hermes_home()))
|
||||
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()
|
||||
|
||||
@@ -25,7 +25,13 @@ 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. Platform-specific list falls back to global."""
|
||||
"""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``.
|
||||
"""
|
||||
skills_cfg = config.get("skills", {})
|
||||
global_disabled = set(skills_cfg.get("disabled", []))
|
||||
if platform is None:
|
||||
@@ -33,7 +39,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 set(platform_disabled)
|
||||
return global_disabled | set(platform_disabled)
|
||||
|
||||
|
||||
def save_disabled_skills(config: dict, disabled: Set[str], platform: Optional[str] = None):
|
||||
|
||||
@@ -60,6 +60,16 @@ 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",
|
||||
|
||||
@@ -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 from active profile",
|
||||
help="Copy config.yaml, .env, SOUL.md, and skills from active profile",
|
||||
)
|
||||
profile_create.add_argument(
|
||||
"--clone-all",
|
||||
action="store_true",
|
||||
help="Full copy of active profile (all state)",
|
||||
help="Full copy of active profile (all state, excluding per-profile history)",
|
||||
)
|
||||
profile_create.add_argument(
|
||||
"--clone-from",
|
||||
metavar="SOURCE",
|
||||
help="Source profile to clone from (default: active)",
|
||||
help="Source profile to clone from; implies --clone unless --clone-all is set",
|
||||
)
|
||||
profile_create.add_argument(
|
||||
"--no-alias", action="store_true", help="Skip wrapper script creation"
|
||||
|
||||
+1
-1
@@ -298,7 +298,7 @@ TIPS = [
|
||||
"Any website can expose skills via /.well-known/skills/index.json — the skills hub discovers them automatically.",
|
||||
"The skills audit log at ~/.hermes/skills/.hub/audit.log tracks every install and removal operation.",
|
||||
"Stale git worktrees are auto-cleaned: 24-72h old with no unpushed commits get pruned on startup.",
|
||||
"Each profile gets its own subprocess HOME at HERMES_HOME/home/ — isolated git, ssh, npm, gh configs.",
|
||||
"Profiles scope Hermes state via HERMES_HOME; host tool subprocesses keep your real HOME unless terminal.home_mode is profile.",
|
||||
"HERMES_HOME_MODE env var (octal, e.g. 0701) sets custom directory permissions for web server traversal.",
|
||||
"Container mode: place .container-mode in HERMES_HOME and the host CLI auto-execs into the container.",
|
||||
"Ctrl+C has 5 priority tiers: cancel recording → cancel prompts → cancel picker → interrupt agent → exit.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user