diff --git a/RELEASE_v0.6.0.md b/RELEASE_v0.6.0.md new file mode 100644 index 0000000000..5bef7c6c51 --- /dev/null +++ b/RELEASE_v0.6.0.md @@ -0,0 +1,249 @@ +# Hermes Agent v0.6.0 (v2026.3.30) + +**Release Date:** March 30, 2026 + +> The multi-instance release — Profiles for running isolated agent instances, MCP server mode, Docker container, fallback provider chains, two new messaging platforms (Feishu/Lark and WeCom), Telegram webhook mode, Slack multi-workspace OAuth, 95 PRs and 16 resolved issues in 2 days. + +--- + +## ✨ Highlights + +- **Profiles — Multi-Instance Hermes** — Run multiple isolated Hermes instances from the same installation. Each profile gets its own config, memory, sessions, skills, and gateway service. Create with `hermes profile create`, switch with `hermes -p `, export/import for sharing. Full token-lock isolation prevents two profiles from using the same bot credential. ([#3681](https://github.com/NousResearch/hermes-agent/pull/3681)) + +- **MCP Server Mode** — Expose Hermes conversations and sessions to any MCP-compatible client (Claude Desktop, Cursor, VS Code, etc.) via `hermes mcp serve`. Browse conversations, read messages, search across sessions, and manage attachments — all through the Model Context Protocol. Supports both stdio and Streamable HTTP transports. ([#3795](https://github.com/NousResearch/hermes-agent/pull/3795)) + +- **Docker Container** — Official Dockerfile for running Hermes Agent in a container. Supports both CLI and gateway modes with volume-mounted config. ([#3668](https://github.com/NousResearch/hermes-agent/pull/3668), closes [#850](https://github.com/NousResearch/hermes-agent/issues/850)) + +- **Ordered Fallback Provider Chain** — Configure multiple inference providers with automatic failover. When your primary provider returns errors or is unreachable, Hermes automatically tries the next provider in the chain. Configure via `fallback_providers` in config.yaml. ([#3813](https://github.com/NousResearch/hermes-agent/pull/3813), closes [#1734](https://github.com/NousResearch/hermes-agent/issues/1734)) + +- **Feishu/Lark Platform Support** — Full gateway adapter for Feishu (飞书) and Lark with event subscriptions, message cards, group chat, image/file attachments, and interactive card callbacks. ([#3799](https://github.com/NousResearch/hermes-agent/pull/3799), [#3817](https://github.com/NousResearch/hermes-agent/pull/3817), closes [#1788](https://github.com/NousResearch/hermes-agent/issues/1788)) + +- **WeCom (Enterprise WeChat) Platform Support** — New gateway adapter for WeCom (企业微信) with text/image/voice messages, group chats, and callback verification. ([#3847](https://github.com/NousResearch/hermes-agent/pull/3847)) + +- **Slack Multi-Workspace OAuth** — Connect a single Hermes gateway to multiple Slack workspaces via OAuth token file. Each workspace gets its own bot token, resolved dynamically per incoming event. ([#3903](https://github.com/NousResearch/hermes-agent/pull/3903)) + +- **Telegram Webhook Mode & Group Controls** — Run the Telegram adapter in webhook mode as an alternative to polling — faster response times and better for production deployments behind a reverse proxy. New group mention gating controls when the bot responds: always, only when @mentioned, or via regex triggers. ([#3880](https://github.com/NousResearch/hermes-agent/pull/3880), [#3870](https://github.com/NousResearch/hermes-agent/pull/3870)) + +- **Exa Search Backend** — Add Exa as an alternative web search and content extraction backend alongside Firecrawl and DuckDuckGo. Set `EXA_API_KEY` and configure as preferred backend. ([#3648](https://github.com/NousResearch/hermes-agent/pull/3648)) + +- **Skills & Credentials on Remote Backends** — Mount skill directories and credential files into Modal and Docker containers, so remote terminal sessions have access to the same skills and secrets as local execution. ([#3890](https://github.com/NousResearch/hermes-agent/pull/3890), [#3671](https://github.com/NousResearch/hermes-agent/pull/3671), closes [#3665](https://github.com/NousResearch/hermes-agent/issues/3665), [#3433](https://github.com/NousResearch/hermes-agent/issues/3433)) + +--- + +## 🏗️ Core Agent & Architecture + +### Provider & Model Support +- **Ordered fallback provider chain** — automatic failover across multiple configured providers ([#3813](https://github.com/NousResearch/hermes-agent/pull/3813)) +- **Fix api_mode on provider switch** — switching providers via `hermes model` now correctly clears stale `api_mode` instead of hardcoding `chat_completions`, fixing 404s for providers with Anthropic-compatible endpoints ([#3726](https://github.com/NousResearch/hermes-agent/pull/3726), [#3857](https://github.com/NousResearch/hermes-agent/pull/3857), closes [#3685](https://github.com/NousResearch/hermes-agent/issues/3685)) +- **Stop silent OpenRouter fallback** — when no provider is configured, Hermes now raises a clear error instead of silently routing to OpenRouter ([#3807](https://github.com/NousResearch/hermes-agent/pull/3807), [#3862](https://github.com/NousResearch/hermes-agent/pull/3862)) +- **Gemini 3.1 preview models** — added to OpenRouter and Nous Portal catalogs ([#3803](https://github.com/NousResearch/hermes-agent/pull/3803), closes [#3753](https://github.com/NousResearch/hermes-agent/issues/3753)) +- **Gemini direct API context length** — full context length resolution for direct Google AI endpoints ([#3876](https://github.com/NousResearch/hermes-agent/pull/3876)) +- **gpt-5.4-mini** added to Codex fallback catalog ([#3855](https://github.com/NousResearch/hermes-agent/pull/3855)) +- **Curated model lists preferred** over live API probe when the probe returns fewer models ([#3856](https://github.com/NousResearch/hermes-agent/pull/3856), [#3867](https://github.com/NousResearch/hermes-agent/pull/3867)) +- **User-friendly 429 rate limit messages** with Retry-After countdown ([#3809](https://github.com/NousResearch/hermes-agent/pull/3809)) +- **Auxiliary client placeholder key** for local servers without auth requirements ([#3842](https://github.com/NousResearch/hermes-agent/pull/3842)) +- **INFO-level logging** for auxiliary provider resolution ([#3866](https://github.com/NousResearch/hermes-agent/pull/3866)) + +### Agent Loop & Conversation +- **Subagent status reporting** — reports `completed` status when summary exists instead of generic failure ([#3829](https://github.com/NousResearch/hermes-agent/pull/3829)) +- **Session log file updated during compression** — prevents stale file references after context compression ([#3835](https://github.com/NousResearch/hermes-agent/pull/3835)) +- **Omit empty tools param** — sends no `tools` parameter when empty instead of `None`, fixing compatibility with strict providers ([#3820](https://github.com/NousResearch/hermes-agent/pull/3820)) + +### Profiles & Multi-Instance +- **Profiles system** — `hermes profile create/list/switch/delete/export/import/rename`. Each profile gets isolated HERMES_HOME, gateway service, CLI wrapper. Token locks prevent credential collisions. Tab completion for profile names. ([#3681](https://github.com/NousResearch/hermes-agent/pull/3681)) +- **Profile-aware display paths** — all user-facing `~/.hermes` paths replaced with `display_hermes_home()` to show the correct profile directory ([#3623](https://github.com/NousResearch/hermes-agent/pull/3623)) +- **Lazy display_hermes_home imports** — prevents `ImportError` during `hermes update` when modules cache stale bytecode ([#3776](https://github.com/NousResearch/hermes-agent/pull/3776)) +- **HERMES_HOME for protected paths** — `.env` write-deny path now respects HERMES_HOME instead of hardcoded `~/.hermes` ([#3840](https://github.com/NousResearch/hermes-agent/pull/3840)) + +--- + +## 📱 Messaging Platforms (Gateway) + +### New Platforms +- **Feishu/Lark** — Full adapter with event subscriptions, message cards, group chat, image/file attachments, interactive card callbacks ([#3799](https://github.com/NousResearch/hermes-agent/pull/3799), [#3817](https://github.com/NousResearch/hermes-agent/pull/3817)) +- **WeCom (Enterprise WeChat)** — Text/image/voice messages, group chats, callback verification ([#3847](https://github.com/NousResearch/hermes-agent/pull/3847)) + +### Telegram +- **Webhook mode** — run as webhook endpoint instead of polling for production deployments ([#3880](https://github.com/NousResearch/hermes-agent/pull/3880)) +- **Group mention gating & regex triggers** — configurable bot response behavior in groups: always, @mention-only, or regex-matched ([#3870](https://github.com/NousResearch/hermes-agent/pull/3870)) +- **Gracefully handle deleted reply targets** — no more crashes when the message being replied to was deleted ([#3858](https://github.com/NousResearch/hermes-agent/pull/3858), closes [#3229](https://github.com/NousResearch/hermes-agent/issues/3229)) + +### Discord +- **Message processing reactions** — adds a reaction emoji while processing and removes it when done, giving visual feedback in channels ([#3871](https://github.com/NousResearch/hermes-agent/pull/3871)) +- **DISCORD_IGNORE_NO_MENTION** — skip messages that @mention other users/bots but not Hermes ([#3640](https://github.com/NousResearch/hermes-agent/pull/3640)) +- **Clean up deferred "thinking..."** — properly removes the "thinking..." indicator after slash commands complete ([#3674](https://github.com/NousResearch/hermes-agent/pull/3674), closes [#3595](https://github.com/NousResearch/hermes-agent/issues/3595)) + +### Slack +- **Multi-workspace OAuth** — connect to multiple Slack workspaces from a single gateway via OAuth token file ([#3903](https://github.com/NousResearch/hermes-agent/pull/3903)) + +### WhatsApp +- **Persistent aiohttp session** — reuse HTTP sessions across requests instead of creating new ones per message ([#3818](https://github.com/NousResearch/hermes-agent/pull/3818)) +- **LID↔phone alias resolution** — correctly match Linked ID and phone number formats in allowlists ([#3830](https://github.com/NousResearch/hermes-agent/pull/3830)) +- **Skip reply prefix in bot mode** — cleaner message formatting when running as a WhatsApp bot ([#3931](https://github.com/NousResearch/hermes-agent/pull/3931)) + +### Matrix +- **Native voice messages via MSC3245** — send voice messages as proper Matrix voice events instead of file attachments ([#3877](https://github.com/NousResearch/hermes-agent/pull/3877)) + +### Mattermost +- **Configurable mention behavior** — respond to messages without requiring @mention ([#3664](https://github.com/NousResearch/hermes-agent/pull/3664)) + +### Signal +- **URL-encode phone numbers** and correct attachment RPC parameter — fixes delivery failures with certain phone number formats ([#3670](https://github.com/NousResearch/hermes-agent/pull/3670)) — @kshitijk4poor + +### Email +- **Close SMTP/IMAP connections on failure** — prevents connection leaks during error scenarios ([#3804](https://github.com/NousResearch/hermes-agent/pull/3804)) + +### Gateway Core +- **Atomic config writes** — use atomic file writes for config.yaml to prevent data loss during crashes ([#3800](https://github.com/NousResearch/hermes-agent/pull/3800)) +- **Home channel env overrides** — apply environment variable overrides for home channels consistently ([#3796](https://github.com/NousResearch/hermes-agent/pull/3796), [#3808](https://github.com/NousResearch/hermes-agent/pull/3808)) +- **Replace print() with logger** — BasePlatformAdapter now uses proper logging instead of print statements ([#3669](https://github.com/NousResearch/hermes-agent/pull/3669)) +- **Cron delivery labels** — resolve human-friendly delivery labels via channel directory ([#3860](https://github.com/NousResearch/hermes-agent/pull/3860), closes [#1945](https://github.com/NousResearch/hermes-agent/issues/1945)) +- **Cron [SILENT] tightening** — prevent agents from prefixing reports with [SILENT] to suppress delivery ([#3901](https://github.com/NousResearch/hermes-agent/pull/3901)) +- **Background task media delivery** and vision download timeout fixes ([#3919](https://github.com/NousResearch/hermes-agent/pull/3919)) +- **Boot-md hook** — example built-in hook to run a BOOT.md file on gateway startup ([#3733](https://github.com/NousResearch/hermes-agent/pull/3733)) + +--- + +## 🖥️ CLI & User Experience + +### Interactive CLI +- **Configurable tool preview length** — show full file paths by default instead of truncating at 40 chars ([#3841](https://github.com/NousResearch/hermes-agent/pull/3841)) +- **Tool token context display** — `hermes tools` checklist now shows estimated token cost per toolset ([#3805](https://github.com/NousResearch/hermes-agent/pull/3805)) +- **/bg spinner TUI fix** — route background task spinner through the TUI widget to prevent status bar collision ([#3643](https://github.com/NousResearch/hermes-agent/pull/3643)) +- **Prevent status bar wrapping** into duplicate rows ([#3883](https://github.com/NousResearch/hermes-agent/pull/3883)) — @kshitijk4poor +- **Handle closed stdout ValueError** in safe print paths — fixes crashes when stdout is closed during gateway thread shutdown ([#3843](https://github.com/NousResearch/hermes-agent/pull/3843), closes [#3534](https://github.com/NousResearch/hermes-agent/issues/3534)) +- **Remove input() from /tools disable** — eliminates freeze in terminal when disabling tools ([#3918](https://github.com/NousResearch/hermes-agent/pull/3918)) +- **TTY guard for interactive CLI commands** — prevent CPU spin when launched without a terminal ([#3933](https://github.com/NousResearch/hermes-agent/pull/3933)) +- **Argparse entrypoint** — use argparse in the top-level launcher for cleaner error handling ([#3874](https://github.com/NousResearch/hermes-agent/pull/3874)) +- **Lazy-initialized tools show yellow** in banner instead of red, reducing false alarm about "missing" tools ([#3822](https://github.com/NousResearch/hermes-agent/pull/3822)) +- **Honcho tools shown in banner** when configured ([#3810](https://github.com/NousResearch/hermes-agent/pull/3810)) + +### Setup & Configuration +- **Auto-install matrix-nio** during `hermes setup` when Matrix is selected ([#3802](https://github.com/NousResearch/hermes-agent/pull/3802), [#3873](https://github.com/NousResearch/hermes-agent/pull/3873)) +- **Session export stdout support** — export sessions to stdout with `-` for piping ([#3641](https://github.com/NousResearch/hermes-agent/pull/3641), closes [#3609](https://github.com/NousResearch/hermes-agent/issues/3609)) +- **Configurable approval timeouts** — set how long dangerous command approval prompts wait before auto-denying ([#3886](https://github.com/NousResearch/hermes-agent/pull/3886), closes [#3765](https://github.com/NousResearch/hermes-agent/issues/3765)) +- **Clear __pycache__ during update** — prevents stale bytecode ImportError after `hermes update` ([#3819](https://github.com/NousResearch/hermes-agent/pull/3819)) + +--- + +## 🔧 Tool System + +### MCP +- **MCP Server Mode** — `hermes mcp serve` exposes conversations, sessions, and attachments to MCP clients via stdio or Streamable HTTP ([#3795](https://github.com/NousResearch/hermes-agent/pull/3795)) +- **Dynamic tool discovery** — respond to `notifications/tools/list_changed` events to pick up new tools from MCP servers without reconnecting ([#3812](https://github.com/NousResearch/hermes-agent/pull/3812)) +- **Non-deprecated HTTP transport** — switched from `sse_client` to `streamable_http_client` ([#3646](https://github.com/NousResearch/hermes-agent/pull/3646)) + +### Web Tools +- **Exa search backend** — alternative to Firecrawl and DuckDuckGo for web search and extraction ([#3648](https://github.com/NousResearch/hermes-agent/pull/3648)) + +### Browser +- **Guard against None LLM responses** in browser snapshot and vision tools ([#3642](https://github.com/NousResearch/hermes-agent/pull/3642)) + +### Terminal & Remote Backends +- **Mount skill directories** into Modal and Docker containers ([#3890](https://github.com/NousResearch/hermes-agent/pull/3890)) +- **Mount credential files** into remote backends with mtime+size caching ([#3671](https://github.com/NousResearch/hermes-agent/pull/3671)) +- **Preserve partial output** when commands time out instead of losing everything ([#3868](https://github.com/NousResearch/hermes-agent/pull/3868)) +- **Stop marking persisted env vars as missing** on remote backends ([#3650](https://github.com/NousResearch/hermes-agent/pull/3650)) + +### Audio +- **.aac format support** in transcription tool ([#3865](https://github.com/NousResearch/hermes-agent/pull/3865), closes [#1963](https://github.com/NousResearch/hermes-agent/issues/1963)) +- **Audio download retry** — retry logic for `cache_audio_from_url` matching the existing image download pattern ([#3401](https://github.com/NousResearch/hermes-agent/pull/3401)) — @binhnt92 + +### Vision +- **Reject non-image files** and enforce website-only policy for vision analysis ([#3845](https://github.com/NousResearch/hermes-agent/pull/3845)) + +### Tool Schema +- **Ensure name field** always present in tool definitions, fixing `KeyError: 'name'` crashes ([#3811](https://github.com/NousResearch/hermes-agent/pull/3811), closes [#3729](https://github.com/NousResearch/hermes-agent/issues/3729)) + +### ACP (Editor Integration) +- **Complete session management surface** for VS Code/Zed/JetBrains clients — proper task lifecycle, cancel support, session persistence ([#3675](https://github.com/NousResearch/hermes-agent/pull/3675)) + +--- + +## 🧩 Skills & Plugins + +### Skills System +- **External skill directories** — configure additional skill directories via `skills.external_dirs` in config.yaml ([#3678](https://github.com/NousResearch/hermes-agent/pull/3678)) +- **Category path traversal blocked** — prevents `../` attacks in skill category names ([#3844](https://github.com/NousResearch/hermes-agent/pull/3844)) +- **parallel-cli moved to optional-skills** — reduces default skill footprint ([#3673](https://github.com/NousResearch/hermes-agent/pull/3673)) — @kshitijk4poor + +### New Skills +- **memento-flashcards** — spaced repetition flashcard system ([#3827](https://github.com/NousResearch/hermes-agent/pull/3827)) +- **songwriting-and-ai-music** — songwriting craft and AI music generation prompts ([#3834](https://github.com/NousResearch/hermes-agent/pull/3834)) +- **SiYuan Note** — integration with SiYuan note-taking app ([#3742](https://github.com/NousResearch/hermes-agent/pull/3742)) +- **Scrapling** — web scraping skill using Scrapling library ([#3742](https://github.com/NousResearch/hermes-agent/pull/3742)) +- **one-three-one-rule** — communication framework skill ([#3797](https://github.com/NousResearch/hermes-agent/pull/3797)) + +### Plugin System +- **Plugin enable/disable commands** — `hermes plugins enable/disable ` for managing plugin state without removing them ([#3747](https://github.com/NousResearch/hermes-agent/pull/3747)) +- **Plugin message injection** — plugins can now inject messages into the conversation stream on behalf of the user via `ctx.inject_message()` ([#3778](https://github.com/NousResearch/hermes-agent/pull/3778)) — @winglian +- **Honcho self-hosted support** — allow local Honcho instances without requiring an API key ([#3644](https://github.com/NousResearch/hermes-agent/pull/3644)) + +--- + +## 🔒 Security & Reliability + +### Security Hardening +- **Hardened dangerous command detection** — expanded pattern matching for risky shell commands and added file tool path guards for sensitive locations (`/etc/`, `/boot/`, docker.sock) ([#3872](https://github.com/NousResearch/hermes-agent/pull/3872)) +- **Sensitive path write checks** in approval system — catch writes to system config files through file tools, not just terminal ([#3859](https://github.com/NousResearch/hermes-agent/pull/3859)) +- **Secret redaction expansion** — now covers ElevenLabs, Tavily, and Exa API keys ([#3920](https://github.com/NousResearch/hermes-agent/pull/3920)) +- **Vision file rejection** — reject non-image files passed to vision analysis to prevent information disclosure ([#3845](https://github.com/NousResearch/hermes-agent/pull/3845)) +- **Category path traversal blocking** — prevent directory traversal in skill category names ([#3844](https://github.com/NousResearch/hermes-agent/pull/3844)) + +### Reliability +- **Atomic config.yaml writes** — prevent data loss during gateway crashes ([#3800](https://github.com/NousResearch/hermes-agent/pull/3800)) +- **Clear __pycache__ on update** — prevent stale bytecode from causing ImportError after updates ([#3819](https://github.com/NousResearch/hermes-agent/pull/3819)) +- **Lazy imports for update safety** — prevent ImportError chains during `hermes update` when modules reference new functions ([#3776](https://github.com/NousResearch/hermes-agent/pull/3776)) +- **Restore terminalbench2 from patch corruption** — recovered file damaged by patch tool's secret redaction ([#3801](https://github.com/NousResearch/hermes-agent/pull/3801)) +- **Terminal timeout preserves partial output** — no more lost command output on timeout ([#3868](https://github.com/NousResearch/hermes-agent/pull/3868)) + +--- + +## 🐛 Notable Bug Fixes + +- **OpenClaw migration model config overwrite** — migration no longer overwrites model config dict with a string ([#3924](https://github.com/NousResearch/hermes-agent/pull/3924)) — @0xbyt4 +- **OpenClaw migration expanded** — covers full data footprint including sessions, cron, memory ([#3869](https://github.com/NousResearch/hermes-agent/pull/3869)) +- **Telegram deleted reply targets** — gracefully handle replies to deleted messages instead of crashing ([#3858](https://github.com/NousResearch/hermes-agent/pull/3858)) +- **Discord "thinking..." persistence** — properly cleans up deferred response indicators ([#3674](https://github.com/NousResearch/hermes-agent/pull/3674)) +- **WhatsApp LID↔phone aliases** — fixes allowlist matching failures with Linked ID format ([#3830](https://github.com/NousResearch/hermes-agent/pull/3830)) +- **Signal URL-encoded phone numbers** — fixes delivery failures with certain formats ([#3670](https://github.com/NousResearch/hermes-agent/pull/3670)) +- **Email connection leaks** — properly close SMTP/IMAP connections on error ([#3804](https://github.com/NousResearch/hermes-agent/pull/3804)) +- **_safe_print ValueError** — no more gateway thread crashes on closed stdout ([#3843](https://github.com/NousResearch/hermes-agent/pull/3843)) +- **Tool schema KeyError 'name'** — ensure name field always present in tool definitions ([#3811](https://github.com/NousResearch/hermes-agent/pull/3811)) +- **api_mode stale on provider switch** — correctly clear when switching providers via `hermes model` ([#3857](https://github.com/NousResearch/hermes-agent/pull/3857)) + +--- + +## 🧪 Testing + +- Resolved 10+ CI failures across hooks, tiktoken, plugins, and skill tests ([#3848](https://github.com/NousResearch/hermes-agent/pull/3848), [#3721](https://github.com/NousResearch/hermes-agent/pull/3721), [#3936](https://github.com/NousResearch/hermes-agent/pull/3936)) + +--- + +## 📚 Documentation + +- **Comprehensive OpenClaw migration guide** — step-by-step guide for migrating from OpenClaw/Claw3D to Hermes Agent ([#3864](https://github.com/NousResearch/hermes-agent/pull/3864), [#3900](https://github.com/NousResearch/hermes-agent/pull/3900)) +- **Credential file passthrough docs** — document how to forward credential files and env vars to remote backends ([#3677](https://github.com/NousResearch/hermes-agent/pull/3677)) +- **DuckDuckGo requirements clarified** — note runtime dependency on duckduckgo-search package ([#3680](https://github.com/NousResearch/hermes-agent/pull/3680)) +- **Skills catalog updated** — added red-teaming category and optional skills listing ([#3745](https://github.com/NousResearch/hermes-agent/pull/3745)) +- **Feishu docs MDX fix** — escape angle-bracket URLs that break Docusaurus build ([#3902](https://github.com/NousResearch/hermes-agent/pull/3902)) + +--- + +## 👥 Contributors + +### Core +- **@teknium1** — 90 PRs across all subsystems + +### Community Contributors +- **@kshitijk4poor** — 3 PRs: Signal phone number fix ([#3670](https://github.com/NousResearch/hermes-agent/pull/3670)), parallel-cli to optional-skills ([#3673](https://github.com/NousResearch/hermes-agent/pull/3673)), status bar wrapping fix ([#3883](https://github.com/NousResearch/hermes-agent/pull/3883)) +- **@winglian** — 1 PR: Plugin message injection interface ([#3778](https://github.com/NousResearch/hermes-agent/pull/3778)) +- **@binhnt92** — 1 PR: Audio download retry logic ([#3401](https://github.com/NousResearch/hermes-agent/pull/3401)) +- **@0xbyt4** — 1 PR: OpenClaw migration model config fix ([#3924](https://github.com/NousResearch/hermes-agent/pull/3924)) + +### Issues Resolved from Community +@Material-Scientist ([#850](https://github.com/NousResearch/hermes-agent/issues/850)), @hanxu98121 ([#1734](https://github.com/NousResearch/hermes-agent/issues/1734)), @penwyp ([#1788](https://github.com/NousResearch/hermes-agent/issues/1788)), @dan-and ([#1945](https://github.com/NousResearch/hermes-agent/issues/1945)), @AdrianScott ([#1963](https://github.com/NousResearch/hermes-agent/issues/1963)), @clawdbot47 ([#3229](https://github.com/NousResearch/hermes-agent/issues/3229)), @alanfwilliams ([#3404](https://github.com/NousResearch/hermes-agent/issues/3404)), @kentimsit ([#3433](https://github.com/NousResearch/hermes-agent/issues/3433)), @hayka-pacha ([#3534](https://github.com/NousResearch/hermes-agent/issues/3534)), @primmer ([#3595](https://github.com/NousResearch/hermes-agent/issues/3595)), @dagelf ([#3609](https://github.com/NousResearch/hermes-agent/issues/3609)), @HenkDz ([#3685](https://github.com/NousResearch/hermes-agent/issues/3685)), @tmdgusya ([#3729](https://github.com/NousResearch/hermes-agent/issues/3729)), @TypQxQ ([#3753](https://github.com/NousResearch/hermes-agent/issues/3753)), @acsezen ([#3765](https://github.com/NousResearch/hermes-agent/issues/3765)) + +--- + +**Full Changelog**: [v2026.3.28...v2026.3.30](https://github.com/NousResearch/hermes-agent/compare/v2026.3.28...v2026.3.30) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index a2a052d0a8..a817364965 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -162,6 +162,21 @@ def _is_oauth_token(key: str) -> bool: return True +def _requires_bearer_auth(base_url: str | None) -> bool: + """Return True for Anthropic-compatible providers that require Bearer auth. + + Some third-party /anthropic endpoints implement Anthropic's Messages API but + require Authorization: Bearer instead of Anthropic's native x-api-key header. + MiniMax's global and China Anthropic-compatible endpoints follow this pattern. + """ + if not base_url: + return False + normalized = base_url.rstrip("/").lower() + return normalized.startswith("https://api.minimax.io/anthropic") or normalized.startswith( + "https://api.minimaxi.com/anthropic" + ) + + def build_anthropic_client(api_key: str, base_url: str = None): """Create an Anthropic client, auto-detecting setup-tokens vs API keys. @@ -180,7 +195,17 @@ def build_anthropic_client(api_key: str, base_url: str = None): if base_url: kwargs["base_url"] = base_url - if _is_oauth_token(api_key): + if _requires_bearer_auth(base_url): + # Some Anthropic-compatible providers (e.g. MiniMax) expect the API key in + # Authorization: Bearer even for regular API keys. Route those endpoints + # through auth_token so the SDK sends Bearer auth instead of x-api-key. + # Check this before OAuth token shape detection because MiniMax secrets do + # not use Anthropic's sk-ant-api prefix and would otherwise be misread as + # Anthropic OAuth/setup tokens. + kwargs["auth_token"] = api_key + if _COMMON_BETAS: + kwargs["default_headers"] = {"anthropic-beta": ",".join(_COMMON_BETAS)} + elif _is_oauth_token(api_key): # OAuth access token / setup-token → Bearer auth + Claude Code identity. # Anthropic routes OAuth requests based on user-agent and headers; # without Claude Code's fingerprint, requests get intermittent 500s. diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 162295f81d..0c121e6f63 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -171,6 +171,7 @@ _URL_TO_PROVIDER: Dict[str, str] = { "dashscope.aliyuncs.com": "alibaba", "dashscope-intl.aliyuncs.com": "alibaba", "openrouter.ai": "openrouter", + "generativelanguage.googleapis.com": "google", "inference-api.nousresearch.com": "nous", "api.deepseek.com": "deepseek", "api.githubcopilot.com": "copilot", diff --git a/agent/redact.py b/agent/redact.py index d298ffb030..895e3265fd 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -37,6 +37,9 @@ _PREFIX_PATTERNS = [ r"dop_v1_[A-Za-z0-9]{10,}", # DigitalOcean PAT r"doo_v1_[A-Za-z0-9]{10,}", # DigitalOcean OAuth r"am_[A-Za-z0-9_-]{10,}", # AgentMail API key + r"sk_[A-Za-z0-9_]{10,}", # ElevenLabs TTS key (sk_ underscore, not sk- dash) + r"tvly-[A-Za-z0-9]{10,}", # Tavily search API key + r"exa_[A-Za-z0-9]{10,}", # Exa search API key ] # ENV assignment patterns: KEY=value where KEY contains a secret-like name diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 504b2178d8..922807f17a 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -324,6 +324,9 @@ compression: # vision: # provider: "auto" # model: "" # e.g. "google/gemini-2.5-flash", "openai/gpt-4o" +# timeout: 30 # LLM API call timeout (seconds) +# download_timeout: 30 # Image HTTP download timeout (seconds) +# # Increase for slow connections or self-hosted image servers # # # Web page scraping / summarization + browser page text extraction # web_extract: diff --git a/cli.py b/cli.py index a601878f24..e01a0e797d 100644 --- a/cli.py +++ b/cli.py @@ -1355,6 +1355,49 @@ class HermesCLI: return snapshot + @staticmethod + def _status_bar_display_width(text: str) -> int: + """Return terminal cell width for status-bar text. + + len() is not enough for prompt_toolkit layout decisions because some + glyphs can render wider than one Python codepoint. Keeping the status + bar within the real display width prevents it from wrapping onto a + second line and leaving behind duplicate rows. + """ + try: + from prompt_toolkit.utils import get_cwidth + return get_cwidth(text or "") + except Exception: + return len(text or "") + + @classmethod + def _trim_status_bar_text(cls, text: str, max_width: int) -> str: + """Trim status-bar text to a single terminal row.""" + if max_width <= 0: + return "" + try: + from prompt_toolkit.utils import get_cwidth + except Exception: + get_cwidth = None + + if cls._status_bar_display_width(text) <= max_width: + return text + + ellipsis = "..." + ellipsis_width = cls._status_bar_display_width(ellipsis) + if max_width <= ellipsis_width: + return ellipsis[:max_width] + + out = [] + width = 0 + for ch in text: + ch_width = get_cwidth(ch) if get_cwidth else len(ch) + if width + ch_width + ellipsis_width > max_width: + break + out.append(ch) + width += ch_width + return "".join(out).rstrip() + ellipsis + def _build_status_bar_text(self, width: Optional[int] = None) -> str: try: snapshot = self._get_status_bar_snapshot() @@ -1369,11 +1412,12 @@ class HermesCLI: duration_label = snapshot["duration"] if width < 52: - return f"⚕ {snapshot['model_short']} · {duration_label}" + text = f"⚕ {snapshot['model_short']} · {duration_label}" + return self._trim_status_bar_text(text, width) if width < 76: parts = [f"⚕ {snapshot['model_short']}", percent_label] parts.append(duration_label) - return " · ".join(parts) + return self._trim_status_bar_text(" · ".join(parts), width) if snapshot["context_length"]: ctx_total = _format_context_length(snapshot["context_length"]) @@ -1384,7 +1428,7 @@ class HermesCLI: parts = [f"⚕ {snapshot['model_short']}", context_label, percent_label] parts.append(duration_label) - return " │ ".join(parts) + return self._trim_status_bar_text(" │ ".join(parts), width) except Exception: return f"⚕ {self.model if getattr(self, 'model', None) else 'Hermes'}" @@ -1406,53 +1450,54 @@ class HermesCLI: duration_label = snapshot["duration"] if width < 52: - return [ - ("class:status-bar", " ⚕ "), - ("class:status-bar-strong", snapshot["model_short"]), - ("class:status-bar-dim", " · "), - ("class:status-bar-dim", duration_label), - ("class:status-bar", " "), - ] - - percent = snapshot["context_percent"] - percent_label = f"{percent}%" if percent is not None else "--" - if width < 76: frags = [ ("class:status-bar", " ⚕ "), ("class:status-bar-strong", snapshot["model_short"]), - ("class:status-bar-dim", " · "), - (self._status_bar_context_style(percent), percent_label), - ] - frags.extend([ ("class:status-bar-dim", " · "), ("class:status-bar-dim", duration_label), ("class:status-bar", " "), - ]) - return frags - - if snapshot["context_length"]: - ctx_total = _format_context_length(snapshot["context_length"]) - ctx_used = format_token_count_compact(snapshot["context_tokens"]) - context_label = f"{ctx_used}/{ctx_total}" + ] else: - context_label = "ctx --" + percent = snapshot["context_percent"] + percent_label = f"{percent}%" if percent is not None else "--" + if width < 76: + frags = [ + ("class:status-bar", " ⚕ "), + ("class:status-bar-strong", snapshot["model_short"]), + ("class:status-bar-dim", " · "), + (self._status_bar_context_style(percent), percent_label), + ("class:status-bar-dim", " · "), + ("class:status-bar-dim", duration_label), + ("class:status-bar", " "), + ] + else: + if snapshot["context_length"]: + ctx_total = _format_context_length(snapshot["context_length"]) + ctx_used = format_token_count_compact(snapshot["context_tokens"]) + context_label = f"{ctx_used}/{ctx_total}" + else: + context_label = "ctx --" - bar_style = self._status_bar_context_style(percent) - frags = [ - ("class:status-bar", " ⚕ "), - ("class:status-bar-strong", snapshot["model_short"]), - ("class:status-bar-dim", " │ "), - ("class:status-bar-dim", context_label), - ("class:status-bar-dim", " │ "), - (bar_style, self._build_context_bar(percent)), - ("class:status-bar-dim", " "), - (bar_style, percent_label), - ] - frags.extend([ - ("class:status-bar-dim", " │ "), - ("class:status-bar-dim", duration_label), - ("class:status-bar", " "), - ]) + bar_style = self._status_bar_context_style(percent) + frags = [ + ("class:status-bar", " ⚕ "), + ("class:status-bar-strong", snapshot["model_short"]), + ("class:status-bar-dim", " │ "), + ("class:status-bar-dim", context_label), + ("class:status-bar-dim", " │ "), + (bar_style, self._build_context_bar(percent)), + ("class:status-bar-dim", " "), + (bar_style, percent_label), + ("class:status-bar-dim", " │ "), + ("class:status-bar-dim", duration_label), + ("class:status-bar", " "), + ] + + total_width = sum(self._status_bar_display_width(text) for _, text in frags) + if total_width > width: + plain_text = "".join(text for _, text in frags) + trimmed = self._trim_status_bar_text(plain_text, width) + return [("class:status-bar", trimmed)] return frags except Exception: return [("class:status-bar", f" {self._build_status_bar_text()} ")] @@ -2744,22 +2789,12 @@ class HermesCLI: print(f" MCP tool: /tools {subcommand} github:create_issue") return - # Confirm session reset before applying - verb = "Disable" if subcommand == "disable" else "Enable" + # Apply the change directly — the user typing the command is implicit + # consent. Do NOT use input() here; it hangs inside prompt_toolkit's + # TUI event loop (known pitfall). + verb = "Disabling" if subcommand == "disable" else "Enabling" label = ", ".join(names) - _cprint(f"{_GOLD}{verb} {label}?{_RST}") - _cprint(f"{_DIM}This will save to config and reset your session so the " - f"change takes effect cleanly.{_RST}") - try: - answer = input(" Continue? [y/N] ").strip().lower() - except (EOFError, KeyboardInterrupt): - print() - _cprint(f"{_DIM}Cancelled.{_RST}") - return - - if answer not in ("y", "yes"): - _cprint(f"{_DIM}Cancelled.{_RST}") - return + _cprint(f"{_GOLD}{verb} {label}...{_RST}") tools_disable_enable_command( Namespace(tools_action=subcommand, names=names, platform="cli")) @@ -2802,6 +2837,28 @@ class HermesCLI: print(" Example: python cli.py --toolsets web,terminal") print() + def _handle_profile_command(self): + """Display active profile name and home directory.""" + from hermes_constants import get_hermes_home, display_hermes_home + + home = get_hermes_home() + display = display_hermes_home() + + profiles_parent = Path.home() / ".hermes" / "profiles" + try: + rel = home.relative_to(profiles_parent) + profile_name = str(rel).split("/")[0] + except ValueError: + profile_name = None + + print() + if profile_name: + print(f" Profile: {profile_name}") + else: + print(" Profile: default") + print(f" Home: {display}") + print() + def show_config(self): """Display current configuration with kawaii ASCII art.""" # Get terminal config from environment (which was set from cli-config.yaml) @@ -3644,6 +3701,8 @@ class HermesCLI: return False elif canonical == "help": self.show_help() + elif canonical == "profile": + self._handle_profile_command() elif canonical == "tools": self._handle_tools_command(cmd_original) elif canonical == "toolsets": @@ -3801,6 +3860,8 @@ class HermesCLI: self.console.print(f" Status bar {state}") elif canonical == "verbose": self._toggle_verbose() + elif canonical == "yolo": + self._toggle_yolo() elif canonical == "reasoning": self._handle_reasoning_command(cmd_original) elif canonical == "compress": @@ -4399,6 +4460,17 @@ class HermesCLI: } _cprint(labels.get(self.tool_progress_mode, "")) + def _toggle_yolo(self): + """Toggle YOLO mode — skip all dangerous command approval prompts.""" + import os + current = bool(os.environ.get("HERMES_YOLO_MODE")) + if current: + os.environ.pop("HERMES_YOLO_MODE", None) + self.console.print(" ⚠ YOLO mode [bold red]OFF[/] — dangerous commands will require approval.") + else: + os.environ["HERMES_YOLO_MODE"] = "1" + self.console.print(" ⚡ YOLO mode [bold green]ON[/] — all commands auto-approved. Use with caution.") + def _handle_reasoning_command(self, cmd: str): """Handle /reasoning — manage effort level and display toggle. @@ -6165,6 +6237,11 @@ class HermesCLI: self._interrupt_queue = queue.Queue() # For messages typed while agent is running self._should_exit = False self._last_ctrl_c_time = 0 # Track double Ctrl+C for force exit + + # Give plugin manager a CLI reference so plugins can inject messages + from hermes_cli.plugins import get_plugin_manager + get_plugin_manager()._cli_ref = self + # Config file watcher — detect mcp_servers changes and auto-reload from hermes_cli.config import get_config_path as _get_config_path _cfg_path = _get_config_path() diff --git a/cron/scheduler.py b/cron/scheduler.py index e4299836f4..a03f00b76d 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -236,11 +236,12 @@ def _build_job_prompt(job: dict) -> str: # Always prepend [SILENT] guidance so the cron agent can suppress # delivery when it has nothing new or noteworthy to report. silent_hint = ( - "[SYSTEM: If you have nothing new or noteworthy to report, respond " - "with exactly \"[SILENT]\" (optionally followed by a brief internal " - "note). This suppresses delivery to the user while still saving " - "output locally. Only use [SILENT] when there are genuinely no " - "changes worth reporting.]\n\n" + "[SYSTEM: If you have a meaningful status report or findings, " + "send them — that is the whole point of this job. Only respond " + "with exactly \"[SILENT]\" (nothing else) when there is genuinely " + "nothing new to report. [SILENT] suppresses delivery to the user. " + "Never combine [SILENT] with content — either report your " + "findings normally, or say [SILENT] and nothing more.]\n\n" ) prompt = silent_hint + prompt if skills is None: diff --git a/gateway/config.py b/gateway/config.py index d7383100e8..fd7500c2e9 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -24,6 +24,15 @@ logger = logging.getLogger(__name__) def _coerce_bool(value: Any, default: bool = True) -> bool: """Coerce bool-ish config values, preserving a caller-provided default.""" + if value is None: + return default + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in ("true", "1", "yes", "on"): + return True + if lowered in ("false", "0", "no", "off"): + return False + return default return is_truthy_value(value, default=default) @@ -510,6 +519,10 @@ def load_gateway_config() -> GatewayConfig: ) if "reply_prefix" in platform_cfg: bridged["reply_prefix"] = platform_cfg["reply_prefix"] + if "require_mention" in platform_cfg: + bridged["require_mention"] = platform_cfg["require_mention"] + if "mention_patterns" in platform_cfg: + bridged["mention_patterns"] = platform_cfg["mention_patterns"] if not bridged: continue plat_data = platforms_data.setdefault(plat.value, {}) @@ -534,6 +547,20 @@ def load_gateway_config() -> GatewayConfig: os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc) if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"): os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower() + + # Telegram settings → env vars (env vars take precedence) + telegram_cfg = yaml_cfg.get("telegram", {}) + if isinstance(telegram_cfg, dict): + if "require_mention" in telegram_cfg and not os.getenv("TELEGRAM_REQUIRE_MENTION"): + os.environ["TELEGRAM_REQUIRE_MENTION"] = str(telegram_cfg["require_mention"]).lower() + if "mention_patterns" in telegram_cfg and not os.getenv("TELEGRAM_MENTION_PATTERNS"): + import json as _json + os.environ["TELEGRAM_MENTION_PATTERNS"] = _json.dumps(telegram_cfg["mention_patterns"]) + frc = telegram_cfg.get("free_response_chats") + if frc is not None and not os.getenv("TELEGRAM_FREE_RESPONSE_CHATS"): + if isinstance(frc, list): + frc = ",".join(str(v) for v in frc) + os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc) except Exception as e: logger.warning( "Failed to process config.yaml — falling back to .env / gateway.json values. " @@ -876,4 +903,3 @@ def _apply_env_overrides(config: GatewayConfig) -> None: config.default_reset_policy.at_hour = int(reset_hour) except ValueError: pass - diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index efa5ed3184..9a821727ed 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -898,6 +898,26 @@ class BasePlatformAdapter(ABC): except Exception: pass + # ── Processing lifecycle hooks ────────────────────────────────────────── + # Subclasses override these to react to message processing events + # (e.g. Discord adds 👀/✅/❌ reactions). + + async def on_processing_start(self, event: MessageEvent) -> None: + """Hook called when background processing begins.""" + + async def on_processing_complete(self, event: MessageEvent, success: bool) -> None: + """Hook called when background processing completes.""" + + async def _run_processing_hook(self, hook_name: str, *args: Any, **kwargs: Any) -> None: + """Run a lifecycle hook without letting failures break message flow.""" + hook = getattr(self, hook_name, None) + if not callable(hook): + return + try: + await hook(*args, **kwargs) + except Exception as e: + logger.warning("[%s] %s hook failed: %s", self.name, hook_name, e) + @staticmethod def _is_retryable_error(error: Optional[str]) -> bool: """Return True if the error string looks like a transient network failure.""" @@ -1060,6 +1080,18 @@ class BasePlatformAdapter(ABC): async def _process_message_background(self, event: MessageEvent, session_key: str) -> None: """Background task that actually processes the message.""" + # Track delivery outcomes for the processing-complete hook + delivery_attempted = False + delivery_succeeded = False + + def _record_delivery(result): + nonlocal delivery_attempted, delivery_succeeded + if result is None: + return + delivery_attempted = True + if getattr(result, "success", False): + delivery_succeeded = True + # Create interrupt event for this session interrupt_event = asyncio.Event() self._active_sessions[session_key] = interrupt_event @@ -1069,6 +1101,8 @@ class BasePlatformAdapter(ABC): typing_task = asyncio.create_task(self._keep_typing(event.source.chat_id, metadata=_thread_metadata)) try: + await self._run_processing_hook("on_processing_start", event) + # Call the handler (this can take a while with tool calls) response = await self._message_handler(event) @@ -1138,6 +1172,7 @@ class BasePlatformAdapter(ABC): reply_to=event.message_id, metadata=_thread_metadata, ) + _record_delivery(result) # Human-like pacing delay between text and media human_delay = self._get_human_delay() @@ -1237,6 +1272,10 @@ class BasePlatformAdapter(ABC): except Exception as file_err: logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err) + # Determine overall success for the processing hook + processing_ok = delivery_succeeded if delivery_attempted else not bool(response) + await self._run_processing_hook("on_processing_complete", event, processing_ok) + # Check if there's a pending message that was queued during our processing if session_key in self._pending_messages: pending_event = self._pending_messages.pop(session_key) @@ -1253,7 +1292,11 @@ class BasePlatformAdapter(ABC): await self._process_message_background(pending_event, session_key) return # Already cleaned up + except asyncio.CancelledError: + await self._run_processing_hook("on_processing_complete", event, False) + raise except Exception as e: + await self._run_processing_hook("on_processing_complete", event, False) logger.error("[%s] Error handling message: %s", self.name, e, exc_info=True) # Send the error to the user so they aren't left with radio silence try: diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 1da9925cd5..9e0c9c123e 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -660,6 +660,41 @@ class DiscordAdapter(BasePlatformAdapter): pass logger.info("[%s] Disconnected", self.name) + + async def _add_reaction(self, message: Any, emoji: str) -> bool: + """Add an emoji reaction to a Discord message.""" + if not message or not hasattr(message, "add_reaction"): + return False + try: + await message.add_reaction(emoji) + return True + except Exception as e: + logger.debug("[%s] add_reaction failed (%s): %s", self.name, emoji, e) + return False + + async def _remove_reaction(self, message: Any, emoji: str) -> bool: + """Remove the bot's own emoji reaction from a Discord message.""" + if not message or not hasattr(message, "remove_reaction") or not self._client or not self._client.user: + return False + try: + await message.remove_reaction(emoji, self._client.user) + return True + except Exception as e: + logger.debug("[%s] remove_reaction failed (%s): %s", self.name, emoji, e) + return False + + async def on_processing_start(self, event: MessageEvent) -> None: + """Add an in-progress reaction for normal Discord message events.""" + message = event.raw_message + if hasattr(message, "add_reaction"): + await self._add_reaction(message, "👀") + + async def on_processing_complete(self, event: MessageEvent, success: bool) -> None: + """Swap the in-progress reaction for a final success/failure reaction.""" + message = event.raw_message + if hasattr(message, "add_reaction"): + await self._remove_reaction(message, "👀") + await self._add_reaction(message, "✅" if success else "❌") async def send( self, diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 3d6a90502c..309baeee73 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -17,6 +17,8 @@ Environment variables: from __future__ import annotations import asyncio +import io +import json import logging import mimetypes import os @@ -512,8 +514,11 @@ class MatrixAdapter(BasePlatformAdapter): reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: - """Upload an audio file as a voice message.""" - return await self._send_local_file(chat_id, audio_path, "m.audio", caption, reply_to, metadata=metadata) + """Upload an audio file as a voice message (MSC3245 native voice).""" + return await self._send_local_file( + chat_id, audio_path, "m.audio", caption, reply_to, + metadata=metadata, is_voice=True + ) async def send_video( self, @@ -546,13 +551,16 @@ class MatrixAdapter(BasePlatformAdapter): caption: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, + is_voice: bool = False, ) -> SendResult: """Upload bytes to Matrix and send as a media message.""" import nio # Upload to homeserver. - resp = await self._client.upload( - data, + # nio expects a DataProvider (callable) or file-like object, not raw bytes. + # nio.upload() returns a tuple (UploadResponse|UploadError, Optional[Dict]) + resp, maybe_encryption_info = await self._client.upload( + io.BytesIO(data), content_type=content_type, filename=filename, ) @@ -574,6 +582,10 @@ class MatrixAdapter(BasePlatformAdapter): }, } + # Add MSC3245 voice flag for native voice messages. + if is_voice: + msg_content["org.matrix.msc3245.voice"] = {} + if reply_to: msg_content["m.relates_to"] = { "m.in_reply_to": {"event_id": reply_to} @@ -601,6 +613,7 @@ class MatrixAdapter(BasePlatformAdapter): reply_to: Optional[str] = None, file_name: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, + is_voice: bool = False, ) -> SendResult: """Read a local file and upload it.""" p = Path(file_path) @@ -613,7 +626,7 @@ class MatrixAdapter(BasePlatformAdapter): ct = mimetypes.guess_type(fname)[0] or "application/octet-stream" data = p.read_bytes() - return await self._upload_and_send(room_id, data, fname, ct, msgtype, caption, reply_to, metadata) + return await self._upload_and_send(room_id, data, fname, ct, msgtype, caption, reply_to, metadata, is_voice) # ------------------------------------------------------------------ # Sync loop @@ -808,11 +821,19 @@ class MatrixAdapter(BasePlatformAdapter): event_mimetype = (content_info.get("info") or {}).get("mimetype", "") media_type = "application/octet-stream" msg_type = MessageType.DOCUMENT + is_voice_message = False + if isinstance(event, nio.RoomMessageImage): msg_type = MessageType.PHOTO media_type = event_mimetype or "image/png" elif isinstance(event, nio.RoomMessageAudio): - msg_type = MessageType.AUDIO + # Check for MSC3245 voice flag: org.matrix.msc3245.voice: {} + source_content = getattr(event, "source", {}).get("content", {}) + if source_content.get("org.matrix.msc3245.voice") is not None: + is_voice_message = True + msg_type = MessageType.VOICE + else: + msg_type = MessageType.AUDIO media_type = event_mimetype or "audio/ogg" elif isinstance(event, nio.RoomMessageVideo): msg_type = MessageType.VIDEO @@ -850,6 +871,31 @@ class MatrixAdapter(BasePlatformAdapter): if relates_to.get("rel_type") == "m.thread": thread_id = relates_to.get("event_id") + # For voice messages, cache audio locally for transcription tools. + # Use the authenticated nio client to download (Matrix requires auth for media). + media_urls = [http_url] if http_url else None + media_types = [media_type] if http_url else None + + if is_voice_message and url and url.startswith("mxc://"): + try: + import nio + from gateway.platforms.base import cache_audio_from_bytes + + resp = await self._client.download(mxc=url) + if isinstance(resp, nio.MemoryDownloadResponse): + # Extract extension from mimetype or default to .ogg + ext = ".ogg" + if media_type and "/" in media_type: + subtype = media_type.split("/")[1] + ext = f".{subtype}" if subtype else ".ogg" + local_path = cache_audio_from_bytes(resp.body, ext) + media_urls = [local_path] + logger.debug("Matrix: cached voice message to %s", local_path) + else: + logger.warning("Matrix: failed to download voice: %s", getattr(resp, "message", resp)) + except Exception as e: + logger.warning("Matrix: failed to cache voice message, using HTTP URL: %s", e) + source = self.build_source( chat_id=room.room_id, chat_type=chat_type, @@ -858,8 +904,9 @@ class MatrixAdapter(BasePlatformAdapter): thread_id=thread_id, ) - # Use cached local path for images, HTTP URL for other media types - media_urls = [cached_path] if cached_path else ([http_url] if http_url else None) + # Use cached local path for images (voice messages already handled above). + if cached_path: + media_urls = [cached_path] media_types = [media_type] if media_urls else None msg_event = MessageEvent( diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 2a7e046f85..88540815e5 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -9,6 +9,7 @@ Uses slack-bolt (Python) with Socket Mode for: """ import asyncio +import json import logging import os import re @@ -73,6 +74,10 @@ class SlackAdapter(BasePlatformAdapter): self._bot_user_id: Optional[str] = None self._user_name_cache: Dict[str, str] = {} # user_id → display name self._socket_mode_task: Optional[asyncio.Task] = None + # Multi-workspace support + self._team_clients: Dict[str, AsyncWebClient] = {} # team_id → WebClient + self._team_bot_user_ids: Dict[str, str] = {} # team_id → bot_user_id + self._channel_team: Dict[str, str] = {} # channel_id → team_id async def connect(self) -> bool: """Connect to Slack via Socket Mode.""" @@ -82,16 +87,34 @@ class SlackAdapter(BasePlatformAdapter): ) return False - bot_token = self.config.token + raw_token = self.config.token app_token = os.getenv("SLACK_APP_TOKEN") - if not bot_token: + if not raw_token: logger.error("[Slack] SLACK_BOT_TOKEN not set") return False if not app_token: logger.error("[Slack] SLACK_APP_TOKEN not set") return False + # Support comma-separated bot tokens for multi-workspace + bot_tokens = [t.strip() for t in raw_token.split(",") if t.strip()] + + # Also load tokens from OAuth token file + from hermes_constants import get_hermes_home + tokens_file = get_hermes_home() / "slack_tokens.json" + if tokens_file.exists(): + try: + saved = json.loads(tokens_file.read_text(encoding="utf-8")) + for team_id, entry in saved.items(): + tok = entry.get("token", "") if isinstance(entry, dict) else "" + if tok and tok not in bot_tokens: + bot_tokens.append(tok) + team_label = entry.get("team_name", team_id) if isinstance(entry, dict) else team_id + logger.info("[Slack] Loaded saved token for workspace %s", team_label) + except Exception as e: + logger.warning("[Slack] Failed to read %s: %s", tokens_file, e) + try: # Acquire scoped lock to prevent duplicate app token usage from gateway.status import acquire_scoped_lock @@ -104,12 +127,30 @@ class SlackAdapter(BasePlatformAdapter): self._set_fatal_error('slack_token_lock', message, retryable=False) return False - self._app = AsyncApp(token=bot_token) + # First token is the primary — used for AsyncApp / Socket Mode + primary_token = bot_tokens[0] + self._app = AsyncApp(token=primary_token) - # Get our own bot user ID for mention detection - auth_response = await self._app.client.auth_test() - self._bot_user_id = auth_response.get("user_id") - bot_name = auth_response.get("user", "unknown") + # Register each bot token and map team_id → client + for token in bot_tokens: + client = AsyncWebClient(token=token) + auth_response = await client.auth_test() + team_id = auth_response.get("team_id", "") + bot_user_id = auth_response.get("user_id", "") + bot_name = auth_response.get("user", "unknown") + team_name = auth_response.get("team", "unknown") + + self._team_clients[team_id] = client + self._team_bot_user_ids[team_id] = bot_user_id + + # First token sets the primary bot_user_id (backward compat) + if self._bot_user_id is None: + self._bot_user_id = bot_user_id + + logger.info( + "[Slack] Authenticated as @%s in workspace %s (team: %s)", + bot_name, team_name, team_id, + ) # Register message event handler @self._app.event("message") @@ -134,7 +175,10 @@ class SlackAdapter(BasePlatformAdapter): self._socket_mode_task = asyncio.create_task(self._handler.start_async()) self._running = True - logger.info("[Slack] Connected as @%s (Socket Mode)", bot_name) + logger.info( + "[Slack] Socket Mode connected (%d workspace(s))", + len(self._team_clients), + ) return True except Exception as e: # pragma: no cover - defensive logging @@ -161,6 +205,13 @@ class SlackAdapter(BasePlatformAdapter): logger.info("[Slack] Disconnected") + def _get_client(self, chat_id: str) -> AsyncWebClient: + """Return the workspace-specific WebClient for a channel.""" + team_id = self._channel_team.get(chat_id) + if team_id and team_id in self._team_clients: + return self._team_clients[team_id] + return self._app.client # fallback to primary + async def send( self, chat_id: str, @@ -197,7 +248,7 @@ class SlackAdapter(BasePlatformAdapter): if broadcast and i == 0: kwargs["reply_broadcast"] = True - last_result = await self._app.client.chat_postMessage(**kwargs) + last_result = await self._get_client(chat_id).chat_postMessage(**kwargs) return SendResult( success=True, @@ -219,7 +270,7 @@ class SlackAdapter(BasePlatformAdapter): if not self._app: return SendResult(success=False, error="Not connected") try: - await self._app.client.chat_update( + await self._get_client(chat_id).chat_update( channel=chat_id, ts=message_id, text=content, @@ -253,7 +304,7 @@ class SlackAdapter(BasePlatformAdapter): return # Can only set status in a thread context try: - await self._app.client.assistant_threads_setStatus( + await self._get_client(chat_id).assistant_threads_setStatus( channel_id=chat_id, thread_ts=thread_ts, status="is thinking...", @@ -295,7 +346,7 @@ class SlackAdapter(BasePlatformAdapter): if not os.path.exists(file_path): raise FileNotFoundError(f"File not found: {file_path}") - result = await self._app.client.files_upload_v2( + result = await self._get_client(chat_id).files_upload_v2( channel=chat_id, file=file_path, filename=os.path.basename(file_path), @@ -397,7 +448,7 @@ class SlackAdapter(BasePlatformAdapter): if not self._app: return False try: - await self._app.client.reactions_add( + await self._get_client(channel).reactions_add( channel=channel, timestamp=timestamp, name=emoji ) return True @@ -413,7 +464,7 @@ class SlackAdapter(BasePlatformAdapter): if not self._app: return False try: - await self._app.client.reactions_remove( + await self._get_client(channel).reactions_remove( channel=channel, timestamp=timestamp, name=emoji ) return True @@ -423,7 +474,7 @@ class SlackAdapter(BasePlatformAdapter): # ----- User identity resolution ----- - async def _resolve_user_name(self, user_id: str) -> str: + async def _resolve_user_name(self, user_id: str, chat_id: str = "") -> str: """Resolve a Slack user ID to a display name, with caching.""" if not user_id: return "" @@ -434,7 +485,8 @@ class SlackAdapter(BasePlatformAdapter): return user_id try: - result = await self._app.client.users_info(user=user_id) + client = self._get_client(chat_id) if chat_id else self._app.client + result = await client.users_info(user=user_id) user = result.get("user", {}) # Prefer display_name → real_name → user_id profile = user.get("profile", {}) @@ -498,7 +550,7 @@ class SlackAdapter(BasePlatformAdapter): response = await client.get(image_url) response.raise_for_status() - result = await self._app.client.files_upload_v2( + result = await self._get_client(chat_id).files_upload_v2( channel=chat_id, content=response.content, filename="image.png", @@ -558,7 +610,7 @@ class SlackAdapter(BasePlatformAdapter): return SendResult(success=False, error=f"Video file not found: {video_path}") try: - result = await self._app.client.files_upload_v2( + result = await self._get_client(chat_id).files_upload_v2( channel=chat_id, file=video_path, filename=os.path.basename(video_path), @@ -599,7 +651,7 @@ class SlackAdapter(BasePlatformAdapter): display_name = file_name or os.path.basename(file_path) try: - result = await self._app.client.files_upload_v2( + result = await self._get_client(chat_id).files_upload_v2( channel=chat_id, file=file_path, filename=display_name, @@ -627,7 +679,7 @@ class SlackAdapter(BasePlatformAdapter): return {"name": chat_id, "type": "unknown"} try: - result = await self._app.client.conversations_info(channel=chat_id) + result = await self._get_client(chat_id).conversations_info(channel=chat_id) channel = result.get("channel", {}) is_dm = channel.get("is_im", False) return { @@ -660,6 +712,11 @@ class SlackAdapter(BasePlatformAdapter): user_id = event.get("user", "") channel_id = event.get("channel", "") ts = event.get("ts", "") + team_id = event.get("team", "") + + # Track which workspace owns this channel + if team_id and channel_id: + self._channel_team[channel_id] = team_id # Determine if this is a DM or channel message channel_type = event.get("channel_type", "") @@ -676,11 +733,12 @@ class SlackAdapter(BasePlatformAdapter): thread_ts = event.get("thread_ts") or ts # ts fallback for channels # In channels, only respond if bot is mentioned - if not is_dm and self._bot_user_id: - if f"<@{self._bot_user_id}>" not in text: + bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id) + if not is_dm and bot_uid: + if f"<@{bot_uid}>" not in text: return # Strip the bot mention from the text - text = text.replace(f"<@{self._bot_user_id}>", "").strip() + text = text.replace(f"<@{bot_uid}>", "").strip() # Determine message type msg_type = MessageType.TEXT @@ -700,7 +758,7 @@ class SlackAdapter(BasePlatformAdapter): if ext not in (".jpg", ".jpeg", ".png", ".gif", ".webp"): ext = ".jpg" # Slack private URLs require the bot token as auth header - cached = await self._download_slack_file(url, ext) + cached = await self._download_slack_file(url, ext, team_id=team_id) media_urls.append(cached) media_types.append(mimetype) msg_type = MessageType.PHOTO @@ -711,7 +769,7 @@ class SlackAdapter(BasePlatformAdapter): ext = "." + mimetype.split("/")[-1].split(";")[0] if ext not in (".ogg", ".mp3", ".wav", ".webm", ".m4a"): ext = ".ogg" - cached = await self._download_slack_file(url, ext, audio=True) + cached = await self._download_slack_file(url, ext, audio=True, team_id=team_id) media_urls.append(cached) media_types.append(mimetype) msg_type = MessageType.VOICE @@ -742,7 +800,7 @@ class SlackAdapter(BasePlatformAdapter): continue # Download and cache - raw_bytes = await self._download_slack_file_bytes(url) + raw_bytes = await self._download_slack_file_bytes(url, team_id=team_id) cached_path = cache_document_from_bytes( raw_bytes, original_filename or f"document{ext}" ) @@ -771,7 +829,7 @@ class SlackAdapter(BasePlatformAdapter): logger.warning("[Slack] Failed to cache document from %s: %s", url, e, exc_info=True) # Resolve user display name (cached after first lookup) - user_name = await self._resolve_user_name(user_id) + user_name = await self._resolve_user_name(user_id, chat_id=channel_id) # Build source source = self.build_source( @@ -808,6 +866,11 @@ class SlackAdapter(BasePlatformAdapter): text = command.get("text", "").strip() user_id = command.get("user_id", "") channel_id = command.get("channel_id", "") + team_id = command.get("team_id", "") + + # Track which workspace owns this channel + if team_id and channel_id: + self._channel_team[channel_id] = team_id # Map subcommands to gateway commands — derived from central registry. # Also keep "compact" as a Slack-specific alias for /compress. @@ -839,12 +902,12 @@ class SlackAdapter(BasePlatformAdapter): await self.handle_message(event) - async def _download_slack_file(self, url: str, ext: str, audio: bool = False) -> str: + async def _download_slack_file(self, url: str, ext: str, audio: bool = False, team_id: str = "") -> str: """Download a Slack file using the bot token for auth, with retry.""" import asyncio import httpx - bot_token = self.config.token + bot_token = self._team_clients[team_id].token if team_id and team_id in self._team_clients else self.config.token last_exc = None async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: @@ -874,12 +937,12 @@ class SlackAdapter(BasePlatformAdapter): raise raise last_exc - async def _download_slack_file_bytes(self, url: str) -> bytes: + async def _download_slack_file_bytes(self, url: str, team_id: str = "") -> bytes: """Download a Slack file and return raw bytes, with retry.""" import asyncio import httpx - bot_token = self.config.token + bot_token = self._team_clients[team_id].token if team_id and team_id in self._team_clients else self.config.token last_exc = None async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 5f497221fc..db1b19431c 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -8,6 +8,7 @@ Uses python-telegram-bot library for: """ import asyncio +import json import logging import os import re @@ -122,6 +123,8 @@ class TelegramAdapter(BasePlatformAdapter): super().__init__(config, Platform.TELEGRAM) self._app: Optional[Application] = None self._bot: Optional[Bot] = None + self._webhook_mode: bool = False + self._mention_patterns = self._compile_mention_patterns() self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first' # Buffer rapid/album photo updates so Telegram image bursts are handled # as a single MessageEvent instead of self-interrupting multiple turns. @@ -456,7 +459,19 @@ class TelegramAdapter(BasePlatformAdapter): self._persist_dm_topic_thread_id(int(chat_id), topic_name, thread_id) async def connect(self) -> bool: - """Connect to Telegram and start polling for updates.""" + """Connect to Telegram via polling or webhook. + + By default, uses long polling (outbound connection to Telegram). + If ``TELEGRAM_WEBHOOK_URL`` is set, starts an HTTP webhook server + instead. Webhook mode is useful for cloud deployments (Fly.io, + Railway) where inbound HTTP can wake a suspended machine. + + Env vars for webhook mode:: + + TELEGRAM_WEBHOOK_URL Public HTTPS URL (e.g. https://app.fly.dev/telegram) + TELEGRAM_WEBHOOK_PORT Local listen port (default 8443) + TELEGRAM_WEBHOOK_SECRET Secret token for update verification + """ if not TELEGRAM_AVAILABLE: logger.error( "[%s] python-telegram-bot not installed. Run: pip install python-telegram-bot", @@ -550,37 +565,76 @@ class TelegramAdapter(BasePlatformAdapter): else: raise await self._app.start() - loop = asyncio.get_running_loop() - def _polling_error_callback(error: Exception) -> None: - if self._polling_error_task and not self._polling_error_task.done(): - return - if self._looks_like_polling_conflict(error): - self._polling_error_task = loop.create_task(self._handle_polling_conflict(error)) - elif self._looks_like_network_error(error): - logger.warning("[%s] Telegram network error, scheduling reconnect: %s", self.name, error) - self._polling_error_task = loop.create_task(self._handle_polling_network_error(error)) - else: - logger.error("[%s] Telegram polling error: %s", self.name, error, exc_info=True) + # Decide between webhook and polling mode + webhook_url = os.getenv("TELEGRAM_WEBHOOK_URL", "").strip() - # Store reference for retry use in _handle_polling_conflict - self._polling_error_callback_ref = _polling_error_callback + if webhook_url: + # ── Webhook mode ───────────────────────────────────── + # Telegram pushes updates to our HTTP endpoint. This + # enables cloud platforms (Fly.io, Railway) to auto-wake + # suspended machines on inbound HTTP traffic. + webhook_port = int(os.getenv("TELEGRAM_WEBHOOK_PORT", "8443")) + webhook_secret = os.getenv("TELEGRAM_WEBHOOK_SECRET", "").strip() or None + from urllib.parse import urlparse + webhook_path = urlparse(webhook_url).path or "/telegram" - await self._app.updater.start_polling( - allowed_updates=Update.ALL_TYPES, - drop_pending_updates=True, - error_callback=_polling_error_callback, - ) + await self._app.updater.start_webhook( + listen="0.0.0.0", + port=webhook_port, + url_path=webhook_path, + webhook_url=webhook_url, + secret_token=webhook_secret, + allowed_updates=Update.ALL_TYPES, + drop_pending_updates=True, + ) + self._webhook_mode = True + logger.info( + "[%s] Webhook server listening on 0.0.0.0:%d%s", + self.name, webhook_port, webhook_path, + ) + else: + # ── Polling mode (default) ─────────────────────────── + loop = asyncio.get_running_loop() + + def _polling_error_callback(error: Exception) -> None: + if self._polling_error_task and not self._polling_error_task.done(): + return + if self._looks_like_polling_conflict(error): + self._polling_error_task = loop.create_task(self._handle_polling_conflict(error)) + elif self._looks_like_network_error(error): + logger.warning("[%s] Telegram network error, scheduling reconnect: %s", self.name, error) + self._polling_error_task = loop.create_task(self._handle_polling_network_error(error)) + else: + logger.error("[%s] Telegram polling error: %s", self.name, error, exc_info=True) + + # Store reference for retry use in _handle_polling_conflict + self._polling_error_callback_ref = _polling_error_callback + + await self._app.updater.start_polling( + allowed_updates=Update.ALL_TYPES, + drop_pending_updates=True, + error_callback=_polling_error_callback, + ) # Register bot commands so Telegram shows a hint menu when users type / # List is derived from the central COMMAND_REGISTRY — adding a new # gateway command there automatically adds it to the Telegram menu. try: from telegram import BotCommand - from hermes_cli.commands import telegram_bot_commands + from hermes_cli.commands import telegram_menu_commands + # Telegram allows up to 100 commands but has an undocumented + # payload size limit. Skill descriptions are truncated to 40 + # chars in telegram_menu_commands() to fit 100 commands safely. + menu_commands, hidden_count = telegram_menu_commands(max_commands=100) await self._bot.set_my_commands([ - BotCommand(name, desc) for name, desc in telegram_bot_commands() + BotCommand(name, desc) for name, desc in menu_commands ]) + if hidden_count: + logger.info( + "[%s] Telegram menu: %d commands registered, %d hidden (over 100 limit). Use /commands for full list.", + self.name, len(menu_commands), hidden_count, + ) except Exception as e: logger.warning( "[%s] Could not register Telegram command menu: %s", @@ -590,7 +644,8 @@ class TelegramAdapter(BasePlatformAdapter): ) self._mark_connected() - logger.info("[%s] Connected and polling for Telegram updates", self.name) + mode = "webhook" if self._webhook_mode else "polling" + logger.info("[%s] Connected to Telegram (%s mode)", self.name, mode) # Set up DM topics (Bot API 9.4 — Private Chat Topics) # Runs after connection is established so the bot can call createForumTopic. @@ -618,7 +673,7 @@ class TelegramAdapter(BasePlatformAdapter): return False async def disconnect(self) -> None: - """Stop polling, cancel pending album flushes, and disconnect.""" + """Stop polling/webhook, cancel pending album flushes, and disconnect.""" pending_media_group_tasks = list(self._media_group_tasks.values()) for task in pending_media_group_tasks: task.cancel() @@ -1325,6 +1380,148 @@ class TelegramAdapter(BasePlatformAdapter): return text + # ── Group mention gating ────────────────────────────────────────────── + + def _telegram_require_mention(self) -> bool: + """Return whether group chats should require an explicit bot trigger.""" + configured = self.config.extra.get("require_mention") + if configured is not None: + if isinstance(configured, str): + return configured.lower() in ("true", "1", "yes", "on") + return bool(configured) + return os.getenv("TELEGRAM_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on") + + def _telegram_free_response_chats(self) -> set[str]: + raw = self.config.extra.get("free_response_chats") + if raw is None: + raw = os.getenv("TELEGRAM_FREE_RESPONSE_CHATS", "") + if isinstance(raw, list): + return {str(part).strip() for part in raw if str(part).strip()} + return {part.strip() for part in str(raw).split(",") if part.strip()} + + def _compile_mention_patterns(self) -> List[re.Pattern]: + """Compile optional regex wake-word patterns for group triggers.""" + patterns = self.config.extra.get("mention_patterns") + if patterns is None: + raw = os.getenv("TELEGRAM_MENTION_PATTERNS", "").strip() + if raw: + try: + loaded = json.loads(raw) + except Exception: + loaded = [part.strip() for part in raw.splitlines() if part.strip()] + if not loaded: + loaded = [part.strip() for part in raw.split(",") if part.strip()] + patterns = loaded + + if patterns is None: + return [] + if isinstance(patterns, str): + patterns = [patterns] + if not isinstance(patterns, list): + logger.warning( + "[%s] telegram mention_patterns must be a list or string; got %s", + self.name, + type(patterns).__name__, + ) + return [] + + compiled: List[re.Pattern] = [] + for pattern in patterns: + if not isinstance(pattern, str) or not pattern.strip(): + continue + try: + compiled.append(re.compile(pattern, re.IGNORECASE)) + except re.error as exc: + logger.warning("[%s] Invalid Telegram mention pattern %r: %s", self.name, pattern, exc) + if compiled: + logger.info("[%s] Loaded %d Telegram mention pattern(s)", self.name, len(compiled)) + return compiled + + def _is_group_chat(self, message: Message) -> bool: + chat = getattr(message, "chat", None) + if not chat: + return False + chat_type = str(getattr(chat, "type", "")).split(".")[-1].lower() + return chat_type in ("group", "supergroup") + + def _is_reply_to_bot(self, message: Message) -> bool: + if not self._bot or not getattr(message, "reply_to_message", None): + return False + reply_user = getattr(message.reply_to_message, "from_user", None) + return bool(reply_user and getattr(reply_user, "id", None) == getattr(self._bot, "id", None)) + + def _message_mentions_bot(self, message: Message) -> bool: + if not self._bot: + return False + + bot_username = (getattr(self._bot, "username", None) or "").lstrip("@").lower() + bot_id = getattr(self._bot, "id", None) + + def _iter_sources(): + yield getattr(message, "text", None) or "", getattr(message, "entities", None) or [] + yield getattr(message, "caption", None) or "", getattr(message, "caption_entities", None) or [] + + for source_text, entities in _iter_sources(): + if bot_username and f"@{bot_username}" in source_text.lower(): + return True + for entity in entities: + entity_type = str(getattr(entity, "type", "")).split(".")[-1].lower() + if entity_type == "mention" and bot_username: + offset = int(getattr(entity, "offset", -1)) + length = int(getattr(entity, "length", 0)) + if offset < 0 or length <= 0: + continue + if source_text[offset:offset + length].strip().lower() == f"@{bot_username}": + return True + elif entity_type == "text_mention": + user = getattr(entity, "user", None) + if user and getattr(user, "id", None) == bot_id: + return True + return False + + def _message_matches_mention_patterns(self, message: Message) -> bool: + if not self._mention_patterns: + return False + for candidate in (getattr(message, "text", None), getattr(message, "caption", None)): + if not candidate: + continue + for pattern in self._mention_patterns: + if pattern.search(candidate): + return True + return False + + def _clean_bot_trigger_text(self, text: Optional[str]) -> Optional[str]: + if not text or not self._bot or not getattr(self._bot, "username", None): + return text + username = re.escape(self._bot.username) + cleaned = re.sub(rf"(?i)@{username}\b[,:\-]*\s*", "", text).strip() + return cleaned or text + + def _should_process_message(self, message: Message, *, is_command: bool = False) -> bool: + """Apply Telegram group trigger rules. + + DMs remain unrestricted. Group/supergroup messages are accepted when: + - the chat is explicitly allowlisted in ``free_response_chats`` + - ``require_mention`` is disabled + - the message is a command + - the message replies to the bot + - the bot is @mentioned + - the text/caption matches a configured regex wake-word pattern + """ + if not self._is_group_chat(message): + return True + if str(getattr(getattr(message, "chat", None), "id", "")) in self._telegram_free_response_chats(): + return True + if not self._telegram_require_mention(): + return True + if is_command: + return True + if self._is_reply_to_bot(message): + return True + if self._message_mentions_bot(message): + return True + return self._message_matches_mention_patterns(message) + async def _handle_text_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle incoming text messages. @@ -1334,14 +1531,19 @@ class TelegramAdapter(BasePlatformAdapter): """ if not update.message or not update.message.text: return + if not self._should_process_message(update.message): + return event = self._build_message_event(update.message, MessageType.TEXT) + event.text = self._clean_bot_trigger_text(event.text) self._enqueue_text_event(event) async def _handle_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle incoming command messages.""" if not update.message or not update.message.text: return + if not self._should_process_message(update.message, is_command=True): + return event = self._build_message_event(update.message, MessageType.COMMAND) await self.handle_message(event) @@ -1350,6 +1552,8 @@ class TelegramAdapter(BasePlatformAdapter): """Handle incoming location/venue pin messages.""" if not update.message: return + if not self._should_process_message(update.message): + return msg = update.message venue = getattr(msg, "venue", None) @@ -1493,6 +1697,8 @@ class TelegramAdapter(BasePlatformAdapter): """Handle incoming media messages, downloading images to local cache.""" if not update.message: return + if not self._should_process_message(update.message): + return msg = update.message @@ -1516,7 +1722,7 @@ class TelegramAdapter(BasePlatformAdapter): # Add caption as text if msg.caption: - event.text = msg.caption + event.text = self._clean_bot_trigger_text(msg.caption) # Handle stickers: describe via vision tool with caching if msg.sticker: diff --git a/gateway/run.py b/gateway/run.py index 3c0ca18181..7358327445 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -301,6 +301,50 @@ def _resolve_runtime_agent_kwargs() -> dict: } +def _check_unavailable_skill(command_name: str) -> str | None: + """Check if a command matches a known-but-inactive skill. + + Returns a helpful message if the skill exists but is disabled or only + available as an optional install. Returns None if no match found. + """ + # Normalize: command uses hyphens, skill names may use hyphens or underscores + normalized = command_name.lower().replace("_", "-") + try: + from tools.skills_tool import SKILLS_DIR, _get_disabled_skill_names + disabled = _get_disabled_skill_names() + + # Check disabled built-in skills + for skill_md in SKILLS_DIR.rglob("SKILL.md"): + if any(part in ('.git', '.github', '.hub') for part in skill_md.parts): + continue + name = skill_md.parent.name.lower().replace("_", "-") + if name == normalized and name in disabled: + return ( + f"The **{command_name}** skill is installed but disabled.\n" + f"Enable it with: `hermes skills config`" + ) + + # Check optional skills (shipped with repo but not installed) + from hermes_constants import get_hermes_home + repo_root = Path(__file__).resolve().parent.parent + optional_dir = repo_root / "optional-skills" + if optional_dir.exists(): + for skill_md in optional_dir.rglob("SKILL.md"): + name = skill_md.parent.name.lower().replace("_", "-") + if name == normalized: + # Build install path: official// + rel = skill_md.parent.relative_to(optional_dir) + parts = list(rel.parts) + install_path = f"official/{'/'.join(parts)}" + return ( + f"The **{command_name}** skill is available but not installed.\n" + f"Install it with: `hermes skills install {install_path}`" + ) + except Exception: + pass + return None + + def _platform_config_key(platform: "Platform") -> str: """Map a Platform enum to its config.yaml key (LOCAL→"cli", rest→enum value).""" return "cli" if platform == Platform.LOCAL else platform.value @@ -432,6 +476,13 @@ class GatewayRunner: self._honcho_managers: Dict[str, Any] = {} self._honcho_configs: Dict[str, Any] = {} + # Rate-limit compression warning messages sent to users. + # Keyed by chat_id — value is the timestamp of the last warning sent. + # Prevents the warning from firing on every message when a session + # remains above the threshold after compression. + self._compression_warn_sent: Dict[str, float] = {} + self._compression_warn_cooldown: int = 3600 # seconds (1 hour) + # Ensure tirith security scanner is available (downloads if needed) try: from tools.tirith_security import ensure_installed @@ -1651,6 +1702,11 @@ class GatewayRunner: # In DMs: offer pairing code. In groups: silently ignore. if source.chat_type == "dm" and self._get_unauthorized_dm_behavior(source.platform) == "pair": platform_name = source.platform.value if source.platform else "unknown" + # Rate-limit ALL pairing responses (code or rejection) to + # prevent spamming the user with repeated messages when + # multiple DMs arrive in quick succession. + if self.pairing_store._is_rate_limited(platform_name, source.user_id): + return None code = self.pairing_store.generate_code( platform_name, source.user_id, source.user_name or "" ) @@ -1672,6 +1728,8 @@ class GatewayRunner: "Too many pairing requests right now~ " "Please try again later!" ) + # Record rate limit so subsequent messages are silently ignored + self.pairing_store._record_rate_limit(platform_name, source.user_id) return None # PRIORITY handling when an agent is already running for this session. @@ -1817,7 +1875,13 @@ class GatewayRunner: if canonical == "help": return await self._handle_help_command(event) + + if canonical == "commands": + return await self._handle_commands_command(event) + if canonical == "profile": + return await self._handle_profile_command(event) + if canonical == "status": return await self._handle_status_command(event) @@ -1830,6 +1894,9 @@ class GatewayRunner: if canonical == "verbose": return await self._handle_verbose_command(event) + if canonical == "yolo": + return await self._handle_yolo_command(event) + if canonical == "provider": return await self._handle_provider_command(event) @@ -1974,6 +2041,12 @@ class GatewayRunner: if msg: event.text = msg # Fall through to normal message processing with skill content + else: + # Not an active skill — check if it's a known-but-disabled or + # uninstalled skill and give actionable guidance. + _unavail_msg = _check_unavailable_skill(command) + if _unavail_msg: + return _unavail_msg except Exception as e: logger.debug("Skill command check failed (non-fatal): %s", e) @@ -2344,13 +2417,18 @@ class GatewayRunner: pass # Still too large after compression — warn user + # Rate-limited to once per cooldown period per + # chat to avoid spamming on every message. if _new_tokens >= _warn_token_threshold: logger.warning( "Session hygiene: still ~%s tokens after " "compression — suggesting /reset", f"{_new_tokens:,}", ) - if _hyg_adapter: + _now = time.time() + _last_warn = self._compression_warn_sent.get(source.chat_id, 0) + if _hyg_adapter and _now - _last_warn >= self._compression_warn_cooldown: + self._compression_warn_sent[source.chat_id] = _now try: await _hyg_adapter.send( source.chat_id, @@ -2372,7 +2450,10 @@ class GatewayRunner: if _approx_tokens >= _warn_token_threshold: _hyg_adapter = self.adapters.get(source.platform) _hyg_meta = {"thread_id": source.thread_id} if source.thread_id else None - if _hyg_adapter: + _now = time.time() + _last_warn = self._compression_warn_sent.get(source.chat_id, 0) + if _hyg_adapter and _now - _last_warn >= self._compression_warn_cooldown: + self._compression_warn_sent[source.chat_id] = _now try: await _hyg_adapter.send( source.chat_id, @@ -2999,6 +3080,36 @@ class GatewayRunner: return f"{header}\n\n{session_info}" return header + async def _handle_profile_command(self, event: MessageEvent) -> str: + """Handle /profile — show active profile name and home directory.""" + from hermes_constants import get_hermes_home, display_hermes_home + from pathlib import Path + + home = get_hermes_home() + display = display_hermes_home() + + # Detect profile name from HERMES_HOME path + # Profile paths look like: ~/.hermes/profiles/ + profiles_parent = Path.home() / ".hermes" / "profiles" + try: + rel = home.relative_to(profiles_parent) + profile_name = str(rel).split("/")[0] + except ValueError: + profile_name = None + + if profile_name: + lines = [ + f"👤 **Profile:** `{profile_name}`", + f"📂 **Home:** `{display}`", + ] + else: + lines = [ + "👤 **Profile:** default", + f"📂 **Home:** `{display}`", + ] + + return "\n".join(lines) + async def _handle_status_command(self, event: MessageEvent) -> str: """Handle /status command.""" source = event.source @@ -3065,12 +3176,69 @@ class GatewayRunner: from agent.skill_commands import get_skill_commands skill_cmds = get_skill_commands() if skill_cmds: - lines.append(f"\n⚡ **Skill Commands** ({len(skill_cmds)} installed):") - for cmd in sorted(skill_cmds): + lines.append(f"\n⚡ **Skill Commands** ({len(skill_cmds)} active):") + # Show first 10, then point to /commands for the rest + sorted_cmds = sorted(skill_cmds) + for cmd in sorted_cmds[:10]: lines.append(f"`{cmd}` — {skill_cmds[cmd]['description']}") + if len(sorted_cmds) > 10: + lines.append(f"\n... and {len(sorted_cmds) - 10} more. Use `/commands` for the full paginated list.") except Exception: pass return "\n".join(lines) + + async def _handle_commands_command(self, event: MessageEvent) -> str: + """Handle /commands [page] - paginated list of all commands and skills.""" + from hermes_cli.commands import gateway_help_lines + + raw_args = event.get_command_args().strip() + if raw_args: + try: + requested_page = int(raw_args) + except ValueError: + return "Usage: `/commands [page]`" + else: + requested_page = 1 + + # Build combined entry list: built-in commands + skill commands + entries = list(gateway_help_lines()) + try: + from agent.skill_commands import get_skill_commands + skill_cmds = get_skill_commands() + if skill_cmds: + entries.append("") + entries.append("⚡ **Skill Commands**:") + for cmd in sorted(skill_cmds): + desc = skill_cmds[cmd].get("description", "").strip() or "Skill command" + entries.append(f"`{cmd}` — {desc}") + except Exception: + pass + + if not entries: + return "No commands available." + + from gateway.config import Platform + page_size = 15 if event.source.platform == Platform.TELEGRAM else 20 + total_pages = max(1, (len(entries) + page_size - 1) // page_size) + page = max(1, min(requested_page, total_pages)) + start = (page - 1) * page_size + page_entries = entries[start:start + page_size] + + lines = [ + f"📚 **Commands** ({len(entries)} total, page {page}/{total_pages})", + "", + *page_entries, + ] + if total_pages > 1: + nav_parts = [] + if page > 1: + nav_parts.append(f"`/commands {page - 1}` ← prev") + if page < total_pages: + nav_parts.append(f"next → `/commands {page + 1}`") + lines.extend(["", " | ".join(nav_parts)]) + if page != requested_page: + lines.append(f"_(Requested page {requested_page} was out of range, showing page {page}.)_") + return "\n".join(lines) async def _handle_provider_command(self, event: MessageEvent) -> str: """Handle /provider command - show available providers.""" @@ -3891,7 +4059,7 @@ class GatewayRunner: # Send media files for media_path in (media_files or []): try: - await adapter.send_file( + await adapter.send_document( chat_id=source.chat_id, file_path=media_path, ) @@ -3999,6 +4167,16 @@ class GatewayRunner: else: return f"🧠 ✓ Reasoning effort set to `{effort}` (this session only)" + async def _handle_yolo_command(self, event: MessageEvent) -> str: + """Handle /yolo — toggle dangerous command approval bypass.""" + current = bool(os.environ.get("HERMES_YOLO_MODE")) + if current: + os.environ.pop("HERMES_YOLO_MODE", None) + return "⚠️ YOLO mode **OFF** — dangerous commands will require approval." + else: + os.environ["HERMES_YOLO_MODE"] = "1" + return "⚡ YOLO mode **ON** — all commands auto-approved. Use with caution." + async def _handle_verbose_command(self, event: MessageEvent) -> str: """Handle /verbose command — cycle tool progress display mode. diff --git a/hermes b/hermes index f0feeb2bad..3172ca91ca 100755 --- a/hermes +++ b/hermes @@ -1,12 +1,11 @@ #!/usr/bin/env python3 """ -Hermes Agent CLI Launcher +Hermes Agent CLI launcher. -This is a convenience wrapper to launch the Hermes CLI. -Usage: ./hermes [options] +This wrapper should behave like the installed `hermes` command, including +subcommands such as `gateway`, `cron`, and `doctor`. """ if __name__ == "__main__": - from cli import main - import fire - fire.Fire(main) + from hermes_cli.main import main + main() diff --git a/hermes_cli/__init__.py b/hermes_cli/__init__.py index 797c7e8d64..5f4b1b9cf0 100644 --- a/hermes_cli/__init__.py +++ b/hermes_cli/__init__.py @@ -11,5 +11,5 @@ Provides subcommands for: - hermes cron - Manage cron jobs """ -__version__ = "0.5.0" -__release_date__ = "2026.3.28" +__version__ = "0.6.0" +__release_date__ = "2026.3.30" diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index faebe94435..21b03d0bae 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -2393,21 +2393,20 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: raise AuthError("No runtime API key available to fetch models", provider="nous", code="invalid_token") - model_ids = fetch_nous_models( - inference_base_url=runtime_base_url, - api_key=runtime_key, - timeout_seconds=timeout_seconds, - verify=verify, - ) + # Use curated model list (same as OpenRouter defaults) instead + # of the full /models dump which returns hundreds of models. + from hermes_cli.models import _PROVIDER_MODELS + model_ids = _PROVIDER_MODELS.get("nous", []) print() if model_ids: + print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.") selected_model = _prompt_model_selection(model_ids) if selected_model: _save_model_choice(selected_model) print(f"Default model set to: {selected_model}") else: - print("No models were returned by the inference API.") + print("No curated models available for Nous Portal.") except Exception as exc: message = format_auth_error(exc) if isinstance(exc, AuthError) else str(exc) print() diff --git a/hermes_cli/callbacks.py b/hermes_cli/callbacks.py index fa51ee1576..87f86b84dc 100644 --- a/hermes_cli/callbacks.py +++ b/hermes_cli/callbacks.py @@ -241,7 +241,8 @@ def approval_callback(cli, command: str, description: str) -> str: lock = cli._approval_lock with lock: - timeout = 60 + from cli import CLI_CONFIG + timeout = CLI_CONFIG.get("approvals", {}).get("timeout", 60) response_queue = queue.Queue() choices = ["once", "session", "always", "deny"] if len(command) > 70: diff --git a/hermes_cli/checklist.py b/hermes_cli/checklist.py index 1c56725aae..1a8d9720aa 100644 --- a/hermes_cli/checklist.py +++ b/hermes_cli/checklist.py @@ -5,6 +5,7 @@ toggleable list of items. Falls back to a numbered text UI when curses is unavailable (Windows without curses, piped stdin, etc.). """ +import sys from typing import List, Set from hermes_cli.colors import Colors, color @@ -26,6 +27,10 @@ def curses_checklist( The indices the user confirmed as checked. On cancel (ESC/q), returns ``pre_selected`` unchanged. """ + # Safety: return defaults when stdin is not a terminal. + if not sys.stdin.isatty(): + return set(pre_selected) + try: import curses selected = set(pre_selected) diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py index 97e1acc406..014a2abeb4 100644 --- a/hermes_cli/claw.py +++ b/hermes_cli/claw.py @@ -88,7 +88,19 @@ def claw_command(args): def _cmd_migrate(args): """Run the OpenClaw → Hermes migration.""" - source_dir = Path(getattr(args, "source", None) or Path.home() / ".openclaw") + # Check current and legacy OpenClaw directories + explicit_source = getattr(args, "source", None) + if explicit_source: + source_dir = Path(explicit_source) + else: + source_dir = Path.home() / ".openclaw" + if not source_dir.is_dir(): + # Try legacy directory names + for legacy in (".clawdbot", ".moldbot"): + candidate = Path.home() / legacy + if candidate.is_dir(): + source_dir = candidate + break dry_run = getattr(args, "dry_run", False) preset = getattr(args, "preset", "full") overwrite = getattr(args, "overwrite", False) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index d442f7f94d..d9de67175d 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -71,6 +71,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ aliases=("q",), args_hint=""), CommandDef("status", "Show session info", "Session", gateway_only=True), + CommandDef("profile", "Show active profile name and home directory", "Info"), CommandDef("sethome", "Set this chat as the home channel", "Session", gateway_only=True, aliases=("set-home",)), CommandDef("resume", "Resume a previously-named session", "Session", @@ -90,6 +91,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose", "Configuration", cli_only=True, gateway_config_gate="display.tool_progress_command"), + CommandDef("yolo", "Toggle YOLO mode (skip all dangerous command approvals)", + "Configuration"), CommandDef("reasoning", "Manage reasoning effort and display", "Configuration", args_hint="[level|show|hide]", subcommands=("none", "low", "minimal", "medium", "high", "xhigh", "show", "hide", "on", "off")), @@ -118,6 +121,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ "Tools & Skills", cli_only=True), # Info + CommandDef("commands", "Browse all commands and skills (paginated)", "Info", + gateway_only=True, args_hint="[page]"), CommandDef("help", "Show available commands", "Info"), CommandDef("usage", "Show token usage for the current session", "Info"), CommandDef("insights", "Show usage insights and analytics", "Info", @@ -361,6 +366,69 @@ def telegram_bot_commands() -> list[tuple[str, str]]: return result +def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str]], int]: + """Return Telegram menu commands capped to the Bot API limit. + + Priority order (higher priority = never bumped by overflow): + 1. Core CommandDef commands (always included) + 2. Plugin slash commands (take precedence over skills) + 3. Built-in skill commands (fill remaining slots, alphabetical) + + Skills are the only tier that gets trimmed when the cap is hit. + User-installed hub skills are excluded — accessible via /skills. + + Returns: + (menu_commands, hidden_count) where hidden_count is the number of + skill commands omitted due to the cap. + """ + all_commands = list(telegram_bot_commands()) + + # Plugin slash commands get priority over skills + try: + from hermes_cli.plugins import get_plugin_manager + pm = get_plugin_manager() + plugin_cmds = getattr(pm, "_plugin_commands", {}) + for cmd_name in sorted(plugin_cmds): + tg_name = cmd_name.replace("-", "_") + desc = "Plugin command" + if len(desc) > 40: + desc = desc[:37] + "..." + all_commands.append((tg_name, desc)) + except Exception: + pass + + # Remaining slots go to built-in skill commands (not hub-installed). + skill_entries: list[tuple[str, str]] = [] + try: + from agent.skill_commands import get_skill_commands + from tools.skills_tool import SKILLS_DIR + _skills_dir = str(SKILLS_DIR.resolve()) + _hub_dir = str((SKILLS_DIR / ".hub").resolve()) + skill_cmds = get_skill_commands() + for cmd_key in sorted(skill_cmds): + info = skill_cmds[cmd_key] + skill_path = info.get("skill_md_path", "") + if not skill_path.startswith(_skills_dir): + continue + if skill_path.startswith(_hub_dir): + continue + name = cmd_key.lstrip("/").replace("-", "_") + desc = info.get("description", "") + # Keep descriptions short — setMyCommands has an undocumented + # total payload limit. 40 chars fits 100 commands safely. + if len(desc) > 40: + desc = desc[:37] + "..." + skill_entries.append((name, desc)) + except Exception: + pass + + # Skills fill remaining slots — they're the only tier that gets trimmed + remaining_slots = max(0, max_commands - len(all_commands)) + hidden_count = max(0, len(skill_entries) - remaining_slots) + all_commands.extend(skill_entries[:remaining_slots]) + return all_commands[:max_commands], hidden_count + + def slack_subcommand_map() -> dict[str, str]: """Return subcommand -> /command mapping for Slack /hermes handler. diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 0041cf6650..7157175df0 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -225,7 +225,8 @@ DEFAULT_CONFIG = { "model": "", # e.g. "google/gemini-2.5-flash", "gpt-4o" "base_url": "", # direct OpenAI-compatible endpoint (takes precedence over provider) "api_key": "", # API key for base_url (falls back to OPENAI_API_KEY) - "timeout": 30, # seconds — increase for slow local vision models + "timeout": 30, # seconds — LLM API call timeout; increase for slow local vision models + "download_timeout": 30, # seconds — image HTTP download timeout; increase for slow connections }, "web_extract": { "provider": "auto", @@ -409,6 +410,7 @@ DEFAULT_CONFIG = { # off — skip all approval prompts (equivalent to --yolo) "approvals": { "mode": "manual", + "timeout": 60, }, # Permanently allowed dangerous command patterns (added via "always" approval) @@ -739,6 +741,14 @@ OPTIONAL_ENV_VARS = { "password": True, "category": "tool", }, + "CAMOFOX_URL": { + "description": "Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)", + "prompt": "Camofox server URL", + "url": "https://github.com/jo-inc/camofox-browser", + "tools": ["browser_navigate", "browser_click"], + "password": False, + "category": "tool", + }, "FAL_KEY": { "description": "FAL API key for image generation", "prompt": "FAL API key", diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py index dce620b8c6..c4b79091e8 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -4,6 +4,7 @@ Used by `hermes tools` and `hermes skills` for interactive checklists. Provides a curses multi-select with keyboard navigation, plus a text-based numbered fallback for terminals without curses support. """ +import sys from typing import Callable, List, Optional, Set from hermes_cli.colors import Colors, color @@ -31,6 +32,11 @@ def curses_checklist( if cancel_returns is None: cancel_returns = set(selected) + # Safety: curses and input() both hang or spin when stdin is not a + # terminal (e.g. subprocess pipe). Return defaults immediately. + if not sys.stdin.isatty(): + return cancel_returns + try: import curses chosen = set(selected) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index a0a8419053..b9fd8d3270 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -406,8 +406,11 @@ def run_doctor(args): if terminal_env == "docker": if shutil.which("docker"): # Check if docker daemon is running - result = subprocess.run(["docker", "info"], capture_output=True) - if result.returncode == 0: + try: + result = subprocess.run(["docker", "info"], capture_output=True, timeout=10) + except subprocess.TimeoutExpired: + result = None + if result is not None and result.returncode == 0: check_ok("docker", "(daemon running)") else: check_fail("docker daemon not running") @@ -426,12 +429,16 @@ def run_doctor(args): ssh_host = os.getenv("TERMINAL_SSH_HOST") if ssh_host: # Try to connect - result = subprocess.run( - ["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", ssh_host, "echo ok"], - capture_output=True, - text=True - ) - if result.returncode == 0: + try: + result = subprocess.run( + ["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", ssh_host, "echo ok"], + capture_output=True, + text=True, + timeout=15 + ) + except subprocess.TimeoutExpired: + result = None + if result is not None and result.returncode == 0: check_ok(f"SSH connection to {ssh_host}") else: check_fail(f"SSH connection to {ssh_host}") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 95932013f6..5c0ad14579 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -50,6 +50,23 @@ import sys from pathlib import Path from typing import Optional +def _require_tty(command_name: str) -> None: + """Exit with a clear error if stdin is not a terminal. + + Interactive TUI commands (hermes tools, hermes setup, hermes model) use + curses or input() prompts that spin at 100% CPU when stdin is a pipe. + This guard prevents accidental non-interactive invocation. + """ + if not sys.stdin.isatty(): + print( + f"Error: 'hermes {command_name}' requires an interactive terminal.\n" + f"It cannot be run through a pipe or non-interactive subprocess.\n" + f"Run it directly in your terminal instead.", + file=sys.stderr, + ) + sys.exit(1) + + # Add project root to path PROJECT_ROOT = Path(__file__).parent.parent.resolve() sys.path.insert(0, str(PROJECT_ROOT)) @@ -617,6 +634,7 @@ def cmd_gateway(args): def cmd_whatsapp(args): """Set up WhatsApp: choose mode, configure, install bridge, pair via QR.""" + _require_tty("whatsapp") import subprocess from pathlib import Path from hermes_cli.config import get_env_value, save_env_value @@ -803,12 +821,14 @@ def cmd_whatsapp(args): def cmd_setup(args): """Interactive setup wizard.""" + _require_tty("setup") from hermes_cli.setup import run_setup_wizard run_setup_wizard(args) def cmd_model(args): """Select default model — starts with provider selection, then model picker.""" + _require_tty("model") from hermes_cli.auth import ( resolve_provider, AuthError, format_auth_error, ) @@ -1096,14 +1116,20 @@ def _model_flow_nous(config, current_model="", args=None): # login_nous already handles model selection + config update return - # Already logged in — fetch models and select - print("Fetching models from Nous Portal...") + # Already logged in — use curated model list (same as OpenRouter defaults). + # The live /models endpoint returns hundreds of models; the curated list + # shows only agentic models users recognize from OpenRouter. + from hermes_cli.models import _PROVIDER_MODELS + model_ids = _PROVIDER_MODELS.get("nous", []) + if not model_ids: + print("No curated models available for Nous Portal.") + return + + print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.") + + # Verify credentials are still valid (catches expired sessions early) try: creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=5 * 60) - model_ids = fetch_nous_models( - inference_base_url=creds.get("base_url", ""), - api_key=creds.get("api_key", ""), - ) except Exception as exc: relogin = isinstance(exc, AuthError) and exc.relogin_required msg = format_auth_error(exc) if isinstance(exc, AuthError) else str(exc) @@ -1120,11 +1146,7 @@ def _model_flow_nous(config, current_model="", args=None): except Exception as login_exc: print(f"Re-login failed: {login_exc}") return - print(f"Could not fetch models: {msg}") - return - - if not model_ids: - print("No models returned by the inference API.") + print(f"Could not verify credentials: {msg}") return selected = _prompt_model_selection(model_ids, current_model=current_model) @@ -2494,6 +2516,7 @@ def cmd_version(args): def cmd_uninstall(args): """Uninstall Hermes Agent.""" + _require_tty("uninstall") from hermes_cli.uninstall import run_uninstall run_uninstall(args) @@ -4204,6 +4227,7 @@ For more help on a command: def cmd_skills(args): # Route 'config' action to skills_config module if getattr(args, 'skills_action', None) == 'config': + _require_tty("skills config") from hermes_cli.skills_config import skills_command as skills_config_command skills_config_command(args) else: @@ -4414,6 +4438,7 @@ For more help on a command: from hermes_cli.tools_config import tools_disable_enable_command tools_disable_enable_command(args) else: + _require_tty("tools") from hermes_cli.tools_config import tools_command tools_command(args) diff --git a/hermes_cli/mcp_config.py b/hermes_cli/mcp_config.py index 0f08e46739..9154ed50a3 100644 --- a/hermes_cli/mcp_config.py +++ b/hermes_cli/mcp_config.py @@ -511,6 +511,10 @@ def _interpolate_value(value: str) -> str: def cmd_mcp_configure(args): """Reconfigure which tools are enabled for an existing MCP server.""" + import sys as _sys + if not _sys.stdin.isatty(): + print("Error: 'hermes mcp configure' requires an interactive terminal.", file=_sys.stderr) + _sys.exit(1) name = args.name servers = _get_mcp_servers() diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index 5e4b36c303..02814f75d3 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -131,6 +131,7 @@ def _browser_label(current_provider: str) -> str: mapping = { "browserbase": "Browserbase", "browser-use": "Browser Use", + "camofox": "Camofox", "local": "Local browser", } return mapping.get(current_provider or "local", current_provider or "Local browser") @@ -144,6 +145,64 @@ def _tts_label(current_provider: str) -> str: "neutts": "NeuTTS", } return mapping.get(current_provider or "edge", current_provider or "Edge TTS") + + +def _resolve_browser_feature_state( + *, + browser_tool_enabled: bool, + browser_provider: str, + browser_provider_explicit: bool, + browser_local_available: bool, + direct_camofox: bool, + direct_browserbase: bool, + direct_browser_use: bool, + managed_browser_available: bool, +) -> tuple[str, bool, bool, bool]: + """Resolve browser availability using the same precedence as runtime.""" + if direct_camofox: + return "camofox", True, bool(browser_tool_enabled), False + + if browser_provider_explicit: + current_provider = browser_provider or "local" + if current_provider == "browserbase": + provider_available = managed_browser_available or direct_browserbase + available = bool(browser_local_available and provider_available) + managed = bool( + browser_tool_enabled + and browser_local_available + and managed_browser_available + and not direct_browserbase + ) + active = bool(browser_tool_enabled and available) + return current_provider, available, active, managed + if current_provider == "browser-use": + available = bool(browser_local_available and direct_browser_use) + active = bool(browser_tool_enabled and available) + return current_provider, available, active, False + if current_provider == "camofox": + return current_provider, False, False, False + + current_provider = "local" + available = bool(browser_local_available) + active = bool(browser_tool_enabled and available) + return current_provider, available, active, False + + if managed_browser_available or direct_browserbase: + available = bool(browser_local_available) + managed = bool( + browser_tool_enabled + and browser_local_available + and managed_browser_available + and not direct_browserbase + ) + active = bool(browser_tool_enabled and available) + return "browserbase", available, active, managed + + available = bool(browser_local_available) + active = bool(browser_tool_enabled and available) + return "local", available, active, False + + def get_nous_subscription_features( config: Optional[Dict[str, object]] = None, ) -> NousSubscriptionFeatures: @@ -168,22 +227,22 @@ def get_nous_subscription_features( browser_tool_enabled = _toolset_enabled(config, "browser") modal_tool_enabled = _toolset_enabled(config, "terminal") - web_backend = str(config.get("web", {}).get("backend") or "").strip().lower() if isinstance(config.get("web"), dict) else "" - tts_provider = str(config.get("tts", {}).get("provider") or "edge").strip().lower() if isinstance(config.get("tts"), dict) else "edge" + web_cfg = config.get("web") if isinstance(config.get("web"), dict) else {} + tts_cfg = config.get("tts") if isinstance(config.get("tts"), dict) else {} + browser_cfg = config.get("browser") if isinstance(config.get("browser"), dict) else {} + terminal_cfg = config.get("terminal") if isinstance(config.get("terminal"), dict) else {} + + web_backend = str(web_cfg.get("backend") or "").strip().lower() + tts_provider = str(tts_cfg.get("provider") or "edge").strip().lower() + browser_provider_explicit = "cloud_provider" in browser_cfg browser_provider = normalize_browser_cloud_provider( - config.get("browser", {}).get("cloud_provider") - if isinstance(config.get("browser"), dict) - else None + browser_cfg.get("cloud_provider") if browser_provider_explicit else None ) terminal_backend = ( - str(config.get("terminal", {}).get("backend") or "local").strip().lower() - if isinstance(config.get("terminal"), dict) - else "local" + str(terminal_cfg.get("backend") or "local").strip().lower() ) modal_mode = normalize_modal_mode( - config.get("terminal", {}).get("modal_mode") - if isinstance(config.get("terminal"), dict) - else None + terminal_cfg.get("modal_mode") ) direct_exa = bool(get_env_value("EXA_API_KEY")) @@ -193,6 +252,7 @@ def get_nous_subscription_features( direct_fal = bool(get_env_value("FAL_KEY")) direct_openai_tts = bool(resolve_openai_audio_api_key()) direct_elevenlabs = bool(get_env_value("ELEVENLABS_API_KEY")) + direct_camofox = bool(get_env_value("CAMOFOX_URL")) direct_browserbase = bool(get_env_value("BROWSERBASE_API_KEY") and get_env_value("BROWSERBASE_PROJECT_ID")) direct_browser_use = bool(get_env_value("BROWSER_USE_API_KEY")) direct_modal = has_direct_modal_credentials() @@ -241,26 +301,21 @@ def get_nous_subscription_features( ) tts_active = bool(tts_tool_enabled and tts_available) - browser_current_provider = browser_provider or "local" browser_local_available = _has_agent_browser() - browser_managed = ( - browser_tool_enabled - and browser_current_provider == "browserbase" - and managed_browser_available - and not direct_browserbase - ) - browser_available = bool( - browser_local_available - or (browser_current_provider == "browserbase" and (managed_browser_available or direct_browserbase)) - or (browser_current_provider == "browser-use" and direct_browser_use) - ) - browser_active = bool( - browser_tool_enabled - and ( - (browser_current_provider == "local" and browser_local_available) - or (browser_current_provider == "browserbase" and (managed_browser_available or direct_browserbase)) - or (browser_current_provider == "browser-use" and direct_browser_use) - ) + ( + browser_current_provider, + browser_available, + browser_active, + browser_managed, + ) = _resolve_browser_feature_state( + browser_tool_enabled=browser_tool_enabled, + browser_provider=browser_provider, + browser_provider_explicit=browser_provider_explicit, + browser_local_available=browser_local_available, + direct_camofox=direct_camofox, + direct_browserbase=direct_browserbase, + direct_browser_use=direct_browser_use, + managed_browser_available=managed_browser_available, ) if terminal_backend != "modal": @@ -346,7 +401,7 @@ def get_nous_subscription_features( direct_override=browser_active and not browser_managed, toolset_enabled=browser_tool_enabled, current_provider=_browser_label(browser_current_provider), - explicit_configured=isinstance(config.get("browser"), dict) and "cloud_provider" in config.get("browser", {}), + explicit_configured=browser_provider_explicit, ), "modal": NousFeatureState( key="modal", diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 7779ab964f..141923b0fc 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -154,6 +154,34 @@ class PluginContext: self._manager._plugin_tool_names.add(name) logger.debug("Plugin %s registered tool: %s", self.manifest.name, name) + # -- message injection -------------------------------------------------- + + def inject_message(self, content: str, role: str = "user") -> bool: + """Inject a message into the active conversation. + + If the agent is idle (waiting for user input), this starts a new turn. + If the agent is running, this interrupts and injects the message. + + This enables plugins (e.g. remote control viewers, messaging bridges) + to send messages into the conversation from external sources. + + Returns True if the message was queued successfully. + """ + cli = self._manager._cli_ref + if cli is None: + logger.warning("inject_message: no CLI reference (not available in gateway mode)") + return False + + msg = content if role == "user" else f"[{role}] {content}" + + if getattr(cli, "_agent_running", False): + # Agent is mid-turn — interrupt with the message + cli._interrupt_queue.put(msg) + else: + # Agent is idle — queue as next input + cli._pending_input.put(msg) + return True + # -- hook registration -------------------------------------------------- def register_hook(self, hook_name: str, callback: Callable) -> None: @@ -186,6 +214,7 @@ class PluginManager: self._hooks: Dict[str, List[Callable]] = {} self._plugin_tool_names: Set[str] = set() self._discovered: bool = False + self._cli_ref = None # Set by CLI after plugin discovery # ----------------------------------------------------------------------- # Public diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index fc34c0b7bc..c29af3a6bb 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -616,24 +616,32 @@ def _print_setup_summary(config: dict, hermes_home): else: tool_status.append(("Web Search & Extract", False, "EXA_API_KEY, PARALLEL_API_KEY, FIRECRAWL_API_KEY/FIRECRAWL_API_URL, or TAVILY_API_KEY")) - # Browser tools (local Chromium or Browserbase cloud) - import shutil - - _ab_found = ( - shutil.which("agent-browser") - or ( - Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser" - ).exists() - ) + # Browser tools (local Chromium, Camofox, Browserbase, or Browser Use) + browser_provider = subscription_features.browser.current_provider if subscription_features.browser.managed_by_nous: tool_status.append(("Browser Automation (Nous Browserbase)", True, None)) - elif subscription_features.browser.current_provider == "Browserbase" and subscription_features.browser.available: - tool_status.append(("Browser Automation (Browserbase)", True, None)) - elif _ab_found: - tool_status.append(("Browser Automation (local)", True, None)) + elif subscription_features.browser.available: + label = "Browser Automation" + if browser_provider: + label = f"Browser Automation ({browser_provider})" + tool_status.append((label, True, None)) else: + missing_browser_hint = "npm install -g agent-browser, set CAMOFOX_URL, or configure Browserbase" + if browser_provider == "Browserbase": + missing_browser_hint = ( + "npm install -g agent-browser and set " + "BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID" + ) + elif browser_provider == "Browser Use": + missing_browser_hint = ( + "npm install -g agent-browser and set BROWSER_USE_API_KEY" + ) + elif browser_provider == "Camofox": + missing_browser_hint = "CAMOFOX_URL" + elif browser_provider == "Local browser": + missing_browser_hint = "npm install -g agent-browser" tool_status.append( - ("Browser Automation", False, "npm install -g agent-browser") + ("Browser Automation", False, missing_browser_hint) ) # FAL (image generation) @@ -1045,10 +1053,9 @@ def setup_model_provider(config: dict): min_key_ttl_seconds=5 * 60, timeout_seconds=15.0, ) - nous_models = fetch_nous_models( - inference_base_url=creds.get("base_url", ""), - api_key=creds.get("api_key", ""), - ) + # Use curated model list instead of full /models dump + from hermes_cli.models import _PROVIDER_MODELS + nous_models = _PROVIDER_MODELS.get("nous", []) except Exception as e: logger.debug("Could not fetch Nous models after login: %s", e) @@ -2821,10 +2828,38 @@ def setup_gateway(config: dict): if token or get_env_value("MATRIX_PASSWORD"): # E2EE print() - if prompt_yes_no("Enable end-to-end encryption (E2EE)?", False): + want_e2ee = prompt_yes_no("Enable end-to-end encryption (E2EE)?", False) + if want_e2ee: save_env_value("MATRIX_ENCRYPTION", "true") print_success("E2EE enabled") - print_info(" Requires: pip install 'matrix-nio[e2e]'") + + # Auto-install matrix-nio + matrix_pkg = "matrix-nio[e2e]" if want_e2ee else "matrix-nio" + try: + __import__("nio") + except ImportError: + print_info(f"Installing {matrix_pkg}...") + import subprocess + + uv_bin = shutil.which("uv") + if uv_bin: + result = subprocess.run( + [uv_bin, "pip", "install", "--python", sys.executable, matrix_pkg], + capture_output=True, + text=True, + ) + else: + result = subprocess.run( + [sys.executable, "-m", "pip", "install", matrix_pkg], + capture_output=True, + text=True, + ) + if result.returncode == 0: + print_success(f"{matrix_pkg} installed") + else: + print_warning(f"Install failed — run manually: pip install '{matrix_pkg}'") + if result.stderr: + print_info(f" Error: {result.stderr.strip().splitlines()[-1]}") # Allowed users print() diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index 359e8b912a..370b69ab0c 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -354,7 +354,14 @@ def do_install(identifier: str, category: str = "", force: bool = False, extra_metadata.update(getattr(bundle, "metadata", {}) or {}) # Quarantine the bundle - q_path = quarantine_bundle(bundle) + try: + q_path = quarantine_bundle(bundle) + except ValueError as exc: + c.print(f"[bold red]Installation blocked:[/] {exc}\n") + from tools.skills_hub import append_audit_log + append_audit_log("BLOCKED", bundle.name, bundle.source, + bundle.trust_level, "invalid_path", str(exc)) + return c.print(f"[dim]Quarantined to {q_path.relative_to(q_path.parent.parent.parent)}[/]") # Scan @@ -414,7 +421,15 @@ def do_install(identifier: str, category: str = "", force: bool = False, return # Install - install_dir = install_from_quarantine(q_path, bundle.name, category, bundle, result) + try: + install_dir = install_from_quarantine(q_path, bundle.name, category, bundle, result) + except ValueError as exc: + c.print(f"[bold red]Installation blocked:[/] {exc}\n") + shutil.rmtree(q_path, ignore_errors=True) + from tools.skills_hub import append_audit_log + append_audit_log("BLOCKED", bundle.name, bundle.source, + bundle.trust_level, "invalid_path", str(exc)) + return from tools.skills_hub import SKILLS_DIR c.print(f"[bold green]Installed:[/] {install_dir.relative_to(SKILLS_DIR)}") c.print(f"[dim]Files: {', '.join(bundle.files.keys())}[/]\n") diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 14d0a9c08b..67b15bab78 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -312,23 +312,31 @@ def show_status(args): _gw_svc = get_service_name() except Exception: _gw_svc = "hermes-gateway" - result = subprocess.run( - ["systemctl", "--user", "is-active", _gw_svc], - capture_output=True, - text=True - ) - is_active = result.stdout.strip() == "active" + try: + result = subprocess.run( + ["systemctl", "--user", "is-active", _gw_svc], + capture_output=True, + text=True, + timeout=5 + ) + is_active = result.stdout.strip() == "active" + except subprocess.TimeoutExpired: + is_active = False print(f" Status: {check_mark(is_active)} {'running' if is_active else 'stopped'}") print(" Manager: systemd (user)") elif sys.platform == 'darwin': from hermes_cli.gateway import get_launchd_label - result = subprocess.run( - ["launchctl", "list", get_launchd_label()], - capture_output=True, - text=True - ) - is_loaded = result.returncode == 0 + try: + result = subprocess.run( + ["launchctl", "list", get_launchd_label()], + capture_output=True, + text=True, + timeout=5 + ) + is_loaded = result.returncode == 0 + except subprocess.TimeoutExpired: + is_loaded = False print(f" Status: {check_mark(is_loaded)} {'loaded' if is_loaded else 'not loaded'}") print(" Manager: launchd") else: diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index f9e6d28ee2..cbe9b332c2 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -314,6 +314,16 @@ TOOL_CATEGORIES = { "browser_provider": "browser-use", "post_setup": "browserbase", }, + { + "name": "Camofox", + "tag": "Local anti-detection browser (Firefox/Camoufox)", + "env_vars": [ + {"key": "CAMOFOX_URL", "prompt": "Camofox server URL", "default": "http://localhost:9377", + "url": "https://github.com/jo-inc/camofox-browser"}, + ], + "browser_provider": "camofox", + "post_setup": "camofox", + }, ], }, "homeassistant": { @@ -378,6 +388,28 @@ def _run_post_setup(post_setup_key: str): elif not node_modules.exists(): _print_warning(" Node.js not found - browser tools require: npm install (in hermes-agent directory)") + elif post_setup_key == "camofox": + camofox_dir = PROJECT_ROOT / "node_modules" / "@askjo" / "camoufox-browser" + if not camofox_dir.exists() and shutil.which("npm"): + _print_info(" Installing Camofox browser server...") + import subprocess + result = subprocess.run( + ["npm", "install", "--silent"], + capture_output=True, text=True, cwd=str(PROJECT_ROOT) + ) + if result.returncode == 0: + _print_success(" Camofox installed") + else: + _print_warning(" npm install failed - run manually: npm install") + if camofox_dir.exists(): + _print_info(" Start the Camofox server:") + _print_info(" npx @askjo/camoufox-browser") + _print_info(" First run downloads the Camoufox engine (~300MB)") + _print_info(" Or use Docker: docker run -p 9377:9377 jo-inc/camofox-browser") + elif not shutil.which("npm"): + _print_warning(" Node.js not found. Install Camofox via Docker:") + _print_info(" docker run -p 9377:9377 jo-inc/camofox-browser") + elif post_setup_key == "rl_training": try: __import__("tinker_atropos") @@ -615,7 +647,9 @@ def _toolset_has_keys(ts_key: str, config: dict = None) -> bool: if cat: for provider in _visible_providers(cat, config): env_vars = provider.get("env_vars", []) - if env_vars and all(get_env_value(e["key"]) for e in env_vars): + if not env_vars: + return True # No-key provider (e.g. Local Browser, Edge TTS) + if all(get_env_value(e["key"]) for e in env_vars): return True return False diff --git a/honcho_integration/cli.py b/honcho_integration/cli.py index ae09c37134..f6cbcedf66 100644 --- a/honcho_integration/cli.py +++ b/honcho_integration/cli.py @@ -10,16 +10,27 @@ import os import sys from pathlib import Path +from hermes_constants import get_hermes_home from honcho_integration.client import resolve_config_path, GLOBAL_CONFIG_PATH HOST = "hermes" def _config_path() -> Path: - """Return the active Honcho config path (instance-local or global).""" + """Return the active Honcho config path for reading (instance-local or global).""" return resolve_config_path() +def _local_config_path() -> Path: + """Return the instance-local Honcho config path for writing. + + Always returns $HERMES_HOME/honcho.json so each profile/instance gets + its own config file. The global ~/.honcho/config.json is only used as + a read fallback (via resolve_config_path) for cross-app interop. + """ + return get_hermes_home() / "honcho.json" + + def _read_config() -> dict: path = _config_path() if path.exists(): @@ -31,7 +42,7 @@ def _read_config() -> dict: def _write_config(cfg: dict, path: Path | None = None) -> None: - path = path or _config_path() + path = path or _local_config_path() path.parent.mkdir(parents=True, exist_ok=True) path.write_text( json.dumps(cfg, indent=2, ensure_ascii=False) + "\n", @@ -95,13 +106,13 @@ def cmd_setup(args) -> None: """Interactive Honcho setup wizard.""" cfg = _read_config() - active_path = _config_path() + write_path = _local_config_path() + read_path = _config_path() print("\nHoncho memory setup\n" + "─" * 40) print(" Honcho gives Hermes persistent cross-session memory.") - if active_path != GLOBAL_CONFIG_PATH: - print(f" Instance config: {active_path}") - else: - print(" Config is shared with other hosts at ~/.honcho/config.json") + print(f" Config: {write_path}") + if read_path != write_path and read_path.exists(): + print(f" (seeding from existing config at {read_path})") print() if not _ensure_sdk_installed(): @@ -189,7 +200,7 @@ def cmd_setup(args) -> None: hermes_host.setdefault("saveMessages", True) _write_config(cfg) - print(f"\n Config written to {active_path}") + print(f"\n Config written to {write_path}") # Test connection print(" Testing connection... ", end="", flush=True) @@ -237,6 +248,7 @@ def cmd_status(args) -> None: cfg = _read_config() active_path = _config_path() + write_path = _local_config_path() if not cfg: print(f" No Honcho config found at {active_path}") @@ -259,6 +271,8 @@ def cmd_status(args) -> None: print(f" Workspace: {hcfg.workspace_id}") print(f" Host: {hcfg.host}") print(f" Config path: {active_path}") + if write_path != active_path: + print(f" Write path: {write_path} (instance-local)") print(f" AI peer: {hcfg.ai_peer}") print(f" User peer: {hcfg.peer_name or 'not set'}") print(f" Session key: {hcfg.resolve_session_name()}") diff --git a/skills/inference-sh/cli/SKILL.md b/optional-skills/devops/cli/SKILL.md similarity index 100% rename from skills/inference-sh/cli/SKILL.md rename to optional-skills/devops/cli/SKILL.md diff --git a/skills/inference-sh/cli/references/app-discovery.md b/optional-skills/devops/cli/references/app-discovery.md similarity index 100% rename from skills/inference-sh/cli/references/app-discovery.md rename to optional-skills/devops/cli/references/app-discovery.md diff --git a/skills/inference-sh/cli/references/authentication.md b/optional-skills/devops/cli/references/authentication.md similarity index 100% rename from skills/inference-sh/cli/references/authentication.md rename to optional-skills/devops/cli/references/authentication.md diff --git a/skills/inference-sh/cli/references/cli-reference.md b/optional-skills/devops/cli/references/cli-reference.md similarity index 100% rename from skills/inference-sh/cli/references/cli-reference.md rename to optional-skills/devops/cli/references/cli-reference.md diff --git a/skills/inference-sh/cli/references/running-apps.md b/optional-skills/devops/cli/references/running-apps.md similarity index 100% rename from skills/inference-sh/cli/references/running-apps.md rename to optional-skills/devops/cli/references/running-apps.md diff --git a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py index f607ee56ba..ac99e2a6fd 100644 --- a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py +++ b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py @@ -304,6 +304,29 @@ def ensure_parent(path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) +def resolve_secret_input(value: Any, env: Optional[Dict[str, str]] = None) -> Optional[str]: + """Resolve an OpenClaw SecretInput value to a plain string. + + SecretInput can be: + - A plain string: "sk-..." + - An env template: "${OPENROUTER_API_KEY}" + - A SecretRef object: {"source": "env", "id": "OPENROUTER_API_KEY"} + """ + if isinstance(value, str): + # Check for env template: "${VAR_NAME}" + m = re.match(r"^\$\{(\w+)\}$", value.strip()) + if m and env: + return env.get(m.group(1), "").strip() or None + return value.strip() or None + if isinstance(value, dict): + source = value.get("source", "") + ref_id = value.get("id", "") + if source == "env" and ref_id and env: + return env.get(ref_id, "").strip() or None + # File/exec sources can't be resolved here — return None + return None + + def load_yaml_file(path: Path) -> Dict[str, Any]: if yaml is None or not path.exists(): return {} @@ -890,14 +913,20 @@ class Migrator: self.record("command-allowlist", source, destination, "migrated", "Would merge patterns", added_patterns=added) def load_openclaw_config(self) -> Dict[str, Any]: - config_path = self.source_root / "openclaw.json" - if not config_path.exists(): - return {} - try: - data = json.loads(config_path.read_text(encoding="utf-8")) - return data if isinstance(data, dict) else {} - except json.JSONDecodeError: - return {} + # Check current name and legacy config filenames + for name in ("openclaw.json", "clawdbot.json", "moldbot.json"): + config_path = self.source_root / name + if config_path.exists(): + try: + data = json.loads(config_path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except json.JSONDecodeError: + continue + return {} + + def load_openclaw_env(self) -> Dict[str, str]: + """Load the OpenClaw .env file for secrets that live there instead of config.""" + return parse_env_file(self.source_root / ".env") def merge_env_values(self, additions: Dict[str, str], kind: str, source: Path) -> None: destination = self.target_root / ".env" @@ -1024,6 +1053,10 @@ class Migrator: supported_targets=sorted(SUPPORTED_SECRET_TARGETS), ) + def _resolve_channel_secret(self, value: Any) -> Optional[str]: + """Resolve a channel config value that may be a SecretRef.""" + return resolve_secret_input(value, self.load_openclaw_env()) + def migrate_discord_settings(self, config: Optional[Dict[str, Any]] = None) -> None: config = config or self.load_openclaw_config() additions: Dict[str, str] = {} @@ -1118,15 +1151,17 @@ class Migrator: secret_additions: Dict[str, str] = {} # Extract provider API keys from models.providers + # Note: apiKey values can be strings, env templates, or SecretRef objects + openclaw_env = self.load_openclaw_env() providers = config.get("models", {}).get("providers", {}) if isinstance(providers, dict): for provider_name, provider_cfg in providers.items(): if not isinstance(provider_cfg, dict): continue - api_key = provider_cfg.get("apiKey") - if not isinstance(api_key, str) or not api_key.strip(): + raw_key = provider_cfg.get("apiKey") + api_key = resolve_secret_input(raw_key, openclaw_env) + if not api_key: continue - api_key = api_key.strip() base_url = provider_cfg.get("baseUrl", "") api_type = provider_cfg.get("api", "") @@ -1170,6 +1205,50 @@ class Migrator: if isinstance(oai_key, str) and oai_key.strip(): secret_additions["VOICE_TOOLS_OPENAI_KEY"] = oai_key.strip() + # Also check the OpenClaw .env file — many users store keys there + # instead of inline in openclaw.json + openclaw_env = self.load_openclaw_env() + env_key_mapping = { + "OPENROUTER_API_KEY": "OPENROUTER_API_KEY", + "OPENAI_API_KEY": "OPENAI_API_KEY", + "ANTHROPIC_API_KEY": "ANTHROPIC_API_KEY", + "ELEVENLABS_API_KEY": "ELEVENLABS_API_KEY", + "TELEGRAM_BOT_TOKEN": "TELEGRAM_BOT_TOKEN", + "DEEPSEEK_API_KEY": "DEEPSEEK_API_KEY", + "GEMINI_API_KEY": "GEMINI_API_KEY", + "ZAI_API_KEY": "ZAI_API_KEY", + "MINIMAX_API_KEY": "MINIMAX_API_KEY", + } + for oc_key, hermes_key in env_key_mapping.items(): + val = openclaw_env.get(oc_key, "").strip() + if val and hermes_key not in secret_additions: + secret_additions[hermes_key] = val + + # Check per-agent auth-profiles.json for additional credentials + auth_profiles_path = self.source_root / "agents" / "main" / "agent" / "auth-profiles.json" + if auth_profiles_path.exists(): + try: + profiles = json.loads(auth_profiles_path.read_text(encoding="utf-8")) + if isinstance(profiles, dict): + # auth-profiles.json wraps profiles in a "profiles" key + profile_entries = profiles.get("profiles", profiles) if isinstance(profiles.get("profiles"), dict) else profiles + for profile_name, profile_data in profile_entries.items(): + if not isinstance(profile_data, dict): + continue + # Canonical field is "key", "apiKey" is accepted as alias + api_key = profile_data.get("key", "") or profile_data.get("apiKey", "") + if not isinstance(api_key, str) or not api_key.strip(): + continue + name_lower = profile_name.lower() + if "openrouter" in name_lower and "OPENROUTER_API_KEY" not in secret_additions: + secret_additions["OPENROUTER_API_KEY"] = api_key.strip() + elif "openai" in name_lower and "OPENAI_API_KEY" not in secret_additions: + secret_additions["OPENAI_API_KEY"] = api_key.strip() + elif "anthropic" in name_lower and "ANTHROPIC_API_KEY" not in secret_additions: + secret_additions["ANTHROPIC_API_KEY"] = api_key.strip() + except (json.JSONDecodeError, OSError): + pass + if secret_additions: self.merge_env_values(secret_additions, "provider-keys", self.source_root / "openclaw.json") else: @@ -1218,7 +1297,11 @@ class Migrator: if self.execute: backup_path = self.maybe_backup(destination) - hermes_config["model"] = model_str + existing_model = hermes_config.get("model") + if isinstance(existing_model, dict): + existing_model["default"] = model_str + else: + hermes_config["model"] = {"default": model_str} dump_yaml_file(destination, hermes_config) self.record("model-config", source_path, destination, "migrated", backup=str(backup_path) if backup_path else "", model=model_str) else: @@ -1244,22 +1327,44 @@ class Migrator: if isinstance(provider, str) and provider in ("elevenlabs", "openai", "edge"): tts_data["provider"] = provider - elevenlabs = tts.get("elevenlabs", {}) + # TTS provider settings live under messages.tts.providers.{provider} + # in OpenClaw (not messages.tts.elevenlabs directly) + providers = tts.get("providers") or {} + + # Also check the top-level "talk" config which has provider settings too + talk_cfg = (config or self.load_openclaw_config()).get("talk") or {} + talk_providers = talk_cfg.get("providers") or {} + + # Merge: messages.tts.providers takes priority, then talk.providers, + # then legacy flat keys (messages.tts.elevenlabs, etc.) + elevenlabs = ( + (providers.get("elevenlabs") or {}) + if isinstance(providers.get("elevenlabs"), dict) else + (talk_providers.get("elevenlabs") or {}) + if isinstance(talk_providers.get("elevenlabs"), dict) else + (tts.get("elevenlabs") or {}) + ) if isinstance(elevenlabs, dict): el_settings: Dict[str, str] = {} - voice_id = elevenlabs.get("voiceId") + voice_id = elevenlabs.get("voiceId") or talk_cfg.get("voiceId") if isinstance(voice_id, str) and voice_id.strip(): el_settings["voice_id"] = voice_id.strip() - model_id = elevenlabs.get("modelId") + model_id = elevenlabs.get("modelId") or talk_cfg.get("modelId") if isinstance(model_id, str) and model_id.strip(): el_settings["model_id"] = model_id.strip() if el_settings: tts_data["elevenlabs"] = el_settings - openai_tts = tts.get("openai", {}) + openai_tts = ( + (providers.get("openai") or {}) + if isinstance(providers.get("openai"), dict) else + (talk_providers.get("openai") or {}) + if isinstance(talk_providers.get("openai"), dict) else + (tts.get("openai") or {}) + ) if isinstance(openai_tts, dict): oai_settings: Dict[str, str] = {} - oai_model = openai_tts.get("model") + oai_model = openai_tts.get("model") or openai_tts.get("modelId") if isinstance(oai_model, str) and oai_model.strip(): oai_settings["model"] = oai_model.strip() oai_voice = openai_tts.get("voice") @@ -1268,7 +1373,11 @@ class Migrator: if oai_settings: tts_data["openai"] = oai_settings - edge_tts = tts.get("edge", {}) + edge_tts = ( + (providers.get("edge") or {}) + if isinstance(providers.get("edge"), dict) else + (tts.get("edge") or {}) + ) if isinstance(edge_tts, dict): edge_voice = edge_tts.get("voice") if isinstance(edge_voice, str) and edge_voice.strip(): @@ -1298,15 +1407,29 @@ class Migrator: self.record("tts-config", source_path, destination, "migrated", "Would set TTS config", settings=list(tts_data.keys())) def migrate_shared_skills(self) -> None: - source_root = self.source_root / "skills" + # Check all OpenClaw skill sources: managed, personal, project-level + skill_sources = [ + (self.source_root / "skills", "shared-skills", "managed skills"), + (Path.home() / ".agents" / "skills", "personal-skills", "personal cross-project skills"), + (self.source_root / "workspace" / ".agents" / "skills", "project-skills", "project-level shared skills"), + (self.source_root / "workspace.default" / ".agents" / "skills", "project-skills", "project-level shared skills"), + ] + found_any = False + for source_root, kind_label, desc in skill_sources: + if source_root.exists(): + found_any = True + self._import_skill_directory(source_root, kind_label, desc) + if not found_any: + destination_root = self.target_root / "skills" / SKILL_CATEGORY_DIRNAME + self.record("shared-skills", None, destination_root, "skipped", "No shared OpenClaw skills directories found") + + def _import_skill_directory(self, source_root: Path, kind_label: str, desc: str) -> None: + """Import skills from a single source directory into openclaw-imports.""" destination_root = self.target_root / "skills" / SKILL_CATEGORY_DIRNAME - if not source_root.exists(): - self.record("shared-skills", None, destination_root, "skipped", "No shared OpenClaw skills directory found") - return skill_dirs = [p for p in sorted(source_root.iterdir()) if p.is_dir() and (p / "SKILL.md").exists()] if not skill_dirs: - self.record("shared-skills", source_root, destination_root, "skipped", "No shared skills with SKILL.md found") + self.record(kind_label, source_root, destination_root, "skipped", f"No skills with SKILL.md found in {desc}") return for skill_dir in skill_dirs: @@ -1314,7 +1437,7 @@ class Migrator: final_destination = destination if destination.exists(): if self.skill_conflict_mode == "skip": - self.record("shared-skill", skill_dir, destination, "conflict", "Destination skill already exists") + self.record(kind_label, skill_dir, destination, "conflict", "Destination skill already exists") continue if self.skill_conflict_mode == "rename": final_destination = self.resolve_skill_destination(destination) @@ -1329,19 +1452,19 @@ class Migrator: details: Dict[str, Any] = {"backup": str(backup_path) if backup_path else ""} if final_destination != destination: details["renamed_from"] = str(destination) - self.record("shared-skill", skill_dir, final_destination, "migrated", **details) + self.record(kind_label, skill_dir, final_destination, "migrated", **details) else: if final_destination != destination: self.record( - "shared-skill", + kind_label, skill_dir, final_destination, "migrated", - "Would copy shared skill directory under a renamed folder", + f"Would copy {desc} directory under a renamed folder", renamed_from=str(destination), ) else: - self.record("shared-skill", skill_dir, final_destination, "migrated", "Would copy shared skill directory") + self.record(kind_label, skill_dir, final_destination, "migrated", f"Would copy {desc} directory") desc_path = destination_root / "DESCRIPTION.md" if self.execute: @@ -1518,6 +1641,7 @@ class Migrator: self.source_candidate("workspace/IDENTITY.md", "workspace.default/IDENTITY.md"), self.source_candidate("workspace/TOOLS.md", "workspace.default/TOOLS.md"), self.source_candidate("workspace/HEARTBEAT.md", "workspace.default/HEARTBEAT.md"), + self.source_candidate("workspace/BOOTSTRAP.md", "workspace.default/BOOTSTRAP.md"), ] for candidate in candidates: if candidate: @@ -1789,8 +1913,9 @@ class Migrator: human_delay = defaults.get("humanDelay") or {} if human_delay: hd = hermes_cfg.get("human_delay") or {} - if human_delay.get("enabled"): - hd["mode"] = "natural" + hd_mode = human_delay.get("mode") or ("natural" if human_delay.get("enabled") else None) + if hd_mode and hd_mode != "off": + hd["mode"] = hd_mode if human_delay.get("minMs"): hd["min_ms"] = human_delay["minMs"] if human_delay.get("maxMs"): @@ -1804,11 +1929,11 @@ class Migrator: changes = True # Map terminal/exec settings - exec_cfg = defaults.get("exec") or (config.get("tools") or {}).get("exec") or {} + exec_cfg = (config.get("tools") or {}).get("exec") or {} if exec_cfg: terminal_cfg = hermes_cfg.get("terminal") or {} - if exec_cfg.get("timeout"): - terminal_cfg["timeout"] = exec_cfg["timeout"] + if exec_cfg.get("timeoutSec") or exec_cfg.get("timeout"): + terminal_cfg["timeout"] = exec_cfg.get("timeoutSec") or exec_cfg.get("timeout") changes = True hermes_cfg["terminal"] = terminal_cfg @@ -1883,24 +2008,34 @@ class Migrator: sr = hermes_cfg.get("session_reset") or {} changes = False - reset_triggers = session.get("resetTriggers") or session.get("reset_triggers") or {} - if reset_triggers: - daily = reset_triggers.get("daily") or {} - idle = reset_triggers.get("idle") or {} + # OpenClaw uses session.reset (structured) and session.resetTriggers (string array) + reset = session.get("reset") or {} + reset_triggers = session.get("resetTriggers") or session.get("reset_triggers") or [] - if daily.get("enabled") and idle.get("enabled"): - sr["mode"] = "both" - elif daily.get("enabled"): + if reset: + # Structured reset config: has mode, atHour, idleMinutes + mode = reset.get("mode", "") + if mode == "daily": sr["mode"] = "daily" - elif idle.get("enabled"): + elif mode == "idle": sr["mode"] = "idle" else: - sr["mode"] = "none" - - if daily.get("hour") is not None: - sr["at_hour"] = daily["hour"] - if idle.get("minutes") or idle.get("timeoutMinutes"): - sr["idle_minutes"] = idle.get("minutes") or idle.get("timeoutMinutes") + sr["mode"] = mode or "none" + if reset.get("atHour") is not None: + sr["at_hour"] = reset["atHour"] + if reset.get("idleMinutes"): + sr["idle_minutes"] = reset["idleMinutes"] + changes = True + elif isinstance(reset_triggers, list) and reset_triggers: + # Simple string triggers: ["daily", "idle"] + has_daily = "daily" in reset_triggers + has_idle = "idle" in reset_triggers + if has_daily and has_idle: + sr["mode"] = "both" + elif has_daily: + sr["mode"] = "daily" + elif has_idle: + sr["mode"] = "idle" changes = True if changes: @@ -2092,11 +2227,12 @@ class Migrator: browser_hermes = hermes_cfg.get("browser") or {} changed = False - if browser.get("inactivityTimeoutMs"): - browser_hermes["inactivity_timeout"] = browser["inactivityTimeoutMs"] // 1000 + # Map fields that have Hermes equivalents + if browser.get("cdpUrl"): + browser_hermes["cdp_url"] = browser["cdpUrl"] changed = True - if browser.get("commandTimeoutMs"): - browser_hermes["command_timeout"] = browser["commandTimeoutMs"] // 1000 + if browser.get("headless") is not None: + browser_hermes["headless"] = browser["headless"] changed = True if changed: @@ -2107,9 +2243,9 @@ class Migrator: self.record("browser-config", "openclaw.json browser.*", "config.yaml browser", "migrated") - # Archive advanced browser settings + # Archive remaining browser settings advanced = {k: v for k, v in browser.items() - if k not in ("inactivityTimeoutMs", "commandTimeoutMs") and v} + if k not in ("cdpUrl", "headless") and v} if advanced and self.archive_dir: if self.execute: self.archive_dir.mkdir(parents=True, exist_ok=True) @@ -2130,18 +2266,22 @@ class Migrator: hermes_cfg = load_yaml_file(hermes_cfg_path) changed = False - # Map exec timeout -> terminal timeout + # Map exec timeout -> terminal timeout (field is timeoutSec in OpenClaw) exec_cfg = tools.get("exec") or {} - if exec_cfg.get("timeout"): + timeout_val = exec_cfg.get("timeoutSec") or exec_cfg.get("timeout") + if timeout_val: terminal_cfg = hermes_cfg.get("terminal") or {} - terminal_cfg["timeout"] = exec_cfg["timeout"] + terminal_cfg["timeout"] = timeout_val hermes_cfg["terminal"] = terminal_cfg changed = True - # Map web search API key - web_cfg = tools.get("webSearch") or tools.get("web") or {} - if web_cfg.get("braveApiKey") and self.migrate_secrets: - self._set_env_var("BRAVE_API_KEY", web_cfg["braveApiKey"], "tools.webSearch.braveApiKey") + # Map web search API key (path: tools.web.search.brave.apiKey in OpenClaw) + web_cfg = tools.get("web") or tools.get("webSearch") or {} + search_cfg = web_cfg.get("search") or web_cfg if not web_cfg.get("search") else web_cfg["search"] + brave_cfg = search_cfg.get("brave") or {} + brave_key = brave_cfg.get("apiKey") or search_cfg.get("braveApiKey") or web_cfg.get("braveApiKey") + if brave_key and isinstance(brave_key, str) and self.migrate_secrets: + self._set_env_var("BRAVE_API_KEY", brave_key, "tools.web.search.brave.apiKey") if changed and self.execute: self.maybe_backup(hermes_cfg_path) @@ -2169,8 +2309,9 @@ class Migrator: hermes_cfg_path = self.target_root / "config.yaml" hermes_cfg = load_yaml_file(hermes_cfg_path) - # Map approval mode - mode = approvals.get("mode") or approvals.get("defaultMode") + # Map approval mode (nested under approvals.exec.mode in OpenClaw) + exec_approvals = approvals.get("exec") or {} + mode = (exec_approvals.get("mode") if isinstance(exec_approvals, dict) else None) or approvals.get("mode") or approvals.get("defaultMode") if mode: mode_map = {"auto": "off", "always": "manual", "smart": "smart", "manual": "manual"} hermes_mode = mode_map.get(mode, "manual") diff --git a/skills/mlops/training/accelerate/SKILL.md b/optional-skills/mlops/accelerate/SKILL.md similarity index 100% rename from skills/mlops/training/accelerate/SKILL.md rename to optional-skills/mlops/accelerate/SKILL.md diff --git a/skills/mlops/training/accelerate/references/custom-plugins.md b/optional-skills/mlops/accelerate/references/custom-plugins.md similarity index 100% rename from skills/mlops/training/accelerate/references/custom-plugins.md rename to optional-skills/mlops/accelerate/references/custom-plugins.md diff --git a/skills/mlops/training/accelerate/references/megatron-integration.md b/optional-skills/mlops/accelerate/references/megatron-integration.md similarity index 100% rename from skills/mlops/training/accelerate/references/megatron-integration.md rename to optional-skills/mlops/accelerate/references/megatron-integration.md diff --git a/skills/mlops/training/accelerate/references/performance.md b/optional-skills/mlops/accelerate/references/performance.md similarity index 100% rename from skills/mlops/training/accelerate/references/performance.md rename to optional-skills/mlops/accelerate/references/performance.md diff --git a/skills/mlops/vector-databases/chroma/SKILL.md b/optional-skills/mlops/chroma/SKILL.md similarity index 100% rename from skills/mlops/vector-databases/chroma/SKILL.md rename to optional-skills/mlops/chroma/SKILL.md diff --git a/skills/mlops/vector-databases/chroma/references/integration.md b/optional-skills/mlops/chroma/references/integration.md similarity index 100% rename from skills/mlops/vector-databases/chroma/references/integration.md rename to optional-skills/mlops/chroma/references/integration.md diff --git a/skills/mlops/vector-databases/faiss/SKILL.md b/optional-skills/mlops/faiss/SKILL.md similarity index 100% rename from skills/mlops/vector-databases/faiss/SKILL.md rename to optional-skills/mlops/faiss/SKILL.md diff --git a/skills/mlops/vector-databases/faiss/references/index_types.md b/optional-skills/mlops/faiss/references/index_types.md similarity index 100% rename from skills/mlops/vector-databases/faiss/references/index_types.md rename to optional-skills/mlops/faiss/references/index_types.md diff --git a/skills/mlops/training/flash-attention/SKILL.md b/optional-skills/mlops/flash-attention/SKILL.md similarity index 100% rename from skills/mlops/training/flash-attention/SKILL.md rename to optional-skills/mlops/flash-attention/SKILL.md diff --git a/skills/mlops/training/flash-attention/references/benchmarks.md b/optional-skills/mlops/flash-attention/references/benchmarks.md similarity index 100% rename from skills/mlops/training/flash-attention/references/benchmarks.md rename to optional-skills/mlops/flash-attention/references/benchmarks.md diff --git a/skills/mlops/training/flash-attention/references/transformers-integration.md b/optional-skills/mlops/flash-attention/references/transformers-integration.md similarity index 100% rename from skills/mlops/training/flash-attention/references/transformers-integration.md rename to optional-skills/mlops/flash-attention/references/transformers-integration.md diff --git a/skills/mlops/training/hermes-atropos-environments/SKILL.md b/optional-skills/mlops/hermes-atropos-environments/SKILL.md similarity index 100% rename from skills/mlops/training/hermes-atropos-environments/SKILL.md rename to optional-skills/mlops/hermes-atropos-environments/SKILL.md diff --git a/skills/mlops/training/hermes-atropos-environments/references/agentresult-fields.md b/optional-skills/mlops/hermes-atropos-environments/references/agentresult-fields.md similarity index 100% rename from skills/mlops/training/hermes-atropos-environments/references/agentresult-fields.md rename to optional-skills/mlops/hermes-atropos-environments/references/agentresult-fields.md diff --git a/skills/mlops/training/hermes-atropos-environments/references/atropos-base-env.md b/optional-skills/mlops/hermes-atropos-environments/references/atropos-base-env.md similarity index 100% rename from skills/mlops/training/hermes-atropos-environments/references/atropos-base-env.md rename to optional-skills/mlops/hermes-atropos-environments/references/atropos-base-env.md diff --git a/skills/mlops/training/hermes-atropos-environments/references/usage-patterns.md b/optional-skills/mlops/hermes-atropos-environments/references/usage-patterns.md similarity index 100% rename from skills/mlops/training/hermes-atropos-environments/references/usage-patterns.md rename to optional-skills/mlops/hermes-atropos-environments/references/usage-patterns.md diff --git a/skills/mlops/evaluation/huggingface-tokenizers/SKILL.md b/optional-skills/mlops/huggingface-tokenizers/SKILL.md similarity index 100% rename from skills/mlops/evaluation/huggingface-tokenizers/SKILL.md rename to optional-skills/mlops/huggingface-tokenizers/SKILL.md diff --git a/skills/mlops/evaluation/huggingface-tokenizers/references/algorithms.md b/optional-skills/mlops/huggingface-tokenizers/references/algorithms.md similarity index 100% rename from skills/mlops/evaluation/huggingface-tokenizers/references/algorithms.md rename to optional-skills/mlops/huggingface-tokenizers/references/algorithms.md diff --git a/skills/mlops/evaluation/huggingface-tokenizers/references/integration.md b/optional-skills/mlops/huggingface-tokenizers/references/integration.md similarity index 100% rename from skills/mlops/evaluation/huggingface-tokenizers/references/integration.md rename to optional-skills/mlops/huggingface-tokenizers/references/integration.md diff --git a/skills/mlops/evaluation/huggingface-tokenizers/references/pipeline.md b/optional-skills/mlops/huggingface-tokenizers/references/pipeline.md similarity index 100% rename from skills/mlops/evaluation/huggingface-tokenizers/references/pipeline.md rename to optional-skills/mlops/huggingface-tokenizers/references/pipeline.md diff --git a/skills/mlops/evaluation/huggingface-tokenizers/references/training.md b/optional-skills/mlops/huggingface-tokenizers/references/training.md similarity index 100% rename from skills/mlops/evaluation/huggingface-tokenizers/references/training.md rename to optional-skills/mlops/huggingface-tokenizers/references/training.md diff --git a/skills/mlops/inference/instructor/SKILL.md b/optional-skills/mlops/instructor/SKILL.md similarity index 100% rename from skills/mlops/inference/instructor/SKILL.md rename to optional-skills/mlops/instructor/SKILL.md diff --git a/skills/mlops/inference/instructor/references/examples.md b/optional-skills/mlops/instructor/references/examples.md similarity index 100% rename from skills/mlops/inference/instructor/references/examples.md rename to optional-skills/mlops/instructor/references/examples.md diff --git a/skills/mlops/inference/instructor/references/providers.md b/optional-skills/mlops/instructor/references/providers.md similarity index 100% rename from skills/mlops/inference/instructor/references/providers.md rename to optional-skills/mlops/instructor/references/providers.md diff --git a/skills/mlops/inference/instructor/references/validation.md b/optional-skills/mlops/instructor/references/validation.md similarity index 100% rename from skills/mlops/inference/instructor/references/validation.md rename to optional-skills/mlops/instructor/references/validation.md diff --git a/skills/mlops/cloud/lambda-labs/SKILL.md b/optional-skills/mlops/lambda-labs/SKILL.md similarity index 100% rename from skills/mlops/cloud/lambda-labs/SKILL.md rename to optional-skills/mlops/lambda-labs/SKILL.md diff --git a/skills/mlops/cloud/lambda-labs/references/advanced-usage.md b/optional-skills/mlops/lambda-labs/references/advanced-usage.md similarity index 100% rename from skills/mlops/cloud/lambda-labs/references/advanced-usage.md rename to optional-skills/mlops/lambda-labs/references/advanced-usage.md diff --git a/skills/mlops/cloud/lambda-labs/references/troubleshooting.md b/optional-skills/mlops/lambda-labs/references/troubleshooting.md similarity index 100% rename from skills/mlops/cloud/lambda-labs/references/troubleshooting.md rename to optional-skills/mlops/lambda-labs/references/troubleshooting.md diff --git a/skills/mlops/models/llava/SKILL.md b/optional-skills/mlops/llava/SKILL.md similarity index 100% rename from skills/mlops/models/llava/SKILL.md rename to optional-skills/mlops/llava/SKILL.md diff --git a/skills/mlops/models/llava/references/training.md b/optional-skills/mlops/llava/references/training.md similarity index 100% rename from skills/mlops/models/llava/references/training.md rename to optional-skills/mlops/llava/references/training.md diff --git a/skills/mlops/evaluation/nemo-curator/SKILL.md b/optional-skills/mlops/nemo-curator/SKILL.md similarity index 100% rename from skills/mlops/evaluation/nemo-curator/SKILL.md rename to optional-skills/mlops/nemo-curator/SKILL.md diff --git a/skills/mlops/evaluation/nemo-curator/references/deduplication.md b/optional-skills/mlops/nemo-curator/references/deduplication.md similarity index 100% rename from skills/mlops/evaluation/nemo-curator/references/deduplication.md rename to optional-skills/mlops/nemo-curator/references/deduplication.md diff --git a/skills/mlops/evaluation/nemo-curator/references/filtering.md b/optional-skills/mlops/nemo-curator/references/filtering.md similarity index 100% rename from skills/mlops/evaluation/nemo-curator/references/filtering.md rename to optional-skills/mlops/nemo-curator/references/filtering.md diff --git a/skills/mlops/vector-databases/pinecone/SKILL.md b/optional-skills/mlops/pinecone/SKILL.md similarity index 100% rename from skills/mlops/vector-databases/pinecone/SKILL.md rename to optional-skills/mlops/pinecone/SKILL.md diff --git a/skills/mlops/vector-databases/pinecone/references/deployment.md b/optional-skills/mlops/pinecone/references/deployment.md similarity index 100% rename from skills/mlops/vector-databases/pinecone/references/deployment.md rename to optional-skills/mlops/pinecone/references/deployment.md diff --git a/skills/mlops/training/pytorch-lightning/SKILL.md b/optional-skills/mlops/pytorch-lightning/SKILL.md similarity index 100% rename from skills/mlops/training/pytorch-lightning/SKILL.md rename to optional-skills/mlops/pytorch-lightning/SKILL.md diff --git a/skills/mlops/training/pytorch-lightning/references/callbacks.md b/optional-skills/mlops/pytorch-lightning/references/callbacks.md similarity index 100% rename from skills/mlops/training/pytorch-lightning/references/callbacks.md rename to optional-skills/mlops/pytorch-lightning/references/callbacks.md diff --git a/skills/mlops/training/pytorch-lightning/references/distributed.md b/optional-skills/mlops/pytorch-lightning/references/distributed.md similarity index 100% rename from skills/mlops/training/pytorch-lightning/references/distributed.md rename to optional-skills/mlops/pytorch-lightning/references/distributed.md diff --git a/skills/mlops/training/pytorch-lightning/references/hyperparameter-tuning.md b/optional-skills/mlops/pytorch-lightning/references/hyperparameter-tuning.md similarity index 100% rename from skills/mlops/training/pytorch-lightning/references/hyperparameter-tuning.md rename to optional-skills/mlops/pytorch-lightning/references/hyperparameter-tuning.md diff --git a/skills/mlops/vector-databases/qdrant/SKILL.md b/optional-skills/mlops/qdrant/SKILL.md similarity index 100% rename from skills/mlops/vector-databases/qdrant/SKILL.md rename to optional-skills/mlops/qdrant/SKILL.md diff --git a/skills/mlops/vector-databases/qdrant/references/advanced-usage.md b/optional-skills/mlops/qdrant/references/advanced-usage.md similarity index 100% rename from skills/mlops/vector-databases/qdrant/references/advanced-usage.md rename to optional-skills/mlops/qdrant/references/advanced-usage.md diff --git a/skills/mlops/vector-databases/qdrant/references/troubleshooting.md b/optional-skills/mlops/qdrant/references/troubleshooting.md similarity index 100% rename from skills/mlops/vector-databases/qdrant/references/troubleshooting.md rename to optional-skills/mlops/qdrant/references/troubleshooting.md diff --git a/skills/mlops/evaluation/saelens/SKILL.md b/optional-skills/mlops/saelens/SKILL.md similarity index 100% rename from skills/mlops/evaluation/saelens/SKILL.md rename to optional-skills/mlops/saelens/SKILL.md diff --git a/skills/mlops/evaluation/saelens/references/README.md b/optional-skills/mlops/saelens/references/README.md similarity index 100% rename from skills/mlops/evaluation/saelens/references/README.md rename to optional-skills/mlops/saelens/references/README.md diff --git a/skills/mlops/evaluation/saelens/references/api.md b/optional-skills/mlops/saelens/references/api.md similarity index 100% rename from skills/mlops/evaluation/saelens/references/api.md rename to optional-skills/mlops/saelens/references/api.md diff --git a/skills/mlops/evaluation/saelens/references/tutorials.md b/optional-skills/mlops/saelens/references/tutorials.md similarity index 100% rename from skills/mlops/evaluation/saelens/references/tutorials.md rename to optional-skills/mlops/saelens/references/tutorials.md diff --git a/skills/mlops/training/simpo/SKILL.md b/optional-skills/mlops/simpo/SKILL.md similarity index 100% rename from skills/mlops/training/simpo/SKILL.md rename to optional-skills/mlops/simpo/SKILL.md diff --git a/skills/mlops/training/simpo/references/datasets.md b/optional-skills/mlops/simpo/references/datasets.md similarity index 100% rename from skills/mlops/training/simpo/references/datasets.md rename to optional-skills/mlops/simpo/references/datasets.md diff --git a/skills/mlops/training/simpo/references/hyperparameters.md b/optional-skills/mlops/simpo/references/hyperparameters.md similarity index 100% rename from skills/mlops/training/simpo/references/hyperparameters.md rename to optional-skills/mlops/simpo/references/hyperparameters.md diff --git a/skills/mlops/training/simpo/references/loss-functions.md b/optional-skills/mlops/simpo/references/loss-functions.md similarity index 100% rename from skills/mlops/training/simpo/references/loss-functions.md rename to optional-skills/mlops/simpo/references/loss-functions.md diff --git a/skills/mlops/training/slime/SKILL.md b/optional-skills/mlops/slime/SKILL.md similarity index 100% rename from skills/mlops/training/slime/SKILL.md rename to optional-skills/mlops/slime/SKILL.md diff --git a/skills/mlops/training/slime/references/api-reference.md b/optional-skills/mlops/slime/references/api-reference.md similarity index 100% rename from skills/mlops/training/slime/references/api-reference.md rename to optional-skills/mlops/slime/references/api-reference.md diff --git a/skills/mlops/training/slime/references/troubleshooting.md b/optional-skills/mlops/slime/references/troubleshooting.md similarity index 100% rename from skills/mlops/training/slime/references/troubleshooting.md rename to optional-skills/mlops/slime/references/troubleshooting.md diff --git a/skills/mlops/inference/tensorrt-llm/SKILL.md b/optional-skills/mlops/tensorrt-llm/SKILL.md similarity index 100% rename from skills/mlops/inference/tensorrt-llm/SKILL.md rename to optional-skills/mlops/tensorrt-llm/SKILL.md diff --git a/skills/mlops/inference/tensorrt-llm/references/multi-gpu.md b/optional-skills/mlops/tensorrt-llm/references/multi-gpu.md similarity index 100% rename from skills/mlops/inference/tensorrt-llm/references/multi-gpu.md rename to optional-skills/mlops/tensorrt-llm/references/multi-gpu.md diff --git a/skills/mlops/inference/tensorrt-llm/references/optimization.md b/optional-skills/mlops/tensorrt-llm/references/optimization.md similarity index 100% rename from skills/mlops/inference/tensorrt-llm/references/optimization.md rename to optional-skills/mlops/tensorrt-llm/references/optimization.md diff --git a/skills/mlops/inference/tensorrt-llm/references/serving.md b/optional-skills/mlops/tensorrt-llm/references/serving.md similarity index 100% rename from skills/mlops/inference/tensorrt-llm/references/serving.md rename to optional-skills/mlops/tensorrt-llm/references/serving.md diff --git a/skills/mlops/training/torchtitan/SKILL.md b/optional-skills/mlops/torchtitan/SKILL.md similarity index 100% rename from skills/mlops/training/torchtitan/SKILL.md rename to optional-skills/mlops/torchtitan/SKILL.md diff --git a/skills/mlops/training/torchtitan/references/checkpoint.md b/optional-skills/mlops/torchtitan/references/checkpoint.md similarity index 100% rename from skills/mlops/training/torchtitan/references/checkpoint.md rename to optional-skills/mlops/torchtitan/references/checkpoint.md diff --git a/skills/mlops/training/torchtitan/references/custom-models.md b/optional-skills/mlops/torchtitan/references/custom-models.md similarity index 100% rename from skills/mlops/training/torchtitan/references/custom-models.md rename to optional-skills/mlops/torchtitan/references/custom-models.md diff --git a/skills/mlops/training/torchtitan/references/float8.md b/optional-skills/mlops/torchtitan/references/float8.md similarity index 100% rename from skills/mlops/training/torchtitan/references/float8.md rename to optional-skills/mlops/torchtitan/references/float8.md diff --git a/skills/mlops/training/torchtitan/references/fsdp.md b/optional-skills/mlops/torchtitan/references/fsdp.md similarity index 100% rename from skills/mlops/training/torchtitan/references/fsdp.md rename to optional-skills/mlops/torchtitan/references/fsdp.md diff --git a/skills/research/domain-intel/SKILL.md b/optional-skills/research/domain-intel/SKILL.md similarity index 100% rename from skills/research/domain-intel/SKILL.md rename to optional-skills/research/domain-intel/SKILL.md diff --git a/skills/research/domain-intel/scripts/domain_intel.py b/optional-skills/research/domain-intel/scripts/domain_intel.py similarity index 100% rename from skills/research/domain-intel/scripts/domain_intel.py rename to optional-skills/research/domain-intel/scripts/domain_intel.py diff --git a/skills/research/duckduckgo-search/SKILL.md b/optional-skills/research/duckduckgo-search/SKILL.md similarity index 100% rename from skills/research/duckduckgo-search/SKILL.md rename to optional-skills/research/duckduckgo-search/SKILL.md diff --git a/skills/research/duckduckgo-search/scripts/duckduckgo.sh b/optional-skills/research/duckduckgo-search/scripts/duckduckgo.sh similarity index 100% rename from skills/research/duckduckgo-search/scripts/duckduckgo.sh rename to optional-skills/research/duckduckgo-search/scripts/duckduckgo.sh diff --git a/package.json b/package.json index 5e593367b7..309217c822 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ }, "homepage": "https://github.com/NousResearch/Hermes-Agent#readme", "dependencies": { - "agent-browser": "^0.13.0" + "agent-browser": "^0.13.0", + "@askjo/camoufox-browser": "^1.0.0" }, "engines": { "node": ">=18.0.0" diff --git a/pyproject.toml b/pyproject.toml index d044739a05..125f810f0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "hermes-agent" -version = "0.5.0" +version = "0.6.0" description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere" readme = "README.md" requires-python = ">=3.11" @@ -71,6 +71,7 @@ all = [ "hermes-agent[modal]", "hermes-agent[daytona]", "hermes-agent[messaging]", + "hermes-agent[matrix]", "hermes-agent[cron]", "hermes-agent[cli]", "hermes-agent[dev]", diff --git a/run_agent.py b/run_agent.py index 090fea978a..25b9ba67f0 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2911,6 +2911,19 @@ class AIAgent: }) return converted or None + @staticmethod + def _deterministic_call_id(fn_name: str, arguments: str, index: int = 0) -> str: + """Generate a deterministic call_id from tool call content. + + Used as a fallback when the API doesn't provide a call_id. + Deterministic IDs prevent cache invalidation — random UUIDs would + make every API call's prefix unique, breaking OpenAI's prompt cache. + """ + import hashlib + seed = f"{fn_name}:{arguments}:{index}" + digest = hashlib.sha256(seed.encode("utf-8", errors="replace")).hexdigest()[:12] + return f"call_{digest}" + @staticmethod def _split_responses_tool_id(raw_id: Any) -> tuple[Optional[str], Optional[str]]: """Split a stored tool id into (call_id, response_item_id).""" @@ -3017,7 +3030,8 @@ class AIAgent: ): call_id = f"call_{embedded_response_item_id[len('fc_'):]}" else: - call_id = f"call_{uuid.uuid4().hex[:12]}" + _raw_args = str(fn.get("arguments", "{}")) + call_id = self._deterministic_call_id(fn_name, _raw_args, len(items)) call_id = call_id.strip() arguments = fn.get("arguments", "{}") @@ -3381,7 +3395,7 @@ class AIAgent: embedded_call_id, _ = self._split_responses_tool_id(raw_item_id) call_id = raw_call_id if isinstance(raw_call_id, str) and raw_call_id.strip() else embedded_call_id if not isinstance(call_id, str) or not call_id.strip(): - call_id = f"call_{uuid.uuid4().hex[:12]}" + call_id = self._deterministic_call_id(fn_name, arguments, len(tool_calls)) call_id = call_id.strip() response_item_id = raw_item_id if isinstance(raw_item_id, str) else None response_item_id = self._derive_responses_function_call_id(call_id, response_item_id) @@ -3402,7 +3416,7 @@ class AIAgent: embedded_call_id, _ = self._split_responses_tool_id(raw_item_id) call_id = raw_call_id if isinstance(raw_call_id, str) and raw_call_id.strip() else embedded_call_id if not isinstance(call_id, str) or not call_id.strip(): - call_id = f"call_{uuid.uuid4().hex[:12]}" + call_id = self._deterministic_call_id(fn_name, arguments, len(tool_calls)) call_id = call_id.strip() response_item_id = raw_item_id if isinstance(raw_item_id, str) else None response_item_id = self._derive_responses_function_call_id(call_id, response_item_id) @@ -4937,7 +4951,10 @@ class AIAgent: if isinstance(raw_id, str) and raw_id.strip(): call_id = raw_id.strip() else: - call_id = f"call_{uuid.uuid4().hex[:12]}" + _fn = getattr(tool_call, "function", None) + _fn_name = getattr(_fn, "name", "") if _fn else "" + _fn_args = getattr(_fn, "arguments", "{}") if _fn else "{}" + call_id = self._deterministic_call_id(_fn_name, _fn_args, len(tool_calls)) call_id = call_id.strip() response_item_id = getattr(tool_call, "response_item_id", None) @@ -5208,11 +5225,8 @@ class AIAgent: except Exception as e: logger.warning("Session DB compression split failed — new session will NOT be indexed: %s", e) - # Reset context pressure warning and token estimate — usage drops - # after compaction. Without this, the stale last_prompt_tokens from - # the previous API call causes the pressure calculation to stay at - # >1000% and spam warnings / re-trigger compression in a loop. - self._context_pressure_warned = False + # Update token estimate after compaction so pressure calculations + # use the post-compression count, not the stale pre-compression one. _compressed_est = ( estimate_tokens_rough(new_system_prompt) + estimate_messages_tokens_rough(compressed) @@ -5220,6 +5234,16 @@ class AIAgent: self.context_compressor.last_prompt_tokens = _compressed_est self.context_compressor.last_completion_tokens = 0 + # Only reset the pressure warning if compression actually brought + # us below the warning level (85% of threshold). When compression + # can't reduce enough (e.g. threshold is very low, or system prompt + # alone exceeds the warning level), keep the flag set to prevent + # spamming the user with repeated warnings every loop iteration. + if self.context_compressor.threshold_tokens > 0: + _post_progress = _compressed_est / self.context_compressor.threshold_tokens + if _post_progress < 0.85: + self._context_pressure_warned = False + return compressed, new_system_prompt def _execute_tool_calls(self, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None: diff --git a/scripts/install.sh b/scripts/install.sh index 6fbb22b45b..c04dc4a9d5 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -94,7 +94,7 @@ print_banner() { echo "" echo -e "${MAGENTA}${BOLD}" echo "┌─────────────────────────────────────────────────────────┐" - echo "│ ⚕ Hermes Agent Installer │" + echo "│ ⚕ Hermes Agent Installer │" echo "├─────────────────────────────────────────────────────────┤" echo "│ An open source AI agent by Nous Research. │" echo "└─────────────────────────────────────────────────────────┘" @@ -699,14 +699,19 @@ install_deps() { # Install the main package in editable mode with all extras. # Try [all] first, fall back to base install if extras have issues. - if ! $UV_CMD pip install -e ".[all]" 2>/dev/null; then + ALL_INSTALL_LOG=$(mktemp) + if ! $UV_CMD pip install -e ".[all]" 2>"$ALL_INSTALL_LOG"; then log_warn "Full install (.[all]) failed, trying base install..." + log_info "Reason: $(tail -5 "$ALL_INSTALL_LOG" | head -3)" + rm -f "$ALL_INSTALL_LOG" if ! $UV_CMD pip install -e "."; then log_error "Package installation failed." log_info "Check that build tools are installed: sudo apt install build-essential python3-dev" log_info "Then re-run: cd $INSTALL_DIR && uv pip install -e '.[all]'" exit 1 fi + else + rm -f "$ALL_INSTALL_LOG" fi log_success "Main package installed" @@ -1070,7 +1075,14 @@ print_success() { echo "" echo -e "${YELLOW}⚡ Reload your shell to use 'hermes' command:${NC}" echo "" - echo " source ~/.bashrc # or ~/.zshrc" + LOGIN_SHELL="$(basename "${SHELL:-/bin/bash}")" + if [ "$LOGIN_SHELL" = "zsh" ]; then + echo " source ~/.zshrc" + elif [ "$LOGIN_SHELL" = "bash" ]; then + echo " source ~/.bashrc" + else + echo " source ~/.bashrc # or ~/.zshrc" + fi echo "" # Show Node.js warning if auto-install failed diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index 46cc5c339e..5f0cb729f6 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -55,6 +55,10 @@ const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined : process.env.WHATSAPP_REPLY_PREFIX.replace(/\\n/g, '\n'); function formatOutgoingMessage(message) { + // In bot mode, messages come from a different number so the prefix is + // redundant — the sender identity is already clear. Only prepend in + // self-chat mode where bot and user share the same number. + if (WHATSAPP_MODE !== 'self-chat') return message; return REPLY_PREFIX ? `${REPLY_PREFIX}${message}` : message; } diff --git a/skills/creative/ascii-video/references/composition.md b/skills/creative/ascii-video/references/composition.md index 0028b93fa2..f7e6eff899 100644 --- a/skills/creative/ascii-video/references/composition.md +++ b/skills/creative/ascii-video/references/composition.md @@ -744,3 +744,149 @@ class PixelBlendStack: result = blend_canvas(result, canvas, mode, opacity) return result ``` + +## Text Backdrop (Readability Mask) + +When placing readable text over busy multi-grid ASCII backgrounds, the text will blend into the background and become illegible. **Always apply a dark backdrop behind text regions.** + +The technique: compute the bounding box of all text glyphs, create a gaussian-blurred dark mask covering that area with padding, and multiply the background by `(1 - mask * darkness)` before rendering text on top. + +```python +from scipy.ndimage import gaussian_filter + +def apply_text_backdrop(canvas, glyphs, padding=80, darkness=0.75): + """Darken the background behind text for readability. + + Call AFTER rendering background, BEFORE rendering text. + + Args: + canvas: (VH, VW, 3) uint8 background + glyphs: list of {"x": float, "y": float, ...} glyph positions + padding: pixel padding around text bounding box + darkness: 0.0 = no darkening, 1.0 = fully black + Returns: + darkened canvas (uint8) + """ + if not glyphs: + return canvas + xs = [g['x'] for g in glyphs] + ys = [g['y'] for g in glyphs] + x0 = max(0, int(min(xs)) - padding) + y0 = max(0, int(min(ys)) - padding) + x1 = min(VW, int(max(xs)) + padding + 50) # extra for char width + y1 = min(VH, int(max(ys)) + padding + 60) # extra for char height + + # Soft dark mask with gaussian blur for feathered edges + mask = np.zeros((VH, VW), dtype=np.float32) + mask[y0:y1, x0:x1] = 1.0 + mask = gaussian_filter(mask, sigma=padding * 0.6) + + factor = 1.0 - mask * darkness + return (canvas.astype(np.float32) * factor[:, :, np.newaxis]).astype(np.uint8) +``` + +### Usage in render pipeline + +Insert between background rendering and text rendering: + +```python +# 1. Render background (multi-grid ASCII effects) +bg = render_background(cfg, t) + +# 2. Darken behind text region +bg = apply_text_backdrop(bg, frame_glyphs, padding=80, darkness=0.75) + +# 3. Render text on top (now readable against dark backdrop) +bg = text_renderer.render(bg, frame_glyphs, color=(255, 255, 255)) +``` + +Combine with **reverse vignette** (see shaders.md) for scenes where text is always centered — the reverse vignette provides a persistent center-dark zone, while the backdrop handles per-frame glyph positions. + +## External Layout Oracle Pattern + +For text-heavy videos where text needs to dynamically reflow around obstacles (shapes, icons, other text), use an external layout engine to pre-compute glyph positions and feed them into the Python renderer via JSON. + +### Architecture + +``` +Layout Engine (browser/Node.js) → layouts.json → Python ASCII Renderer + ↑ ↑ + Computes per-frame Reads glyph positions, + glyph (x,y) positions renders as ASCII chars + with obstacle-aware reflow with full effect pipeline +``` + +### JSON interchange format + +```json +{ + "meta": { + "canvas_width": 1080, "canvas_height": 1080, + "fps": 24, "total_frames": 1248, + "fonts": { + "body": {"charW": 12.04, "charH": 24, "fontSize": 20}, + "hero": {"charW": 24.08, "charH": 48, "fontSize": 40} + } + }, + "scenes": [ + { + "id": "scene_name", + "start_frame": 0, "end_frame": 96, + "frames": { + "0": { + "glyphs": [ + {"char": "H", "x": 287.1, "y": 400.0, "alpha": 1.0}, + {"char": "e", "x": 311.2, "y": 400.0, "alpha": 1.0} + ], + "obstacles": [ + {"type": "circle", "cx": 540, "cy": 540, "r": 80}, + {"type": "rect", "x": 300, "y": 500, "w": 120, "h": 80} + ] + } + } + } + ] +} +``` + +### When to use + +- Text that dynamically reflows around moving objects +- Per-glyph animation (reveal, scatter, physics) +- Variable typography that needs precise measurement +- Any case where Python's Pillow text layout is insufficient + +### When NOT to use + +- Static centered text (just use PIL `draw.text()` directly) +- Text that only fades in/out without spatial animation +- Simple typewriter effects (handle in Python with a character counter) + +### Running the oracle + +Use Playwright to run the layout engine in a headless browser: + +```javascript +// extract.mjs +import { chromium } from 'playwright'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage(); +await page.goto(`file://${oraclePath}`); +await page.waitForFunction(() => window.__ORACLE_DONE__ === true, null, { timeout: 60000 }); +const result = await page.evaluate(() => window.__ORACLE_RESULT__); +writeFileSync('layouts.json', JSON.stringify(result)); +await browser.close(); +``` + +### Consuming in Python + +```python +# In the renderer, map pixel positions to the canvas: +for glyph in frame_data['glyphs']: + char, px, py = glyph['char'], glyph['x'], glyph['y'] + alpha = glyph.get('alpha', 1.0) + # Render using PIL draw.text() at exact pixel position + draw.text((px, py), char, fill=(int(255*alpha),)*3, font=font) +``` + +Obstacles from the JSON can also be rendered as glowing ASCII shapes (circles, rectangles) to visualize the reflow zones. diff --git a/skills/creative/ascii-video/references/shaders.md b/skills/creative/ascii-video/references/shaders.md index fce436a4d8..a4cf7a2e5d 100644 --- a/skills/creative/ascii-video/references/shaders.md +++ b/skills/creative/ascii-video/references/shaders.md @@ -834,6 +834,39 @@ def sh_vignette(c, s=0.22): return np.clip(c * _vig_cache[k][:,:,None], 0, 255).astype(np.uint8) ``` +#### Reverse Vignette + +Inverted vignette: darkens the **center** and leaves edges bright. Useful when text is centered over busy backgrounds — creates a natural dark zone for readability without a hard-edged box. + +Combine with `apply_text_backdrop()` (see composition.md) for per-frame glyph-aware darkening. + +```python +_rvignette_cache = {} + +def sh_reverse_vignette(c, strength=0.5): + """Center darkening, edge brightening. Cached.""" + k = ('rv', c.shape[0], c.shape[1], round(strength, 2)) + if k not in _rvignette_cache: + h, w = c.shape[:2] + Y = np.linspace(-1, 1, h)[:, None] + X = np.linspace(-1, 1, w)[None, :] + d = np.sqrt(X**2 + Y**2) + # Invert: bright at edges, dark at center + mask = np.clip(1.0 - (1.0 - d * 0.7) * strength, 0.2, 1.0) + _rvignette_cache[k] = mask[:, :, np.newaxis].astype(np.float32) + return np.clip(c.astype(np.float32) * _rvignette_cache[k], 0, 255).astype(np.uint8) +``` + +| Param | Default | Effect | +|-------|---------|--------| +| `strength` | 0.5 | 0 = no effect, 1.0 = center nearly black | + +Add to ShaderChain dispatch: +```python +elif name == "reverse_vignette": + return sh_reverse_vignette(canvas, kwargs.get("strength", 0.5)) +``` + #### Contrast ```python def sh_contrast(c, factor=1.3): diff --git a/skills/creative/ascii-video/references/troubleshooting.md b/skills/creative/ascii-video/references/troubleshooting.md index 8c4bb02293..6b38382cd6 100644 --- a/skills/creative/ascii-video/references/troubleshooting.md +++ b/skills/creative/ascii-video/references/troubleshooting.md @@ -14,6 +14,8 @@ | Random dark holes in output | Font missing Unicode glyphs | Validate palettes at init | | Audio-visual desync | Frame timing accumulation | Use integer frame counter, compute t fresh each frame | | Single-color flat output | Hue field shape mismatch | Ensure h,s,v arrays all (rows,cols) before hsv2rgb | +| Text unreadable over busy bg | No contrast between text and background | Use `apply_text_backdrop()` (composition.md) + `reverse_vignette` shader (shaders.md) | +| Text garbled/mirrored | Kaleidoscope or mirror shader applied to text scene | **Never apply kaleidoscope, mirror_h/v/quad/diag to scenes with readable text** — radial folding destroys legibility. Apply these only to background layers or text-free scenes | Common bugs, gotchas, and platform-specific issues encountered during ASCII video development. diff --git a/tests/agent/test_redact.py b/tests/agent/test_redact.py index 2ab6b0ea4a..27098ee6dc 100644 --- a/tests/agent/test_redact.py +++ b/tests/agent/test_redact.py @@ -201,3 +201,52 @@ class TestSecretCapturePayloadRedaction: text = '{"raw_secret": "ghp_abc123def456ghi789jkl"}' result = redact_sensitive_text(text) assert "abc123def456" not in result + + +class TestElevenLabsTavilyExaKeys: + """Regression tests for ElevenLabs (sk_), Tavily (tvly-), and Exa (exa_) keys.""" + + def test_elevenlabs_key_redacted(self): + text = "ELEVENLABS_API_KEY=sk_abc123def456ghi789jklmnopqrstu" + result = redact_sensitive_text(text) + assert "abc123def456ghi" not in result + + def test_elevenlabs_key_in_log_line(self): + text = "Connecting to ElevenLabs with key sk_abc123def456ghi789jklmnopqrstu" + result = redact_sensitive_text(text) + assert "abc123def456ghi" not in result + + def test_tavily_key_redacted(self): + text = "TAVILY_API_KEY=tvly-ABCdef123456789GHIJKL0000" + result = redact_sensitive_text(text) + assert "ABCdef123456789" not in result + + def test_tavily_key_in_log_line(self): + text = "Initialising Tavily client with tvly-ABCdef123456789GHIJKL0000" + result = redact_sensitive_text(text) + assert "ABCdef123456789" not in result + + def test_exa_key_redacted(self): + text = "EXA_API_KEY=exa_XYZ789abcdef000000000000000" + result = redact_sensitive_text(text) + assert "XYZ789abcdef" not in result + + def test_exa_key_in_log_line(self): + text = "Using Exa client with key exa_XYZ789abcdef000000000000000" + result = redact_sensitive_text(text) + assert "XYZ789abcdef" not in result + + def test_all_three_in_env_dump(self): + env_dump = ( + "HOME=/home/user\n" + "ELEVENLABS_API_KEY=sk_abc123def456ghi789jklmnopqrstu\n" + "TAVILY_API_KEY=tvly-ABCdef123456789GHIJKL0000\n" + "EXA_API_KEY=exa_XYZ789abcdef000000000000000\n" + "SHELL=/bin/bash\n" + ) + result = redact_sensitive_text(env_dump) + assert "abc123def456ghi" not in result + assert "ABCdef123456789" not in result + assert "XYZ789abcdef" not in result + assert "HOME=/home/user" in result + assert "SHELL=/bin/bash" in result diff --git a/tests/gateway/test_base_topic_sessions.py b/tests/gateway/test_base_topic_sessions.py index e3ca7ae722..37e00b279d 100644 --- a/tests/gateway/test_base_topic_sessions.py +++ b/tests/gateway/test_base_topic_sessions.py @@ -15,6 +15,7 @@ class DummyTelegramAdapter(BasePlatformAdapter): super().__init__(PlatformConfig(enabled=True, token="fake-token"), Platform.TELEGRAM) self.sent = [] self.typing = [] + self.processing_hooks = [] async def connect(self) -> bool: return True @@ -40,6 +41,12 @@ class DummyTelegramAdapter(BasePlatformAdapter): async def get_chat_info(self, chat_id: str): return {"id": chat_id} + async def on_processing_start(self, event: MessageEvent) -> None: + self.processing_hooks.append(("start", event.message_id)) + + async def on_processing_complete(self, event: MessageEvent, success: bool) -> None: + self.processing_hooks.append(("complete", event.message_id, success)) + def _make_event(chat_id: str, thread_id: str, message_id: str = "1") -> MessageEvent: return MessageEvent( @@ -133,3 +140,83 @@ class TestBasePlatformTopicSessions: "metadata": {"thread_id": "17585"}, } ] + assert adapter.processing_hooks == [ + ("start", "1"), + ("complete", "1", True), + ] + + @pytest.mark.asyncio + async def test_process_message_background_marks_total_send_failure_unsuccessful(self): + adapter = DummyTelegramAdapter() + + async def handler(_event): + await asyncio.sleep(0) + return "ack" + + async def failing_send(*_args, **_kwargs): + return SendResult(success=False, error="send failed") + + async def hold_typing(_chat_id, interval=2.0, metadata=None): + await asyncio.Event().wait() + + adapter.set_message_handler(handler) + adapter.send = failing_send + adapter._keep_typing = hold_typing + + event = _make_event("-1001", "17585") + await adapter._process_message_background(event, build_session_key(event.source)) + + assert adapter.processing_hooks == [ + ("start", "1"), + ("complete", "1", False), + ] + + @pytest.mark.asyncio + async def test_process_message_background_marks_exception_unsuccessful(self): + adapter = DummyTelegramAdapter() + + async def handler(_event): + await asyncio.sleep(0) + raise RuntimeError("boom") + + async def hold_typing(_chat_id, interval=2.0, metadata=None): + await asyncio.Event().wait() + + adapter.set_message_handler(handler) + adapter._keep_typing = hold_typing + + event = _make_event("-1001", "17585") + await adapter._process_message_background(event, build_session_key(event.source)) + + assert adapter.processing_hooks == [ + ("start", "1"), + ("complete", "1", False), + ] + + @pytest.mark.asyncio + async def test_process_message_background_marks_cancellation_unsuccessful(self): + adapter = DummyTelegramAdapter() + release = asyncio.Event() + + async def handler(_event): + await release.wait() + return "ack" + + async def hold_typing(_chat_id, interval=2.0, metadata=None): + await asyncio.Event().wait() + + adapter.set_message_handler(handler) + adapter._keep_typing = hold_typing + + event = _make_event("-1001", "17585") + task = asyncio.create_task(adapter._process_message_background(event, build_session_key(event.source))) + await asyncio.sleep(0) + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + assert adapter.processing_hooks == [ + ("start", "1"), + ("complete", "1", False), + ] diff --git a/tests/gateway/test_discord_reactions.py b/tests/gateway/test_discord_reactions.py new file mode 100644 index 0000000000..c19913a4cf --- /dev/null +++ b/tests/gateway/test_discord_reactions.py @@ -0,0 +1,170 @@ +"""Tests for Discord message reactions tied to processing lifecycle hooks.""" + +import asyncio +import sys +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import MessageEvent, MessageType, SendResult +from gateway.session import SessionSource, build_session_key + + +def _ensure_discord_mock(): + if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"): + return + + discord_mod = MagicMock() + discord_mod.Intents.default.return_value = MagicMock() + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + discord_mod.Interaction = object + discord_mod.app_commands = SimpleNamespace( + describe=lambda **kwargs: (lambda fn: fn), + choices=lambda **kwargs: (lambda fn: fn), + Choice=lambda **kwargs: SimpleNamespace(**kwargs), + ) + + ext_mod = MagicMock() + commands_mod = MagicMock() + commands_mod.Bot = MagicMock + ext_mod.commands = commands_mod + + sys.modules.setdefault("discord", discord_mod) + sys.modules.setdefault("discord.ext", ext_mod) + sys.modules.setdefault("discord.ext.commands", commands_mod) + + +_ensure_discord_mock() + +from gateway.platforms.discord import DiscordAdapter # noqa: E402 + + +class FakeTree: + def __init__(self): + self.commands = {} + + def command(self, *, name, description): + def decorator(fn): + self.commands[name] = fn + return fn + + return decorator + + +@pytest.fixture +def adapter(): + config = PlatformConfig(enabled=True, token="***") + adapter = DiscordAdapter(config) + adapter._client = SimpleNamespace( + tree=FakeTree(), + get_channel=lambda _id: None, + fetch_channel=AsyncMock(), + user=SimpleNamespace(id=99999, name="HermesBot"), + ) + return adapter + + +def _make_event(message_id: str, raw_message) -> MessageEvent: + return MessageEvent( + text="hello", + message_type=MessageType.TEXT, + source=SessionSource( + platform=Platform.DISCORD, + chat_id="123", + chat_type="dm", + user_id="42", + user_name="Jezza", + ), + raw_message=raw_message, + message_id=message_id, + ) + + +@pytest.mark.asyncio +async def test_process_message_background_adds_and_swaps_reactions(adapter): + raw_message = SimpleNamespace( + add_reaction=AsyncMock(), + remove_reaction=AsyncMock(), + ) + + async def handler(_event): + await asyncio.sleep(0) + return "ack" + + async def hold_typing(_chat_id, interval=2.0, metadata=None): + await asyncio.Event().wait() + + adapter.set_message_handler(handler) + adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="999")) + adapter._keep_typing = hold_typing + + event = _make_event("1", raw_message) + await adapter._process_message_background(event, build_session_key(event.source)) + + assert raw_message.add_reaction.await_args_list[0].args == ("👀",) + assert raw_message.remove_reaction.await_args_list[0].args == ("👀", adapter._client.user) + assert raw_message.add_reaction.await_args_list[1].args == ("✅",) + + +@pytest.mark.asyncio +async def test_interaction_backed_events_do_not_attempt_reactions(adapter): + interaction = SimpleNamespace(guild_id=123456789) + + async def handler(_event): + await asyncio.sleep(0) + return None + + async def hold_typing(_chat_id, interval=2.0, metadata=None): + await asyncio.Event().wait() + + adapter.set_message_handler(handler) + adapter._add_reaction = AsyncMock() + adapter._remove_reaction = AsyncMock() + adapter._keep_typing = hold_typing + + event = MessageEvent( + text="/status", + message_type=MessageType.COMMAND, + source=SessionSource( + platform=Platform.DISCORD, + chat_id="123", + chat_type="dm", + user_id="42", + user_name="Jezza", + ), + raw_message=interaction, + message_id="2", + ) + + await adapter._process_message_background(event, build_session_key(event.source)) + + adapter._add_reaction.assert_not_awaited() + adapter._remove_reaction.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reaction_helper_failures_do_not_break_message_flow(adapter): + raw_message = SimpleNamespace( + add_reaction=AsyncMock(side_effect=[RuntimeError("no perms"), RuntimeError("no perms")]), + remove_reaction=AsyncMock(side_effect=RuntimeError("no perms")), + ) + + async def handler(_event): + await asyncio.sleep(0) + return "ack" + + async def hold_typing(_chat_id, interval=2.0, metadata=None): + await asyncio.Event().wait() + + adapter.set_message_handler(handler) + adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="999")) + adapter._keep_typing = hold_typing + + event = _make_event("3", raw_message) + await adapter._process_message_background(event, build_session_key(event.source)) + + adapter.send.assert_awaited_once() diff --git a/tests/gateway/test_matrix_voice.py b/tests/gateway/test_matrix_voice.py new file mode 100644 index 0000000000..79f0947f61 --- /dev/null +++ b/tests/gateway/test_matrix_voice.py @@ -0,0 +1,340 @@ +"""Tests for Matrix voice message support (MSC3245).""" +import io + +import pytest +from unittest.mock import AsyncMock, MagicMock + +nio = pytest.importorskip("nio", reason="matrix-nio not installed") + +from gateway.platforms.base import MessageType + + +# --------------------------------------------------------------------------- +# Adapter helpers +# --------------------------------------------------------------------------- + +def _make_adapter(): + """Create a MatrixAdapter with mocked config.""" + from gateway.platforms.matrix import MatrixAdapter + from gateway.config import PlatformConfig + + config = PlatformConfig( + enabled=True, + token="***", + extra={ + "homeserver": "https://matrix.example.org", + "user_id": "@bot:example.org", + }, + ) + adapter = MatrixAdapter(config) + return adapter + + +def _make_room(room_id: str = "!test:example.org", member_count: int = 2): + """Create a mock Matrix room.""" + room = MagicMock() + room.room_id = room_id + room.member_count = member_count + return room + + +def _make_audio_event( + event_id: str = "$audio_event", + sender: str = "@alice:example.org", + body: str = "Voice message", + url: str = "mxc://example.org/abc123", + is_voice: bool = False, + mimetype: str = "audio/ogg", + timestamp: float = 9999999999000, # ms +): + """ + Create a mock RoomMessageAudio event that passes isinstance checks. + + Args: + is_voice: If True, adds org.matrix.msc3245.voice field to content + """ + import nio + + # Build the source dict that nio events expose via .source + content = { + "msgtype": "m.audio", + "body": body, + "url": url, + "info": { + "mimetype": mimetype, + }, + } + + if is_voice: + content["org.matrix.msc3245.voice"] = {} + + # Create a real nio RoomMessageAudio-like object + # We use MagicMock but configure __class__ to pass isinstance check + event = MagicMock(spec=nio.RoomMessageAudio) + event.event_id = event_id + event.sender = sender + event.body = body + event.url = url + event.server_timestamp = timestamp + event.source = { + "type": "m.room.message", + "content": content, + } + # For MIME type extraction - needs to be a dict + event.content = content + + return event + + +def _make_download_response(body: bytes = b"fake audio data"): + """Create a mock nio.MemoryDownloadResponse.""" + import nio + resp = MagicMock() + resp.body = body + resp.__class__ = nio.MemoryDownloadResponse + return resp + + +# --------------------------------------------------------------------------- +# Tests: MSC3245 Voice Detection (RED -> GREEN) +# --------------------------------------------------------------------------- + +class TestMatrixVoiceMessageDetection: + """Test that MSC3245 voice messages are detected and tagged correctly.""" + + def setup_method(self): + self.adapter = _make_adapter() + self.adapter._user_id = "@bot:example.org" + self.adapter._startup_ts = 0.0 + self.adapter._dm_rooms = {} + self.adapter._message_handler = AsyncMock() + # Mock _mxc_to_http to return a fake HTTP URL + self.adapter._mxc_to_http = lambda url: f"https://matrix.example.org/_matrix/media/v3/download/{url[6:]}" + # Mock client for authenticated download + self.adapter._client = MagicMock() + self.adapter._client.download = AsyncMock(return_value=_make_download_response()) + + @pytest.mark.asyncio + async def test_voice_message_has_type_voice(self): + """Voice messages (with MSC3245 field) should be MessageType.VOICE.""" + room = _make_room() + event = _make_audio_event(is_voice=True) + + # Capture the MessageEvent passed to handle_message + captured_event = None + + async def capture(msg_event): + nonlocal captured_event + captured_event = msg_event + + self.adapter.handle_message = capture + + await self.adapter._on_room_message_media(room, event) + + assert captured_event is not None, "No event was captured" + assert captured_event.message_type == MessageType.VOICE, \ + f"Expected MessageType.VOICE, got {captured_event.message_type}" + + @pytest.mark.asyncio + async def test_voice_message_has_local_path(self): + """Voice messages should have a local cached path in media_urls.""" + room = _make_room() + event = _make_audio_event(is_voice=True) + + captured_event = None + + async def capture(msg_event): + nonlocal captured_event + captured_event = msg_event + + self.adapter.handle_message = capture + + await self.adapter._on_room_message_media(room, event) + + assert captured_event is not None + assert captured_event.media_urls is not None + assert len(captured_event.media_urls) > 0 + # Should be a local path, not an HTTP URL + assert not captured_event.media_urls[0].startswith("http"), \ + f"media_urls should contain local path, got {captured_event.media_urls[0]}" + self.adapter._client.download.assert_awaited_once_with(mxc=event.url) + assert captured_event.media_types == ["audio/ogg"] + + @pytest.mark.asyncio + async def test_audio_without_msc3245_stays_audio_type(self): + """Regular audio uploads (no MSC3245 field) should remain MessageType.AUDIO.""" + room = _make_room() + event = _make_audio_event(is_voice=False) # NOT a voice message + + captured_event = None + + async def capture(msg_event): + nonlocal captured_event + captured_event = msg_event + + self.adapter.handle_message = capture + + await self.adapter._on_room_message_media(room, event) + + assert captured_event is not None + assert captured_event.message_type == MessageType.AUDIO, \ + f"Expected MessageType.AUDIO for non-voice, got {captured_event.message_type}" + + @pytest.mark.asyncio + async def test_regular_audio_has_http_url(self): + """Regular audio uploads should keep HTTP URL (not cached locally).""" + room = _make_room() + event = _make_audio_event(is_voice=False) + + captured_event = None + + async def capture(msg_event): + nonlocal captured_event + captured_event = msg_event + + self.adapter.handle_message = capture + + await self.adapter._on_room_message_media(room, event) + + assert captured_event is not None + assert captured_event.media_urls is not None + # Should be HTTP URL, not local path + assert captured_event.media_urls[0].startswith("http"), \ + f"Non-voice audio should have HTTP URL, got {captured_event.media_urls[0]}" + self.adapter._client.download.assert_not_awaited() + assert captured_event.media_types == ["audio/ogg"] + + +class TestMatrixVoiceCacheFallback: + """Test graceful fallback when voice caching fails.""" + + def setup_method(self): + self.adapter = _make_adapter() + self.adapter._user_id = "@bot:example.org" + self.adapter._startup_ts = 0.0 + self.adapter._dm_rooms = {} + self.adapter._message_handler = AsyncMock() + self.adapter._mxc_to_http = lambda url: f"https://matrix.example.org/_matrix/media/v3/download/{url[6:]}" + self.adapter._client = MagicMock() + + @pytest.mark.asyncio + async def test_voice_cache_failure_falls_back_to_http_url(self): + """If caching fails, voice message should still be delivered with HTTP URL.""" + room = _make_room() + event = _make_audio_event(is_voice=True) + + # Make download fail + import nio + error_resp = MagicMock() + error_resp.__class__ = nio.DownloadError + self.adapter._client.download = AsyncMock(return_value=error_resp) + + captured_event = None + + async def capture(msg_event): + nonlocal captured_event + captured_event = msg_event + + self.adapter.handle_message = capture + + await self.adapter._on_room_message_media(room, event) + + assert captured_event is not None + assert captured_event.media_urls is not None + # Should fall back to HTTP URL + assert captured_event.media_urls[0].startswith("http"), \ + f"Should fall back to HTTP URL on cache failure, got {captured_event.media_urls[0]}" + + @pytest.mark.asyncio + async def test_voice_cache_exception_falls_back_to_http_url(self): + """Unexpected download exceptions should also fall back to HTTP URL.""" + room = _make_room() + event = _make_audio_event(is_voice=True) + + self.adapter._client.download = AsyncMock(side_effect=RuntimeError("boom")) + + captured_event = None + + async def capture(msg_event): + nonlocal captured_event + captured_event = msg_event + + self.adapter.handle_message = capture + + await self.adapter._on_room_message_media(room, event) + + assert captured_event is not None + assert captured_event.media_urls is not None + assert captured_event.media_urls[0].startswith("http"), \ + f"Should fall back to HTTP URL on exception, got {captured_event.media_urls[0]}" + + +# --------------------------------------------------------------------------- +# Tests: send_voice includes MSC3245 field (RED -> GREEN) +# --------------------------------------------------------------------------- + +class TestMatrixSendVoiceMSC3245: + """Test that send_voice includes MSC3245 field for native voice rendering.""" + + def setup_method(self): + self.adapter = _make_adapter() + self.adapter._user_id = "@bot:example.org" + # Mock client with successful upload + self.adapter._client = MagicMock() + self.upload_call = None + + async def mock_upload(*args, **kwargs): + self.upload_call = (args, kwargs) + import nio + resp = MagicMock() + resp.content_uri = "mxc://example.org/uploaded" + resp.__class__ = nio.UploadResponse + return resp, None + + self.adapter._client.upload = mock_upload + + @pytest.mark.asyncio + async def test_send_voice_includes_msc3245_field(self): + """send_voice should include org.matrix.msc3245.voice in message content.""" + import tempfile + import os + + # Create a temp audio file + with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f: + f.write(b"fake audio data") + temp_path = f.name + + try: + # Capture the message content sent to room_send + sent_content = None + + async def mock_room_send(room_id, event_type, content): + nonlocal sent_content + sent_content = content + resp = MagicMock() + resp.event_id = "$sent_event" + import nio + resp.__class__ = nio.RoomSendResponse + return resp + + self.adapter._client.room_send = mock_room_send + + await self.adapter.send_voice( + chat_id="!room:example.org", + audio_path=temp_path, + caption="Test voice", + ) + + assert sent_content is not None, "No message was sent" + assert "org.matrix.msc3245.voice" in sent_content, \ + f"MSC3245 voice field missing from content: {sent_content.keys()}" + assert sent_content["msgtype"] == "m.audio" + assert sent_content["info"]["mimetype"] == "audio/ogg" + assert self.upload_call is not None, "Expected upload() to be called" + args, kwargs = self.upload_call + assert isinstance(args[0], io.BytesIO) + assert kwargs["content_type"] == "audio/ogg" + assert kwargs["filename"].endswith(".ogg") + + finally: + os.unlink(temp_path) diff --git a/tests/gateway/test_session_hygiene.py b/tests/gateway/test_session_hygiene.py index b8ff8f8a88..843c0d4167 100644 --- a/tests/gateway/test_session_hygiene.py +++ b/tests/gateway/test_session_hygiene.py @@ -212,6 +212,49 @@ class TestSessionHygieneWarnThreshold: assert post_compress_tokens < warn_threshold +class TestCompressionWarnRateLimit: + """Compression warning messages must be rate-limited per chat_id.""" + + def _make_runner(self): + from unittest.mock import MagicMock, patch + with patch("gateway.run.load_gateway_config"), \ + patch("gateway.run.SessionStore"), \ + patch("gateway.run.DeliveryRouter"): + from gateway.run import GatewayRunner + runner = GatewayRunner.__new__(GatewayRunner) + runner._compression_warn_sent = {} + runner._compression_warn_cooldown = 3600 + return runner + + def test_first_warn_is_sent(self): + runner = self._make_runner() + now = 1_000_000.0 + last = runner._compression_warn_sent.get("chat:1", 0) + assert now - last >= runner._compression_warn_cooldown + + def test_second_warn_suppressed_within_cooldown(self): + runner = self._make_runner() + now = 1_000_000.0 + runner._compression_warn_sent["chat:1"] = now - 60 # 1 minute ago + last = runner._compression_warn_sent.get("chat:1", 0) + assert now - last < runner._compression_warn_cooldown + + def test_warn_allowed_after_cooldown(self): + runner = self._make_runner() + now = 1_000_000.0 + runner._compression_warn_sent["chat:1"] = now - 3601 # just past cooldown + last = runner._compression_warn_sent.get("chat:1", 0) + assert now - last >= runner._compression_warn_cooldown + + def test_rate_limit_is_per_chat(self): + """Rate-limiting one chat must not suppress warnings for another.""" + runner = self._make_runner() + now = 1_000_000.0 + runner._compression_warn_sent["chat:1"] = now - 60 # suppressed + last_other = runner._compression_warn_sent.get("chat:2", 0) + assert now - last_other >= runner._compression_warn_cooldown + + class TestEstimatedTokenThreshold: """Verify that hygiene thresholds are always below the model's context limit — for both actual and estimated token counts. diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index f4e687f0f0..16924b5901 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -126,9 +126,20 @@ class TestAppMentionHandler: "user": "testbot", }) + # Mock AsyncWebClient so multi-workspace auth_test is awaitable + mock_web_client = AsyncMock() + mock_web_client.auth_test = AsyncMock(return_value={ + "user_id": "U_BOT", + "user": "testbot", + "team_id": "T_FAKE", + "team": "FakeTeam", + }) + with patch.object(_slack_mod, "AsyncApp", return_value=mock_app), \ + patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), \ patch.object(_slack_mod, "AsyncSocketModeHandler", return_value=MagicMock()), \ patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), \ + patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \ patch("asyncio.create_task"): asyncio.run(adapter.connect()) diff --git a/tests/gateway/test_telegram_group_gating.py b/tests/gateway/test_telegram_group_gating.py new file mode 100644 index 0000000000..99675605d0 --- /dev/null +++ b/tests/gateway/test_telegram_group_gating.py @@ -0,0 +1,110 @@ +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from gateway.config import Platform, PlatformConfig, load_gateway_config + + +def _make_adapter(require_mention=None, free_response_chats=None, mention_patterns=None): + from gateway.platforms.telegram import TelegramAdapter + + extra = {} + if require_mention is not None: + extra["require_mention"] = require_mention + if free_response_chats is not None: + extra["free_response_chats"] = free_response_chats + if mention_patterns is not None: + extra["mention_patterns"] = mention_patterns + + adapter = object.__new__(TelegramAdapter) + adapter.platform = Platform.TELEGRAM + adapter.config = PlatformConfig(enabled=True, token="***", extra=extra) + adapter._bot = SimpleNamespace(id=999, username="hermes_bot") + adapter._message_handler = AsyncMock() + adapter._pending_text_batches = {} + adapter._pending_text_batch_tasks = {} + adapter._text_batch_delay_seconds = 0.01 + adapter._mention_patterns = adapter._compile_mention_patterns() + return adapter + + +def _group_message(text="hello", *, chat_id=-100, reply_to_bot=False, entities=None, caption=None, caption_entities=None): + reply_to_message = None + if reply_to_bot: + reply_to_message = SimpleNamespace(from_user=SimpleNamespace(id=999)) + return SimpleNamespace( + text=text, + caption=caption, + entities=entities or [], + caption_entities=caption_entities or [], + chat=SimpleNamespace(id=chat_id, type="group"), + reply_to_message=reply_to_message, + ) + + +def _mention_entity(text, mention="@hermes_bot"): + offset = text.index(mention) + return SimpleNamespace(type="mention", offset=offset, length=len(mention)) + + +def test_group_messages_can_be_opened_via_config(): + adapter = _make_adapter(require_mention=False) + + assert adapter._should_process_message(_group_message("hello everyone")) is True + + +def test_group_messages_can_require_direct_trigger_via_config(): + adapter = _make_adapter(require_mention=True) + + assert adapter._should_process_message(_group_message("hello everyone")) is False + assert adapter._should_process_message(_group_message("hi @hermes_bot", entities=[_mention_entity("hi @hermes_bot")])) is True + assert adapter._should_process_message(_group_message("replying", reply_to_bot=True)) is True + assert adapter._should_process_message(_group_message("/status"), is_command=True) is True + + +def test_free_response_chats_bypass_mention_requirement(): + adapter = _make_adapter(require_mention=True, free_response_chats=["-200"]) + + assert adapter._should_process_message(_group_message("hello everyone", chat_id=-200)) is True + assert adapter._should_process_message(_group_message("hello everyone", chat_id=-201)) is False + + +def test_regex_mention_patterns_allow_custom_wake_words(): + adapter = _make_adapter(require_mention=True, mention_patterns=[r"^\s*chompy\b"]) + + assert adapter._should_process_message(_group_message("chompy status")) is True + assert adapter._should_process_message(_group_message(" chompy help")) is True + assert adapter._should_process_message(_group_message("hey chompy")) is False + + +def test_invalid_regex_patterns_are_ignored(): + adapter = _make_adapter(require_mention=True, mention_patterns=[r"(", r"^\s*chompy\b"]) + + assert adapter._should_process_message(_group_message("chompy status")) is True + assert adapter._should_process_message(_group_message("hello everyone")) is False + + +def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "telegram:\n" + " require_mention: true\n" + " mention_patterns:\n" + " - \"^\\\\s*chompy\\\\b\"\n" + " free_response_chats:\n" + " - \"-123\"\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("TELEGRAM_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("TELEGRAM_MENTION_PATTERNS", raising=False) + monkeypatch.delenv("TELEGRAM_FREE_RESPONSE_CHATS", raising=False) + + config = load_gateway_config() + + assert config is not None + assert __import__("os").environ["TELEGRAM_REQUIRE_MENTION"] == "true" + assert json.loads(__import__("os").environ["TELEGRAM_MENTION_PATTERNS"]) == [r"^\s*chompy\b"] + assert __import__("os").environ["TELEGRAM_FREE_RESPONSE_CHATS"] == "-123" diff --git a/tests/gateway/test_unauthorized_dm_behavior.py b/tests/gateway/test_unauthorized_dm_behavior.py index 02aae301c1..25b51dc2f2 100644 --- a/tests/gateway/test_unauthorized_dm_behavior.py +++ b/tests/gateway/test_unauthorized_dm_behavior.py @@ -60,6 +60,7 @@ def _make_runner(platform: Platform, config: GatewayConfig): runner.adapters = {platform: adapter} runner.pairing_store = MagicMock() runner.pairing_store.is_approved.return_value = False + runner.pairing_store._is_rate_limited.return_value = False return runner, adapter @@ -142,6 +143,56 @@ async def test_unauthorized_whatsapp_dm_can_be_ignored(monkeypatch): adapter.send.assert_not_awaited() +@pytest.mark.asyncio +async def test_rate_limited_user_gets_no_response(monkeypatch): + """When a user is already rate-limited, pairing messages are silently ignored.""" + _clear_auth_env(monkeypatch) + config = GatewayConfig( + platforms={Platform.WHATSAPP: PlatformConfig(enabled=True)}, + ) + runner, adapter = _make_runner(Platform.WHATSAPP, config) + runner.pairing_store._is_rate_limited.return_value = True + + result = await runner._handle_message( + _make_event( + Platform.WHATSAPP, + "15551234567@s.whatsapp.net", + "15551234567@s.whatsapp.net", + ) + ) + + assert result is None + runner.pairing_store.generate_code.assert_not_called() + adapter.send.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_rejection_message_records_rate_limit(monkeypatch): + """After sending a 'too many requests' rejection, rate limit is recorded + so subsequent messages are silently ignored.""" + _clear_auth_env(monkeypatch) + config = GatewayConfig( + platforms={Platform.WHATSAPP: PlatformConfig(enabled=True)}, + ) + runner, adapter = _make_runner(Platform.WHATSAPP, config) + runner.pairing_store.generate_code.return_value = None # triggers rejection + + result = await runner._handle_message( + _make_event( + Platform.WHATSAPP, + "15551234567@s.whatsapp.net", + "15551234567@s.whatsapp.net", + ) + ) + + assert result is None + adapter.send.assert_awaited_once() + assert "Too many" in adapter.send.await_args.args[1] + runner.pairing_store._record_rate_limit.assert_called_once_with( + "whatsapp", "15551234567@s.whatsapp.net" + ) + + @pytest.mark.asyncio async def test_global_ignore_suppresses_pairing_reply(monkeypatch): _clear_auth_env(monkeypatch) diff --git a/tests/hermes_cli/test_launcher.py b/tests/hermes_cli/test_launcher.py new file mode 100644 index 0000000000..9c3cea851f --- /dev/null +++ b/tests/hermes_cli/test_launcher.py @@ -0,0 +1,42 @@ +"""Tests for the top-level `./hermes` launcher script.""" + +import runpy +import sys +import types +from pathlib import Path + + +def test_launcher_delegates_to_argparse_entrypoint(monkeypatch): + """`./hermes` should use `hermes_cli.main`, not the legacy Fire wrapper.""" + launcher_path = Path(__file__).resolve().parents[2] / "hermes" + called = [] + + fake_main_module = types.ModuleType("hermes_cli.main") + + def fake_main(): + called.append("hermes_cli.main") + + fake_main_module.main = fake_main + monkeypatch.setitem(sys.modules, "hermes_cli.main", fake_main_module) + + fake_cli_module = types.ModuleType("cli") + + def legacy_cli_main(*args, **kwargs): + raise AssertionError("launcher should not import cli.main") + + fake_cli_module.main = legacy_cli_main + monkeypatch.setitem(sys.modules, "cli", fake_cli_module) + + fake_fire_module = types.ModuleType("fire") + + def legacy_fire(*args, **kwargs): + raise AssertionError("launcher should not invoke fire.Fire") + + fake_fire_module.Fire = legacy_fire + monkeypatch.setitem(sys.modules, "fire", fake_fire_module) + + monkeypatch.setattr(sys, "argv", [str(launcher_path), "gateway", "status"]) + + runpy.run_path(str(launcher_path), run_name="__main__") + + assert called == ["hermes_cli.main"] diff --git a/tests/hermes_cli/test_nous_subscription.py b/tests/hermes_cli/test_nous_subscription.py index 2c41c0b220..69428ab080 100644 --- a/tests/hermes_cli/test_nous_subscription.py +++ b/tests/hermes_cli/test_nous_subscription.py @@ -42,3 +42,55 @@ def test_get_nous_subscription_features_prefers_managed_modal_in_auto_mode(monke assert features.modal.active is True assert features.modal.managed_by_nous is True assert features.modal.direct_override is False + + +def test_get_nous_subscription_features_prefers_camofox_over_managed_browserbase(monkeypatch): + env = {"CAMOFOX_URL": "http://localhost:9377"} + + monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, "")) + monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True}) + monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True) + monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser") + monkeypatch.setattr(ns, "_has_agent_browser", lambda: False) + monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "") + monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False) + monkeypatch.setattr( + ns, + "is_managed_tool_gateway_ready", + lambda vendor: vendor == "browserbase", + ) + + features = ns.get_nous_subscription_features( + {"browser": {"cloud_provider": "browserbase"}} + ) + + assert features.browser.available is True + assert features.browser.active is True + assert features.browser.managed_by_nous is False + assert features.browser.direct_override is True + assert features.browser.current_provider == "Camofox" + + +def test_get_nous_subscription_features_requires_agent_browser_for_browserbase(monkeypatch): + env = { + "BROWSERBASE_API_KEY": "bb-key", + "BROWSERBASE_PROJECT_ID": "bb-project", + } + + monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, "")) + monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {}) + monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: False) + monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser") + monkeypatch.setattr(ns, "_has_agent_browser", lambda: False) + monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "") + monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False) + monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda vendor: False) + + features = ns.get_nous_subscription_features( + {"browser": {"cloud_provider": "browserbase"}} + ) + + assert features.browser.available is False + assert features.browser.active is False + assert features.browser.managed_by_nous is False + assert features.browser.current_provider == "Browserbase" diff --git a/tests/hermes_cli/test_setup_model_provider.py b/tests/hermes_cli/test_setup_model_provider.py index 0acbfea512..42e1b05fbd 100644 --- a/tests/hermes_cli/test_setup_model_provider.py +++ b/tests/hermes_cli/test_setup_model_provider.py @@ -3,6 +3,7 @@ from __future__ import annotations from hermes_cli.config import load_config, save_config, save_env_value +from hermes_cli.nous_subscription import NousFeatureState, NousSubscriptionFeatures from hermes_cli.setup import _print_setup_summary, setup_model_provider @@ -471,3 +472,58 @@ def test_setup_summary_marks_anthropic_auth_as_vision_available(tmp_path, monkey assert "Vision (image analysis)" in output assert "missing run 'hermes setup' to configure" not in output + + +def test_setup_summary_shows_camofox_when_browser_feature_is_camofox(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + monkeypatch.setattr( + "hermes_cli.setup.get_nous_subscription_features", + lambda config: NousSubscriptionFeatures( + subscribed=False, + nous_auth_present=False, + provider_is_nous=False, + features={ + "web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""), + "image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""), + "tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""), + "browser": NousFeatureState("browser", "Browser automation", True, True, True, False, True, True, "Camofox"), + "modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, "local"), + }, + ), + ) + monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: []) + + _print_setup_summary(load_config(), tmp_path) + output = capsys.readouterr().out + + assert "Browser Automation (Camofox)" in output + + +def test_setup_summary_does_not_mark_incomplete_browserbase_as_available(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + monkeypatch.setenv("BROWSERBASE_API_KEY", "bb-key") + monkeypatch.setattr( + "hermes_cli.setup.get_nous_subscription_features", + lambda config: NousSubscriptionFeatures( + subscribed=False, + nous_auth_present=False, + provider_is_nous=False, + features={ + "web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""), + "image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""), + "tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""), + "browser": NousFeatureState("browser", "Browser automation", True, False, False, False, False, True, "Browserbase"), + "modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, "local"), + }, + ), + ) + monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: []) + + _print_setup_summary(load_config(), tmp_path) + output = capsys.readouterr().out + + assert "Browser Automation (Browserbase)" not in output + assert "Browser Automation" in output + assert "BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID" in output diff --git a/tests/hermes_cli/test_subprocess_timeouts.py b/tests/hermes_cli/test_subprocess_timeouts.py new file mode 100644 index 0000000000..47146aac44 --- /dev/null +++ b/tests/hermes_cli/test_subprocess_timeouts.py @@ -0,0 +1,44 @@ +"""Tests for subprocess.run() timeout coverage in CLI utilities.""" +import ast +from pathlib import Path + +import pytest + + +# Parameterise over every CLI module that calls subprocess.run +_CLI_MODULES = [ + "hermes_cli/doctor.py", + "hermes_cli/status.py", + "hermes_cli/clipboard.py", + "hermes_cli/banner.py", +] + + +def _subprocess_run_calls(filepath: str) -> list[dict]: + """Parse a Python file and return info about subprocess.run() calls.""" + source = Path(filepath).read_text() + tree = ast.parse(source, filename=filepath) + calls = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if (isinstance(func, ast.Attribute) and func.attr == "run" + and isinstance(func.value, ast.Name) + and func.value.id == "subprocess"): + has_timeout = any(kw.arg == "timeout" for kw in node.keywords) + calls.append({"line": node.lineno, "has_timeout": has_timeout}) + return calls + + +@pytest.mark.parametrize("filepath", _CLI_MODULES) +def test_all_subprocess_run_calls_have_timeout(filepath): + """Every subprocess.run() call in CLI modules must specify a timeout.""" + if not Path(filepath).exists(): + pytest.skip(f"{filepath} not found") + calls = _subprocess_run_calls(filepath) + missing = [c for c in calls if not c["has_timeout"]] + assert not missing, ( + f"{filepath} has subprocess.run() without timeout at " + f"line(s): {[c['line'] for c in missing]}" + ) diff --git a/tests/honcho_integration/test_config_isolation.py b/tests/honcho_integration/test_config_isolation.py new file mode 100644 index 0000000000..4d9898e681 --- /dev/null +++ b/tests/honcho_integration/test_config_isolation.py @@ -0,0 +1,190 @@ +"""Tests for Honcho config profile isolation. + +Verifies that each Hermes profile writes to its own instance-local +honcho.json ($HERMES_HOME/honcho.json) rather than the shared global +~/.honcho/config.json. +""" + +import json +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from honcho_integration.cli import ( + _config_path, + _local_config_path, + _read_config, + _write_config, +) + + +@pytest.fixture +def isolated_home(tmp_path, monkeypatch): + """Create an isolated HERMES_HOME + real home for testing.""" + hermes_home = tmp_path / "profile_a" + hermes_home.mkdir() + global_dir = tmp_path / "home" / ".honcho" + global_dir.mkdir(parents=True) + global_config = global_dir / "config.json" + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path / "home")) + # GLOBAL_CONFIG_PATH is a module-level constant cached at import time, + # so we must patch it in both the defining module and the importing module. + import honcho_integration.client as _client_mod + import honcho_integration.cli as _cli_mod + monkeypatch.setattr(_client_mod, "GLOBAL_CONFIG_PATH", global_config) + monkeypatch.setattr(_cli_mod, "GLOBAL_CONFIG_PATH", global_config) + + return { + "hermes_home": hermes_home, + "global_config": global_config, + "local_config": hermes_home / "honcho.json", + } + + +class TestLocalConfigPath: + """_local_config_path always returns $HERMES_HOME/honcho.json.""" + + def test_returns_hermes_home_path(self, isolated_home): + assert _local_config_path() == isolated_home["local_config"] + + def test_differs_from_global(self, isolated_home): + from honcho_integration.client import GLOBAL_CONFIG_PATH + assert _local_config_path() != GLOBAL_CONFIG_PATH + + +class TestWriteConfigIsolation: + """_write_config defaults to the instance-local path.""" + + def test_write_creates_local_file(self, isolated_home): + cfg = {"apiKey": "test-key", "hosts": {"hermes": {"enabled": True}}} + _write_config(cfg) + + assert isolated_home["local_config"].exists() + written = json.loads(isolated_home["local_config"].read_text()) + assert written["apiKey"] == "test-key" + + def test_write_does_not_touch_global(self, isolated_home): + # Pre-populate global config + isolated_home["global_config"].write_text( + json.dumps({"apiKey": "global-key"}) + ) + + cfg = {"apiKey": "profile-key"} + _write_config(cfg) + + # Global should be untouched + global_data = json.loads(isolated_home["global_config"].read_text()) + assert global_data["apiKey"] == "global-key" + + # Local should have the new value + local_data = json.loads(isolated_home["local_config"].read_text()) + assert local_data["apiKey"] == "profile-key" + + def test_explicit_path_override_still_works(self, isolated_home): + custom = isolated_home["hermes_home"] / "custom.json" + _write_config({"custom": True}, path=custom) + assert custom.exists() + assert not isolated_home["local_config"].exists() + + +class TestReadConfigFallback: + """_read_config falls back to global when no local file exists.""" + + def test_reads_local_when_exists(self, isolated_home): + isolated_home["local_config"].write_text( + json.dumps({"source": "local"}) + ) + cfg = _read_config() + assert cfg["source"] == "local" + + def test_falls_back_to_global(self, isolated_home): + isolated_home["global_config"].write_text( + json.dumps({"source": "global"}) + ) + # No local file exists + assert not isolated_home["local_config"].exists() + cfg = _read_config() + assert cfg["source"] == "global" + + def test_local_takes_priority_over_global(self, isolated_home): + isolated_home["local_config"].write_text( + json.dumps({"source": "local"}) + ) + isolated_home["global_config"].write_text( + json.dumps({"source": "global"}) + ) + cfg = _read_config() + assert cfg["source"] == "local" + + +class TestMultiProfileIsolation: + """Two profiles writing config don't interfere with each other.""" + + def test_two_profiles_get_separate_configs(self, tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: home)) + + profile_a = tmp_path / "profile_a" + profile_b = tmp_path / "profile_b" + profile_a.mkdir() + profile_b.mkdir() + + # Profile A writes its config + monkeypatch.setenv("HERMES_HOME", str(profile_a)) + _write_config({"apiKey": "key-a", "hosts": {"hermes": {"peerName": "alice"}}}) + + # Profile B writes its config + monkeypatch.setenv("HERMES_HOME", str(profile_b)) + _write_config({"apiKey": "key-b", "hosts": {"hermes": {"peerName": "bob"}}}) + + # Verify isolation + a_data = json.loads((profile_a / "honcho.json").read_text()) + b_data = json.loads((profile_b / "honcho.json").read_text()) + + assert a_data["hosts"]["hermes"]["peerName"] == "alice" + assert b_data["hosts"]["hermes"]["peerName"] == "bob" + + def test_first_setup_seeds_from_global(self, tmp_path, monkeypatch): + """First setup reads global config, writes to local.""" + home = tmp_path / "home" + global_dir = home / ".honcho" + global_dir.mkdir(parents=True) + monkeypatch.setattr(Path, "home", staticmethod(lambda: home)) + import honcho_integration.client as _client_mod + import honcho_integration.cli as _cli_mod + global_cfg_path = global_dir / "config.json" + monkeypatch.setattr(_client_mod, "GLOBAL_CONFIG_PATH", global_cfg_path) + monkeypatch.setattr(_cli_mod, "GLOBAL_CONFIG_PATH", global_cfg_path) + + # Existing global config + global_config = global_dir / "config.json" + global_config.write_text(json.dumps({ + "apiKey": "shared-key", + "hosts": {"hermes": {"workspace": "shared-ws"}}, + })) + + profile = tmp_path / "new_profile" + profile.mkdir() + monkeypatch.setenv("HERMES_HOME", str(profile)) + + # Read seeds from global + cfg = _read_config() + assert cfg["apiKey"] == "shared-key" + + # Modify and write goes to local + cfg["hosts"]["hermes"]["peerName"] = "new-user" + _write_config(cfg) + + local_config = profile / "honcho.json" + assert local_config.exists() + local_data = json.loads(local_config.read_text()) + assert local_data["hosts"]["hermes"]["peerName"] == "new-user" + + # Global unchanged + global_data = json.loads(global_config.read_text()) + assert "peerName" not in global_data["hosts"]["hermes"] diff --git a/tests/test_anthropic_adapter.py b/tests/test_anthropic_adapter.py index 7e2e1c767e..4b4669eabc 100644 --- a/tests/test_anthropic_adapter.py +++ b/tests/test_anthropic_adapter.py @@ -81,6 +81,19 @@ class TestBuildAnthropicClient: kwargs = mock_sdk.Anthropic.call_args[1] assert kwargs["base_url"] == "https://custom.api.com" + def test_minimax_anthropic_endpoint_uses_bearer_auth_for_regular_api_keys(self): + with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + build_anthropic_client( + "minimax-secret-123", + base_url="https://api.minimax.io/anthropic", + ) + kwargs = mock_sdk.Anthropic.call_args[1] + assert kwargs["auth_token"] == "minimax-secret-123" + assert "api_key" not in kwargs + assert kwargs["default_headers"] == { + "anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" + } + class TestReadClaudeCodeCredentials: def test_reads_valid_credentials(self, tmp_path, monkeypatch): diff --git a/tests/test_cli_status_bar.py b/tests/test_cli_status_bar.py index 936ec21902..104c58b1f8 100644 --- a/tests/test_cli_status_bar.py +++ b/tests/test_cli_status_bar.py @@ -214,8 +214,9 @@ class TestStatusBarWidthSource: frags = cli_obj._get_status_bar_fragments() total_text = "".join(text for _, text in frags) - assert len(total_text) <= width + 4, ( # +4 for minor padding chars - f"At width={width}, fragment total {len(total_text)} chars overflows " + display_width = cli_obj._status_bar_display_width(total_text) + assert display_width <= width + 4, ( # +4 for minor padding chars + f"At width={width}, fragment total {display_width} cells overflows " f"({total_text!r})" ) diff --git a/tests/test_cli_tools_command.py b/tests/test_cli_tools_command.py index 9e648aecbf..2f0b096d2e 100644 --- a/tests/test_cli_tools_command.py +++ b/tests/test_cli_tools_command.py @@ -60,34 +60,43 @@ class TestToolsSlashList: class TestToolsSlashDisableWithReset: - def test_disable_confirms_then_resets_session(self): + def test_disable_applies_directly_and_resets_session(self): + """Disable applies immediately (no confirmation prompt) and resets session.""" cli_obj = _make_cli(["web", "memory"]) with patch("hermes_cli.tools_config.load_config", return_value={"platform_toolsets": {"cli": ["web", "memory"]}}), \ patch("hermes_cli.tools_config.save_config"), \ patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory"}), \ patch("hermes_cli.config.load_config", return_value={}), \ - patch.object(cli_obj, "new_session") as mock_reset, \ - patch("builtins.input", return_value="y"): + patch.object(cli_obj, "new_session") as mock_reset: cli_obj._handle_tools_command("/tools disable web") mock_reset.assert_called_once() assert "web" not in cli_obj.enabled_toolsets - def test_disable_cancelled_does_not_reset(self): + def test_disable_does_not_prompt_for_confirmation(self): + """Disable no longer uses input() — it applies directly.""" cli_obj = _make_cli(["web", "memory"]) - with patch.object(cli_obj, "new_session") as mock_reset, \ - patch("builtins.input", return_value="n"): + with patch("hermes_cli.tools_config.load_config", + return_value={"platform_toolsets": {"cli": ["web", "memory"]}}), \ + patch("hermes_cli.tools_config.save_config"), \ + patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory"}), \ + patch("hermes_cli.config.load_config", return_value={}), \ + patch.object(cli_obj, "new_session"), \ + patch("builtins.input") as mock_input: cli_obj._handle_tools_command("/tools disable web") - mock_reset.assert_not_called() - # Toolsets unchanged - assert cli_obj.enabled_toolsets == {"web", "memory"} + mock_input.assert_not_called() - def test_disable_eof_cancels(self): + def test_disable_always_resets_session(self): + """Even without a confirmation prompt, disable always resets the session.""" cli_obj = _make_cli(["web", "memory"]) - with patch.object(cli_obj, "new_session") as mock_reset, \ - patch("builtins.input", side_effect=EOFError): + with patch("hermes_cli.tools_config.load_config", + return_value={"platform_toolsets": {"cli": ["web", "memory"]}}), \ + patch("hermes_cli.tools_config.save_config"), \ + patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory"}), \ + patch("hermes_cli.config.load_config", return_value={}), \ + patch.object(cli_obj, "new_session") as mock_reset: cli_obj._handle_tools_command("/tools disable web") - mock_reset.assert_not_called() + mock_reset.assert_called_once() def test_disable_missing_name_prints_usage(self, capsys): cli_obj = _make_cli() @@ -101,15 +110,15 @@ class TestToolsSlashDisableWithReset: class TestToolsSlashEnableWithReset: - def test_enable_confirms_then_resets_session(self): + def test_enable_applies_directly_and_resets_session(self): + """Enable applies immediately (no confirmation prompt) and resets session.""" cli_obj = _make_cli(["memory"]) with patch("hermes_cli.tools_config.load_config", return_value={"platform_toolsets": {"cli": ["memory"]}}), \ patch("hermes_cli.tools_config.save_config"), \ patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory", "web"}), \ patch("hermes_cli.config.load_config", return_value={}), \ - patch.object(cli_obj, "new_session") as mock_reset, \ - patch("builtins.input", return_value="y"): + patch.object(cli_obj, "new_session") as mock_reset: cli_obj._handle_tools_command("/tools enable web") mock_reset.assert_called_once() assert "web" in cli_obj.enabled_toolsets diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py new file mode 100644 index 0000000000..1a377f5f5e --- /dev/null +++ b/tests/test_project_metadata.py @@ -0,0 +1,18 @@ +"""Regression tests for packaging metadata in pyproject.toml.""" + +from pathlib import Path +import tomllib + + +def _load_optional_dependencies(): + pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml" + with pyproject_path.open("rb") as handle: + project = tomllib.load(handle)["project"] + return project["optional-dependencies"] + + +def test_all_extra_includes_matrix_dependency(): + optional_dependencies = _load_optional_dependencies() + + assert "matrix" in optional_dependencies + assert "hermes-agent[matrix]" in optional_dependencies["all"] diff --git a/tests/test_trajectory_compressor_async.py b/tests/test_trajectory_compressor_async.py new file mode 100644 index 0000000000..2b276d03d0 --- /dev/null +++ b/tests/test_trajectory_compressor_async.py @@ -0,0 +1,115 @@ +"""Tests for trajectory_compressor AsyncOpenAI event loop binding. + +The AsyncOpenAI client was created once at __init__ time and stored as an +instance attribute. When process_directory() calls asyncio.run() — which +creates and closes a fresh event loop — the client's internal httpx +transport remains bound to the now-closed loop. A second call to +process_directory() would fail with "Event loop is closed". + +The fix creates the AsyncOpenAI client lazily via _get_async_client() so +each asyncio.run() gets a client bound to the current loop. +""" + +import types +from unittest.mock import MagicMock, patch + +import pytest + + +class TestAsyncClientLazyCreation: + """trajectory_compressor.py — _get_async_client()""" + + def test_async_client_none_after_init(self): + """async_client should be None after __init__ (not eagerly created).""" + from trajectory_compressor import TrajectoryCompressor + + comp = TrajectoryCompressor.__new__(TrajectoryCompressor) + comp.config = MagicMock() + comp.config.base_url = "https://api.example.com/v1" + comp.config.api_key_env = "TEST_API_KEY" + comp._use_call_llm = False + comp.async_client = None + comp._async_client_api_key = "test-key" + + assert comp.async_client is None + + def test_get_async_client_creates_new_client(self): + """_get_async_client() should create a fresh AsyncOpenAI instance.""" + from trajectory_compressor import TrajectoryCompressor + + comp = TrajectoryCompressor.__new__(TrajectoryCompressor) + comp.config = MagicMock() + comp.config.base_url = "https://api.example.com/v1" + comp._async_client_api_key = "test-key" + comp.async_client = None + + mock_async_openai = MagicMock() + with patch("openai.AsyncOpenAI", mock_async_openai): + client = comp._get_async_client() + + mock_async_openai.assert_called_once_with( + api_key="test-key", + base_url="https://api.example.com/v1", + ) + assert comp.async_client is not None + + def test_get_async_client_creates_fresh_each_call(self): + """Each call to _get_async_client() creates a NEW client instance, + so it binds to the current event loop.""" + from trajectory_compressor import TrajectoryCompressor + + comp = TrajectoryCompressor.__new__(TrajectoryCompressor) + comp.config = MagicMock() + comp.config.base_url = "https://api.example.com/v1" + comp._async_client_api_key = "test-key" + comp.async_client = None + + call_count = 0 + instances = [] + + def mock_constructor(**kwargs): + nonlocal call_count + call_count += 1 + instance = MagicMock() + instances.append(instance) + return instance + + with patch("openai.AsyncOpenAI", side_effect=mock_constructor): + client1 = comp._get_async_client() + client2 = comp._get_async_client() + + # Should have created two separate instances + assert call_count == 2 + assert instances[0] is not instances[1] + + +class TestSourceLineVerification: + """Verify the actual source has the lazy pattern applied.""" + + @staticmethod + def _read_file() -> str: + import os + base = os.path.dirname(os.path.dirname(__file__)) + with open(os.path.join(base, "trajectory_compressor.py")) as f: + return f.read() + + def test_no_eager_async_openai_in_init(self): + """__init__ should NOT create AsyncOpenAI eagerly.""" + src = self._read_file() + # The old pattern: self.async_client = AsyncOpenAI(...) in _init_summarizer + # should not exist — only self.async_client = None + lines = src.split("\n") + for i, line in enumerate(lines, 1): + if "self.async_client = AsyncOpenAI(" in line and "_get_async_client" not in lines[max(0,i-3):i+1]: + # Allow it inside _get_async_client method + # Check if we're inside _get_async_client by looking at context + context = "\n".join(lines[max(0,i-10):i+1]) + if "_get_async_client" not in context: + pytest.fail( + f"Line {i}: AsyncOpenAI created eagerly outside _get_async_client()" + ) + + def test_get_async_client_method_exists(self): + """_get_async_client method should exist.""" + src = self._read_file() + assert "def _get_async_client(self)" in src diff --git a/tests/tools/test_browser_camofox.py b/tests/tools/test_browser_camofox.py new file mode 100644 index 0000000000..a59862b9bd --- /dev/null +++ b/tests/tools/test_browser_camofox.py @@ -0,0 +1,290 @@ +"""Tests for the Camofox browser backend.""" + +import json +import os +from unittest.mock import MagicMock, patch + +import pytest + +from tools.browser_camofox import ( + camofox_back, + camofox_click, + camofox_close, + camofox_console, + camofox_get_images, + camofox_navigate, + camofox_press, + camofox_scroll, + camofox_snapshot, + camofox_type, + camofox_vision, + check_camofox_available, + cleanup_all_camofox_sessions, + is_camofox_mode, +) + + +# --------------------------------------------------------------------------- +# Configuration detection +# --------------------------------------------------------------------------- + + +class TestCamofoxMode: + def test_disabled_by_default(self, monkeypatch): + monkeypatch.delenv("CAMOFOX_URL", raising=False) + assert is_camofox_mode() is False + + def test_enabled_when_url_set(self, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + assert is_camofox_mode() is True + + def test_health_check_unreachable(self, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:19999") + assert check_camofox_available() is False + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _mock_response(status=200, json_data=None): + resp = MagicMock() + resp.status_code = status + resp.json.return_value = json_data or {} + resp.content = b"\x89PNG\r\n\x1a\nfake" + resp.raise_for_status = MagicMock() + return resp + + +# --------------------------------------------------------------------------- +# Navigate +# --------------------------------------------------------------------------- + + +class TestCamofoxNavigate: + @patch("tools.browser_camofox.requests.post") + def test_creates_tab_on_first_navigate(self, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + mock_post.return_value = _mock_response(json_data={"tabId": "tab1", "url": "https://example.com"}) + + result = json.loads(camofox_navigate("https://example.com", task_id="t1")) + assert result["success"] is True + assert result["url"] == "https://example.com" + + @patch("tools.browser_camofox.requests.post") + def test_navigates_existing_tab(self, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + # First call creates tab + mock_post.return_value = _mock_response(json_data={"tabId": "tab2", "url": "https://a.com"}) + camofox_navigate("https://a.com", task_id="t2") + + # Second call navigates + mock_post.return_value = _mock_response(json_data={"ok": True, "url": "https://b.com"}) + result = json.loads(camofox_navigate("https://b.com", task_id="t2")) + assert result["success"] is True + assert result["url"] == "https://b.com" + + def test_connection_error_returns_helpful_message(self, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:19999") + result = json.loads(camofox_navigate("https://example.com", task_id="t_err")) + assert result["success"] is False + assert "Cannot connect" in result["error"] + + +# --------------------------------------------------------------------------- +# Snapshot +# --------------------------------------------------------------------------- + + +class TestCamofoxSnapshot: + def test_no_session_returns_error(self, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + result = json.loads(camofox_snapshot(task_id="no_such_task")) + assert result["success"] is False + assert "browser_navigate" in result["error"] + + @patch("tools.browser_camofox.requests.post") + @patch("tools.browser_camofox.requests.get") + def test_returns_snapshot(self, mock_get, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + # Create session + mock_post.return_value = _mock_response(json_data={"tabId": "tab3", "url": "https://x.com"}) + camofox_navigate("https://x.com", task_id="t3") + + # Return snapshot + mock_get.return_value = _mock_response(json_data={ + "snapshot": "- heading \"Test\" [e1]\n- button \"Submit\" [e2]", + "refsCount": 2, + }) + result = json.loads(camofox_snapshot(task_id="t3")) + assert result["success"] is True + assert "[e1]" in result["snapshot"] + assert result["element_count"] == 2 + + +# --------------------------------------------------------------------------- +# Click / Type / Scroll / Back / Press +# --------------------------------------------------------------------------- + + +class TestCamofoxInteractions: + @patch("tools.browser_camofox.requests.post") + def test_click(self, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + mock_post.return_value = _mock_response(json_data={"tabId": "tab4", "url": "https://x.com"}) + camofox_navigate("https://x.com", task_id="t4") + + mock_post.return_value = _mock_response(json_data={"ok": True, "url": "https://x.com"}) + result = json.loads(camofox_click("@e5", task_id="t4")) + assert result["success"] is True + assert result["clicked"] == "e5" + + @patch("tools.browser_camofox.requests.post") + def test_type(self, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + mock_post.return_value = _mock_response(json_data={"tabId": "tab5", "url": "https://x.com"}) + camofox_navigate("https://x.com", task_id="t5") + + mock_post.return_value = _mock_response(json_data={"ok": True}) + result = json.loads(camofox_type("@e3", "hello world", task_id="t5")) + assert result["success"] is True + assert result["typed"] == "hello world" + + @patch("tools.browser_camofox.requests.post") + def test_scroll(self, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + mock_post.return_value = _mock_response(json_data={"tabId": "tab6", "url": "https://x.com"}) + camofox_navigate("https://x.com", task_id="t6") + + mock_post.return_value = _mock_response(json_data={"ok": True}) + result = json.loads(camofox_scroll("down", task_id="t6")) + assert result["success"] is True + assert result["scrolled"] == "down" + + @patch("tools.browser_camofox.requests.post") + def test_back(self, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + mock_post.return_value = _mock_response(json_data={"tabId": "tab7", "url": "https://x.com"}) + camofox_navigate("https://x.com", task_id="t7") + + mock_post.return_value = _mock_response(json_data={"ok": True, "url": "https://prev.com"}) + result = json.loads(camofox_back(task_id="t7")) + assert result["success"] is True + + @patch("tools.browser_camofox.requests.post") + def test_press(self, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + mock_post.return_value = _mock_response(json_data={"tabId": "tab8", "url": "https://x.com"}) + camofox_navigate("https://x.com", task_id="t8") + + mock_post.return_value = _mock_response(json_data={"ok": True}) + result = json.loads(camofox_press("Enter", task_id="t8")) + assert result["success"] is True + assert result["pressed"] == "Enter" + + +# --------------------------------------------------------------------------- +# Close +# --------------------------------------------------------------------------- + + +class TestCamofoxClose: + @patch("tools.browser_camofox.requests.delete") + @patch("tools.browser_camofox.requests.post") + def test_close_session(self, mock_post, mock_delete, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + mock_post.return_value = _mock_response(json_data={"tabId": "tab9", "url": "https://x.com"}) + camofox_navigate("https://x.com", task_id="t9") + + mock_delete.return_value = _mock_response(json_data={"ok": True}) + result = json.loads(camofox_close(task_id="t9")) + assert result["success"] is True + assert result["closed"] is True + + def test_close_nonexistent_session(self, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + result = json.loads(camofox_close(task_id="nonexistent")) + assert result["success"] is True + + +# --------------------------------------------------------------------------- +# Console (limited support) +# --------------------------------------------------------------------------- + + +class TestCamofoxConsole: + def test_console_returns_empty_with_note(self, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + result = json.loads(camofox_console(task_id="t_console")) + assert result["success"] is True + assert result["total_messages"] == 0 + assert "not available" in result["note"] + + +# --------------------------------------------------------------------------- +# Images +# --------------------------------------------------------------------------- + + +class TestCamofoxGetImages: + @patch("tools.browser_camofox.requests.post") + @patch("tools.browser_camofox.requests.get") + def test_get_images(self, mock_get, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + mock_post.return_value = _mock_response(json_data={"tabId": "tab10", "url": "https://x.com"}) + camofox_navigate("https://x.com", task_id="t10") + + mock_get.return_value = _mock_response(json_data={ + "images": [{"src": "https://x.com/img.png", "alt": "Logo"}], + }) + result = json.loads(camofox_get_images(task_id="t10")) + assert result["success"] is True + assert result["count"] == 1 + assert result["images"][0]["src"] == "https://x.com/img.png" + + +# --------------------------------------------------------------------------- +# Routing integration — verify browser_tool routes to camofox +# --------------------------------------------------------------------------- + + +class TestBrowserToolRouting: + """Verify that browser_tool.py delegates to camofox when CAMOFOX_URL is set.""" + + @patch("tools.browser_camofox.requests.post") + def test_browser_navigate_routes_to_camofox(self, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + mock_post.return_value = _mock_response(json_data={"tabId": "tab_rt", "url": "https://example.com"}) + + from tools.browser_tool import browser_navigate + # Bypass SSRF check for test URL + with patch("tools.browser_tool._is_safe_url", return_value=True): + result = json.loads(browser_navigate("https://example.com", task_id="t_route")) + assert result["success"] is True + + def test_check_requirements_passes_with_camofox(self, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + from tools.browser_tool import check_browser_requirements + assert check_browser_requirements() is True + + +# --------------------------------------------------------------------------- +# Cleanup helper +# --------------------------------------------------------------------------- + + +class TestCamofoxCleanup: + @patch("tools.browser_camofox.requests.post") + @patch("tools.browser_camofox.requests.delete") + def test_cleanup_all(self, mock_delete, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + mock_post.return_value = _mock_response(json_data={"tabId": "tab_c", "url": "https://x.com"}) + camofox_navigate("https://x.com", task_id="t_cleanup") + + mock_delete.return_value = _mock_response(json_data={"ok": True}) + cleanup_all_camofox_sessions() + + # Session should be gone + result = json.loads(camofox_snapshot(task_id="t_cleanup")) + assert result["success"] is False diff --git a/tests/tools/test_credential_files.py b/tests/tools/test_credential_files.py index 293e2c6da7..c46f73fae3 100644 --- a/tests/tools/test_credential_files.py +++ b/tests/tools/test_credential_files.py @@ -1,13 +1,17 @@ -"""Tests for credential file passthrough registry (tools/credential_files.py).""" +"""Tests for credential file passthrough and skills directory mounting.""" +import json import os from pathlib import Path +from unittest.mock import patch import pytest from tools.credential_files import ( clear_credential_files, get_credential_file_mounts, + get_skills_directory_mount, + iter_skills_files, register_credential_file, register_credential_files, reset_config_cache, @@ -15,8 +19,8 @@ from tools.credential_files import ( @pytest.fixture(autouse=True) -def _clean_registry(): - """Reset registry between tests.""" +def _clean_state(): + """Reset module state between tests.""" clear_credential_files() reset_config_cache() yield @@ -24,135 +28,172 @@ def _clean_registry(): reset_config_cache() -class TestRegisterCredentialFile: - def test_registers_existing_file(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - (tmp_path / "token.json").write_text('{"token": "abc"}') +class TestRegisterCredentialFiles: + def test_dict_with_path_key(self, tmp_path): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "token.json").write_text("{}") - result = register_credential_file("token.json") + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + missing = register_credential_files([{"path": "token.json"}]) - assert result is True + assert missing == [] mounts = get_credential_file_mounts() assert len(mounts) == 1 - assert mounts[0]["host_path"] == str(tmp_path / "token.json") + assert mounts[0]["host_path"] == str(hermes_home / "token.json") assert mounts[0]["container_path"] == "/root/.hermes/token.json" - def test_skips_missing_file(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + def test_dict_with_name_key_fallback(self, tmp_path): + """Skills use 'name' instead of 'path' — both should work.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "google_token.json").write_text("{}") - result = register_credential_file("nonexistent.json") - - assert result is False - assert get_credential_file_mounts() == [] - - def test_custom_container_base(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - (tmp_path / "cred.json").write_text("{}") - - register_credential_file("cred.json", container_base="/home/user/.hermes") + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + missing = register_credential_files([ + {"name": "google_token.json", "description": "OAuth token"}, + ]) + assert missing == [] mounts = get_credential_file_mounts() - assert mounts[0]["container_path"] == "/home/user/.hermes/cred.json" + assert len(mounts) == 1 + assert "google_token.json" in mounts[0]["container_path"] - def test_deduplicates_by_container_path(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - (tmp_path / "token.json").write_text("{}") + def test_string_entry(self, tmp_path): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "secret.key").write_text("key") - register_credential_file("token.json") - register_credential_file("token.json") + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + missing = register_credential_files(["secret.key"]) + assert missing == [] mounts = get_credential_file_mounts() assert len(mounts) == 1 + def test_missing_file_reported(self, tmp_path): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() -class TestRegisterCredentialFiles: - def test_string_entries(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - (tmp_path / "a.json").write_text("{}") - (tmp_path / "b.json").write_text("{}") + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + missing = register_credential_files([ + {"name": "does_not_exist.json"}, + ]) - missing = register_credential_files(["a.json", "b.json"]) - - assert missing == [] - assert len(get_credential_file_mounts()) == 2 - - def test_dict_entries(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - (tmp_path / "token.json").write_text("{}") - - missing = register_credential_files([ - {"path": "token.json", "description": "OAuth token"}, - ]) - - assert missing == [] - assert len(get_credential_file_mounts()) == 1 - - def test_returns_missing_files(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - (tmp_path / "exists.json").write_text("{}") - - missing = register_credential_files([ - "exists.json", - "missing.json", - {"path": "also_missing.json"}, - ]) - - assert missing == ["missing.json", "also_missing.json"] - assert len(get_credential_file_mounts()) == 1 - - def test_empty_list(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - assert register_credential_files([]) == [] - - -class TestConfigCredentialFiles: - def test_loads_from_config(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - (tmp_path / "oauth.json").write_text("{}") - (tmp_path / "config.yaml").write_text( - "terminal:\n credential_files:\n - oauth.json\n" - ) - - mounts = get_credential_file_mounts() - - assert len(mounts) == 1 - assert mounts[0]["host_path"] == str(tmp_path / "oauth.json") - - def test_config_skips_missing_files(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - (tmp_path / "config.yaml").write_text( - "terminal:\n credential_files:\n - nonexistent.json\n" - ) - - mounts = get_credential_file_mounts() - assert mounts == [] - - def test_combines_skill_and_config(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - (tmp_path / "skill_token.json").write_text("{}") - (tmp_path / "config_token.json").write_text("{}") - (tmp_path / "config.yaml").write_text( - "terminal:\n credential_files:\n - config_token.json\n" - ) - - register_credential_file("skill_token.json") - mounts = get_credential_file_mounts() - - assert len(mounts) == 2 - paths = {m["container_path"] for m in mounts} - assert "/root/.hermes/skill_token.json" in paths - assert "/root/.hermes/config_token.json" in paths - - -class TestGetMountsRechecksExistence: - def test_removed_file_excluded_from_mounts(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - token = tmp_path / "token.json" - token.write_text("{}") - - register_credential_file("token.json") - assert len(get_credential_file_mounts()) == 1 - - # Delete the file after registration - token.unlink() + assert "does_not_exist.json" in missing assert get_credential_file_mounts() == [] + + def test_path_takes_precedence_over_name(self, tmp_path): + """When both path and name are present, path wins.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "real.json").write_text("{}") + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + missing = register_credential_files([ + {"path": "real.json", "name": "wrong.json"}, + ]) + + assert missing == [] + mounts = get_credential_file_mounts() + assert "real.json" in mounts[0]["container_path"] + + +class TestSkillsDirectoryMount: + def test_returns_mount_when_skills_dir_exists(self, tmp_path): + hermes_home = tmp_path / ".hermes" + skills_dir = hermes_home / "skills" + skills_dir.mkdir(parents=True) + (skills_dir / "test-skill").mkdir() + (skills_dir / "test-skill" / "SKILL.md").write_text("# test") + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + mount = get_skills_directory_mount() + + assert mount is not None + assert mount["host_path"] == str(skills_dir) + assert mount["container_path"] == "/root/.hermes/skills" + + def test_returns_none_when_no_skills_dir(self, tmp_path): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + mount = get_skills_directory_mount() + + assert mount is None + + def test_custom_container_base(self, tmp_path): + hermes_home = tmp_path / ".hermes" + (hermes_home / "skills").mkdir(parents=True) + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + mount = get_skills_directory_mount(container_base="/home/user/.hermes") + + assert mount["container_path"] == "/home/user/.hermes/skills" + + def test_symlinks_are_sanitized(self, tmp_path): + """Symlinks in skills dir should be excluded from the mount.""" + hermes_home = tmp_path / ".hermes" + skills_dir = hermes_home / "skills" + skills_dir.mkdir(parents=True) + (skills_dir / "legit.md").write_text("# real skill") + # Create a symlink pointing outside the skills tree + secret = tmp_path / "secret.txt" + secret.write_text("TOP SECRET") + (skills_dir / "evil_link").symlink_to(secret) + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + mount = get_skills_directory_mount() + + assert mount is not None + # The mount path should be a sanitized copy, not the original + safe_path = Path(mount["host_path"]) + assert safe_path != skills_dir + # Legitimate file should be present + assert (safe_path / "legit.md").exists() + assert (safe_path / "legit.md").read_text() == "# real skill" + # Symlink should NOT be present + assert not (safe_path / "evil_link").exists() + + def test_no_symlinks_returns_original_dir(self, tmp_path): + """When no symlinks exist, the original dir is returned (no copy).""" + hermes_home = tmp_path / ".hermes" + skills_dir = hermes_home / "skills" + skills_dir.mkdir(parents=True) + (skills_dir / "skill.md").write_text("ok") + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + mount = get_skills_directory_mount() + + assert mount["host_path"] == str(skills_dir) + + +class TestIterSkillsFiles: + def test_returns_files_skipping_symlinks(self, tmp_path): + hermes_home = tmp_path / ".hermes" + skills_dir = hermes_home / "skills" + (skills_dir / "cat" / "myskill").mkdir(parents=True) + (skills_dir / "cat" / "myskill" / "SKILL.md").write_text("# skill") + (skills_dir / "cat" / "myskill" / "scripts").mkdir() + (skills_dir / "cat" / "myskill" / "scripts" / "run.sh").write_text("#!/bin/bash") + # Add a symlink that should be filtered + secret = tmp_path / "secret" + secret.write_text("nope") + (skills_dir / "cat" / "myskill" / "evil").symlink_to(secret) + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + files = iter_skills_files() + + paths = {f["container_path"] for f in files} + assert "/root/.hermes/skills/cat/myskill/SKILL.md" in paths + assert "/root/.hermes/skills/cat/myskill/scripts/run.sh" in paths + # Symlink should be excluded + assert not any("evil" in f["container_path"] for f in files) + + def test_empty_when_no_skills_dir(self, tmp_path): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + assert iter_skills_files() == [] diff --git a/tests/tools/test_daytona_environment.py b/tests/tools/test_daytona_environment.py index 94a28dc7f2..04e6347955 100644 --- a/tests/tools/test_daytona_environment.py +++ b/tests/tools/test_daytona_environment.py @@ -61,6 +61,10 @@ def make_env(daytona_sdk, monkeypatch): """Factory that creates a DaytonaEnvironment with a mocked SDK.""" # Prevent is_interrupted from interfering monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) + # Prevent skills/credential sync from consuming mock exec calls + monkeypatch.setattr("tools.credential_files.get_credential_file_mounts", lambda: []) + monkeypatch.setattr("tools.credential_files.get_skills_directory_mount", lambda **kw: None) + monkeypatch.setattr("tools.credential_files.iter_skills_files", lambda **kw: []) def _factory( sandbox=None, diff --git a/tests/tools/test_modal_snapshot_isolation.py b/tests/tools/test_modal_snapshot_isolation.py index e2daf6a310..1f9d9ff95b 100644 --- a/tests/tools/test_modal_snapshot_isolation.py +++ b/tests/tools/test_modal_snapshot_isolation.py @@ -1,4 +1,5 @@ import json +import os import sys import types from importlib.util import module_from_spec, spec_from_file_location @@ -28,6 +29,7 @@ def _reset_modules(prefixes: tuple[str, ...]): @pytest.fixture(autouse=True) def _restore_tool_modules(): + original_hermes_home = os.environ.get("HERMES_HOME") original_modules = { name: module for name, module in sys.modules.items() @@ -41,6 +43,10 @@ def _restore_tool_modules(): try: yield finally: + if original_hermes_home is None: + os.environ.pop("HERMES_HOME", None) + else: + os.environ["HERMES_HOME"] = original_hermes_home _reset_modules(("tools", "hermes_cli", "modal")) sys.modules.update(original_modules) @@ -57,6 +63,7 @@ def _install_modal_test_modules( hermes_cli.__path__ = [] # type: ignore[attr-defined] sys.modules["hermes_cli"] = hermes_cli hermes_home = tmp_path / "hermes-home" + os.environ["HERMES_HOME"] = str(hermes_home) sys.modules["hermes_cli.config"] = types.SimpleNamespace( get_hermes_home=lambda: hermes_home, ) diff --git a/tests/tools/test_skills_hub.py b/tests/tools/test_skills_hub.py index a55a91e001..58e0354697 100644 --- a/tests/tools/test_skills_hub.py +++ b/tests/tools/test_skills_hub.py @@ -5,6 +5,7 @@ from pathlib import Path from unittest.mock import patch, MagicMock import httpx +import pytest from tools.skills_hub import ( GitHubAuth, @@ -648,6 +649,29 @@ class TestWellKnownSkillSource: assert bundle.files["SKILL.md"] == "# Code Review\n" assert bundle.files["references/checklist.md"] == "- [ ] security\n" + @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_fetch_rejects_unsafe_file_paths_from_well_known_endpoint(self, mock_get, _mock_read_cache, _mock_write_cache): + def fake_get(url, *args, **kwargs): + if url.endswith("/index.json"): + return MagicMock(status_code=200, json=lambda: { + "skills": [{ + "name": "code-review", + "description": "Review code", + "files": ["SKILL.md", "../../../escape.txt"], + }] + }) + if url.endswith("/code-review/SKILL.md"): + return MagicMock(status_code=200, text="# Code Review\n") + raise AssertionError(url) + + mock_get.side_effect = fake_get + + bundle = self._source().fetch("well-known:https://example.com/.well-known/skills/code-review") + + assert bundle is None + class TestCheckForSkillUpdates: def test_bundle_content_hash_matches_installed_content_hash(self, tmp_path): @@ -1143,6 +1167,61 @@ class TestQuarantineBundleBinaryAssets: assert (q_path / "SKILL.md").read_text(encoding="utf-8").startswith("---") assert (q_path / "assets" / "neutts-cli" / "samples" / "jo.wav").read_bytes() == b"RIFF\x00\x01fakewav" + def test_quarantine_bundle_rejects_traversal_file_paths(self, tmp_path): + import tools.skills_hub as hub + + hub_dir = tmp_path / "skills" / ".hub" + with patch.object(hub, "SKILLS_DIR", tmp_path / "skills"), \ + patch.object(hub, "HUB_DIR", hub_dir), \ + patch.object(hub, "LOCK_FILE", hub_dir / "lock.json"), \ + patch.object(hub, "QUARANTINE_DIR", hub_dir / "quarantine"), \ + patch.object(hub, "AUDIT_LOG", hub_dir / "audit.log"), \ + patch.object(hub, "TAPS_FILE", hub_dir / "taps.json"), \ + patch.object(hub, "INDEX_CACHE_DIR", hub_dir / "index-cache"): + bundle = SkillBundle( + name="demo", + files={ + "SKILL.md": "---\nname: demo\n---\n", + "../../../escape.txt": "owned", + }, + source="well-known", + identifier="well-known:https://example.com/.well-known/skills/demo", + trust_level="community", + ) + + with pytest.raises(ValueError, match="Unsafe bundle file path"): + quarantine_bundle(bundle) + + assert not (tmp_path / "skills" / "escape.txt").exists() + + def test_quarantine_bundle_rejects_absolute_file_paths(self, tmp_path): + import tools.skills_hub as hub + + hub_dir = tmp_path / "skills" / ".hub" + absolute_target = tmp_path / "outside.txt" + with patch.object(hub, "SKILLS_DIR", tmp_path / "skills"), \ + patch.object(hub, "HUB_DIR", hub_dir), \ + patch.object(hub, "LOCK_FILE", hub_dir / "lock.json"), \ + patch.object(hub, "QUARANTINE_DIR", hub_dir / "quarantine"), \ + patch.object(hub, "AUDIT_LOG", hub_dir / "audit.log"), \ + patch.object(hub, "TAPS_FILE", hub_dir / "taps.json"), \ + patch.object(hub, "INDEX_CACHE_DIR", hub_dir / "index-cache"): + bundle = SkillBundle( + name="demo", + files={ + "SKILL.md": "---\nname: demo\n---\n", + str(absolute_target): "owned", + }, + source="well-known", + identifier="well-known:https://example.com/.well-known/skills/demo", + trust_level="community", + ) + + with pytest.raises(ValueError, match="Unsafe bundle file path"): + quarantine_bundle(bundle) + + assert not absolute_target.exists() + # --------------------------------------------------------------------------- # GitHubSource._download_directory — tree API + fallback (#2940) diff --git a/tests/tools/test_terminal_timeout_output.py b/tests/tools/test_terminal_timeout_output.py new file mode 100644 index 0000000000..52823581fc --- /dev/null +++ b/tests/tools/test_terminal_timeout_output.py @@ -0,0 +1,27 @@ +"""Verify that terminal command timeouts preserve partial output.""" +from tools.environments.local import LocalEnvironment + + +class TestTimeoutPreservesPartialOutput: + """When a command times out, any output captured before the deadline + should be included in the result — not discarded.""" + + def test_timeout_includes_partial_output(self): + """A command that prints then sleeps past the deadline should + return both the printed text and the timeout notice.""" + env = LocalEnvironment() + result = env.execute("echo 'hello from test' && sleep 30", timeout=2) + + assert result["returncode"] == 124 + assert "hello from test" in result["output"] + assert "timed out" in result["output"].lower() + + def test_timeout_with_no_output(self): + """A command that produces nothing before timeout should still + return a clean timeout message.""" + env = LocalEnvironment() + result = env.execute("sleep 30", timeout=1) + + assert result["returncode"] == 124 + assert "timed out" in result["output"].lower() + assert not result["output"].startswith("\n") diff --git a/tests/tools/test_website_policy.py b/tests/tools/test_website_policy.py index 4312d970e2..4573e02765 100644 --- a/tests/tools/test_website_policy.py +++ b/tests/tools/test_website_policy.py @@ -259,6 +259,12 @@ def test_check_website_access_uses_dynamic_hermes_home(monkeypatch, tmp_path): monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + # Invalidate the module-level cache so the new HERMES_HOME is picked up. + # A prior test may have cached a default policy (enabled=False) under the + # old HERMES_HOME set by the autouse _isolate_hermes_home fixture. + from tools.website_policy import invalidate_cache + invalidate_cache() + blocked = check_website_access("https://dynamic.example/path") assert blocked is not None diff --git a/tools/approval.py b/tools/approval.py index 8ae52407f9..95011173fd 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -41,8 +41,8 @@ DANGEROUS_PATTERNS = [ (r'\brm\s+(-[^\s]*\s+)*/', "delete in root path"), (r'\brm\s+-[^\s]*r', "recursive delete"), (r'\brm\s+--recursive\b', "recursive delete (long flag)"), - (r'\bchmod\s+(-[^\s]*\s+)*777\b', "world-writable permissions"), - (r'\bchmod\s+--recursive\b.*777', "recursive world-writable (long flag)"), + (r'\bchmod\s+(-[^\s]*\s+)*(777|666|o\+[rwx]*w|a\+[rwx]*w)\b', "world/other-writable permissions"), + (r'\bchmod\s+--recursive\b.*(777|666|o\+[rwx]*w|a\+[rwx]*w)', "recursive world/other-writable (long flag)"), (r'\bchown\s+(-[^\s]*)?R\s+root', "recursive chown to root"), (r'\bchown\s+--recursive\b.*root', "recursive chown to root (long flag)"), (r'\bmkfs\b', "format filesystem"), @@ -71,6 +71,10 @@ DANGEROUS_PATTERNS = [ (r'\bnohup\b.*gateway\s+run\b', "start gateway outside systemd (use 'systemctl --user restart hermes-gateway')"), # Self-termination protection: prevent agent from killing its own process (r'\b(pkill|killall)\b.*\b(hermes|gateway|cli\.py)\b', "kill hermes/gateway process (self-termination)"), + # File copy/move/edit into sensitive system paths + (r'\b(cp|mv|install)\b.*\s/etc/', "copy/move file into /etc/"), + (r'\bsed\s+-[^\s]*i.*\s/etc/', "in-place edit of system config"), + (r'\bsed\s+--in-place\b.*\s/etc/', "in-place edit of system config (long flag)"), ] @@ -237,7 +241,7 @@ def save_permanent_allowlist(patterns: set): # ========================================================================= def prompt_dangerous_approval(command: str, description: str, - timeout_seconds: int = 60, + timeout_seconds: int | None = None, allow_permanent: bool = True, approval_callback=None) -> str: """Prompt the user to approve a dangerous command (CLI only). @@ -252,6 +256,9 @@ def prompt_dangerous_approval(command: str, description: str, Returns: 'once', 'session', 'always', or 'deny' """ + if timeout_seconds is None: + timeout_seconds = _get_approval_timeout() + if approval_callback is not None: try: return approval_callback(command, description, @@ -332,15 +339,28 @@ def _normalize_approval_mode(mode) -> str: return "manual" -def _get_approval_mode() -> str: - """Read the approval mode from config. Returns 'manual', 'smart', or 'off'.""" +def _get_approval_config() -> dict: + """Read the approvals config block. Returns a dict with 'mode', 'timeout', etc.""" try: from hermes_cli.config import load_config config = load_config() - mode = config.get("approvals", {}).get("mode", "manual") - return _normalize_approval_mode(mode) + return config.get("approvals", {}) or {} except Exception: - return "manual" + return {} + + +def _get_approval_mode() -> str: + """Read the approval mode from config. Returns 'manual', 'smart', or 'off'.""" + mode = _get_approval_config().get("mode", "manual") + return _normalize_approval_mode(mode) + + +def _get_approval_timeout() -> int: + """Read the approval timeout from config. Defaults to 60 seconds.""" + try: + return int(_get_approval_config().get("timeout", 60)) + except (ValueError, TypeError): + return 60 def _smart_approve(command: str, description: str) -> str: diff --git a/tools/browser_camofox.py b/tools/browser_camofox.py new file mode 100644 index 0000000000..b1925d2c62 --- /dev/null +++ b/tools/browser_camofox.py @@ -0,0 +1,496 @@ +"""Camofox browser backend — local anti-detection browser via REST API. + +Camofox-browser is a self-hosted Node.js server wrapping Camoufox (Firefox +fork with C++ fingerprint spoofing). It exposes a REST API that maps 1:1 +to our browser tool interface: accessibility snapshots with element refs, +click/type/scroll by ref, screenshots, etc. + +When ``CAMOFOX_URL`` is set (e.g. ``http://localhost:9377``), the browser +tools route through this module instead of the ``agent-browser`` CLI. + +Setup:: + + # Option 1: npm + git clone https://github.com/jo-inc/camofox-browser && cd camofox-browser + npm install && npm start # downloads Camoufox (~300MB) on first run + + # Option 2: Docker + docker run -p 9377:9377 jo-inc/camofox-browser + +Then set ``CAMOFOX_URL=http://localhost:9377`` in ``~/.hermes/.env``. +""" + +from __future__ import annotations + +import base64 +import json +import logging +import os +import threading +import time +import uuid +from pathlib import Path +from typing import Any, Dict, Optional + +import requests + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +_DEFAULT_TIMEOUT = 30 # seconds per HTTP request +_SNAPSHOT_MAX_CHARS = 80_000 # camofox paginates at this limit + + +def get_camofox_url() -> str: + """Return the configured Camofox server URL, or empty string.""" + return os.getenv("CAMOFOX_URL", "").rstrip("/") + + +def is_camofox_mode() -> bool: + """True when Camofox backend is configured.""" + return bool(get_camofox_url()) + + +def check_camofox_available() -> bool: + """Verify the Camofox server is reachable.""" + url = get_camofox_url() + if not url: + return False + try: + resp = requests.get(f"{url}/health", timeout=5) + return resp.status_code == 200 + except Exception: + return False + + +# --------------------------------------------------------------------------- +# Session management +# --------------------------------------------------------------------------- +# Maps task_id -> {"user_id": str, "tab_id": str|None} +_sessions: Dict[str, Dict[str, Any]] = {} +_sessions_lock = threading.Lock() + + +def _get_session(task_id: Optional[str]) -> Dict[str, Any]: + """Get or create a camofox session for the given task.""" + task_id = task_id or "default" + with _sessions_lock: + if task_id in _sessions: + return _sessions[task_id] + session = { + "user_id": f"hermes_{uuid.uuid4().hex[:10]}", + "tab_id": None, + "session_key": f"task_{task_id[:16]}", + } + _sessions[task_id] = session + return session + + +def _ensure_tab(task_id: Optional[str], url: str = "about:blank") -> Dict[str, Any]: + """Ensure a tab exists for the session, creating one if needed.""" + session = _get_session(task_id) + if session["tab_id"]: + return session + base = get_camofox_url() + resp = requests.post( + f"{base}/tabs", + json={ + "userId": session["user_id"], + "sessionKey": session["session_key"], + "url": url, + }, + timeout=_DEFAULT_TIMEOUT, + ) + resp.raise_for_status() + data = resp.json() + session["tab_id"] = data.get("tabId") + return session + + +def _drop_session(task_id: Optional[str]) -> Optional[Dict[str, Any]]: + """Remove and return session info.""" + task_id = task_id or "default" + with _sessions_lock: + return _sessions.pop(task_id, None) + + +# --------------------------------------------------------------------------- +# HTTP helpers +# --------------------------------------------------------------------------- + +def _post(path: str, body: dict, timeout: int = _DEFAULT_TIMEOUT) -> dict: + """POST JSON to camofox and return parsed response.""" + url = f"{get_camofox_url()}{path}" + resp = requests.post(url, json=body, timeout=timeout) + resp.raise_for_status() + return resp.json() + + +def _get(path: str, params: dict = None, timeout: int = _DEFAULT_TIMEOUT) -> dict: + """GET from camofox and return parsed response.""" + url = f"{get_camofox_url()}{path}" + resp = requests.get(url, params=params, timeout=timeout) + resp.raise_for_status() + return resp.json() + + +def _get_raw(path: str, params: dict = None, timeout: int = _DEFAULT_TIMEOUT) -> requests.Response: + """GET from camofox and return raw response (for binary data).""" + url = f"{get_camofox_url()}{path}" + resp = requests.get(url, params=params, timeout=timeout) + resp.raise_for_status() + return resp + + +def _delete(path: str, body: dict = None, timeout: int = _DEFAULT_TIMEOUT) -> dict: + """DELETE to camofox and return parsed response.""" + url = f"{get_camofox_url()}{path}" + resp = requests.delete(url, json=body, timeout=timeout) + resp.raise_for_status() + return resp.json() + + +# --------------------------------------------------------------------------- +# Tool implementations +# --------------------------------------------------------------------------- + +def camofox_navigate(url: str, task_id: Optional[str] = None) -> str: + """Navigate to a URL via Camofox.""" + try: + session = _get_session(task_id) + if not session["tab_id"]: + # Create tab with the target URL directly + session = _ensure_tab(task_id, url) + data = {"ok": True, "url": url} + else: + # Navigate existing tab + data = _post( + f"/tabs/{session['tab_id']}/navigate", + {"userId": session["user_id"], "url": url}, + timeout=60, + ) + return json.dumps({ + "success": True, + "url": data.get("url", url), + "title": data.get("title", ""), + }) + except requests.HTTPError as e: + return json.dumps({"success": False, "error": f"Navigation failed: {e}"}) + except requests.ConnectionError: + return json.dumps({ + "success": False, + "error": f"Cannot connect to Camofox at {get_camofox_url()}. " + "Is the server running? Start with: npm start (in camofox-browser dir) " + "or: docker run -p 9377:9377 jo-inc/camofox-browser", + }) + except Exception as e: + return json.dumps({"success": False, "error": str(e)}) + + +def camofox_snapshot(full: bool = False, task_id: Optional[str] = None, + user_task: Optional[str] = None) -> str: + """Get accessibility tree snapshot from Camofox.""" + try: + session = _get_session(task_id) + if not session["tab_id"]: + return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + + data = _get( + f"/tabs/{session['tab_id']}/snapshot", + params={"userId": session["user_id"]}, + ) + + snapshot = data.get("snapshot", "") + refs_count = data.get("refsCount", 0) + + # Apply same summarization logic as the main browser tool + from tools.browser_tool import ( + SNAPSHOT_SUMMARIZE_THRESHOLD, + _extract_relevant_content, + _truncate_snapshot, + ) + + if len(snapshot) > SNAPSHOT_SUMMARIZE_THRESHOLD: + if user_task: + snapshot = _extract_relevant_content(snapshot, user_task) + else: + snapshot = _truncate_snapshot(snapshot) + + return json.dumps({ + "success": True, + "snapshot": snapshot, + "element_count": refs_count, + }) + except Exception as e: + return json.dumps({"success": False, "error": str(e)}) + + +def camofox_click(ref: str, task_id: Optional[str] = None) -> str: + """Click an element by ref via Camofox.""" + try: + session = _get_session(task_id) + if not session["tab_id"]: + return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + + # Strip @ prefix if present (our tool convention) + clean_ref = ref.lstrip("@") + + data = _post( + f"/tabs/{session['tab_id']}/click", + {"userId": session["user_id"], "ref": clean_ref}, + ) + return json.dumps({ + "success": True, + "clicked": clean_ref, + "url": data.get("url", ""), + }) + except Exception as e: + return json.dumps({"success": False, "error": str(e)}) + + +def camofox_type(ref: str, text: str, task_id: Optional[str] = None) -> str: + """Type text into an element by ref via Camofox.""" + try: + session = _get_session(task_id) + if not session["tab_id"]: + return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + + clean_ref = ref.lstrip("@") + + _post( + f"/tabs/{session['tab_id']}/type", + {"userId": session["user_id"], "ref": clean_ref, "text": text}, + ) + return json.dumps({ + "success": True, + "typed": text, + "element": clean_ref, + }) + except Exception as e: + return json.dumps({"success": False, "error": str(e)}) + + +def camofox_scroll(direction: str, task_id: Optional[str] = None) -> str: + """Scroll the page via Camofox.""" + try: + session = _get_session(task_id) + if not session["tab_id"]: + return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + + _post( + f"/tabs/{session['tab_id']}/scroll", + {"userId": session["user_id"], "direction": direction}, + ) + return json.dumps({"success": True, "scrolled": direction}) + except Exception as e: + return json.dumps({"success": False, "error": str(e)}) + + +def camofox_back(task_id: Optional[str] = None) -> str: + """Navigate back via Camofox.""" + try: + session = _get_session(task_id) + if not session["tab_id"]: + return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + + data = _post( + f"/tabs/{session['tab_id']}/back", + {"userId": session["user_id"]}, + ) + return json.dumps({"success": True, "url": data.get("url", "")}) + except Exception as e: + return json.dumps({"success": False, "error": str(e)}) + + +def camofox_press(key: str, task_id: Optional[str] = None) -> str: + """Press a keyboard key via Camofox.""" + try: + session = _get_session(task_id) + if not session["tab_id"]: + return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + + _post( + f"/tabs/{session['tab_id']}/press", + {"userId": session["user_id"], "key": key}, + ) + return json.dumps({"success": True, "pressed": key}) + except Exception as e: + return json.dumps({"success": False, "error": str(e)}) + + +def camofox_close(task_id: Optional[str] = None) -> str: + """Close the browser session via Camofox.""" + try: + session = _drop_session(task_id) + if not session: + return json.dumps({"success": True, "closed": True}) + + _delete( + f"/sessions/{session['user_id']}", + ) + return json.dumps({"success": True, "closed": True}) + except Exception as e: + return json.dumps({"success": True, "closed": True, "warning": str(e)}) + + +def camofox_get_images(task_id: Optional[str] = None) -> str: + """Get images on the current page via Camofox. + + Extracts image information from the accessibility tree snapshot, + since Camofox does not expose a dedicated /images endpoint. + """ + try: + session = _get_session(task_id) + if not session["tab_id"]: + return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + + import re + + data = _get( + f"/tabs/{session['tab_id']}/snapshot", + params={"userId": session["user_id"]}, + ) + snapshot = data.get("snapshot", "") + + # Parse img elements from the accessibility tree. + # Format: img "alt text" or img "alt text" [eN] + # URLs appear on /url: lines following img entries + images = [] + lines = snapshot.split("\n") + for i, line in enumerate(lines): + stripped = line.strip() + if stripped.startswith("- img ") or stripped.startswith("img "): + alt_match = re.search(r'img\s+"([^"]*)"', stripped) + alt = alt_match.group(1) if alt_match else "" + # Look for URL on the next line + src = "" + if i + 1 < len(lines): + url_match = re.search(r'/url:\s*(\S+)', lines[i + 1].strip()) + if url_match: + src = url_match.group(1) + if alt or src: + images.append({"src": src, "alt": alt}) + + return json.dumps({ + "success": True, + "images": images, + "count": len(images), + }) + except Exception as e: + return json.dumps({"success": False, "error": str(e)}) + + +def camofox_vision(question: str, annotate: bool = False, + task_id: Optional[str] = None) -> str: + """Take a screenshot and analyze it with vision AI via Camofox.""" + try: + session = _get_session(task_id) + if not session["tab_id"]: + return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + + # Get screenshot as binary PNG + resp = _get_raw( + f"/tabs/{session['tab_id']}/screenshot", + params={"userId": session["user_id"]}, + ) + + # Save screenshot to cache + from hermes_constants import get_hermes_home + screenshots_dir = get_hermes_home() / "browser_screenshots" + screenshots_dir.mkdir(parents=True, exist_ok=True) + screenshot_path = str(screenshots_dir / f"browser_screenshot_{uuid.uuid4().hex[:8]}.png") + + with open(screenshot_path, "wb") as f: + f.write(resp.content) + + # Encode for vision LLM + img_b64 = base64.b64encode(resp.content).decode("utf-8") + + # Also get annotated snapshot if requested + annotation_context = "" + if annotate: + try: + snap_data = _get( + f"/tabs/{session['tab_id']}/snapshot", + params={"userId": session["user_id"]}, + ) + annotation_context = f"\n\nAccessibility tree (element refs for interaction):\n{snap_data.get('snapshot', '')[:3000]}" + except Exception: + pass + + # Send to vision LLM + from agent.auxiliary_client import call_llm + + vision_prompt = ( + f"Analyze this browser screenshot and answer: {question}" + f"{annotation_context}" + ) + + try: + from hermes_cli.config import load_config + _cfg = load_config() + _vision_timeout = int(_cfg.get("auxiliary", {}).get("vision", {}).get("timeout", 120)) + except Exception: + _vision_timeout = 120 + + analysis = call_llm( + messages=[{ + "role": "user", + "content": [ + {"type": "text", "text": vision_prompt}, + { + "type": "image_url", + "image_url": { + "url": f"data:image/png;base64,{img_b64}", + }, + }, + ], + }], + task="vision", + timeout=_vision_timeout, + ) + + return json.dumps({ + "success": True, + "analysis": analysis, + "screenshot_path": screenshot_path, + }) + except Exception as e: + return json.dumps({"success": False, "error": str(e)}) + + +def camofox_console(clear: bool = False, task_id: Optional[str] = None) -> str: + """Get console output — limited support in Camofox. + + Camofox does not expose browser console logs via its REST API. + Returns an empty result with a note. + """ + return json.dumps({ + "success": True, + "console_messages": [], + "js_errors": [], + "total_messages": 0, + "total_errors": 0, + "note": "Console log capture is not available with the Camofox backend. " + "Use browser_snapshot or browser_vision to inspect page state.", + }) + + +# --------------------------------------------------------------------------- +# Cleanup +# --------------------------------------------------------------------------- + +def cleanup_all_camofox_sessions() -> None: + """Close all active camofox sessions.""" + with _sessions_lock: + sessions = list(_sessions.items()) + for task_id, session in sessions: + try: + _delete(f"/sessions/{session['user_id']}") + except Exception: + pass + with _sessions_lock: + _sessions.clear() diff --git a/tools/browser_tool.py b/tools/browser_tool.py index d229419d8a..c93d7ecff4 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -80,6 +80,14 @@ from tools.browser_providers.browserbase import BrowserbaseProvider from tools.browser_providers.browser_use import BrowserUseProvider from tools.tool_backend_helpers import normalize_browser_cloud_provider +# Camofox local anti-detection browser backend (optional). +# When CAMOFOX_URL is set, all browser operations route through the +# camofox REST API instead of the agent-browser CLI. +try: + from tools.browser_camofox import is_camofox_mode as _is_camofox_mode +except ImportError: + _is_camofox_mode = lambda: False # noqa: E731 + logger = logging.getLogger(__name__) # Standard PATH entries for environments with minimal PATH (e.g. systemd services). @@ -1080,6 +1088,11 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str: "blocked_by_policy": {"host": blocked["host"], "rule": blocked["rule"], "source": blocked["source"]}, }) + # Camofox backend — delegate after safety checks pass + if _is_camofox_mode(): + from tools.browser_camofox import camofox_navigate + return camofox_navigate(url, task_id) + effective_task_id = task_id or "default" # Get session info to check if this is a new session @@ -1169,6 +1182,10 @@ def browser_snapshot( Returns: JSON string with page snapshot """ + if _is_camofox_mode(): + from tools.browser_camofox import camofox_snapshot + return camofox_snapshot(full, task_id, user_task) + effective_task_id = task_id or "default" # Build command args based on full flag @@ -1214,6 +1231,10 @@ def browser_click(ref: str, task_id: Optional[str] = None) -> str: Returns: JSON string with click result """ + if _is_camofox_mode(): + from tools.browser_camofox import camofox_click + return camofox_click(ref, task_id) + effective_task_id = task_id or "default" # Ensure ref starts with @ @@ -1246,6 +1267,10 @@ def browser_type(ref: str, text: str, task_id: Optional[str] = None) -> str: Returns: JSON string with type result """ + if _is_camofox_mode(): + from tools.browser_camofox import camofox_type + return camofox_type(ref, text, task_id) + effective_task_id = task_id or "default" # Ensure ref starts with @ @@ -1279,6 +1304,10 @@ def browser_scroll(direction: str, task_id: Optional[str] = None) -> str: Returns: JSON string with scroll result """ + if _is_camofox_mode(): + from tools.browser_camofox import camofox_scroll + return camofox_scroll(direction, task_id) + effective_task_id = task_id or "default" # Validate direction @@ -1312,6 +1341,10 @@ def browser_back(task_id: Optional[str] = None) -> str: Returns: JSON string with navigation result """ + if _is_camofox_mode(): + from tools.browser_camofox import camofox_back + return camofox_back(task_id) + effective_task_id = task_id or "default" result = _run_browser_command(effective_task_id, "back", []) @@ -1339,6 +1372,10 @@ def browser_press(key: str, task_id: Optional[str] = None) -> str: Returns: JSON string with key press result """ + if _is_camofox_mode(): + from tools.browser_camofox import camofox_press + return camofox_press(key, task_id) + effective_task_id = task_id or "default" result = _run_browser_command(effective_task_id, "press", [key]) @@ -1364,6 +1401,10 @@ def browser_close(task_id: Optional[str] = None) -> str: Returns: JSON string with close result """ + if _is_camofox_mode(): + from tools.browser_camofox import camofox_close + return camofox_close(task_id) + effective_task_id = task_id or "default" with _cleanup_lock: had_session = effective_task_id in _active_sessions @@ -1392,6 +1433,10 @@ def browser_console(clear: bool = False, task_id: Optional[str] = None) -> str: Returns: JSON string with console messages and JS errors """ + if _is_camofox_mode(): + from tools.browser_camofox import camofox_console + return camofox_console(clear, task_id) + effective_task_id = task_id or "default" console_args = ["--clear"] if clear else [] @@ -1486,6 +1531,10 @@ def browser_get_images(task_id: Optional[str] = None) -> str: Returns: JSON string with list of images (src and alt) """ + if _is_camofox_mode(): + from tools.browser_camofox import camofox_get_images + return camofox_get_images(task_id) + effective_task_id = task_id or "default" # Use eval to run JavaScript that extracts images @@ -1550,6 +1599,10 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] Returns: JSON string with vision analysis results and screenshot_path """ + if _is_camofox_mode(): + from tools.browser_camofox import camofox_vision + return camofox_vision(question, annotate, task_id) + import base64 import uuid as uuid_mod from pathlib import Path @@ -1838,6 +1891,10 @@ def check_browser_requirements() -> bool: Returns: True if all requirements are met, False otherwise """ + # Camofox backend — only needs the server URL, no agent-browser CLI + if _is_camofox_mode(): + return True + # The agent-browser CLI is always required try: _find_agent_browser() diff --git a/tools/credential_files.py b/tools/credential_files.py index 56c32d572a..53ddd79d54 100644 --- a/tools/credential_files.py +++ b/tools/credential_files.py @@ -83,7 +83,7 @@ def register_credential_files( if isinstance(entry, str): rel_path = entry.strip() elif isinstance(entry, dict): - rel_path = (entry.get("path") or "").strip() + rel_path = (entry.get("path") or entry.get("name") or "").strip() else: continue if not rel_path: @@ -152,6 +152,107 @@ def get_credential_file_mounts() -> List[Dict[str, str]]: ] +def get_skills_directory_mount( + container_base: str = "/root/.hermes", +) -> Dict[str, str] | None: + """Return mount info for a symlink-safe copy of the skills directory. + + Skills may include ``scripts/``, ``templates/``, and ``references/`` + subdirectories that the agent needs to execute inside remote sandboxes. + + **Security:** Bind mounts follow symlinks, so a malicious symlink inside + the skills tree could expose arbitrary host files to the container. When + symlinks are detected, this function creates a sanitized copy (regular + files only) in a temp directory and returns that path instead. When no + symlinks are present (the common case), the original directory is returned + directly with zero overhead. + + Returns a dict with ``host_path`` and ``container_path`` keys, or None. + """ + hermes_home = _resolve_hermes_home() + skills_dir = hermes_home / "skills" + if not skills_dir.is_dir(): + return None + + host_path = _safe_skills_path(skills_dir) + return { + "host_path": host_path, + "container_path": f"{container_base.rstrip('/')}/skills", + } + + +_safe_skills_tempdir: Path | None = None + + +def _safe_skills_path(skills_dir: Path) -> str: + """Return *skills_dir* if symlink-free, else a sanitized temp copy.""" + global _safe_skills_tempdir + + symlinks = [p for p in skills_dir.rglob("*") if p.is_symlink()] + if not symlinks: + return str(skills_dir) + + for link in symlinks: + logger.warning("credential_files: skipping symlink in skills dir: %s -> %s", + link, os.readlink(link)) + + import atexit + import shutil + import tempfile + + # Reuse the same temp dir across calls to avoid accumulation. + if _safe_skills_tempdir and _safe_skills_tempdir.is_dir(): + shutil.rmtree(_safe_skills_tempdir, ignore_errors=True) + + safe_dir = Path(tempfile.mkdtemp(prefix="hermes-skills-safe-")) + _safe_skills_tempdir = safe_dir + + for item in skills_dir.rglob("*"): + if item.is_symlink(): + continue + rel = item.relative_to(skills_dir) + target = safe_dir / rel + if item.is_dir(): + target.mkdir(parents=True, exist_ok=True) + elif item.is_file(): + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(str(item), str(target)) + + def _cleanup(): + if safe_dir.is_dir(): + shutil.rmtree(safe_dir, ignore_errors=True) + + atexit.register(_cleanup) + logger.info("credential_files: created symlink-safe skills copy at %s", safe_dir) + return str(safe_dir) + + +def iter_skills_files( + container_base: str = "/root/.hermes", +) -> List[Dict[str, str]]: + """Yield individual (host_path, container_path) entries for skills files. + + Skips symlinks entirely. Preferred for backends that upload files + individually (Daytona, Modal) rather than mounting a directory. + """ + hermes_home = _resolve_hermes_home() + skills_dir = hermes_home / "skills" + if not skills_dir.is_dir(): + return [] + + container_root = f"{container_base.rstrip('/')}/skills" + result: List[Dict[str, str]] = [] + for item in skills_dir.rglob("*"): + if item.is_symlink() or not item.is_file(): + continue + rel = item.relative_to(skills_dir) + result.append({ + "host_path": str(item), + "container_path": f"{container_root}/{rel}", + }) + return result + + def clear_credential_files() -> None: """Reset the skill-scoped registry (e.g. on session reset).""" _registered_files.clear() diff --git a/tools/environments/daytona.py b/tools/environments/daytona.py index cc046bb4d3..eb2a673110 100644 --- a/tools/environments/daytona.py +++ b/tools/environments/daytona.py @@ -113,15 +113,61 @@ class DaytonaEnvironment(BaseEnvironment): logger.info("Daytona: created sandbox %s for task %s", self._sandbox.id, task_id) - # Resolve cwd: detect actual home dir inside the sandbox - if self._requested_cwd in ("~", "/home/daytona"): - try: - home = self._sandbox.process.exec("echo $HOME").result.strip() - if home: + # Detect remote home dir first so mounts go to the right place. + self._remote_home = "/root" + try: + home = self._sandbox.process.exec("echo $HOME").result.strip() + if home: + self._remote_home = home + if self._requested_cwd in ("~", "/home/daytona"): self.cwd = home - except Exception: - pass # leave cwd as-is; sandbox will use its own default - logger.info("Daytona: resolved cwd to %s", self.cwd) + except Exception: + pass + logger.info("Daytona: resolved home to %s, cwd to %s", self._remote_home, self.cwd) + + # Track synced files to avoid redundant uploads. + # Key: remote_path, Value: (mtime, size) + self._synced_files: Dict[str, tuple] = {} + + # Upload credential files and skills directory into the sandbox. + self._sync_skills_and_credentials() + + def _upload_if_changed(self, host_path: str, remote_path: str) -> bool: + """Upload a file if its mtime/size changed since last sync.""" + hp = Path(host_path) + try: + stat = hp.stat() + file_key = (stat.st_mtime, stat.st_size) + except OSError: + return False + if self._synced_files.get(remote_path) == file_key: + return False + try: + parent = str(Path(remote_path).parent) + self._sandbox.process.exec(f"mkdir -p {parent}") + self._sandbox.fs.upload_file(host_path, remote_path) + self._synced_files[remote_path] = file_key + return True + except Exception as e: + logger.debug("Daytona: upload failed %s: %s", host_path, e) + return False + + def _sync_skills_and_credentials(self) -> None: + """Upload changed credential files and skill files into the sandbox.""" + container_base = f"{self._remote_home}/.hermes" + try: + from tools.credential_files import get_credential_file_mounts, iter_skills_files + + for mount_entry in get_credential_file_mounts(): + remote_path = mount_entry["container_path"].replace("/root/.hermes", container_base, 1) + if self._upload_if_changed(mount_entry["host_path"], remote_path): + logger.debug("Daytona: synced credential %s", remote_path) + + for entry in iter_skills_files(container_base=container_base): + if self._upload_if_changed(entry["host_path"], entry["container_path"]): + logger.debug("Daytona: synced skill %s", entry["container_path"]) + except Exception as e: + logger.debug("Daytona: could not sync skills/credentials: %s", e) def _ensure_sandbox_ready(self): """Restart sandbox if it was stopped (e.g., by a previous interrupt).""" @@ -191,6 +237,9 @@ class DaytonaEnvironment(BaseEnvironment): stdin_data: Optional[str] = None) -> dict: with self._lock: self._ensure_sandbox_ready() + # Incremental sync before each command so mid-session credential + # refreshes and skill updates are picked up. + self._sync_skills_and_credentials() if stdin_data is not None: marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}" diff --git a/tools/environments/docker.py b/tools/environments/docker.py index a24786d17e..2a7bb62551 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -315,7 +315,7 @@ class DockerEnvironment(BaseEnvironment): # Mount credential files (OAuth tokens, etc.) declared by skills. # Read-only so the container can authenticate but not modify host creds. try: - from tools.credential_files import get_credential_file_mounts + from tools.credential_files import get_credential_file_mounts, get_skills_directory_mount for mount_entry in get_credential_file_mounts(): volume_args.extend([ @@ -327,6 +327,20 @@ class DockerEnvironment(BaseEnvironment): mount_entry["host_path"], mount_entry["container_path"], ) + + # Mount the skills directory so skill scripts/templates are + # available inside the container at the same relative path. + skills_mount = get_skills_directory_mount() + if skills_mount: + volume_args.extend([ + "-v", + f"{skills_mount['host_path']}:{skills_mount['container_path']}:ro", + ]) + logger.info( + "Docker: mounting skills dir %s -> %s", + skills_mount["host_path"], + skills_mount["container_path"], + ) except Exception as e: logger.debug("Docker: could not load credential file mounts: %s", e) diff --git a/tools/environments/local.py b/tools/environments/local.py index 8cd416efaa..27282b6ef6 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -473,7 +473,12 @@ class LocalEnvironment(PersistentShellMixin, BaseEnvironment): except (ProcessLookupError, PermissionError): proc.kill() reader.join(timeout=2) - return self._timeout_result(effective_timeout) + partial = "".join(_output_chunks) + timeout_msg = f"\n[Command timed out after {effective_timeout}s]" + return { + "output": partial + timeout_msg if partial else timeout_msg.lstrip(), + "returncode": 124, + } time.sleep(0.2) reader.join(timeout=5) diff --git a/tools/environments/modal.py b/tools/environments/modal.py index 39fe37adc7..8954a6f34f 100644 --- a/tools/environments/modal.py +++ b/tools/environments/modal.py @@ -155,7 +155,7 @@ class ModalEnvironment(BaseEnvironment): self._sandbox = None self._app = None self._worker = _AsyncWorker() - self._synced_creds: Dict[str, tuple] = {} + self._synced_files: Dict[str, tuple] = {} sandbox_kwargs = dict(modal_sandbox_kwargs or {}) @@ -172,7 +172,7 @@ class ModalEnvironment(BaseEnvironment): cred_mounts = [] try: - from tools.credential_files import get_credential_file_mounts + from tools.credential_files import get_credential_file_mounts, iter_skills_files for mount_entry in get_credential_file_mounts(): cred_mounts.append( @@ -186,6 +186,18 @@ class ModalEnvironment(BaseEnvironment): mount_entry["host_path"], mount_entry["container_path"], ) + + # Mount individual skill files (symlinks filtered out). + skills_files = iter_skills_files() + for entry in skills_files: + cred_mounts.append( + _modal.Mount.from_local_file( + entry["host_path"], + remote_path=entry["container_path"], + ) + ) + if skills_files: + logger.info("Modal: mounting %d skill files", len(skills_files)) except Exception as e: logger.debug("Modal: could not load credential file mounts: %s", e) @@ -212,8 +224,9 @@ class ModalEnvironment(BaseEnvironment): target_image_spec = restored_snapshot_id or image try: # _resolve_modal_image keeps the Modal bootstrap fix together: - # it applies ensurepip via setup_dockerfile_commands before - # Modal builds or restores the image. + # it applies setup_dockerfile_commands with ensurepip before + # Modal builds registry images, while snapshot ids restore via + # modal.Image.from_id() without rebuilding. effective_image = _resolve_modal_image(target_image_spec) self._app, self._sandbox = self._worker.run_coroutine( _create_sandbox(effective_image), @@ -247,55 +260,57 @@ class ModalEnvironment(BaseEnvironment): logger.info("Modal: sandbox created (task=%s)", self._task_id) - def _sync_credential_files(self) -> None: - """Push credential files into the running sandbox.""" + def _push_file_to_sandbox(self, host_path: str, container_path: str) -> bool: + """Push a single file into the sandbox if changed. Returns True if synced.""" + hp = Path(host_path) try: - from tools.credential_files import get_credential_file_mounts + stat = hp.stat() + file_key = (stat.st_mtime, stat.st_size) + except OSError: + return False - mounts = get_credential_file_mounts() - if not mounts: - return + if self._synced_files.get(container_path) == file_key: + return False - for entry in mounts: - host_path = entry["host_path"] - container_path = entry["container_path"] - hp = Path(host_path) - try: - stat = hp.stat() - file_key = (stat.st_mtime, stat.st_size) - except OSError: - continue + try: + content = hp.read_bytes() + except Exception: + return False - if self._synced_creds.get(container_path) == file_key: - continue + import base64 + b64 = base64.b64encode(content).decode("ascii") + container_dir = str(Path(container_path).parent) + cmd = ( + f"mkdir -p {shlex.quote(container_dir)} && " + f"echo {shlex.quote(b64)} | base64 -d > {shlex.quote(container_path)}" + ) - try: - content = hp.read_text(encoding="utf-8") - except Exception: - continue + async def _write(): + proc = await self._sandbox.exec.aio("bash", "-c", cmd) + await proc.wait.aio() - import base64 + self._worker.run_coroutine(_write(), timeout=15) + self._synced_files[container_path] = file_key + return True - b64 = base64.b64encode(content.encode("utf-8")).decode("ascii") - container_dir = str(Path(container_path).parent) - cmd = ( - f"mkdir -p {shlex.quote(container_dir)} && " - f"echo {shlex.quote(b64)} | base64 -d > {shlex.quote(container_path)}" - ) + def _sync_files(self) -> None: + """Push credential files and skill files into the running sandbox. - async def _write(): - proc = await self._sandbox.exec.aio("bash", "-c", cmd) - await proc.wait.aio() + Runs before each command. Uses mtime+size caching so only changed + files are pushed (~13μs overhead in the no-op case). + """ + try: + from tools.credential_files import get_credential_file_mounts, iter_skills_files - self._worker.run_coroutine(_write(), timeout=15) - self._synced_creds[container_path] = file_key - logger.debug( - "Modal: synced credential %s -> %s", - host_path, - container_path, - ) + for entry in get_credential_file_mounts(): + if self._push_file_to_sandbox(entry["host_path"], entry["container_path"]): + logger.debug("Modal: synced credential %s", entry["container_path"]) + + for entry in iter_skills_files(): + if self._push_file_to_sandbox(entry["host_path"], entry["container_path"]): + logger.debug("Modal: synced skill file %s", entry["container_path"]) except Exception as e: - logger.debug("Modal: credential file sync failed: %s", e) + logger.debug("Modal: file sync failed: %s", e) def execute( self, @@ -305,7 +320,7 @@ class ModalEnvironment(BaseEnvironment): timeout: int | None = None, stdin_data: str | None = None, ) -> dict: - self._sync_credential_files() + self._sync_files() if stdin_data is not None: marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}" diff --git a/tools/environments/singularity.py b/tools/environments/singularity.py index aa9c7b009e..2ee525a36b 100644 --- a/tools/environments/singularity.py +++ b/tools/environments/singularity.py @@ -254,6 +254,28 @@ class SingularityEnvironment(BaseEnvironment): else: cmd.append("--writable-tmpfs") + # Mount credential files and skills directory (read-only). + try: + from tools.credential_files import get_credential_file_mounts, get_skills_directory_mount + + for mount_entry in get_credential_file_mounts(): + cmd.extend(["--bind", f"{mount_entry['host_path']}:{mount_entry['container_path']}:ro"]) + logger.info( + "Singularity: binding credential %s -> %s", + mount_entry["host_path"], + mount_entry["container_path"], + ) + skills_mount = get_skills_directory_mount() + if skills_mount: + cmd.extend(["--bind", f"{skills_mount['host_path']}:{skills_mount['container_path']}:ro"]) + logger.info( + "Singularity: binding skills dir %s -> %s", + skills_mount["host_path"], + skills_mount["container_path"], + ) + except Exception as e: + logger.debug("Singularity: could not load credential/skills mounts: %s", e) + # Resource limits (cgroup-based, may require root or appropriate config) if self._memory > 0: cmd.extend(["--memory", f"{self._memory}M"]) diff --git a/tools/environments/ssh.py b/tools/environments/ssh.py index fa3781a990..94b0a6b3f0 100644 --- a/tools/environments/ssh.py +++ b/tools/environments/ssh.py @@ -55,6 +55,8 @@ class SSHEnvironment(PersistentShellMixin, BaseEnvironment): self.control_socket = self.control_dir / f"{user}@{host}:{port}.sock" _ensure_ssh_available() self._establish_connection() + self._remote_home = self._detect_remote_home() + self._sync_skills_and_credentials() if self.persistent: self._init_persistent_shell() @@ -87,6 +89,79 @@ class SSHEnvironment(PersistentShellMixin, BaseEnvironment): except subprocess.TimeoutExpired: raise RuntimeError(f"SSH connection to {self.user}@{self.host} timed out") + def _detect_remote_home(self) -> str: + """Detect the remote user's home directory.""" + try: + cmd = self._build_ssh_command() + cmd.append("echo $HOME") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + home = result.stdout.strip() + if home and result.returncode == 0: + logger.debug("SSH: remote home = %s", home) + return home + except Exception: + pass + # Fallback: guess from username + if self.user == "root": + return "/root" + return f"/home/{self.user}" + + def _sync_skills_and_credentials(self) -> None: + """Rsync skills directory and credential files to the remote host.""" + try: + container_base = f"{self._remote_home}/.hermes" + from tools.credential_files import get_credential_file_mounts, get_skills_directory_mount + + rsync_base = ["rsync", "-az", "--timeout=30", "--safe-links"] + ssh_opts = f"ssh -o ControlPath={self.control_socket} -o ControlMaster=auto" + if self.port != 22: + ssh_opts += f" -p {self.port}" + if self.key_path: + ssh_opts += f" -i {self.key_path}" + rsync_base.extend(["-e", ssh_opts]) + dest_prefix = f"{self.user}@{self.host}" + + # Sync individual credential files (remap /root/.hermes to detected home) + for mount_entry in get_credential_file_mounts(): + remote_path = mount_entry["container_path"].replace("/root/.hermes", container_base, 1) + parent_dir = str(Path(remote_path).parent) + mkdir_cmd = self._build_ssh_command() + mkdir_cmd.append(f"mkdir -p {parent_dir}") + subprocess.run(mkdir_cmd, capture_output=True, text=True, timeout=10) + cmd = rsync_base + [mount_entry["host_path"], f"{dest_prefix}:{remote_path}"] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.returncode == 0: + logger.info("SSH: synced credential %s -> %s", mount_entry["host_path"], remote_path) + else: + logger.debug("SSH: rsync credential failed: %s", result.stderr.strip()) + + # Sync skills directory (remap to detected home) + skills_mount = get_skills_directory_mount(container_base=container_base) + if skills_mount: + remote_path = skills_mount["container_path"] + mkdir_cmd = self._build_ssh_command() + mkdir_cmd.append(f"mkdir -p {remote_path}") + subprocess.run(mkdir_cmd, capture_output=True, text=True, timeout=10) + cmd = rsync_base + [ + skills_mount["host_path"].rstrip("/") + "/", + f"{dest_prefix}:{remote_path}/", + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + if result.returncode == 0: + logger.info("SSH: synced skills dir %s -> %s", skills_mount["host_path"], remote_path) + else: + logger.debug("SSH: rsync skills dir failed: %s", result.stderr.strip()) + except Exception as e: + logger.debug("SSH: could not sync skills/credentials: %s", e) + + def execute(self, command: str, cwd: str = "", *, + timeout: int | None = None, + stdin_data: str | None = None) -> dict: + # Incremental sync before each command so mid-session credential + # refreshes and skill updates are picked up. + self._sync_skills_and_credentials() + return super().execute(command, cwd, timeout=timeout, stdin_data=stdin_data) + _poll_interval_start: float = 0.15 # SSH: higher initial interval (150ms) for network latency @property diff --git a/tools/file_tools.py b/tools/file_tools.py index 7387c4dcbc..6226e76574 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -4,7 +4,9 @@ import errno import json import logging +import os import threading +from pathlib import Path from tools.file_operations import ShellFileOperations from agent.redact import redact_sensitive_text @@ -13,6 +15,31 @@ logger = logging.getLogger(__name__) _EXPECTED_WRITE_ERRNOS = {errno.EACCES, errno.EPERM, errno.EROFS} +# Paths that file tools should refuse to write to without going through the +# terminal tool's approval system. These match prefixes after os.path.realpath. +_SENSITIVE_PATH_PREFIXES = ("/etc/", "/boot/", "/usr/lib/systemd/") +_SENSITIVE_EXACT_PATHS = {"/var/run/docker.sock", "/run/docker.sock"} + + +def _check_sensitive_path(filepath: str) -> str | None: + """Return an error message if the path targets a sensitive system location.""" + try: + resolved = os.path.realpath(os.path.expanduser(filepath)) + except (OSError, ValueError): + resolved = filepath + for prefix in _SENSITIVE_PATH_PREFIXES: + if resolved.startswith(prefix): + return ( + f"Refusing to write to sensitive system path: {filepath}\n" + "Use the terminal tool with sudo if you need to modify system files." + ) + if resolved in _SENSITIVE_EXACT_PATHS: + return ( + f"Refusing to write to sensitive system path: {filepath}\n" + "Use the terminal tool with sudo if you need to modify system files." + ) + return None + def _is_expected_write_exception(exc: Exception) -> bool: """Return True for expected write denials that should not hit error logs.""" @@ -287,6 +314,9 @@ def notify_other_tool_call(task_id: str = "default"): def write_file_tool(path: str, content: str, task_id: str = "default") -> str: """Write content to a file.""" + sensitive_err = _check_sensitive_path(path) + if sensitive_err: + return json.dumps({"error": sensitive_err}, ensure_ascii=False) try: file_ops = _get_file_ops(task_id) result = file_ops.write_file(path, content) @@ -303,6 +333,18 @@ def patch_tool(mode: str = "replace", path: str = None, old_string: str = None, new_string: str = None, replace_all: bool = False, patch: str = None, task_id: str = "default") -> str: """Patch a file using replace mode or V4A patch format.""" + # Check sensitive paths for both replace (explicit path) and V4A patch (extract paths) + _paths_to_check = [] + if path: + _paths_to_check.append(path) + if mode == "patch" and patch: + import re as _re + for _m in _re.finditer(r'^\*\*\*\s+(?:Update|Add|Delete)\s+File:\s*(.+)$', patch, _re.MULTILINE): + _paths_to_check.append(_m.group(1).strip()) + for _p in _paths_to_check: + sensitive_err = _check_sensitive_path(_p) + if sensitive_err: + return json.dumps({"error": sensitive_err}, ensure_ascii=False) try: file_ops = _get_file_ops(task_id) diff --git a/tools/skills_hub.py b/tools/skills_hub.py index 86f8e47d19..a824c3e3bd 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -24,7 +24,7 @@ import time from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import datetime, timezone -from pathlib import Path +from pathlib import Path, PurePosixPath from hermes_constants import get_hermes_home from typing import Any, Dict, List, Optional, Tuple, Union from urllib.parse import urlparse, urlunparse @@ -85,6 +85,43 @@ class SkillBundle: metadata: Dict[str, Any] = field(default_factory=dict) +def _normalize_bundle_path(path_value: str, *, field_name: str, allow_nested: bool) -> str: + """Normalize and validate bundle-controlled paths before touching disk.""" + if not isinstance(path_value, str): + raise ValueError(f"Unsafe {field_name}: expected a string") + + raw = path_value.strip() + if not raw: + raise ValueError(f"Unsafe {field_name}: empty path") + + normalized = raw.replace("\\", "/") + path = PurePosixPath(normalized) + parts = [part for part in path.parts if part not in ("", ".")] + + if normalized.startswith("/") or path.is_absolute(): + raise ValueError(f"Unsafe {field_name}: {path_value}") + if not parts or any(part == ".." for part in parts): + raise ValueError(f"Unsafe {field_name}: {path_value}") + if re.fullmatch(r"[A-Za-z]:", parts[0]): + raise ValueError(f"Unsafe {field_name}: {path_value}") + if not allow_nested and len(parts) != 1: + raise ValueError(f"Unsafe {field_name}: {path_value}") + + return "/".join(parts) + + +def _validate_skill_name(name: str) -> str: + return _normalize_bundle_path(name, field_name="skill name", allow_nested=False) + + +def _validate_category_name(category: str) -> str: + return _normalize_bundle_path(category, field_name="category", allow_nested=False) + + +def _validate_bundle_rel_path(rel_path: str) -> str: + return _normalize_bundle_path(rel_path, field_name="bundle file path", allow_nested=True) + + # --------------------------------------------------------------------------- # GitHub Authentication # --------------------------------------------------------------------------- @@ -701,6 +738,12 @@ class WellKnownSkillSource(SkillSource): if not parsed: return None + try: + skill_name = _validate_skill_name(parsed["skill_name"]) + except ValueError: + logger.warning("Well-known skill identifier contained unsafe skill name: %s", identifier) + return None + entry = self._index_entry(parsed["index_url"], parsed["skill_name"]) if not entry: return None @@ -713,19 +756,28 @@ class WellKnownSkillSource(SkillSource): for rel_path in files: if not isinstance(rel_path, str) or not rel_path: continue - text = self._fetch_text(f"{parsed['skill_url']}/{rel_path}") + try: + safe_rel_path = _validate_bundle_rel_path(rel_path) + except ValueError: + logger.warning( + "Well-known skill %s advertised unsafe file path: %r", + identifier, + rel_path, + ) + return None + text = self._fetch_text(f"{parsed['skill_url']}/{safe_rel_path}") if text is None: return None - downloaded[rel_path] = text + downloaded[safe_rel_path] = text if "SKILL.md" not in downloaded: return None return SkillBundle( - name=parsed["skill_name"], + name=skill_name, files=downloaded, source="well-known", - identifier=self._wrap_identifier(parsed["base_url"], parsed["skill_name"]), + identifier=self._wrap_identifier(parsed["base_url"], skill_name), trust_level="community", metadata={ "index_url": parsed["index_url"], @@ -1752,9 +1804,10 @@ class ClawHubSource(SkillSource): for info in zf.infolist(): if info.is_dir(): continue - # Sanitize path — strip leading slashes and .. - name = info.filename.lstrip("/") - if ".." in name or name.startswith("/"): + try: + name = _validate_bundle_rel_path(info.filename) + except ValueError: + logger.debug("Skipping unsafe ZIP member path: %s", info.filename) continue # Only extract text-sized files (skip large binaries) if info.file_size > 500_000: @@ -2423,13 +2476,19 @@ def ensure_hub_dirs() -> None: def quarantine_bundle(bundle: SkillBundle) -> Path: """Write a skill bundle to the quarantine directory for scanning.""" ensure_hub_dirs() - dest = QUARANTINE_DIR / bundle.name + skill_name = _validate_skill_name(bundle.name) + validated_files: List[Tuple[str, Union[str, bytes]]] = [] + for rel_path, file_content in bundle.files.items(): + safe_rel_path = _validate_bundle_rel_path(rel_path) + validated_files.append((safe_rel_path, file_content)) + + dest = QUARANTINE_DIR / skill_name if dest.exists(): shutil.rmtree(dest) dest.mkdir(parents=True) - for rel_path, file_content in bundle.files.items(): - file_dest = dest / rel_path + for rel_path, file_content in validated_files: + file_dest = dest.joinpath(*rel_path.split("/")) file_dest.parent.mkdir(parents=True, exist_ok=True) if isinstance(file_content, bytes): file_dest.write_bytes(file_content) @@ -2447,10 +2506,17 @@ def install_from_quarantine( scan_result: ScanResult, ) -> Path: """Move a scanned skill from quarantine into the skills directory.""" - if category: - install_dir = SKILLS_DIR / category / skill_name + safe_skill_name = _validate_skill_name(skill_name) + safe_category = _validate_category_name(category) if category else "" + quarantine_resolved = quarantine_path.resolve() + quarantine_root = QUARANTINE_DIR.resolve() + if not quarantine_resolved.is_relative_to(quarantine_root): + raise ValueError(f"Unsafe quarantine path: {quarantine_path}") + + if safe_category: + install_dir = SKILLS_DIR / safe_category / safe_skill_name else: - install_dir = SKILLS_DIR / skill_name + install_dir = SKILLS_DIR / safe_skill_name if install_dir.exists(): shutil.rmtree(install_dir) @@ -2461,7 +2527,7 @@ def install_from_quarantine( # Record in lock file lock = HubLockFile() lock.record_install( - name=skill_name, + name=safe_skill_name, source=bundle.source, identifier=bundle.identifier, trust_level=bundle.trust_level, @@ -2473,7 +2539,7 @@ def install_from_quarantine( ) append_audit_log( - "INSTALL", skill_name, bundle.source, + "INSTALL", safe_skill_name, bundle.source, bundle.trust_level, scan_result.verdict, content_hash(install_dir), ) diff --git a/tools/vision_tools.py b/tools/vision_tools.py index 47b406846e..404d06a500 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -45,6 +45,28 @@ logger = logging.getLogger(__name__) _debug = DebugSession("vision_tools", env_var="VISION_TOOLS_DEBUG") +# Configurable HTTP download timeout for _download_image(). +# Separate from auxiliary.vision.timeout which governs the LLM API call. +# Resolution: config.yaml auxiliary.vision.download_timeout → env var → 30s default. +def _resolve_download_timeout() -> float: + env_val = os.getenv("HERMES_VISION_DOWNLOAD_TIMEOUT", "").strip() + if env_val: + try: + return float(env_val) + except ValueError: + pass + try: + from hermes_cli.config import load_config + cfg = load_config() + val = cfg.get("auxiliary", {}).get("vision", {}).get("download_timeout") + if val is not None: + return float(val) + except Exception: + pass + return 30.0 + +_VISION_DOWNLOAD_TIMEOUT = _resolve_download_timeout() + def _validate_image_url(url: str) -> bool: """ @@ -146,7 +168,7 @@ async def _download_image(image_url: str, destination: Path, max_retries: int = # Enable follow_redirects to handle image CDNs that redirect (e.g., Imgur, Picsum) # SSRF: event_hooks validates each redirect target against private IP ranges async with httpx.AsyncClient( - timeout=30.0, + timeout=_VISION_DOWNLOAD_TIMEOUT, follow_redirects=True, event_hooks={"response": [_ssrf_redirect_guard]}, ) as client: @@ -183,6 +205,10 @@ async def _download_image(image_url: str, destination: Path, max_retries: int = exc_info=True, ) + if last_error is None: + raise RuntimeError( + f"_download_image exited retry loop without attempting (max_retries={max_retries})" + ) raise last_error diff --git a/tools/web_tools.py b/tools/web_tools.py index f9b8dfea93..a6e0640800 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -91,24 +91,20 @@ def _get_backend() -> str: if configured in ("parallel", "firecrawl", "tavily", "exa"): return configured - # Fallback for manual / legacy config — use whichever key is present. - has_firecrawl = ( - _has_env("FIRECRAWL_API_KEY") - or _has_env("FIRECRAWL_API_URL") - or _is_tool_gateway_ready() + # Fallback for manual / legacy config — pick the highest-priority + # available backend. Firecrawl also counts as available when the managed + # tool gateway is configured for Nous subscribers. + backend_candidates = ( + ("firecrawl", _has_env("FIRECRAWL_API_KEY") or _has_env("FIRECRAWL_API_URL") or _is_tool_gateway_ready()), + ("parallel", _has_env("PARALLEL_API_KEY")), + ("tavily", _has_env("TAVILY_API_KEY")), + ("exa", _has_env("EXA_API_KEY")), ) - has_parallel = _has_env("PARALLEL_API_KEY") - has_tavily = _has_env("TAVILY_API_KEY") - has_exa = _has_env("EXA_API_KEY") - if has_exa and not has_firecrawl and not has_parallel and not has_tavily: - return "exa" - if has_tavily and not has_firecrawl and not has_parallel: - return "tavily" - if has_parallel and not has_firecrawl: - return "parallel" + for backend, available in backend_candidates: + if available: + return backend - # Default to firecrawl (backward compat, or when both are set) - return "firecrawl" + return "firecrawl" # default (backward compat) def _is_backend_available(backend: str) -> bool: diff --git a/trajectory_compressor.py b/trajectory_compressor.py index fd69cd18a6..2dfdda7af3 100644 --- a/trajectory_compressor.py +++ b/trajectory_compressor.py @@ -375,15 +375,34 @@ class TrajectoryCompressor: raise RuntimeError( f"Missing API key. Set {self.config.api_key_env} " f"environment variable.") - from openai import OpenAI, AsyncOpenAI + from openai import OpenAI self.client = OpenAI( api_key=api_key, base_url=self.config.base_url) - self.async_client = AsyncOpenAI( - api_key=api_key, base_url=self.config.base_url) + # AsyncOpenAI is created lazily in _get_async_client() so it + # binds to the current event loop — avoids "Event loop is closed" + # when process_directory() is called multiple times (each call + # creates a new loop via asyncio.run()). + self.async_client = None + self._async_client_api_key = api_key print(f"✅ Initialized summarizer client: {self.config.summarization_model}") print(f" Max concurrent requests: {self.config.max_concurrent_requests}") + def _get_async_client(self): + """Return an AsyncOpenAI client bound to the current event loop. + + Created lazily so that each ``asyncio.run()`` call in + ``process_directory()`` gets a client tied to its own loop, + avoiding "Event loop is closed" errors on repeated calls. + """ + from openai import AsyncOpenAI + # Always create a fresh client so it binds to the running loop. + self.async_client = AsyncOpenAI( + api_key=self._async_client_api_key, + base_url=self.config.base_url, + ) + return self.async_client + def _detect_provider(self) -> str: """Detect the provider name from the configured base_url.""" url = (self.config.base_url or "").lower() @@ -615,7 +634,7 @@ Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix.""" max_tokens=self.config.summary_target_tokens * 2, ) else: - response = await self.async_client.chat.completions.create( + response = await self._get_async_client().chat.completions.create( model=self.config.summarization_model, messages=[{"role": "user", "content": prompt}], temperature=self.config.temperature, diff --git a/website/docs/guides/migrate-from-openclaw.md b/website/docs/guides/migrate-from-openclaw.md new file mode 100644 index 0000000000..6c8304a6e4 --- /dev/null +++ b/website/docs/guides/migrate-from-openclaw.md @@ -0,0 +1,242 @@ +--- +sidebar_position: 7 +title: "Migrate from OpenClaw" +description: "Complete guide to migrating your OpenClaw / Clawdbot setup to Hermes Agent — what gets migrated, how config maps, and what to check after." +--- + +# Migrate from OpenClaw + +`hermes claw migrate` imports your OpenClaw (or legacy Clawdbot/Moldbot) setup into Hermes. This guide covers exactly what gets migrated, the config key mappings, and what to verify after migration. + +## Quick start + +```bash +# Preview what would happen (no files changed) +hermes claw migrate --dry-run + +# Run the migration (secrets excluded by default) +hermes claw migrate + +# Full migration including API keys +hermes claw migrate --preset full +``` + +The migration reads from `~/.openclaw/` by default. If you still have a legacy `~/.clawdbot/` or `~/.moldbot/` directory, it's detected automatically. Same for legacy config filenames (`clawdbot.json`, `moldbot.json`). + +## Options + +| Option | Description | +|--------|-------------| +| `--dry-run` | Preview what would be migrated without writing anything. | +| `--preset ` | `full` (default, includes secrets) or `user-data` (excludes API keys). | +| `--overwrite` | Overwrite existing Hermes files on conflicts (default: skip). | +| `--migrate-secrets` | Include API keys (on by default with `--preset full`). | +| `--source ` | Custom OpenClaw directory. | +| `--workspace-target ` | Where to place `AGENTS.md`. | +| `--skill-conflict ` | `skip` (default), `overwrite`, or `rename`. | +| `--yes` | Skip confirmation prompt. | + +## What gets migrated + +### Persona, memory, and instructions + +| What | OpenClaw source | Hermes destination | Notes | +|------|----------------|-------------------|-------| +| Persona | `workspace/SOUL.md` | `~/.hermes/SOUL.md` | Direct copy | +| Workspace instructions | `workspace/AGENTS.md` | `AGENTS.md` in `--workspace-target` | Requires `--workspace-target` flag | +| Long-term memory | `workspace/MEMORY.md` | `~/.hermes/memories/MEMORY.md` | Parsed into entries, merged with existing, deduped. Uses `§` delimiter. | +| User profile | `workspace/USER.md` | `~/.hermes/memories/USER.md` | Same entry-merge logic as memory. | +| Daily memory files | `workspace/memory/*.md` | `~/.hermes/memories/MEMORY.md` | All daily files merged into main memory. | + +All workspace files also check `workspace.default/` as a fallback path. + +### Skills (4 sources) + +| Source | OpenClaw location | Hermes destination | +|--------|------------------|-------------------| +| Workspace skills | `workspace/skills/` | `~/.hermes/skills/openclaw-imports/` | +| Managed/shared skills | `~/.openclaw/skills/` | `~/.hermes/skills/openclaw-imports/` | +| Personal cross-project | `~/.agents/skills/` | `~/.hermes/skills/openclaw-imports/` | +| Project-level shared | `workspace/.agents/skills/` | `~/.hermes/skills/openclaw-imports/` | + +Skill conflicts are handled by `--skill-conflict`: `skip` leaves the existing Hermes skill, `overwrite` replaces it, `rename` creates a `-imported` copy. + +### Model and provider configuration + +| What | OpenClaw config path | Hermes destination | Notes | +|------|---------------------|-------------------|-------| +| Default model | `agents.defaults.model` | `config.yaml` → `model` | Can be a string or `{primary, fallbacks}` object | +| Custom providers | `models.providers.*` | `config.yaml` → `custom_providers` | Maps `baseUrl`, `apiType` ("openai"→"chat_completions", "anthropic"→"anthropic_messages") | +| Provider API keys | `models.providers.*.apiKey` | `~/.hermes/.env` | Requires `--migrate-secrets`. See [API key resolution](#api-key-resolution) below. | + +### Agent behavior + +| What | OpenClaw config path | Hermes config path | Mapping | +|------|---------------------|-------------------|---------| +| Max turns | `agents.defaults.timeoutSeconds` | `agent.max_turns` | `timeoutSeconds / 10`, capped at 200 | +| Verbose mode | `agents.defaults.verboseDefault` | `agent.verbose` | "off" / "on" / "full" | +| Reasoning effort | `agents.defaults.thinkingDefault` | `agent.reasoning_effort` | "always"/"high" → "high", "auto"/"medium" → "medium", "off"/"low"/"none"/"minimal" → "low" | +| Compression | `agents.defaults.compaction.mode` | `compression.enabled` | "off" → false, anything else → true | +| Compression model | `agents.defaults.compaction.model` | `compression.summary_model` | Direct string copy | +| Human delay | `agents.defaults.humanDelay.mode` | `human_delay.mode` | "natural" / "custom" / "off" | +| Human delay timing | `agents.defaults.humanDelay.minMs` / `.maxMs` | `human_delay.min_ms` / `.max_ms` | Direct copy | +| Timezone | `agents.defaults.userTimezone` | `timezone` | Direct string copy | +| Exec timeout | `tools.exec.timeoutSec` | `terminal.timeout` | Direct copy (field is `timeoutSec`, not `timeout`) | +| Docker sandbox | `agents.defaults.sandbox.backend` | `terminal.backend` | "docker" → "docker" | +| Docker image | `agents.defaults.sandbox.docker.image` | `terminal.docker_image` | Direct copy | + +### Session reset policies + +| OpenClaw config path | Hermes config path | Notes | +|---------------------|-------------------|-------| +| `session.reset.mode` | `session_reset.mode` | "daily", "idle", or both | +| `session.reset.atHour` | `session_reset.at_hour` | Hour (0–23) for daily reset | +| `session.reset.idleMinutes` | `session_reset.idle_minutes` | Minutes of inactivity | + +Note: OpenClaw also has `session.resetTriggers` (a simple string array like `["daily", "idle"]`). If the structured `session.reset` isn't present, the migration falls back to inferring from `resetTriggers`. + +### MCP servers + +| OpenClaw field | Hermes field | Notes | +|----------------|-------------|-------| +| `mcp.servers.*.command` | `mcp_servers.*.command` | Stdio transport | +| `mcp.servers.*.args` | `mcp_servers.*.args` | | +| `mcp.servers.*.env` | `mcp_servers.*.env` | | +| `mcp.servers.*.cwd` | `mcp_servers.*.cwd` | | +| `mcp.servers.*.url` | `mcp_servers.*.url` | HTTP/SSE transport | +| `mcp.servers.*.tools.include` | `mcp_servers.*.tools.include` | Tool filtering | +| `mcp.servers.*.tools.exclude` | `mcp_servers.*.tools.exclude` | | + +### TTS (text-to-speech) + +TTS settings are read from **two** OpenClaw config locations with this priority: + +1. `messages.tts.providers.{provider}.*` (canonical location) +2. Top-level `talk.providers.{provider}.*` (fallback) +3. Legacy flat keys `messages.tts.{provider}.*` (oldest format) + +| What | Hermes destination | +|------|-------------------| +| Provider name | `config.yaml` → `tts.provider` | +| ElevenLabs voice ID | `config.yaml` → `tts.elevenlabs.voice_id` | +| ElevenLabs model ID | `config.yaml` → `tts.elevenlabs.model_id` | +| OpenAI model | `config.yaml` → `tts.openai.model` | +| OpenAI voice | `config.yaml` → `tts.openai.voice` | +| Edge TTS voice | `config.yaml` → `tts.edge.voice` | +| TTS assets | `~/.hermes/tts/` (file copy) | + +### Messaging platforms + +| Platform | OpenClaw config path | Hermes `.env` variable | Notes | +|----------|---------------------|----------------------|-------| +| Telegram | `channels.telegram.botToken` | `TELEGRAM_BOT_TOKEN` | Token can be string or [SecretRef](#secretref-handling) | +| Telegram | `credentials/telegram-default-allowFrom.json` | `TELEGRAM_ALLOWED_USERS` | Comma-joined from `allowFrom[]` array | +| Discord | `channels.discord.token` | `DISCORD_BOT_TOKEN` | | +| Discord | `channels.discord.allowFrom` | `DISCORD_ALLOWED_USERS` | | +| Slack | `channels.slack.botToken` | `SLACK_BOT_TOKEN` | | +| Slack | `channels.slack.appToken` | `SLACK_APP_TOKEN` | | +| Slack | `channels.slack.allowFrom` | `SLACK_ALLOWED_USERS` | | +| WhatsApp | `channels.whatsapp.allowFrom` | `WHATSAPP_ALLOWED_USERS` | Auth via Baileys QR pairing (not a token) | +| Signal | `channels.signal.account` | `SIGNAL_ACCOUNT` | | +| Signal | `channels.signal.httpUrl` | `SIGNAL_HTTP_URL` | | +| Signal | `channels.signal.allowFrom` | `SIGNAL_ALLOWED_USERS` | | +| Matrix | `channels.matrix.botToken` | `MATRIX_ACCESS_TOKEN` | Via deep-channels migration | +| Mattermost | `channels.mattermost.botToken` | `MATTERMOST_BOT_TOKEN` | Via deep-channels migration | + +### Other config + +| What | OpenClaw path | Hermes path | Notes | +|------|-------------|-------------|-------| +| Approval mode | `approvals.exec.mode` | `config.yaml` → `approvals.mode` | "auto"→"off", "always"→"manual", "smart"→"smart" | +| Command allowlist | `exec-approvals.json` | `config.yaml` → `command_allowlist` | Patterns merged and deduped | +| Browser CDP URL | `browser.cdpUrl` | `config.yaml` → `browser.cdp_url` | | +| Browser headless | `browser.headless` | `config.yaml` → `browser.headless` | | +| Brave search key | `tools.web.search.brave.apiKey` | `.env` → `BRAVE_API_KEY` | Requires `--migrate-secrets` | +| Gateway auth token | `gateway.auth.token` | `.env` → `HERMES_GATEWAY_TOKEN` | Requires `--migrate-secrets` | +| Working directory | `agents.defaults.workspace` | `.env` → `MESSAGING_CWD` | | + +### Archived (no direct Hermes equivalent) + +These are saved to `~/.hermes/migration/openclaw//archive/` for manual review: + +| What | Archive file | How to recreate in Hermes | +|------|-------------|--------------------------| +| `IDENTITY.md` | `archive/workspace/IDENTITY.md` | Merge into `SOUL.md` | +| `TOOLS.md` | `archive/workspace/TOOLS.md` | Hermes has built-in tool instructions | +| `HEARTBEAT.md` | `archive/workspace/HEARTBEAT.md` | Use cron jobs for periodic tasks | +| `BOOTSTRAP.md` | `archive/workspace/BOOTSTRAP.md` | Use context files or skills | +| Cron jobs | `archive/cron-config.json` | Recreate with `hermes cron create` | +| Plugins | `archive/plugins-config.json` | See [plugins guide](../user-guide/features/hooks.md) | +| Hooks/webhooks | `archive/hooks-config.json` | Use `hermes webhook` or gateway hooks | +| Memory backend | `archive/memory-backend-config.json` | Configure via `hermes honcho` | +| Skills registry | `archive/skills-registry-config.json` | Use `hermes skills config` | +| UI/identity | `archive/ui-identity-config.json` | Use `/skin` command | +| Logging | `archive/logging-diagnostics-config.json` | Set in `config.yaml` logging section | +| Multi-agent list | `archive/agents-list.json` | Use Hermes profiles | +| Channel bindings | `archive/bindings.json` | Manual setup per platform | +| Complex channels | `archive/channels-deep-config.json` | Manual platform config | + +## API key resolution + +When `--migrate-secrets` is enabled, API keys are collected from **three sources** in priority order: + +1. **Config values** — `models.providers.*.apiKey` and TTS provider keys in `openclaw.json` +2. **Environment file** — `~/.openclaw/.env` (keys like `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, etc.) +3. **Auth profiles** — `~/.openclaw/agents/main/agent/auth-profiles.json` (per-agent credentials) + +Config values take priority. The `.env` fills any gaps. Auth profiles fill whatever remains. + +### Supported key targets + +`OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `DEEPSEEK_API_KEY`, `GEMINI_API_KEY`, `ZAI_API_KEY`, `MINIMAX_API_KEY`, `ELEVENLABS_API_KEY`, `TELEGRAM_BOT_TOKEN`, `VOICE_TOOLS_OPENAI_KEY` + +Keys not in this allowlist are never copied. + +## SecretRef handling + +OpenClaw config values for tokens and API keys can be in three formats: + +```json +// Plain string +"channels": { "telegram": { "botToken": "123456:ABC-DEF..." } } + +// Environment template +"channels": { "telegram": { "botToken": "${TELEGRAM_BOT_TOKEN}" } } + +// SecretRef object +"channels": { "telegram": { "botToken": { "source": "env", "id": "TELEGRAM_BOT_TOKEN" } } } +``` + +The migration resolves all three formats. For env templates and SecretRef objects with `source: "env"`, it looks up the value in `~/.openclaw/.env`. SecretRef objects with `source: "file"` or `source: "exec"` can't be resolved automatically — those values must be added to Hermes manually after migration. + +## After migration + +1. **Check the migration report** — printed on completion with counts of migrated, skipped, and conflicting items. + +2. **Review archived files** — anything in `~/.hermes/migration/openclaw//archive/` needs manual attention. + +3. **Verify API keys** — run `hermes status` to check provider authentication. + +4. **Test messaging** — if you migrated platform tokens, restart the gateway: `systemctl --user restart hermes-gateway` + +5. **Check session policies** — verify `hermes config get session_reset` matches your expectations. + +6. **Re-pair WhatsApp** — WhatsApp uses QR code pairing (Baileys), not token migration. Run `hermes whatsapp` to pair. + +## Troubleshooting + +### "OpenClaw directory not found" + +The migration checks `~/.openclaw/`, then `~/.clawdbot/`, then `~/.moldbot/`. If your installation is elsewhere, use `--source /path/to/your/openclaw`. + +### "No provider API keys found" + +Keys might be in your `.env` file instead of `openclaw.json`. The migration checks both — make sure `~/.openclaw/.env` exists and has the keys. If keys use `source: "file"` or `source: "exec"` SecretRefs, they can't be resolved automatically. + +### Skills not appearing after migration + +Imported skills land in `~/.hermes/skills/openclaw-imports/`. Start a new session for them to take effect, or run `/skills` to verify they're loaded. + +### TTS voice not migrated + +OpenClaw stores TTS settings in two places: `messages.tts.providers.*` and the top-level `talk` config. The migration checks both. If your voice ID was set via the OpenClaw UI (stored in a different path), you may need to set it manually: `hermes config set tts.elevenlabs.voice_id YOUR_VOICE_ID`. diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index d27a34f2cc..a9f12d76be 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -466,7 +466,7 @@ hermes insights [--days N] [--source platform] hermes claw migrate [options] ``` -Migrate your OpenClaw setup to Hermes. Reads from `~/.openclaw` (or a custom path) and writes to `~/.hermes`. +Migrate your OpenClaw setup to Hermes. Reads from `~/.openclaw` (or a custom path) and writes to `~/.hermes`. Automatically detects legacy directory names (`~/.clawdbot`, `~/.moldbot`) and config filenames (`clawdbot.json`, `moldbot.json`). | Option | Description | |--------|-------------| @@ -481,57 +481,15 @@ Migrate your OpenClaw setup to Hermes. Reads from `~/.openclaw` (or a custom pat ### What gets migrated -The migration covers your entire OpenClaw footprint. Items are either **directly imported** into Hermes equivalents or **archived** for manual review when there's no direct mapping. +The migration covers 30+ categories across persona, memory, skills, model providers, messaging platforms, agent behavior, session policies, MCP servers, TTS, and more. Items are either **directly imported** into Hermes equivalents or **archived** for manual review. -#### Directly imported +**Directly imported:** SOUL.md, MEMORY.md, USER.md, AGENTS.md, skills (4 source directories), default model, custom providers, MCP servers, messaging platform tokens and allowlists (Telegram, Discord, Slack, WhatsApp, Signal, Matrix, Mattermost), agent defaults (reasoning effort, compression, human delay, timezone, sandbox), session reset policies, approval rules, TTS config, browser settings, tool settings, exec timeout, command allowlist, gateway config, and API keys from 3 sources. -| Category | OpenClaw source | Hermes destination | -|----------|----------------|-------------------| -| **Persona** | `SOUL.md` | `~/.hermes/SOUL.md` | -| **Workspace instructions** | `AGENTS.md` | `AGENTS.md` in target workspace | -| **Long-term memory** | `MEMORY.md` | `~/.hermes/MEMORY.md` (merged with existing entries) | -| **User profile** | `USER.md` | `~/.hermes/USER.md` (merged with existing entries) | -| **Daily memory files** | `workspace/memory/` | Merged into `~/.hermes/MEMORY.md` | -| **Default model** | Config model setting | `config.yaml` model section | -| **Custom providers** | Provider definitions (baseUrl, apiType, headers) | `config.yaml` custom\_providers | -| **MCP servers** | MCP server definitions | `config.yaml` mcp\_servers | -| **User skills** | Workspace skills | `~/.hermes/skills/openclaw-imports/` | -| **Shared skills** | `~/.openclaw/skills/` | `~/.hermes/skills/openclaw-imports/` | -| **Command allowlist** | Exec approval patterns | `config.yaml` command\_allowlist | -| **Messaging settings** | Allowlists, working directory | `config.yaml` messaging section | -| **Session policies** | Daily/idle reset policies | `config.yaml` session\_reset | -| **Agent defaults** | Compaction, context, thinking settings | `config.yaml` agent section | -| **Browser settings** | Browser automation config | `config.yaml` browser section | -| **Tool settings** | Exec timeout, sandbox, web search | `config.yaml` tools section | -| **Approval rules** | Approval mode and rules | `config.yaml` approvals section | -| **TTS config** | TTS provider and voice | `config.yaml` tts section | -| **TTS assets** | Workspace TTS files | `~/.hermes/tts/` | -| **Gateway config** | Gateway port and auth | `config.yaml` gateway section | -| **Telegram settings** | Bot token, allowlist | `~/.hermes/.env` | -| **Discord settings** | Bot token, allowlist | `~/.hermes/.env` | -| **Slack settings** | Bot/app tokens, allowlist | `~/.hermes/.env` | -| **WhatsApp settings** | Allowlist | `~/.hermes/.env` | -| **Signal settings** | Account, HTTP URL, allowlist | `~/.hermes/.env` | -| **Channel config** | Matrix, Mattermost, IRC, group settings | `config.yaml` + archive | -| **Provider API keys** | OPENROUTER\_API\_KEY, OPENAI\_API\_KEY, ANTHROPIC\_API\_KEY, etc. | `~/.hermes/.env` (requires `--migrate-secrets`) | +**Archived for manual review:** Cron jobs, plugins, hooks/webhooks, memory backend (QMD), skills registry config, UI/identity, logging, multi-agent setup, channel bindings, IDENTITY.md, TOOLS.md, HEARTBEAT.md, BOOTSTRAP.md. -#### Archived for manual review +**API key resolution** checks three sources in priority order: config values → `~/.openclaw/.env` → `auth-profiles.json`. All token fields handle plain strings, env templates (`${VAR}`), and SecretRef objects. -These OpenClaw features don't have direct Hermes equivalents. They're saved to an archive directory for you to review and recreate manually. - -| Category | What's archived | How to recreate in Hermes | -|----------|----------------|--------------------------| -| **Cron / scheduled tasks** | Job definitions | Recreate with `hermes cron create` | -| **Plugins** | Plugin configuration, installed extensions | Check the [plugins guide](../user-guide/features/hooks.md) | -| **Hooks and webhooks** | Internal hooks, webhooks, Gmail integration | Use `hermes webhook` or gateway hooks | -| **Memory backend** | QMD, vector search, citation settings | Configure Honcho via `hermes honcho` | -| **Skills registry** | Per-skill enabled/config/env settings | Use `hermes skills config` | -| **UI and identity** | Theme, assistant identity, display prefs | Use `/skin` command or `config.yaml` | -| **Logging** | Diagnostics configuration | Set in `config.yaml` logging section | - -### Security - -API keys are **not migrated by default**. The `--preset full` preset enables secret migration, but only for an allowlist of known keys: `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `ELEVENLABS_API_KEY`, `TELEGRAM_BOT_TOKEN`, and `VOICE_TOOLS_OPENAI_KEY`. All other secrets are skipped. +For the complete config key mapping, SecretRef handling details, and post-migration checklist, see the **[full migration guide](../guides/migrate-from-openclaw.md)**. ### Examples @@ -547,9 +505,6 @@ hermes claw migrate --preset user-data --overwrite # Migrate from a custom OpenClaw path hermes claw migrate --source /home/user/old-openclaw - -# Migrate and place AGENTS.md in a specific project -hermes claw migrate --workspace-target /home/user/my-project ``` ## Maintenance commands diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 3d88377d5f..5e47d0d04a 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -699,66 +699,171 @@ Use this when you want lower latency or cost without fully changing your default ## Terminal Backend Configuration -Configure which environment the agent uses for terminal commands: +Hermes supports six terminal backends. Each determines where the agent's shell commands actually execute — your local machine, a Docker container, a remote server via SSH, a Modal cloud sandbox, a Daytona workspace, or a Singularity/Apptainer container. ```yaml terminal: - backend: local # or: docker, ssh, singularity, modal, daytona - cwd: "." # Working directory ("." = current dir) - timeout: 180 # Command timeout in seconds - - # Docker-specific settings - docker_image: "nikolaik/python-nodejs:python3.11-nodejs20" - docker_mount_cwd_to_workspace: false # SECURITY: off by default. Opt in to mount the launch cwd into /workspace. - docker_forward_env: # Optional explicit allowlist for env passthrough - - "GITHUB_TOKEN" - docker_volumes: # Additional explicit host mounts - - "/home/user/projects:/workspace/projects" - - "/home/user/data:/data:ro" # :ro for read-only - - # Container resource limits (docker, singularity, modal, daytona) - container_cpu: 1 # CPU cores - container_memory: 5120 # MB (default 5GB) - container_disk: 51200 # MB (default 50GB) - container_persistent: true # Persist filesystem across sessions - - # Persistent shell — keep a long-lived bash process across commands - persistent_shell: true # Enabled by default for SSH backend + backend: local # local | docker | ssh | modal | daytona | singularity + cwd: "." # Working directory ("." = current dir for local, "/root" for containers) + timeout: 180 # Per-command timeout in seconds ``` For cloud sandboxes such as Modal and Daytona, `container_persistent: true` means Hermes will try to preserve filesystem state across sandbox recreation. It does not promise that the same live sandbox, PID space, or background processes will still be running later. +### Backend Overview + +| Backend | Where commands run | Isolation | Best for | +|---------|-------------------|-----------|----------| +| **local** | Your machine directly | None | Development, personal use | +| **docker** | Docker container | Full (namespaces, cap-drop) | Safe sandboxing, CI/CD | +| **ssh** | Remote server via SSH | Network boundary | Remote dev, powerful hardware | +| **modal** | Modal cloud sandbox | Full (cloud VM) | Ephemeral cloud compute, evals | +| **daytona** | Daytona workspace | Full (cloud container) | Managed cloud dev environments | +| **singularity** | Singularity/Apptainer container | Namespaces (--containall) | HPC clusters, shared machines | + +### Local Backend + +The default. Commands run directly on your machine with no isolation. No special setup required. + +```yaml +terminal: + backend: local +``` + +:::warning +The agent has the same filesystem access as your user account. Use `hermes tools` to disable tools you don't want, or switch to Docker for sandboxing. +::: + +### Docker Backend + +Runs commands inside a Docker container with security hardening (all capabilities dropped, no privilege escalation, PID limits). + +```yaml +terminal: + backend: docker + docker_image: "nikolaik/python-nodejs:python3.11-nodejs20" + docker_mount_cwd_to_workspace: false # Mount launch dir into /workspace + docker_forward_env: # Env vars to forward into container + - "GITHUB_TOKEN" + docker_volumes: # Host directory mounts + - "/home/user/projects:/workspace/projects" + - "/home/user/data:/data:ro" # :ro for read-only + + # Resource limits + container_cpu: 1 # CPU cores (0 = unlimited) + container_memory: 5120 # MB (0 = unlimited) + container_disk: 51200 # MB (requires overlay2 on XFS+pquota) + container_persistent: true # Persist /workspace and /root across sessions +``` + +**Requirements:** Docker Desktop or Docker Engine installed and running. Hermes probes `$PATH` plus common macOS install locations (`/usr/local/bin/docker`, `/opt/homebrew/bin/docker`, Docker Desktop app bundle). + +**Container lifecycle:** Each session starts a long-lived container (`docker run -d ... sleep 2h`). Commands run via `docker exec` with a login shell. On cleanup, the container is stopped and removed. + +**Security hardening:** +- `--cap-drop ALL` with only `DAC_OVERRIDE`, `CHOWN`, `FOWNER` added back +- `--security-opt no-new-privileges` +- `--pids-limit 256` +- Size-limited tmpfs for `/tmp` (512MB), `/var/tmp` (256MB), `/run` (64MB) + +**Credential forwarding:** Env vars listed in `docker_forward_env` are resolved from your shell environment first, then `~/.hermes/.env`. Skills can also declare `required_environment_variables` which are merged automatically. + +### SSH Backend + +Runs commands on a remote server over SSH. Uses ControlMaster for connection reuse (5-minute idle keepalive). Persistent shell is enabled by default — state (cwd, env vars) survives across commands. + +```yaml +terminal: + backend: ssh + persistent_shell: true # Keep a long-lived bash session (default: true) +``` + +**Required environment variables:** + +```bash +TERMINAL_SSH_HOST=my-server.example.com +TERMINAL_SSH_USER=ubuntu +``` + +**Optional:** + +| Variable | Default | Description | +|----------|---------|-------------| +| `TERMINAL_SSH_PORT` | `22` | SSH port | +| `TERMINAL_SSH_KEY` | (system default) | Path to SSH private key | +| `TERMINAL_SSH_PERSISTENT` | `true` | Enable persistent shell | + +**How it works:** Connects at init time with `BatchMode=yes` and `StrictHostKeyChecking=accept-new`. Persistent shell keeps a single `bash -l` process alive on the remote host, communicating via temporary files. Commands that need `stdin_data` or `sudo` automatically fall back to one-shot mode. + +### Modal Backend + +Runs commands in a [Modal](https://modal.com) cloud sandbox. Each task gets an isolated VM with configurable CPU, memory, and disk. Filesystem can be snapshot/restored across sessions. + +```yaml +terminal: + backend: modal + container_cpu: 1 # CPU cores + container_memory: 5120 # MB (5GB) + container_disk: 51200 # MB (50GB) + container_persistent: true # Snapshot/restore filesystem +``` + +**Required:** Either `MODAL_TOKEN_ID` + `MODAL_TOKEN_SECRET` environment variables, or a `~/.modal.toml` config file. + +**Persistence:** When enabled, the sandbox filesystem is snapshotted on cleanup and restored on next session. Snapshots are tracked in `~/.hermes/modal_snapshots.json`. This preserves filesystem state, not live processes, PID space, or background jobs. + +**Credential files:** Automatically mounted from `~/.hermes/` (OAuth tokens, etc.) and synced before each command. + +### Daytona Backend + +Runs commands in a [Daytona](https://daytona.io) managed workspace. Supports stop/resume for persistence. + +```yaml +terminal: + backend: daytona + container_cpu: 1 # CPU cores + container_memory: 5120 # MB → converted to GiB + container_disk: 10240 # MB → converted to GiB (max 10 GiB) + container_persistent: true # Stop/resume instead of delete +``` + +**Required:** `DAYTONA_API_KEY` environment variable. + +**Persistence:** When enabled, sandboxes are stopped (not deleted) on cleanup and resumed on next session. Sandbox names follow the pattern `hermes-{task_id}`. + +**Disk limit:** Daytona enforces a 10 GiB maximum. Requests above this are capped with a warning. + +### Singularity/Apptainer Backend + +Runs commands in a [Singularity/Apptainer](https://apptainer.org) container. Designed for HPC clusters and shared machines where Docker isn't available. + +```yaml +terminal: + backend: singularity + singularity_image: "docker://nikolaik/python-nodejs:python3.11-nodejs20" + container_cpu: 1 # CPU cores + container_memory: 5120 # MB + container_persistent: true # Writable overlay persists across sessions +``` + +**Requirements:** `apptainer` or `singularity` binary in `$PATH`. + +**Image handling:** Docker URLs (`docker://...`) are automatically converted to SIF files and cached. Existing `.sif` files are used directly. + +**Scratch directory:** Resolved in order: `TERMINAL_SCRATCH_DIR` → `TERMINAL_SANDBOX_DIR/singularity` → `/scratch/$USER/hermes-agent` (HPC convention) → `~/.hermes/sandboxes/singularity`. + +**Isolation:** Uses `--containall --no-home` for full namespace isolation without mounting the host home directory. + ### Common Terminal Backend Issues -If terminal commands fail immediately or the terminal tool is reported as disabled, check the following: +If terminal commands fail immediately or the terminal tool is reported as disabled: -- **Local backend** - - No special requirements. This is the safest default when you are just getting started. - -- **Docker backend** - - Ensure Docker Desktop (or the Docker daemon) is installed and running. - - Hermes needs to be able to find the `docker` CLI. It checks your `$PATH` first and also probes common Docker Desktop install locations on macOS. Run: - ```bash - docker version - ``` - If this fails, fix your Docker installation or switch back to the local backend: - ```bash - hermes config set terminal.backend local - ``` - -- **SSH backend** - - Both `TERMINAL_SSH_HOST` and `TERMINAL_SSH_USER` must be set, for example: - ```bash - export TERMINAL_ENV=ssh - export TERMINAL_SSH_HOST=my-server.example.com - export TERMINAL_SSH_USER=ubuntu - ``` - - If either value is missing, Hermes will log a clear error and refuse to use the SSH backend. - -- **Modal backend** - - You need either a `MODAL_TOKEN_ID` environment variable or a `~/.modal.toml` config file. - - Modal persistence is resumable filesystem state, not durable process continuity. If you need something to stay continuously up, use a deployment-oriented tool instead of the terminal sandbox. - - If neither is present, the backend check fails and Hermes will report that the Modal backend is not available. +- **Local** — No special requirements. The safest default when getting started. +- **Docker** — Run `docker version` to verify Docker is working. If it fails, fix Docker or `hermes config set terminal.backend local`. +- **SSH** — Both `TERMINAL_SSH_HOST` and `TERMINAL_SSH_USER` must be set. Hermes logs a clear error if either is missing. +- **Modal** — Needs `MODAL_TOKEN_ID` env var or `~/.modal.toml`. Run `hermes doctor` to check. +- **Daytona** — Needs `DAYTONA_API_KEY`. The Daytona SDK handles server URL configuration. +- **Singularity** — Needs `apptainer` or `singularity` in `$PATH`. Common on HPC clusters. When in doubt, set `terminal.backend` back to `local` and verify that commands run there first. @@ -1021,7 +1126,8 @@ auxiliary: model: "" # e.g. "openai/gpt-4o", "google/gemini-2.5-flash" base_url: "" # Custom OpenAI-compatible endpoint (overrides provider) api_key: "" # API key for base_url (falls back to OPENAI_API_KEY) - timeout: 30 # seconds — increase for slow local vision models + timeout: 30 # seconds — LLM API call; increase for slow local vision models + download_timeout: 30 # seconds — image HTTP download; increase for slow connections # Web page summarization + browser page text extraction web_extract: @@ -1045,7 +1151,7 @@ auxiliary: ``` :::tip -Each auxiliary task has a configurable `timeout` (in seconds). Defaults: vision 30s, web_extract 30s, approval 30s, compression 120s. Increase these if you use slow local models for auxiliary tasks. +Each auxiliary task has a configurable `timeout` (in seconds). Defaults: vision 30s, web_extract 30s, approval 30s, compression 120s. Increase these if you use slow local models for auxiliary tasks. Vision also has a separate `download_timeout` (default 30s) for the HTTP image download — increase this for slow connections or self-hosted image servers. ::: :::info diff --git a/website/docs/user-guide/messaging/feishu.md b/website/docs/user-guide/messaging/feishu.md index f515648b86..1b7141e78c 100644 --- a/website/docs/user-guide/messaging/feishu.md +++ b/website/docs/user-guide/messaging/feishu.md @@ -32,8 +32,8 @@ Set it to `false` only if you explicitly want one shared conversation per chat. ## Step 1: Create a Feishu / Lark App 1. Open the Feishu or Lark developer console: - - Feishu: - - Lark: + - Feishu: [https://open.feishu.cn/](https://open.feishu.cn/) + - Lark: [https://open.larksuite.com/](https://open.larksuite.com/) 2. Create a new app. 3. In **Credentials & Basic Info**, copy the **App ID** and **App Secret**. 4. Enable the **Bot** capability for the app. diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index 794845effb..be99eaa757 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -161,9 +161,35 @@ Configure the TTS provider in your `config.yaml` under the `tts.provider` key. Hermes Agent works in Telegram group chats with a few considerations: - **Privacy mode** determines what messages the bot can see (see [Step 3](#step-3-privacy-mode-critical-for-groups)) -- When privacy mode is on, **@mention the bot** (e.g., `@my_hermes_bot what's the weather?`) or **reply to its messages** to interact -- When privacy mode is off (or bot is admin), the bot sees all messages and can participate naturally - `TELEGRAM_ALLOWED_USERS` still applies — only authorized users can trigger the bot, even in groups +- You can keep the bot from responding to ordinary group chatter with `telegram.require_mention: true` +- With `telegram.require_mention: true`, group messages are accepted when they are: + - slash commands + - replies to one of the bot's messages + - `@botusername` mentions + - matches for one of your configured regex wake words in `telegram.mention_patterns` +- If `telegram.require_mention` is left unset or false, Hermes keeps the previous open-group behavior and responds to normal group messages it can see + +### Example group trigger configuration + +Add this to `~/.hermes/config.yaml`: + +```yaml +telegram: + require_mention: true + mention_patterns: + - "^\\s*chompy\\b" +``` + +This example allows all the usual direct triggers plus messages that begin with `chompy`, even if they do not use an `@mention`. + +### Notes on `mention_patterns` + +- Patterns use Python regular expressions +- Matching is case-insensitive +- Patterns are checked against both text messages and media captions +- Invalid regex patterns are ignored with a warning in the gateway logs rather than crashing the bot +- If you want a pattern to match only at the start of a message, anchor it with `^` ## Private Chat Topics (Bot API 9.4) diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 6d8b52bfe8..bbd7d4ea98 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -65,6 +65,12 @@ const config: Config = { defaultMode: 'dark', respectPrefersColorScheme: true, }, + docs: { + sidebar: { + hideable: true, + autoCollapseCategories: true, + }, + }, navbar: { title: 'Hermes Agent', logo: { diff --git a/website/sidebars.ts b/website/sidebars.ts index 73c9430318..4c7bfc2e2f 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -5,7 +5,7 @@ const sidebars: SidebarsConfig = { { type: 'category', label: 'Getting Started', - collapsed: false, + collapsed: true, items: [ 'getting-started/quickstart', 'getting-started/installation', @@ -17,7 +17,7 @@ const sidebars: SidebarsConfig = { { type: 'category', label: 'Guides & Tutorials', - collapsed: false, + collapsed: true, items: [ 'guides/tips', 'guides/daily-briefing-bot', @@ -26,12 +26,13 @@ const sidebars: SidebarsConfig = { 'guides/use-mcp-with-hermes', 'guides/use-soul-with-hermes', 'guides/use-voice-mode-with-hermes', + 'guides/migrate-from-openclaw', ], }, { type: 'category', label: 'User Guide', - collapsed: false, + collapsed: true, items: [ 'user-guide/cli', 'user-guide/configuration', diff --git a/website/src/css/custom.css b/website/src/css/custom.css index 1df449986d..7c70003917 100644 --- a/website/src/css/custom.css +++ b/website/src/css/custom.css @@ -199,6 +199,46 @@ pre.prism-code.language-ascii code { border: 1px solid rgba(255, 215, 0, 0.08); } +/* ─── Mobile sidebar improvements ─────────────────────────────────────────── */ + +/* Larger touch targets on mobile */ +@media (max-width: 996px) { + .menu__link { + padding: 0.6rem 0.75rem; + font-size: 0.95rem; + } + + .menu__list-item-collapsible > .menu__link { + font-weight: 600; + font-size: 1rem; + padding: 0.75rem 0.75rem; + border-bottom: 1px solid rgba(255, 215, 0, 0.06); + } + + /* Category caret — more visible */ + .menu__caret::before { + background-size: 1.5rem 1.5rem; + } + + /* Indent subcategories clearly */ + .menu__list .menu__list { + padding-left: 0.75rem; + border-left: 1px solid rgba(255, 215, 0, 0.06); + margin-left: 0.5rem; + } + + /* Sidebar overlay — slightly more opaque for readability */ + .navbar-sidebar__backdrop { + background-color: rgba(0, 0, 0, 0.6); + } + + /* Sidebar width on mobile — use more of the screen */ + .navbar-sidebar { + width: 85vw; + max-width: 360px; + } +} + /* Hero banner for docs landing if needed */ .hero--hermes { background: linear-gradient(135deg, #07070d 0%, #0f0f18 100%);