Compare commits
72
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3123be445 | ||
|
|
e46d5b2c13 | ||
|
|
34cc666105 | ||
|
|
d6832260f9 | ||
|
|
d2652e980f | ||
|
|
89cea9fd2d | ||
|
|
143e72c145 | ||
|
|
51305b3f3d | ||
|
|
570e52b342 | ||
|
|
d6e874491d | ||
|
|
dd3812dffe | ||
|
|
6e17630bac | ||
|
|
53b710b13f | ||
|
|
5b1e8059cb | ||
|
|
ff16a33cdd | ||
|
|
7cfb9eb1f6 | ||
|
|
c7b15f8ce1 | ||
|
|
7602c462ee | ||
|
|
e38c24363c | ||
|
|
d768b244a5 | ||
|
|
97d6813f51 | ||
|
|
37825189dd | ||
|
|
e08778fa1e | ||
|
|
fb634068df | ||
|
|
74181fe726 | ||
|
|
1e896b0251 | ||
|
|
0b0c1b326c | ||
|
|
b4496b33b5 | ||
|
|
d028a94b83 | ||
|
|
0e592aa5b4 | ||
|
|
efae525dc5 | ||
|
|
5148682b43 | ||
|
|
791f4e94b2 | ||
|
|
a4b064763d | ||
|
|
138ea3fbe8 | ||
|
|
ee61485cac | ||
|
|
947faed3bc | ||
|
|
c288bbfb57 | ||
|
|
a347921314 | ||
|
|
09def65eff | ||
|
|
649d149438 | ||
|
|
5602458794 | ||
|
|
1c900c45e3 | ||
|
|
227601c200 | ||
|
|
fd29933a6d | ||
|
|
839f798b74 | ||
|
|
366bfc3c76 | ||
|
|
b4ceb541a7 | ||
|
|
ccf7bb1102 | ||
|
|
ce2841f3c9 | ||
|
|
e296efbf24 | ||
|
|
2ff2cd3a59 | ||
|
|
f39ca81bab | ||
|
|
3fad1e7cc1 | ||
|
|
86ac23c8da | ||
|
|
3cc50532d1 | ||
|
|
2d607d36f6 | ||
|
|
aa389924ad | ||
|
|
5e67fc8c40 | ||
|
|
b60cfd6ce6 | ||
|
|
981e14001c | ||
|
|
9d28f4aba3 | ||
|
|
3e203de125 | ||
|
|
2d264a4562 | ||
|
|
3e2c8c529b | ||
|
|
e4d575e563 | ||
|
|
2a0e8b001f | ||
|
|
ca4907dfbc | ||
|
|
e314833c9d | ||
|
|
59f2b228f7 | ||
|
|
d6b7836210 | ||
|
|
17b6000e90 |
@@ -1,45 +0,0 @@
|
||||
# Scope labels for actions/labeler@v5
|
||||
# Maps labels to file path patterns
|
||||
|
||||
agent-core:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- run_agent.py
|
||||
- model_tools.py
|
||||
|
||||
cli:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- cli.py
|
||||
- hermes_cli/**
|
||||
|
||||
gateway:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- gateway/**
|
||||
|
||||
tools:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- tools/**
|
||||
|
||||
cron:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- cron/**
|
||||
|
||||
tests:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- tests/**
|
||||
|
||||
docs:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- website/**
|
||||
|
||||
config:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- hermes_cli/config.py
|
||||
- hermes_cli/setup.py
|
||||
@@ -1,33 +0,0 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: lint-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Ruff Lint & Format Check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install ruff
|
||||
run: pip install ruff
|
||||
|
||||
- name: Run ruff linter
|
||||
run: ruff check .
|
||||
|
||||
- name: Check ruff formatting
|
||||
run: ruff format --check .
|
||||
@@ -1,93 +0,0 @@
|
||||
name: PR Labels
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: pr-labels-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
scope-labels:
|
||||
name: Scope Labels
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Apply scope labels
|
||||
uses: actions/labeler@v5
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
configuration-path: .github/labeler.yml
|
||||
|
||||
size-label:
|
||||
name: Size Label
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Label PR by size
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const { data: files } = await github.rest.pulls.listFiles({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
let totalChanges = 0;
|
||||
for (const file of files) {
|
||||
totalChanges += file.additions + file.deletions;
|
||||
}
|
||||
|
||||
let sizeLabel;
|
||||
if (totalChanges < 10) {
|
||||
sizeLabel = 'size/XS';
|
||||
} else if (totalChanges < 50) {
|
||||
sizeLabel = 'size/S';
|
||||
} else if (totalChanges < 200) {
|
||||
sizeLabel = 'size/M';
|
||||
} else if (totalChanges < 500) {
|
||||
sizeLabel = 'size/L';
|
||||
} else {
|
||||
sizeLabel = 'size/XL';
|
||||
}
|
||||
|
||||
// Remove any existing size labels
|
||||
const sizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'];
|
||||
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
for (const label of currentLabels) {
|
||||
if (sizeLabels.includes(label.name) && label.name !== sizeLabel) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: label.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add the new size label
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: [sizeLabel],
|
||||
});
|
||||
|
||||
console.log(`PR has ${totalChanges} lines changed → ${sizeLabel}`);
|
||||
@@ -1,105 +0,0 @@
|
||||
name: Regression Test Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: regression-test-check-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check-tests:
|
||||
name: Check for Regression Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check high-risk files have corresponding tests
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
# High-risk files that should have test coverage when modified
|
||||
HIGH_RISK_FILES=(
|
||||
"run_agent.py"
|
||||
"cli.py"
|
||||
"gateway/run.py"
|
||||
"tools/approval.py"
|
||||
"tools/terminal_tool.py"
|
||||
"model_tools.py"
|
||||
)
|
||||
|
||||
# Get the list of changed files in this PR
|
||||
CHANGED_FILES=$(gh pr diff "$PR_NUMBER" --name-only)
|
||||
|
||||
# Check if any high-risk files were modified
|
||||
HIGH_RISK_MODIFIED=()
|
||||
for file in "${HIGH_RISK_FILES[@]}"; do
|
||||
if echo "$CHANGED_FILES" | grep -qx "$file"; then
|
||||
HIGH_RISK_MODIFIED+=("$file")
|
||||
fi
|
||||
done
|
||||
|
||||
# If no high-risk files modified, we're good
|
||||
if [ ${#HIGH_RISK_MODIFIED[@]} -eq 0 ]; then
|
||||
echo "No high-risk files modified. Skipping test check."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "High-risk files modified: ${HIGH_RISK_MODIFIED[*]}"
|
||||
|
||||
# Check if any test files were added or modified
|
||||
TEST_FILES_CHANGED=$(echo "$CHANGED_FILES" | grep -E "^tests/" || true)
|
||||
|
||||
if [ -z "$TEST_FILES_CHANGED" ]; then
|
||||
echo "::warning::High-risk files modified without test changes!"
|
||||
|
||||
# Build the warning comment
|
||||
MODIFIED_LIST=""
|
||||
for file in "${HIGH_RISK_MODIFIED[@]}"; do
|
||||
MODIFIED_LIST="$MODIFIED_LIST\n- \`$file\`"
|
||||
done
|
||||
|
||||
COMMENT_BODY=$(cat <<EOF
|
||||
⚠️ **Regression Test Check Warning**
|
||||
|
||||
This PR modifies the following high-risk files:
|
||||
$(for file in "${HIGH_RISK_MODIFIED[@]}"; do echo "- \`$file\`"; done)
|
||||
|
||||
However, no test files under \`tests/\` were added or modified.
|
||||
|
||||
Please consider adding or updating tests to cover these changes. This helps prevent regressions in critical code paths.
|
||||
|
||||
> _This is an automated check. If the changes are trivial or already covered by existing tests, you can disregard this warning._
|
||||
EOF
|
||||
)
|
||||
|
||||
# Post a comment on the PR (update existing comment if one exists)
|
||||
EXISTING_COMMENT_ID=$(gh api \
|
||||
"repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
|
||||
--jq '.[] | select(.body | startswith("⚠️ **Regression Test Check Warning**")) | .id' \
|
||||
| head -1)
|
||||
|
||||
if [ -n "$EXISTING_COMMENT_ID" ]; then
|
||||
gh api \
|
||||
"repos/${{ github.repository }}/issues/comments/${EXISTING_COMMENT_ID}" \
|
||||
-X PATCH \
|
||||
-f body="$COMMENT_BODY"
|
||||
echo "Updated existing warning comment."
|
||||
else
|
||||
gh pr comment "$PR_NUMBER" --body "$COMMENT_BODY"
|
||||
echo "Posted warning comment on PR."
|
||||
fi
|
||||
else
|
||||
echo "Test files were modified. Check passed."
|
||||
echo "Changed test files:"
|
||||
echo "$TEST_FILES_CHANGED"
|
||||
fi
|
||||
@@ -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 <name>`, 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 <name>` 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)
|
||||
@@ -627,8 +627,6 @@ def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str]]:
|
||||
custom_key = runtime.get("api_key")
|
||||
if not isinstance(custom_base, str) or not custom_base.strip():
|
||||
return None, None
|
||||
if not isinstance(custom_key, str) or not custom_key.strip():
|
||||
return None, None
|
||||
|
||||
custom_base = custom_base.strip().rstrip("/")
|
||||
if "openrouter.ai" in custom_base.lower():
|
||||
@@ -636,6 +634,13 @@ def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str]]:
|
||||
# configured. Treat that as "no custom endpoint" for auxiliary routing.
|
||||
return None, None
|
||||
|
||||
# Local servers (Ollama, llama.cpp, vLLM, LM Studio) don't require auth.
|
||||
# Use a placeholder key — the OpenAI SDK requires a non-empty string but
|
||||
# local servers ignore the Authorization header. Same fix as cli.py
|
||||
# _ensure_runtime_credentials() (PR #2556).
|
||||
if not isinstance(custom_key, str) or not custom_key.strip():
|
||||
custom_key = "no-key-required"
|
||||
|
||||
return custom_base, custom_key.strip()
|
||||
|
||||
|
||||
@@ -737,16 +742,37 @@ def _resolve_forced_provider(forced: str) -> Tuple[Optional[OpenAI], Optional[st
|
||||
return None, None
|
||||
|
||||
|
||||
_AUTO_PROVIDER_LABELS = {
|
||||
"_try_openrouter": "openrouter",
|
||||
"_try_nous": "nous",
|
||||
"_try_custom_endpoint": "local/custom",
|
||||
"_try_codex": "openai-codex",
|
||||
"_resolve_api_key_provider": "api-key",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
"""Full auto-detection chain: OpenRouter → Nous → custom → Codex → API-key → None."""
|
||||
global auxiliary_is_nous
|
||||
auxiliary_is_nous = False # Reset — _try_nous() will set True if it wins
|
||||
tried = []
|
||||
for try_fn in (_try_openrouter, _try_nous, _try_custom_endpoint,
|
||||
_try_codex, _resolve_api_key_provider):
|
||||
fn_name = getattr(try_fn, "__name__", "unknown")
|
||||
label = _AUTO_PROVIDER_LABELS.get(fn_name, fn_name)
|
||||
client, model = try_fn()
|
||||
if client is not None:
|
||||
if tried:
|
||||
logger.info("Auxiliary auto-detect: using %s (%s) — skipped: %s",
|
||||
label, model or "default", ", ".join(tried))
|
||||
else:
|
||||
logger.info("Auxiliary auto-detect: using %s (%s)", label, model or "default")
|
||||
return client, model
|
||||
logger.debug("Auxiliary client: none available")
|
||||
tried.append(label)
|
||||
logger.warning("Auxiliary auto-detect: no provider available (tried: %s). "
|
||||
"Compression, summarization, and memory flush will not work. "
|
||||
"Set OPENROUTER_API_KEY or configure a local model in config.yaml.",
|
||||
", ".join(tried))
|
||||
return None, None
|
||||
|
||||
|
||||
@@ -897,11 +923,12 @@ def resolve_provider_client(
|
||||
custom_key = (
|
||||
(explicit_api_key or "").strip()
|
||||
or os.getenv("OPENAI_API_KEY", "").strip()
|
||||
or "no-key-required" # local servers don't need auth
|
||||
)
|
||||
if not custom_base or not custom_key:
|
||||
if not custom_base:
|
||||
logger.warning(
|
||||
"resolve_provider_client: explicit custom endpoint requested "
|
||||
"but no API key was found (set explicit_api_key or OPENAI_API_KEY)"
|
||||
"but base_url is empty"
|
||||
)
|
||||
return None, None
|
||||
final_model = model or _read_main_model() or "gpt-4o-mini"
|
||||
@@ -1612,8 +1639,8 @@ def call_llm(
|
||||
)
|
||||
# For auto/custom, fall back to OpenRouter
|
||||
if not resolved_base_url:
|
||||
logger.warning("Provider %s unavailable, falling back to openrouter",
|
||||
resolved_provider)
|
||||
logger.info("Auxiliary %s: provider %s unavailable, falling back to openrouter",
|
||||
task or "call", resolved_provider)
|
||||
client, final_model = _get_cached_client(
|
||||
"openrouter", resolved_model or _OPENROUTER_MODEL)
|
||||
if client is None:
|
||||
@@ -1623,6 +1650,13 @@ def call_llm(
|
||||
|
||||
effective_timeout = timeout if timeout is not None else _get_task_timeout(task)
|
||||
|
||||
# Log what we're about to do — makes auxiliary operations visible
|
||||
_base_info = str(getattr(client, "base_url", resolved_base_url) or "")
|
||||
if task:
|
||||
logger.info("Auxiliary %s: using %s (%s)%s",
|
||||
task, resolved_provider or "auto", final_model or "default",
|
||||
f" at {_base_info}" if _base_info and "openrouter" not in _base_info else "")
|
||||
|
||||
kwargs = _build_call_kwargs(
|
||||
resolved_provider, final_model, messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
|
||||
+30
-3
@@ -17,6 +17,23 @@ _RESET = "\033[0m"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =========================================================================
|
||||
# Configurable tool preview length (0 = no limit)
|
||||
# Set once at startup by CLI or gateway from display.tool_preview_length config.
|
||||
# =========================================================================
|
||||
_tool_preview_max_len: int = 0 # 0 = unlimited
|
||||
|
||||
|
||||
def set_tool_preview_max_len(n: int) -> None:
|
||||
"""Set the global max length for tool call previews. 0 = no limit."""
|
||||
global _tool_preview_max_len
|
||||
_tool_preview_max_len = max(int(n), 0) if n else 0
|
||||
|
||||
|
||||
def get_tool_preview_max_len() -> int:
|
||||
"""Return the configured max preview length (0 = unlimited)."""
|
||||
return _tool_preview_max_len
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Skin-aware helpers (lazy import to avoid circular deps)
|
||||
@@ -94,8 +111,14 @@ def _oneline(text: str) -> str:
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def build_tool_preview(tool_name: str, args: dict, max_len: int = 40) -> str | None:
|
||||
"""Build a short preview of a tool call's primary argument for display."""
|
||||
def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -> str | None:
|
||||
"""Build a short preview of a tool call's primary argument for display.
|
||||
|
||||
*max_len* controls truncation. ``None`` (default) defers to the global
|
||||
``_tool_preview_max_len`` set via config; ``0`` means unlimited.
|
||||
"""
|
||||
if max_len is None:
|
||||
max_len = _tool_preview_max_len
|
||||
if not args:
|
||||
return None
|
||||
primary_args = {
|
||||
@@ -190,7 +213,7 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int = 40) -> str | N
|
||||
preview = _oneline(str(value))
|
||||
if not preview:
|
||||
return None
|
||||
if len(preview) > max_len:
|
||||
if max_len > 0 and len(preview) > max_len:
|
||||
preview = preview[:max_len - 3] + "..."
|
||||
return preview
|
||||
|
||||
@@ -484,10 +507,14 @@ def get_cute_tool_message(
|
||||
|
||||
def _trunc(s, n=40):
|
||||
s = str(s)
|
||||
if _tool_preview_max_len == 0:
|
||||
return s # no limit
|
||||
return (s[:n-3] + "...") if len(s) > n else s
|
||||
|
||||
def _path(p, n=35):
|
||||
p = str(p)
|
||||
if _tool_preview_max_len == 0:
|
||||
return p # no limit
|
||||
return ("..." + p[-(n-3):]) if len(p) > n else p
|
||||
|
||||
def _wrap(line: str) -> str:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+26
-8
@@ -11,14 +11,29 @@ model:
|
||||
default: "anthropic/claude-opus-4.6"
|
||||
|
||||
# Inference provider selection:
|
||||
# "auto" - Use Nous Portal if logged in, otherwise OpenRouter/env vars (default)
|
||||
# "nous-api" - Use Nous Portal via API key (requires: NOUS_API_KEY)
|
||||
# "openrouter" - Always use OpenRouter API key from OPENROUTER_API_KEY
|
||||
# "nous" - Always use Nous Portal (requires: hermes login)
|
||||
# "zai" - Use z.ai / ZhipuAI GLM models (requires: GLM_API_KEY)
|
||||
# "kimi-coding"- Use Kimi / Moonshot AI models (requires: KIMI_API_KEY)
|
||||
# "minimax" - Use MiniMax global endpoint (requires: MINIMAX_API_KEY)
|
||||
# "minimax-cn" - Use MiniMax China endpoint (requires: MINIMAX_CN_API_KEY)
|
||||
# "auto" - Auto-detect from credentials (default)
|
||||
# "openrouter" - OpenRouter (requires: OPENROUTER_API_KEY or OPENAI_API_KEY)
|
||||
# "nous" - Nous Portal OAuth (requires: hermes login)
|
||||
# "nous-api" - Nous Portal API key (requires: NOUS_API_KEY)
|
||||
# "anthropic" - Direct Anthropic API (requires: ANTHROPIC_API_KEY)
|
||||
# "openai-codex" - OpenAI Codex (requires: hermes login --provider openai-codex)
|
||||
# "copilot" - GitHub Copilot / GitHub Models (requires: GITHUB_TOKEN)
|
||||
# "zai" - z.ai / ZhipuAI GLM (requires: GLM_API_KEY)
|
||||
# "kimi-coding" - Kimi / Moonshot AI (requires: KIMI_API_KEY)
|
||||
# "minimax" - MiniMax global (requires: MINIMAX_API_KEY)
|
||||
# "minimax-cn" - MiniMax China (requires: MINIMAX_CN_API_KEY)
|
||||
# "huggingface" - Hugging Face Inference (requires: HF_TOKEN)
|
||||
# "kilocode" - KiloCode gateway (requires: KILOCODE_API_KEY)
|
||||
# "ai-gateway" - Vercel AI Gateway (requires: AI_GATEWAY_API_KEY)
|
||||
#
|
||||
# Local servers (LM Studio, Ollama, vLLM, llama.cpp):
|
||||
# "custom" - Any OpenAI-compatible endpoint. Set base_url below.
|
||||
# Aliases: "lmstudio", "ollama", "vllm", "llamacpp" all map to "custom".
|
||||
# Example for LM Studio:
|
||||
# provider: "lmstudio"
|
||||
# base_url: "http://localhost:1234/v1"
|
||||
# No API key needed — local servers typically ignore auth.
|
||||
#
|
||||
# Can also be overridden with --provider flag or HERMES_INFERENCE_PROVIDER env var.
|
||||
provider: "auto"
|
||||
|
||||
@@ -309,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:
|
||||
|
||||
@@ -449,6 +449,14 @@ try:
|
||||
except Exception:
|
||||
pass # Skin engine is optional — default skin used if unavailable
|
||||
|
||||
# Initialize tool preview length from config
|
||||
try:
|
||||
from agent.display import set_tool_preview_max_len
|
||||
_tpl = CLI_CONFIG.get("display", {}).get("tool_preview_length", 0)
|
||||
set_tool_preview_max_len(int(_tpl) if _tpl else 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Neuter AsyncHttpxClientWrapper.__del__ before any AsyncOpenAI clients are
|
||||
# created. The SDK's __del__ schedules aclose() on asyncio.get_running_loop()
|
||||
# which, during CLI idle time, finds prompt_toolkit's event loop and tries to
|
||||
@@ -1079,10 +1087,10 @@ class HermesCLI:
|
||||
# env vars would stomp each other.
|
||||
_model_config = CLI_CONFIG.get("model", {})
|
||||
_config_model = (_model_config.get("default") or _model_config.get("model") or "") if isinstance(_model_config, dict) else (_model_config or "")
|
||||
_FALLBACK_MODEL = "anthropic/claude-opus-4.6"
|
||||
self.model = model or _config_model or _FALLBACK_MODEL
|
||||
# Auto-detect model from local server if still on fallback
|
||||
if self.model == _FALLBACK_MODEL:
|
||||
_DEFAULT_CONFIG_MODEL = "anthropic/claude-opus-4.6"
|
||||
self.model = model or _config_model or _DEFAULT_CONFIG_MODEL
|
||||
# Auto-detect model from local server if still on default
|
||||
if self.model == _DEFAULT_CONFIG_MODEL:
|
||||
_base_url = (_model_config.get("base_url") or "") if isinstance(_model_config, dict) else ""
|
||||
if "localhost" in _base_url or "127.0.0.1" in _base_url:
|
||||
from hermes_cli.runtime_provider import _auto_detect_local_model
|
||||
@@ -1096,7 +1104,7 @@ class HermesCLI:
|
||||
# explicit choice — the user just never changed it. But a config model
|
||||
# like "gpt-5.3-codex" IS explicit and must be preserved.
|
||||
self._model_is_default = not model and (
|
||||
not _config_model or _config_model == _FALLBACK_MODEL
|
||||
not _config_model or _config_model == _DEFAULT_CONFIG_MODEL
|
||||
)
|
||||
|
||||
self._explicit_api_key = api_key
|
||||
@@ -1347,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()
|
||||
@@ -1361,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"])
|
||||
@@ -1376,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'}"
|
||||
|
||||
@@ -1398,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()} ")]
|
||||
@@ -2736,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"))
|
||||
@@ -4782,8 +4825,10 @@ class HermesCLI:
|
||||
from agent.display import get_tool_emoji
|
||||
emoji = get_tool_emoji(function_name)
|
||||
label = preview or function_name
|
||||
if len(label) > 50:
|
||||
label = label[:47] + "..."
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_pl = get_tool_preview_max_len()
|
||||
if _pl > 0 and len(label) > _pl:
|
||||
label = label[:_pl - 3] + "..."
|
||||
self._spinner_text = f"{emoji} {label}"
|
||||
self._invalidate()
|
||||
|
||||
@@ -6155,6 +6200,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()
|
||||
|
||||
+25
-6
@@ -87,6 +87,22 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]:
|
||||
chat_id, thread_id = rest.split(":", 1)
|
||||
else:
|
||||
chat_id, thread_id = rest, None
|
||||
|
||||
# Resolve human-friendly labels like "Alice (dm)" to real IDs.
|
||||
# send_message(action="list") shows labels with display suffixes
|
||||
# that aren't valid platform IDs (e.g. WhatsApp JIDs).
|
||||
try:
|
||||
from gateway.channel_directory import resolve_channel_name
|
||||
target = chat_id
|
||||
# Strip display suffix like " (dm)" or " (group)"
|
||||
if target.endswith(")") and " (" in target:
|
||||
target = target.rsplit(" (", 1)[0].strip()
|
||||
resolved = resolve_channel_name(platform_name.lower(), target)
|
||||
if resolved:
|
||||
chat_id = resolved
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"platform": platform_name,
|
||||
"chat_id": chat_id,
|
||||
@@ -146,6 +162,8 @@ def _deliver_result(job: dict, content: str) -> None:
|
||||
"mattermost": Platform.MATTERMOST,
|
||||
"homeassistant": Platform.HOMEASSISTANT,
|
||||
"dingtalk": Platform.DINGTALK,
|
||||
"feishu": Platform.FEISHU,
|
||||
"wecom": Platform.WECOM,
|
||||
"email": Platform.EMAIL,
|
||||
"sms": Platform.SMS,
|
||||
}
|
||||
@@ -218,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:
|
||||
@@ -320,7 +339,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
if delivery_target.get("thread_id") is not None:
|
||||
os.environ["HERMES_CRON_AUTO_DELIVER_THREAD_ID"] = str(delivery_target["thread_id"])
|
||||
|
||||
model = job.get("model") or os.getenv("HERMES_MODEL") or "anthropic/claude-opus-4.6"
|
||||
model = job.get("model") or os.getenv("HERMES_MODEL") or ""
|
||||
|
||||
# Load config.yaml for model, reasoning, prefill, toolsets, provider routing
|
||||
_cfg = {}
|
||||
|
||||
@@ -13,6 +13,7 @@ Core layers:
|
||||
Concrete environments:
|
||||
- terminal_test_env/: Simple file-creation tasks for testing the stack
|
||||
- hermes_swe_env/: SWE-bench style tasks with Modal sandboxes
|
||||
- endless_terminals/: Terminal tasks from HuggingFace dataset with Apptainer containers
|
||||
|
||||
Benchmarks (eval-only):
|
||||
- benchmarks/terminalbench_2/: Terminal-Bench 2.0 evaluation
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Endless Terminals Environment - Terminal task training from HuggingFace dataset."""
|
||||
|
||||
from .endless_terminals_env import EndlessTerminalsEnv, EndlessTerminalsEnvConfig
|
||||
|
||||
__all__ = ["EndlessTerminalsEnv", "EndlessTerminalsEnvConfig"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
# Endless Terminals - Qwen3-4B-Instruct-2507
|
||||
# Single config for both trainer (launch_training.py) and env (endless_terminals_env.py serve)
|
||||
#
|
||||
# Usage:
|
||||
# Terminal 1: run-api
|
||||
# Terminal 2: cd tinker-atropos && python launch_training.py --config ../environments/endless_terminals/tinker_qwen.yaml
|
||||
# Terminal 3: python environments/endless_terminals/endless_terminals_env.py serve --config environments/endless_terminals/tinker_qwen.yaml
|
||||
|
||||
env:
|
||||
# Toolsets
|
||||
enabled_toolsets: ["terminal", "file"]
|
||||
|
||||
# Model / tokenizer
|
||||
tokenizer_name: "Qwen/Qwen3-4B-Instruct-2507"
|
||||
|
||||
# Agent configuration
|
||||
max_agent_turns: 16
|
||||
max_token_length: 2048
|
||||
agent_temperature: 0.6
|
||||
extra_body:
|
||||
chat_template_kwargs:
|
||||
enable_thinking: false
|
||||
tool_call_parser: "hermes"
|
||||
|
||||
# Terminal backend
|
||||
terminal_backend: "docker"
|
||||
|
||||
# Dataset settings
|
||||
use_dataset: true
|
||||
dataset_name: "obiwan96/endless-terminals"
|
||||
dataset_split: "train"
|
||||
dataset_cache_dir: "~/.cache/huggingface/datasets"
|
||||
tasks_base_dir: "/Users/samherring/Desktop/Projects/Hermes-Agent/endless-terminals"
|
||||
|
||||
# Test execution
|
||||
test_timeout_s: 180
|
||||
default_docker_image: "ubuntu:22.04"
|
||||
max_concurrent_containers: 16
|
||||
|
||||
# Training configuration
|
||||
group_size: 16
|
||||
batch_size: 64 # 4 groups × 16 rollouts per step
|
||||
total_steps: 500
|
||||
steps_per_eval: 5
|
||||
min_items_sent_before_logging: 1
|
||||
ensure_scores_are_not_same: true
|
||||
max_num_workers: 2048
|
||||
worker_timeout: 3600
|
||||
inference_weight: 1.0
|
||||
eval_limit_ratio: 0.1
|
||||
rollout_server_url: "http://localhost:8000"
|
||||
|
||||
# Evaluation configuration
|
||||
num_eval_tasks: 20
|
||||
eval_split_ratio: 0.1
|
||||
|
||||
# Logging
|
||||
use_wandb: true
|
||||
wandb_name: "endless-terminals-qwen3-4b"
|
||||
|
||||
# System prompt
|
||||
system_prompt: >
|
||||
You are a skilled Linux system administrator and programmer.
|
||||
You have access to a terminal and file tools to complete system administration
|
||||
and programming tasks. Use the tools effectively to solve the given task,
|
||||
and verify your solution works correctly before finishing.
|
||||
Keep each command short and focused — break complex tasks into multiple steps
|
||||
rather than writing long one-liners.
|
||||
|
||||
tinker:
|
||||
lora_rank: 32
|
||||
learning_rate: 0.0000005
|
||||
max_token_trainer_length: 32768
|
||||
checkpoint_dir: "./temp/"
|
||||
save_checkpoint_interval: 50
|
||||
wandb_project: "endless-terminals"
|
||||
wandb_group: null
|
||||
wandb_run_name: "qwen3-4b"
|
||||
tool_call_parser: "hermes"
|
||||
|
||||
openai:
|
||||
- model_name: "Qwen/Qwen3-4B-Instruct-2507"
|
||||
base_url: "http://localhost:8001/v1"
|
||||
api_key: "x"
|
||||
weight: 1.0
|
||||
num_requests_for_eval: 64
|
||||
timeout: 600
|
||||
server_type: "sglang"
|
||||
|
||||
slurm: false
|
||||
testing: false
|
||||
@@ -298,7 +298,6 @@ class HermesAgentBaseEnv(BaseEnv):
|
||||
return False
|
||||
|
||||
server = self.server.servers[0]
|
||||
# If the server is an OpenAI server (not VLLM/SGLang), use direct mode
|
||||
from atroposlib.envs.server_handling.openai_server import OpenAIServer
|
||||
return not isinstance(server, OpenAIServer)
|
||||
|
||||
|
||||
@@ -48,7 +48,13 @@ class HermesToolCallParser(ToolCallParser):
|
||||
if not raw_json.strip():
|
||||
continue
|
||||
|
||||
tc_data = json.loads(raw_json)
|
||||
try:
|
||||
tc_data = json.loads(raw_json)
|
||||
except json.JSONDecodeError:
|
||||
# Fix invalid backslash escapes from shell commands in JSON strings
|
||||
# e.g. \s \w \d \n (unescaped) → \\s \\w \\d \\n
|
||||
fixed = re.sub(r'\\([^"\\/bfnrtu0-9\n])', r'\\\\\1', raw_json)
|
||||
tc_data = json.loads(fixed)
|
||||
tool_calls.append(
|
||||
ChatCompletionMessageToolCall(
|
||||
id=f"call_{uuid.uuid4().hex[:8]}",
|
||||
|
||||
@@ -57,6 +57,8 @@ class Platform(Enum):
|
||||
DINGTALK = "dingtalk"
|
||||
API_SERVER = "api_server"
|
||||
WEBHOOK = "webhook"
|
||||
FEISHU = "feishu"
|
||||
WECOM = "wecom"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -274,6 +276,12 @@ class GatewayConfig:
|
||||
# Webhook uses enabled flag only (secrets are per-route)
|
||||
elif platform == Platform.WEBHOOK:
|
||||
connected.append(platform)
|
||||
# Feishu uses extra dict for app credentials
|
||||
elif platform == Platform.FEISHU and config.extra.get("app_id"):
|
||||
connected.append(platform)
|
||||
# WeCom uses extra dict for bot credentials
|
||||
elif platform == Platform.WECOM and config.extra.get("bot_id"):
|
||||
connected.append(platform)
|
||||
return connected
|
||||
|
||||
def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:
|
||||
@@ -507,6 +515,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, {})
|
||||
@@ -531,6 +543,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. "
|
||||
@@ -810,6 +836,55 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
if webhook_secret:
|
||||
config.platforms[Platform.WEBHOOK].extra["secret"] = webhook_secret
|
||||
|
||||
# Feishu / Lark
|
||||
feishu_app_id = os.getenv("FEISHU_APP_ID")
|
||||
feishu_app_secret = os.getenv("FEISHU_APP_SECRET")
|
||||
if feishu_app_id and feishu_app_secret:
|
||||
if Platform.FEISHU not in config.platforms:
|
||||
config.platforms[Platform.FEISHU] = PlatformConfig()
|
||||
config.platforms[Platform.FEISHU].enabled = True
|
||||
config.platforms[Platform.FEISHU].extra.update({
|
||||
"app_id": feishu_app_id,
|
||||
"app_secret": feishu_app_secret,
|
||||
"domain": os.getenv("FEISHU_DOMAIN", "feishu"),
|
||||
"connection_mode": os.getenv("FEISHU_CONNECTION_MODE", "websocket"),
|
||||
})
|
||||
feishu_encrypt_key = os.getenv("FEISHU_ENCRYPT_KEY", "")
|
||||
if feishu_encrypt_key:
|
||||
config.platforms[Platform.FEISHU].extra["encrypt_key"] = feishu_encrypt_key
|
||||
feishu_verification_token = os.getenv("FEISHU_VERIFICATION_TOKEN", "")
|
||||
if feishu_verification_token:
|
||||
config.platforms[Platform.FEISHU].extra["verification_token"] = feishu_verification_token
|
||||
feishu_home = os.getenv("FEISHU_HOME_CHANNEL")
|
||||
if feishu_home:
|
||||
config.platforms[Platform.FEISHU].home_channel = HomeChannel(
|
||||
platform=Platform.FEISHU,
|
||||
chat_id=feishu_home,
|
||||
name=os.getenv("FEISHU_HOME_CHANNEL_NAME", "Home"),
|
||||
)
|
||||
|
||||
# WeCom (Enterprise WeChat)
|
||||
wecom_bot_id = os.getenv("WECOM_BOT_ID")
|
||||
wecom_secret = os.getenv("WECOM_SECRET")
|
||||
if wecom_bot_id and wecom_secret:
|
||||
if Platform.WECOM not in config.platforms:
|
||||
config.platforms[Platform.WECOM] = PlatformConfig()
|
||||
config.platforms[Platform.WECOM].enabled = True
|
||||
config.platforms[Platform.WECOM].extra.update({
|
||||
"bot_id": wecom_bot_id,
|
||||
"secret": wecom_secret,
|
||||
})
|
||||
wecom_ws_url = os.getenv("WECOM_WEBSOCKET_URL", "")
|
||||
if wecom_ws_url:
|
||||
config.platforms[Platform.WECOM].extra["websocket_url"] = wecom_ws_url
|
||||
wecom_home = os.getenv("WECOM_HOME_CHANNEL")
|
||||
if wecom_home:
|
||||
config.platforms[Platform.WECOM].home_channel = HomeChannel(
|
||||
platform=Platform.WECOM,
|
||||
chat_id=wecom_home,
|
||||
name=os.getenv("WECOM_HOME_CHANNEL_NAME", "Home"),
|
||||
)
|
||||
|
||||
# Session settings
|
||||
idle_minutes = os.getenv("SESSION_IDLE_MINUTES")
|
||||
if idle_minutes:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
+94
-31
@@ -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:
|
||||
|
||||
+229
-22
@@ -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,27 +565,57 @@ 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
|
||||
@@ -590,7 +635,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 +664,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()
|
||||
@@ -762,6 +808,16 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
effective_thread_id = None
|
||||
continue
|
||||
if "message to be replied not found" in err_lower and reply_to_id is not None:
|
||||
# Original message was deleted before we
|
||||
# could reply — clear reply target and retry
|
||||
# so the response is still delivered.
|
||||
logger.warning(
|
||||
"[%s] Reply target deleted, retrying without reply_to: %s",
|
||||
self.name, send_err,
|
||||
)
|
||||
reply_to_id = None
|
||||
continue
|
||||
# Other BadRequest errors are permanent — don't retry
|
||||
raise
|
||||
if _send_attempt < 2:
|
||||
@@ -1315,6 +1371,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.
|
||||
|
||||
@@ -1324,14 +1522,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)
|
||||
@@ -1340,6 +1543,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)
|
||||
@@ -1483,6 +1688,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
|
||||
|
||||
@@ -1506,7 +1713,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:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+97
-9
@@ -225,6 +225,49 @@ from gateway.session import (
|
||||
from gateway.delivery import DeliveryRouter
|
||||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType
|
||||
|
||||
|
||||
def _normalize_whatsapp_identifier(value: str) -> str:
|
||||
"""Strip WhatsApp JID/LID syntax down to its stable numeric identifier."""
|
||||
return (
|
||||
str(value or "")
|
||||
.strip()
|
||||
.replace("+", "", 1)
|
||||
.split(":", 1)[0]
|
||||
.split("@", 1)[0]
|
||||
)
|
||||
|
||||
|
||||
def _expand_whatsapp_auth_aliases(identifier: str) -> set:
|
||||
"""Resolve WhatsApp phone/LID aliases using bridge session mapping files."""
|
||||
normalized = _normalize_whatsapp_identifier(identifier)
|
||||
if not normalized:
|
||||
return set()
|
||||
|
||||
session_dir = _hermes_home / "whatsapp" / "session"
|
||||
resolved = set()
|
||||
queue = [normalized]
|
||||
|
||||
while queue:
|
||||
current = queue.pop(0)
|
||||
if not current or current in resolved:
|
||||
continue
|
||||
|
||||
resolved.add(current)
|
||||
for suffix in ("", "_reverse"):
|
||||
mapping_path = session_dir / f"lid-mapping-{current}{suffix}.json"
|
||||
if not mapping_path.exists():
|
||||
continue
|
||||
try:
|
||||
mapped = _normalize_whatsapp_identifier(
|
||||
json.loads(mapping_path.read_text(encoding="utf-8"))
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if mapped and mapped not in resolved:
|
||||
queue.append(mapped)
|
||||
|
||||
return resolved
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Sentinel placed into _running_agents immediately when a session starts
|
||||
@@ -280,10 +323,10 @@ def _resolve_gateway_model(config: dict | None = None) -> str:
|
||||
"""Read model from env/config — mirrors the resolution in _run_agent_sync.
|
||||
|
||||
Without this, temporary AIAgent instances (memory flush, /compress) fall
|
||||
back to the hardcoded default ("anthropic/claude-opus-4.6") which fails
|
||||
when the active provider is openai-codex.
|
||||
back to the hardcoded default which fails when the active provider is
|
||||
openai-codex.
|
||||
"""
|
||||
model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or "anthropic/claude-opus-4.6"
|
||||
model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or ""
|
||||
cfg = config if config is not None else _load_gateway_config()
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, str):
|
||||
@@ -983,6 +1026,8 @@ class GatewayRunner:
|
||||
"EMAIL_ALLOWED_USERS",
|
||||
"SMS_ALLOWED_USERS", "MATTERMOST_ALLOWED_USERS",
|
||||
"MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS",
|
||||
"FEISHU_ALLOWED_USERS",
|
||||
"WECOM_ALLOWED_USERS",
|
||||
"GATEWAY_ALLOWED_USERS")
|
||||
)
|
||||
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any(
|
||||
@@ -991,7 +1036,9 @@ class GatewayRunner:
|
||||
"WHATSAPP_ALLOW_ALL_USERS", "SLACK_ALLOW_ALL_USERS",
|
||||
"SIGNAL_ALLOW_ALL_USERS", "EMAIL_ALLOW_ALL_USERS",
|
||||
"SMS_ALLOW_ALL_USERS", "MATTERMOST_ALLOW_ALL_USERS",
|
||||
"MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS")
|
||||
"MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS",
|
||||
"FEISHU_ALLOW_ALL_USERS",
|
||||
"WECOM_ALLOW_ALL_USERS")
|
||||
)
|
||||
if not _any_allowlist and not _allow_all:
|
||||
logger.warning(
|
||||
@@ -1434,6 +1481,20 @@ class GatewayRunner:
|
||||
return None
|
||||
return DingTalkAdapter(config)
|
||||
|
||||
elif platform == Platform.FEISHU:
|
||||
from gateway.platforms.feishu import FeishuAdapter, check_feishu_requirements
|
||||
if not check_feishu_requirements():
|
||||
logger.warning("Feishu: lark-oapi not installed or FEISHU_APP_ID/SECRET not set")
|
||||
return None
|
||||
return FeishuAdapter(config)
|
||||
|
||||
elif platform == Platform.WECOM:
|
||||
from gateway.platforms.wecom import WeComAdapter, check_wecom_requirements
|
||||
if not check_wecom_requirements():
|
||||
logger.warning("WeCom: aiohttp not installed or WECOM_BOT_ID/SECRET not set")
|
||||
return None
|
||||
return WeComAdapter(config)
|
||||
|
||||
elif platform == Platform.MATTERMOST:
|
||||
from gateway.platforms.mattermost import MattermostAdapter, check_mattermost_requirements
|
||||
if not check_mattermost_requirements():
|
||||
@@ -1500,6 +1561,8 @@ class GatewayRunner:
|
||||
Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS",
|
||||
Platform.MATRIX: "MATRIX_ALLOWED_USERS",
|
||||
Platform.DINGTALK: "DINGTALK_ALLOWED_USERS",
|
||||
Platform.FEISHU: "FEISHU_ALLOWED_USERS",
|
||||
Platform.WECOM: "WECOM_ALLOWED_USERS",
|
||||
}
|
||||
platform_allow_all_map = {
|
||||
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
|
||||
@@ -1512,6 +1575,8 @@ class GatewayRunner:
|
||||
Platform.MATTERMOST: "MATTERMOST_ALLOW_ALL_USERS",
|
||||
Platform.MATRIX: "MATRIX_ALLOW_ALL_USERS",
|
||||
Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS",
|
||||
Platform.FEISHU: "FEISHU_ALLOW_ALL_USERS",
|
||||
Platform.WECOM: "WECOM_ALLOW_ALL_USERS",
|
||||
}
|
||||
|
||||
# Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
|
||||
@@ -1539,10 +1604,23 @@ class GatewayRunner:
|
||||
if global_allowlist:
|
||||
allowed_ids.update(uid.strip() for uid in global_allowlist.split(",") if uid.strip())
|
||||
|
||||
# WhatsApp JIDs have @s.whatsapp.net suffix — strip it for comparison
|
||||
check_ids = {user_id}
|
||||
if "@" in user_id:
|
||||
check_ids.add(user_id.split("@")[0])
|
||||
|
||||
# WhatsApp: resolve phone↔LID aliases from bridge session mapping files
|
||||
if source.platform == Platform.WHATSAPP:
|
||||
normalized_allowed_ids = set()
|
||||
for allowed_id in allowed_ids:
|
||||
normalized_allowed_ids.update(_expand_whatsapp_auth_aliases(allowed_id))
|
||||
if normalized_allowed_ids:
|
||||
allowed_ids = normalized_allowed_ids
|
||||
|
||||
check_ids.update(_expand_whatsapp_auth_aliases(user_id))
|
||||
normalized_user_id = _normalize_whatsapp_identifier(user_id)
|
||||
if normalized_user_id:
|
||||
check_ids.add(normalized_user_id)
|
||||
|
||||
return bool(check_ids & allowed_ids)
|
||||
|
||||
def _get_unauthorized_dm_behavior(self, platform: Optional[Platform]) -> str:
|
||||
@@ -3813,7 +3891,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,
|
||||
)
|
||||
@@ -4941,6 +5019,14 @@ class GatewayRunner:
|
||||
from hermes_cli.tools_config import _get_platform_tools
|
||||
enabled_toolsets = sorted(_get_platform_tools(user_config, platform_key))
|
||||
|
||||
# Apply tool preview length config (0 = no limit)
|
||||
try:
|
||||
from agent.display import set_tool_preview_max_len
|
||||
_tpl = user_config.get("display", {}).get("tool_preview_length", 0)
|
||||
set_tool_preview_max_len(int(_tpl) if _tpl else 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Tool progress mode from config.yaml: "all", "new", "verbose", "off"
|
||||
# Falls back to env vars for backward compatibility.
|
||||
# YAML 1.1 parses bare `off` as boolean False — normalise before
|
||||
@@ -4986,9 +5072,11 @@ class GatewayRunner:
|
||||
return
|
||||
|
||||
if preview:
|
||||
# Truncate preview to keep messages clean
|
||||
if len(preview) > 80:
|
||||
preview = preview[:77] + "..."
|
||||
# Truncate preview unless config says unlimited
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_pl = get_tool_preview_max_len()
|
||||
if _pl > 0 and len(preview) > _pl:
|
||||
preview = preview[:_pl - 3] + "..."
|
||||
msg = f"{emoji} {tool_name}: \"{preview}\""
|
||||
else:
|
||||
msg = f"{emoji} {tool_name}..."
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
+16
-8
@@ -696,6 +696,10 @@ def resolve_provider(
|
||||
"hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface",
|
||||
"go": "opencode-go", "opencode-go-sub": "opencode-go",
|
||||
"kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode",
|
||||
# Local server aliases — route through the generic custom provider
|
||||
"lmstudio": "custom", "lm-studio": "custom", "lm_studio": "custom",
|
||||
"ollama": "custom", "vllm": "custom", "llamacpp": "custom",
|
||||
"llama.cpp": "custom", "llama-cpp": "custom",
|
||||
}
|
||||
normalized = _PROVIDER_ALIASES.get(normalized, normalized)
|
||||
|
||||
@@ -742,7 +746,12 @@ def resolve_provider(
|
||||
if has_usable_secret(os.getenv(env_var, "")):
|
||||
return pid
|
||||
|
||||
return "openrouter"
|
||||
raise AuthError(
|
||||
"No inference provider configured. Run 'hermes model' to choose a "
|
||||
"provider and model, or set an API key (OPENROUTER_API_KEY, "
|
||||
"OPENAI_API_KEY, etc.) in ~/.hermes/.env.",
|
||||
code="no_provider_configured",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -2301,21 +2310,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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
+13
-1
@@ -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)
|
||||
|
||||
@@ -12,6 +12,8 @@ import os
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_CODEX_MODELS: List[str] = [
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.4",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.1-codex-max",
|
||||
@@ -19,8 +21,9 @@ DEFAULT_CODEX_MODELS: List[str] = [
|
||||
]
|
||||
|
||||
_FORWARD_COMPAT_TEMPLATE_MODELS: List[tuple[str, tuple[str, ...]]] = [
|
||||
("gpt-5.3-codex", ("gpt-5.2-codex",)),
|
||||
("gpt-5.4-mini", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
||||
("gpt-5.4", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
||||
("gpt-5.3-codex", ("gpt-5.2-codex",)),
|
||||
("gpt-5.3-codex-spark", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
||||
]
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ _EXTRA_ENV_KEYS = frozenset({
|
||||
"SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL",
|
||||
"SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
|
||||
"DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET",
|
||||
"FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN",
|
||||
"WECOM_BOT_ID", "WECOM_SECRET",
|
||||
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
|
||||
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
|
||||
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
|
||||
@@ -221,7 +223,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",
|
||||
@@ -285,6 +288,7 @@ DEFAULT_CONFIG = {
|
||||
"show_cost": False, # Show $ cost in the status bar (off by default)
|
||||
"skin": "default",
|
||||
"tool_progress_command": False, # Enable /verbose command in messaging gateway
|
||||
"tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)
|
||||
},
|
||||
|
||||
# Privacy settings
|
||||
@@ -404,6 +408,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1322,6 +1322,59 @@ _PLATFORMS = [
|
||||
"help": "The AppSecret from your DingTalk application credentials."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "feishu",
|
||||
"label": "Feishu / Lark",
|
||||
"emoji": "🪽",
|
||||
"token_var": "FEISHU_APP_ID",
|
||||
"setup_instructions": [
|
||||
"1. Go to https://open.feishu.cn/ (or https://open.larksuite.com/ for Lark)",
|
||||
"2. Create an app and copy the App ID and App Secret",
|
||||
"3. Enable the Bot capability for the app",
|
||||
"4. Choose WebSocket (recommended) or Webhook connection mode",
|
||||
"5. Add the bot to a group chat or message it directly",
|
||||
"6. Restrict access with FEISHU_ALLOWED_USERS for production use",
|
||||
],
|
||||
"vars": [
|
||||
{"name": "FEISHU_APP_ID", "prompt": "App ID", "password": False,
|
||||
"help": "The App ID from your Feishu/Lark application."},
|
||||
{"name": "FEISHU_APP_SECRET", "prompt": "App Secret", "password": True,
|
||||
"help": "The App Secret from your Feishu/Lark application."},
|
||||
{"name": "FEISHU_DOMAIN", "prompt": "Domain — feishu or lark (default: feishu)", "password": False,
|
||||
"help": "Use 'feishu' for Feishu China, or 'lark' for Lark international."},
|
||||
{"name": "FEISHU_CONNECTION_MODE", "prompt": "Connection mode — websocket or webhook (default: websocket)", "password": False,
|
||||
"help": "websocket is recommended unless you specifically need webhook mode."},
|
||||
{"name": "FEISHU_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated, or empty)", "password": False,
|
||||
"is_allowlist": True,
|
||||
"help": "Restrict which Feishu/Lark users can interact with the bot."},
|
||||
{"name": "FEISHU_HOME_CHANNEL", "prompt": "Home chat ID (optional, for cron/notifications)", "password": False,
|
||||
"help": "Chat ID for scheduled results and notifications."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "wecom",
|
||||
"label": "WeCom (Enterprise WeChat)",
|
||||
"emoji": "💬",
|
||||
"token_var": "WECOM_BOT_ID",
|
||||
"setup_instructions": [
|
||||
"1. Go to WeCom Admin Console → Applications → Create AI Bot",
|
||||
"2. Copy the Bot ID and Secret from the bot's credentials page",
|
||||
"3. The bot connects via WebSocket — no public endpoint needed",
|
||||
"4. Add the bot to a group chat or message it directly in WeCom",
|
||||
"5. Restrict access with WECOM_ALLOWED_USERS for production use",
|
||||
],
|
||||
"vars": [
|
||||
{"name": "WECOM_BOT_ID", "prompt": "Bot ID", "password": False,
|
||||
"help": "The Bot ID from your WeCom AI Bot."},
|
||||
{"name": "WECOM_SECRET", "prompt": "Secret", "password": True,
|
||||
"help": "The secret from your WeCom AI Bot."},
|
||||
{"name": "WECOM_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated, or empty)", "password": False,
|
||||
"is_allowlist": True,
|
||||
"help": "Restrict which WeCom users can interact with the bot."},
|
||||
{"name": "WECOM_HOME_CHANNEL", "prompt": "Home chat ID (optional, for cron/notifications)", "password": False,
|
||||
"help": "Chat ID for scheduled results and notifications."},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
+40
-15
@@ -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,
|
||||
)
|
||||
@@ -1084,14 +1104,20 @@ def _model_flow_nous(config, current_model=""):
|
||||
# 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)
|
||||
@@ -1108,11 +1134,7 @@ def _model_flow_nous(config, current_model=""):
|
||||
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)
|
||||
@@ -1269,7 +1291,7 @@ def _model_flow_custom(config):
|
||||
cfg["model"] = model
|
||||
model["provider"] = "custom"
|
||||
model["base_url"] = effective_url
|
||||
model["api_mode"] = "chat_completions"
|
||||
model.pop("api_mode", None) # let runtime auto-detect from URL
|
||||
save_config(cfg)
|
||||
deactivate_provider()
|
||||
|
||||
@@ -2051,7 +2073,7 @@ def _model_flow_kimi(config, current_model=""):
|
||||
cfg["model"] = model
|
||||
model["provider"] = provider_id
|
||||
model["base_url"] = effective_base
|
||||
model["api_mode"] = "chat_completions"
|
||||
model.pop("api_mode", None) # let runtime auto-detect from URL
|
||||
save_config(cfg)
|
||||
deactivate_provider()
|
||||
|
||||
@@ -2125,7 +2147,7 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||
api_key_for_probe = existing_key or (get_env_value(key_env) if key_env else "")
|
||||
live_models = fetch_api_models(api_key_for_probe, effective_base)
|
||||
|
||||
if live_models:
|
||||
if live_models and len(live_models) >= len(curated):
|
||||
model_list = live_models
|
||||
print(f" Found {len(model_list)} model(s) from {pconfig.name} API")
|
||||
else:
|
||||
@@ -2158,7 +2180,7 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||
cfg["model"] = model
|
||||
model["provider"] = provider_id
|
||||
model["base_url"] = effective_base
|
||||
model["api_mode"] = "chat_completions"
|
||||
model.pop("api_mode", None) # let runtime auto-detect from URL
|
||||
save_config(cfg)
|
||||
deactivate_provider()
|
||||
|
||||
@@ -2457,6 +2479,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)
|
||||
|
||||
@@ -4129,6 +4152,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:
|
||||
@@ -4339,6 +4363,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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -152,6 +152,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:
|
||||
@@ -184,6 +212,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
|
||||
|
||||
+33
-6
@@ -1002,10 +1002,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)
|
||||
|
||||
@@ -2710,10 +2709,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()
|
||||
|
||||
@@ -28,6 +28,8 @@ PLATFORMS = {
|
||||
"mattermost": "💬 Mattermost",
|
||||
"matrix": "💬 Matrix",
|
||||
"dingtalk": "💬 DingTalk",
|
||||
"feishu": "🪽 Feishu",
|
||||
"wecom": "💬 WeCom",
|
||||
}
|
||||
|
||||
# ─── Config Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -254,6 +254,9 @@ def show_status(args):
|
||||
"Slack": ("SLACK_BOT_TOKEN", None),
|
||||
"Email": ("EMAIL_ADDRESS", "EMAIL_HOME_ADDRESS"),
|
||||
"SMS": ("TWILIO_ACCOUNT_SID", "SMS_HOME_CHANNEL"),
|
||||
"DingTalk": ("DINGTALK_CLIENT_ID", None),
|
||||
"Feishu": ("FEISHU_APP_ID", "FEISHU_HOME_CHANNEL"),
|
||||
"WeCom": ("WECOM_BOT_ID", "WECOM_HOME_CHANNEL"),
|
||||
}
|
||||
|
||||
for name, (token_var, home_var) in platforms.items():
|
||||
|
||||
@@ -140,7 +140,9 @@ PLATFORMS = {
|
||||
"homeassistant": {"label": "🏠 Home Assistant", "default_toolset": "hermes-homeassistant"},
|
||||
"email": {"label": "📧 Email", "default_toolset": "hermes-email"},
|
||||
"matrix": {"label": "💬 Matrix", "default_toolset": "hermes-matrix"},
|
||||
"dingtalk": {"label": "💬 DingTalk", "default_toolset": "hermes-dingtalk"},
|
||||
"dingtalk": {"label": "💬 DingTalk", "default_toolset": "hermes-dingtalk"},
|
||||
"feishu": {"label": "🪽 Feishu", "default_toolset": "hermes-feishu"},
|
||||
"wecom": {"label": "💬 WeCom", "default_toolset": "hermes-wecom"},
|
||||
"api_server": {"label": "🌐 API Server", "default_toolset": "hermes-api-server"},
|
||||
"mattermost": {"label": "💬 Mattermost", "default_toolset": "hermes-mattermost"},
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
+4
-1
@@ -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"
|
||||
@@ -58,6 +58,7 @@ homeassistant = ["aiohttp>=3.9.0,<4"]
|
||||
sms = ["aiohttp>=3.9.0,<4"]
|
||||
acp = ["agent-client-protocol>=0.8.1,<0.9"]
|
||||
dingtalk = ["dingtalk-stream>=0.1.0,<1"]
|
||||
feishu = ["lark-oapi>=1.5.3,<2"]
|
||||
rl = [
|
||||
"atroposlib @ git+https://github.com/NousResearch/atropos.git",
|
||||
"tinker @ git+https://github.com/thinking-machines-lab/tinker.git",
|
||||
@@ -70,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]",
|
||||
@@ -83,6 +85,7 @@ all = [
|
||||
"hermes-agent[acp]",
|
||||
"hermes-agent[voice]",
|
||||
"hermes-agent[dingtalk]",
|
||||
"hermes-agent[feishu]",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
+25
-8
@@ -1285,7 +1285,7 @@ class AIAgent:
|
||||
try:
|
||||
fn = self._print_fn or print
|
||||
fn(*args, **kwargs)
|
||||
except OSError:
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
def _vprint(self, *args, force: bool = False, **kwargs):
|
||||
@@ -2907,6 +2907,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)."""
|
||||
@@ -3013,7 +3026,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", "{}")
|
||||
@@ -3377,7 +3391,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)
|
||||
@@ -3398,7 +3412,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)
|
||||
@@ -4933,7 +4947,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)
|
||||
@@ -5183,6 +5200,8 @@ class AIAgent:
|
||||
self._session_db.end_session(self.session_id, "compression")
|
||||
old_session_id = self.session_id
|
||||
self.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
|
||||
# Update session_log_file to point to the new session's JSON file
|
||||
self.session_log_file = self.logs_dir / f"session_{self.session_id}.json"
|
||||
self._session_db.create_session(
|
||||
session_id=self.session_id,
|
||||
source=self.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
|
||||
@@ -5660,8 +5679,6 @@ class AIAgent:
|
||||
face = random.choice(KawaiiSpinner.KAWAII_WAITING)
|
||||
emoji = _get_tool_emoji(function_name)
|
||||
preview = _build_tool_preview(function_name, function_args) or function_name
|
||||
if len(preview) > 30:
|
||||
preview = preview[:27] + "..."
|
||||
spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=self._print_fn)
|
||||
spinner.start()
|
||||
_spinner_result = None
|
||||
@@ -7909,7 +7926,7 @@ class AIAgent:
|
||||
error_msg = f"Error during OpenAI-compatible API call #{api_call_count}: {str(e)}"
|
||||
try:
|
||||
print(f"❌ {error_msg}")
|
||||
except OSError:
|
||||
except (OSError, ValueError):
|
||||
logger.error(error_msg)
|
||||
|
||||
if self.verbose_logging:
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
|
||||
export function normalizeWhatsAppIdentifier(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/:.*@/, '@')
|
||||
.replace(/@.*/, '')
|
||||
.replace(/^\+/, '');
|
||||
}
|
||||
|
||||
export function parseAllowedUsers(rawValue) {
|
||||
return new Set(
|
||||
String(rawValue || '')
|
||||
.split(',')
|
||||
.map((value) => normalizeWhatsAppIdentifier(value))
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
function readMappingFile(sessionDir, identifier, suffix = '') {
|
||||
const filePath = path.join(sessionDir, `lid-mapping-${identifier}${suffix}.json`);
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
const normalized = normalizeWhatsAppIdentifier(parsed);
|
||||
return normalized || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function expandWhatsAppIdentifiers(identifier, sessionDir) {
|
||||
const normalized = normalizeWhatsAppIdentifier(identifier);
|
||||
if (!normalized) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
// Walk both phone->LID and LID->phone mapping files so allowlists can use
|
||||
// either form transparently in bot mode.
|
||||
const resolved = new Set();
|
||||
const queue = [normalized];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!current || resolved.has(current)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
resolved.add(current);
|
||||
|
||||
for (const suffix of ['', '_reverse']) {
|
||||
const mapped = readMappingFile(sessionDir, current, suffix);
|
||||
if (mapped && !resolved.has(mapped)) {
|
||||
queue.push(mapped);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function matchesAllowedUser(senderId, allowedUsers, sessionDir) {
|
||||
if (!allowedUsers || allowedUsers.size === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const aliases = expandWhatsAppIdentifiers(senderId, sessionDir);
|
||||
for (const alias of aliases) {
|
||||
if (allowedUsers.has(alias)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
|
||||
import {
|
||||
expandWhatsAppIdentifiers,
|
||||
matchesAllowedUser,
|
||||
normalizeWhatsAppIdentifier,
|
||||
parseAllowedUsers,
|
||||
} from './allowlist.js';
|
||||
|
||||
test('normalizeWhatsAppIdentifier strips jid syntax and plus prefix', () => {
|
||||
assert.equal(normalizeWhatsAppIdentifier('+19175395595@s.whatsapp.net'), '19175395595');
|
||||
assert.equal(normalizeWhatsAppIdentifier('267383306489914@lid'), '267383306489914');
|
||||
assert.equal(normalizeWhatsAppIdentifier('19175395595:12@s.whatsapp.net'), '19175395595');
|
||||
});
|
||||
|
||||
test('expandWhatsAppIdentifiers resolves phone and lid aliases from session files', () => {
|
||||
const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-'));
|
||||
|
||||
try {
|
||||
writeFileSync(path.join(sessionDir, 'lid-mapping-19175395595.json'), JSON.stringify('267383306489914'));
|
||||
writeFileSync(path.join(sessionDir, 'lid-mapping-267383306489914_reverse.json'), JSON.stringify('19175395595'));
|
||||
|
||||
const aliases = expandWhatsAppIdentifiers('267383306489914@lid', sessionDir);
|
||||
assert.deepEqual([...aliases].sort(), ['19175395595', '267383306489914']);
|
||||
} finally {
|
||||
rmSync(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('matchesAllowedUser accepts mapped lid sender when allowlist only contains phone number', () => {
|
||||
const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-'));
|
||||
|
||||
try {
|
||||
writeFileSync(path.join(sessionDir, 'lid-mapping-19175395595.json'), JSON.stringify('267383306489914'));
|
||||
writeFileSync(path.join(sessionDir, 'lid-mapping-267383306489914_reverse.json'), JSON.stringify('19175395595'));
|
||||
|
||||
const allowedUsers = parseAllowedUsers('+19175395595');
|
||||
assert.equal(matchesAllowedUser('267383306489914@lid', allowedUsers, sessionDir), true);
|
||||
assert.equal(matchesAllowedUser('188012763865257@lid', allowedUsers, sessionDir), false);
|
||||
} finally {
|
||||
rmSync(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import path from 'path';
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
|
||||
import { randomBytes } from 'crypto';
|
||||
import qrcode from 'qrcode-terminal';
|
||||
import { matchesAllowedUser, parseAllowedUsers } from './allowlist.js';
|
||||
|
||||
// Parse CLI args
|
||||
const args = process.argv.slice(2);
|
||||
@@ -47,13 +48,17 @@ const DOCUMENT_CACHE_DIR = path.join(process.env.HOME || '~', '.hermes', 'docume
|
||||
const AUDIO_CACHE_DIR = path.join(process.env.HOME || '~', '.hermes', 'audio_cache');
|
||||
const PAIR_ONLY = args.includes('--pair-only');
|
||||
const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat'); // "bot" or "self-chat"
|
||||
const ALLOWED_USERS = (process.env.WHATSAPP_ALLOWED_USERS || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const ALLOWED_USERS = parseAllowedUsers(process.env.WHATSAPP_ALLOWED_USERS || '');
|
||||
const DEFAULT_REPLY_PREFIX = '⚕ *Hermes Agent*\n────────────\n';
|
||||
const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined
|
||||
? DEFAULT_REPLY_PREFIX
|
||||
: 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;
|
||||
}
|
||||
|
||||
@@ -190,10 +195,9 @@ async function startSocket() {
|
||||
if (!isSelfChat) continue;
|
||||
}
|
||||
|
||||
// Check allowlist for messages from others (resolve LID → phone if needed)
|
||||
if (!msg.key.fromMe && ALLOWED_USERS.length > 0) {
|
||||
const resolvedNumber = lidToPhone[senderNumber] || senderNumber;
|
||||
if (!ALLOWED_USERS.includes(resolvedNumber)) continue;
|
||||
// Check allowlist for messages from others (resolve LID ↔ phone aliases)
|
||||
if (!msg.key.fromMe && !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract message body
|
||||
@@ -515,8 +519,8 @@ if (PAIR_ONLY) {
|
||||
app.listen(PORT, '127.0.0.1', () => {
|
||||
console.log(`🌉 WhatsApp bridge listening on port ${PORT} (mode: ${WHATSAPP_MODE})`);
|
||||
console.log(`📁 Session stored in: ${SESSION_DIR}`);
|
||||
if (ALLOWED_USERS.length > 0) {
|
||||
console.log(`🔒 Allowed users: ${ALLOWED_USERS.join(', ')}`);
|
||||
if (ALLOWED_USERS.size > 0) {
|
||||
console.log(`🔒 Allowed users: ${Array.from(ALLOWED_USERS).join(', ')}`);
|
||||
} else {
|
||||
console.log(`⚠️ No WHATSAPP_ALLOWED_USERS set — all messages will be processed`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
---
|
||||
name: songwriting-and-ai-music
|
||||
description: >
|
||||
Songwriting craft, AI music generation prompts (Suno focus), parody/adaptation
|
||||
techniques, phonetic tricks, and lessons learned. These are tools and ideas,
|
||||
not rules. Break any of them when the art calls for it.
|
||||
tags: [songwriting, music, suno, parody, lyrics, creative]
|
||||
triggers:
|
||||
- writing a song
|
||||
- song lyrics
|
||||
- music prompt
|
||||
- suno prompt
|
||||
- parody song
|
||||
- adapting a song
|
||||
- AI music generation
|
||||
---
|
||||
|
||||
# Songwriting & AI Music Generation
|
||||
|
||||
Everything here is a GUIDELINE, not a rule. Art breaks rules on purpose.
|
||||
Use what serves the song. Ignore what doesn't.
|
||||
|
||||
---
|
||||
|
||||
## 1. Song Structure (Pick One or Invent Your Own)
|
||||
|
||||
Common skeletons — mix, modify, or throw out as needed:
|
||||
|
||||
```
|
||||
ABABCB Verse/Chorus/Verse/Chorus/Bridge/Chorus (most pop/rock)
|
||||
AABA Verse/Verse/Bridge/Verse (refrain-based) (jazz standards, ballads)
|
||||
ABAB Verse/Chorus alternating (simple, direct)
|
||||
AAA Verse/Verse/Verse (strophic, no chorus) (folk, storytelling)
|
||||
```
|
||||
|
||||
The six building blocks:
|
||||
- Intro — set the mood, pull the listener in
|
||||
- Verse — the story, the details, the world-building
|
||||
- Pre-Chorus — optional tension ramp before the payoff
|
||||
- Chorus — the emotional core, the part people remember
|
||||
- Bridge — a detour, a shift in perspective or key
|
||||
- Outro — the farewell, can echo or subvert the rest
|
||||
|
||||
You don't need all of these. Some great songs are just one section
|
||||
that evolves. Structure serves the emotion, not the other way around.
|
||||
|
||||
---
|
||||
|
||||
## 2. Rhyme, Meter, and Sound
|
||||
|
||||
RHYME TYPES (from tight to loose):
|
||||
- Perfect: lean/mean
|
||||
- Family: crate/braid
|
||||
- Assonance: had/glass (same vowels, different endings)
|
||||
- Consonance: scene/when (different vowels, similar endings)
|
||||
- Near/slant: enough to suggest connection without locking it down
|
||||
|
||||
Mix them. All perfect rhymes can sound like a nursery rhyme.
|
||||
All slant rhymes can sound lazy. The blend is where it lives.
|
||||
|
||||
INTERNAL RHYME: Rhyming within a line, not just at the ends.
|
||||
"We pruned the lies from bleeding trees / Distilled the storm
|
||||
from entropy" — "lies/flies," "trees/entropy" create internal echoes.
|
||||
|
||||
METER: The rhythm of stressed vs unstressed syllables.
|
||||
- Matching syllable counts between parallel lines helps singability
|
||||
- The STRESSED syllables matter more than total count
|
||||
- Say it out loud. If you stumble, the meter needs work.
|
||||
- Intentionally breaking meter can create emphasis or surprise
|
||||
|
||||
---
|
||||
|
||||
## 3. Emotional Arc and Dynamics
|
||||
|
||||
Think of a song as a journey, not a flat road.
|
||||
|
||||
ENERGY MAPPING (rough idea, not prescription):
|
||||
Intro: 2-3 | Verse: 5-6 | Pre-Chorus: 7
|
||||
Chorus: 8-9 | Bridge: varies | Final Chorus: 9-10
|
||||
|
||||
The most powerful dynamic trick: CONTRAST.
|
||||
- Whisper before a scream hits harder than just screaming
|
||||
- Sparse before dense. Slow before fast. Low before high.
|
||||
- The drop only works because of the buildup
|
||||
- Silence is an instrument
|
||||
|
||||
"Whisper to roar to whisper" — start intimate, build to full power,
|
||||
strip back to vulnerability. Works for ballads, epics, anthems.
|
||||
|
||||
---
|
||||
|
||||
## 4. Writing Lyrics That Work
|
||||
|
||||
SHOW, DON'T TELL (usually):
|
||||
- "I was sad" = flat
|
||||
- "Your hoodie's still on the hook by the door" = alive
|
||||
- But sometimes "I give my life" said plainly IS the power
|
||||
|
||||
THE HOOK:
|
||||
- The line people remember, hum, repeat
|
||||
- Usually the title or core phrase
|
||||
- Works best when melody + lyric + emotion all align
|
||||
- Place it where it lands hardest (often first/last line of chorus)
|
||||
|
||||
PROSODY — lyrics and music supporting each other:
|
||||
- Stable feelings (resolution, peace) pair with settled melodies,
|
||||
perfect rhymes, resolved chords
|
||||
- Unstable feelings (longing, doubt) pair with wandering melodies,
|
||||
near-rhymes, unresolved chords
|
||||
- Verse melody typically sits lower, chorus goes higher
|
||||
- But flip this if it serves the song
|
||||
|
||||
AVOID (unless you're doing it on purpose):
|
||||
- Cliches on autopilot ("heart of gold" without earning it)
|
||||
- Forcing word order to hit a rhyme ("Yoda-speak")
|
||||
- Same energy in every section (flat dynamics)
|
||||
- Treating your first draft as sacred — revision is creation
|
||||
|
||||
---
|
||||
|
||||
## 5. Parody and Adaptation
|
||||
|
||||
When rewriting an existing song with new lyrics:
|
||||
|
||||
THE SKELETON: Map the original's structure first.
|
||||
- Count syllables per line
|
||||
- Mark the rhyme scheme (ABAB, AABB, etc.)
|
||||
- Identify which syllables are STRESSED
|
||||
- Note where held/sustained notes fall
|
||||
|
||||
FITTING NEW WORDS:
|
||||
- Match stressed syllables to the same beats as the original
|
||||
- Total syllable count can flex by 1-2 unstressed syllables
|
||||
- On long held notes, try to match the VOWEL SOUND of the original
|
||||
(if original holds "LOOOVE" with an "oo" vowel, "FOOOD" fits
|
||||
better than "LIFE")
|
||||
- Monosyllabic swaps in key spots keep rhythm intact
|
||||
(Crime -> Code, Snake -> Noose)
|
||||
- Sing your new words over the original — if you stumble, revise
|
||||
|
||||
CONCEPT:
|
||||
- Pick a concept strong enough to sustain the whole song
|
||||
- Start from the title/hook and build outward
|
||||
- Generate lots of raw material (puns, phrases, images) FIRST,
|
||||
then fit the best ones into the structure
|
||||
- If you need a specific line somewhere, reverse-engineer the
|
||||
rhyme scheme backward to set it up
|
||||
|
||||
KEEP SOME ORIGINALS: Leaving a few original lines or structures
|
||||
intact adds recognizability and lets the audience feel the connection.
|
||||
|
||||
---
|
||||
|
||||
## 6. Suno AI Prompt Engineering
|
||||
|
||||
### Style/Genre Description Field
|
||||
|
||||
FORMULA (adapt as needed):
|
||||
Genre + Mood + Era + Instruments + Vocal Style + Production + Dynamics
|
||||
|
||||
```
|
||||
BAD: "sad rock song"
|
||||
GOOD: "Cinematic orchestral spy thriller, 1960s Cold War era, smoky
|
||||
sultry female vocalist, big band jazz, brass section with
|
||||
trumpets and french horns, sweeping strings, minor key,
|
||||
vintage analog warmth"
|
||||
```
|
||||
|
||||
DESCRIBE THE JOURNEY, not just the genre:
|
||||
```
|
||||
"Begins as a haunting whisper over sparse piano. Gradually layers
|
||||
in muted brass. Builds through the chorus with full orchestra.
|
||||
Second verse erupts with raw belting intensity. Outro strips back
|
||||
to a lone piano and a fragile whisper fading to silence."
|
||||
```
|
||||
|
||||
TIPS:
|
||||
- V4.5+ supports up to 1,000 chars in Style field — use them
|
||||
- NO artist names or trademarks. Describe the sound instead.
|
||||
"1960s Cold War spy thriller brass" not "James Bond style"
|
||||
"90s grunge" not "Nirvana-style"
|
||||
- Specify BPM and key when you have a preference
|
||||
- Use Exclude Styles field for what you DON'T want
|
||||
- Unexpected genre combos can be gold: "bossa nova trap",
|
||||
"Appalachian gothic", "chiptune jazz"
|
||||
- Build a vocal PERSONA, not just a gender:
|
||||
"A weathered torch singer with a smoky alto, slight rasp,
|
||||
who starts vulnerable and builds to devastating power"
|
||||
|
||||
### Metatags (place in [brackets] inside lyrics field)
|
||||
|
||||
STRUCTURE:
|
||||
[Intro] [Verse] [Verse 1] [Pre-Chorus] [Chorus]
|
||||
[Post-Chorus] [Hook] [Bridge] [Interlude]
|
||||
[Instrumental] [Instrumental Break] [Guitar Solo]
|
||||
[Breakdown] [Build-up] [Outro] [Silence] [End]
|
||||
|
||||
VOCAL PERFORMANCE:
|
||||
[Whispered] [Spoken Word] [Belted] [Falsetto] [Powerful]
|
||||
[Soulful] [Raspy] [Breathy] [Smooth] [Gritty]
|
||||
[Staccato] [Legato] [Vibrato] [Melismatic]
|
||||
[Harmonies] [Choir] [Harmonized Chorus]
|
||||
|
||||
DYNAMICS:
|
||||
[High Energy] [Low Energy] [Building Energy] [Explosive]
|
||||
[Emotional Climax] [Gradual swell] [Orchestral swell]
|
||||
[Quiet arrangement] [Falling tension] [Slow Down]
|
||||
|
||||
GENDER:
|
||||
[Female Vocals] [Male Vocals]
|
||||
|
||||
ATMOSPHERE:
|
||||
[Melancholic] [Euphoric] [Nostalgic] [Aggressive]
|
||||
[Dreamy] [Intimate] [Dark Atmosphere]
|
||||
|
||||
SFX:
|
||||
[Vinyl Crackle] [Rain] [Applause] [Static] [Thunder]
|
||||
|
||||
Put tags in BOTH style field AND lyrics for reinforcement.
|
||||
Keep to 5-8 tags per section max — too many confuses the AI.
|
||||
Don't contradict yourself ([Calm] + [Aggressive] in same section).
|
||||
|
||||
### Custom Mode
|
||||
- Always use Custom Mode for serious work (separate Style + Lyrics)
|
||||
- Lyrics field limit: ~3,000 chars (~40-60 lines)
|
||||
- Always add structural tags — without them Suno defaults to
|
||||
flat verse/chorus/verse with no emotional arc
|
||||
|
||||
---
|
||||
|
||||
## 7. Phonetic Tricks for AI Singers
|
||||
|
||||
AI vocalists don't read — they pronounce. Help them:
|
||||
|
||||
PHONETIC RESPELLING:
|
||||
- Spell words as they SOUND: "through" -> "thru"
|
||||
- Proper nouns are highest failure rate — test early
|
||||
- "Nous" -> "Noose" (forces correct pronunciation)
|
||||
- Hyphenate to guide syllables: "Re-search", "bio-engineering"
|
||||
|
||||
DELIVERY CONTROL:
|
||||
- ALL CAPS = louder, more intense
|
||||
- Vowel extension: "lo-o-o-ove" = sustained/melisma
|
||||
- Ellipses: "I... need... you" = dramatic pauses
|
||||
- Hyphenated stretch: "ne-e-ed" = emotional stretch
|
||||
|
||||
ALWAYS:
|
||||
- Spell out numbers: "24/7" -> "twenty four seven"
|
||||
- Space acronyms: "AI" -> "A I" or "A-I"
|
||||
- Test proper nouns/unusual words in a short 30-second clip first
|
||||
- Once generated, pronunciation is baked in — fix in lyrics BEFORE
|
||||
|
||||
---
|
||||
|
||||
## 8. Workflow
|
||||
|
||||
1. Write the concept/hook first — what's the emotional core?
|
||||
2. If adapting, map the original structure (syllables, rhyme, stress)
|
||||
3. Generate raw material — brainstorm freely before structuring
|
||||
4. Draft lyrics into the structure
|
||||
5. Read/sing aloud — catch stumbles, fix meter
|
||||
6. Build the Suno style description — paint the dynamic journey
|
||||
7. Add metatags to lyrics for performance direction
|
||||
8. Generate 3-5 variations minimum — treat them like recording takes
|
||||
9. Pick the best, use Extend/Continue to build on promising sections
|
||||
10. If something great happens by accident, keep it
|
||||
|
||||
EXPECT: ~3-5 generations per 1 good result. Revision is normal.
|
||||
Style can drift in extensions — restate genre/mood when extending.
|
||||
|
||||
---
|
||||
|
||||
## 9. Lessons Learned
|
||||
|
||||
- Describing the dynamic ARC in the style field matters way more
|
||||
than just listing genres. "Whisper to roar to whisper" gives
|
||||
Suno a performance map.
|
||||
- Keeping some original lines intact in a parody adds recognizability
|
||||
and emotional weight — the audience feels the ghost of the original.
|
||||
- The bridge slot in a song is where you can transform imagery.
|
||||
Swap the original's specific references for your theme's metaphors
|
||||
while keeping the emotional function (reflection, shift, revelation).
|
||||
- Monosyllabic word swaps in hooks/tags are the cleanest way to
|
||||
maintain rhythm while changing meaning.
|
||||
- A strong vocal persona description in the style field makes a
|
||||
bigger difference than any single metatag.
|
||||
- Don't be precious about rules. If a line breaks meter but hits
|
||||
harder, keep it. The feeling is what matters. Craft serves art,
|
||||
not the other way around.
|
||||
@@ -1,3 +0,0 @@
|
||||
---
|
||||
description: Skills for generating, editing, and processing music and audio using AI models and audio tools.
|
||||
---
|
||||
@@ -491,15 +491,17 @@ class TestGetTextAuxiliaryClient:
|
||||
assert mock_openai.call_args.kwargs["base_url"] == "http://localhost:2345/v1"
|
||||
assert mock_openai.call_args.kwargs["api_key"] == "task-key"
|
||||
|
||||
def test_task_direct_endpoint_without_openai_key_does_not_fall_back(self, monkeypatch):
|
||||
def test_task_direct_endpoint_without_openai_key_uses_placeholder(self, monkeypatch):
|
||||
"""Local endpoints without an API key should use 'no-key-required' placeholder."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_BASE_URL", "http://localhost:2345/v1")
|
||||
monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_MODEL", "task-model")
|
||||
with patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
client, model = get_text_auxiliary_client("web_extract")
|
||||
assert client is None
|
||||
assert model is None
|
||||
mock_openai.assert_not_called()
|
||||
assert client is not None
|
||||
assert model == "task-model"
|
||||
assert mock_openai.call_args.kwargs["api_key"] == "no-key-required"
|
||||
assert mock_openai.call_args.kwargs["base_url"] == "http://localhost:2345/v1"
|
||||
|
||||
def test_custom_endpoint_uses_config_saved_base_url(self, monkeypatch):
|
||||
config = {
|
||||
@@ -696,15 +698,16 @@ class TestVisionClientFallback:
|
||||
assert mock_openai.call_args.kwargs["base_url"] == "http://localhost:4567/v1"
|
||||
assert mock_openai.call_args.kwargs["api_key"] == "vision-key"
|
||||
|
||||
def test_vision_direct_endpoint_requires_openai_api_key(self, monkeypatch):
|
||||
def test_vision_direct_endpoint_without_key_uses_placeholder(self, monkeypatch):
|
||||
"""Vision endpoint without API key should use 'no-key-required' placeholder."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
monkeypatch.setenv("AUXILIARY_VISION_BASE_URL", "http://localhost:4567/v1")
|
||||
monkeypatch.setenv("AUXILIARY_VISION_MODEL", "vision-model")
|
||||
with patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
client, model = get_vision_auxiliary_client()
|
||||
assert client is None
|
||||
assert model is None
|
||||
mock_openai.assert_not_called()
|
||||
assert client is not None
|
||||
assert model == "vision-model"
|
||||
assert mock_openai.call_args.kwargs["api_key"] == "no-key-required"
|
||||
|
||||
def test_vision_uses_openrouter_when_available(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -84,6 +84,48 @@ class TestResolveDeliveryTarget:
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
def test_human_friendly_label_resolved_via_channel_directory(self):
|
||||
"""deliver: 'whatsapp:Alice (dm)' resolves to the real JID."""
|
||||
job = {"deliver": "whatsapp:Alice (dm)"}
|
||||
with patch(
|
||||
"gateway.channel_directory.resolve_channel_name",
|
||||
return_value="12345678901234@lid",
|
||||
):
|
||||
result = _resolve_delivery_target(job)
|
||||
assert result == {
|
||||
"platform": "whatsapp",
|
||||
"chat_id": "12345678901234@lid",
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
def test_human_friendly_label_without_suffix_resolved(self):
|
||||
"""deliver: 'telegram:My Group' resolves without display suffix."""
|
||||
job = {"deliver": "telegram:My Group"}
|
||||
with patch(
|
||||
"gateway.channel_directory.resolve_channel_name",
|
||||
return_value="-1009999",
|
||||
):
|
||||
result = _resolve_delivery_target(job)
|
||||
assert result == {
|
||||
"platform": "telegram",
|
||||
"chat_id": "-1009999",
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
def test_raw_id_not_mangled_when_directory_returns_none(self):
|
||||
"""deliver: 'whatsapp:12345@lid' passes through when directory has no match."""
|
||||
job = {"deliver": "whatsapp:12345@lid"}
|
||||
with patch(
|
||||
"gateway.channel_directory.resolve_channel_name",
|
||||
return_value=None,
|
||||
):
|
||||
result = _resolve_delivery_target(job)
|
||||
assert result == {
|
||||
"platform": "whatsapp",
|
||||
"chat_id": "12345@lid",
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
def test_bare_platform_uses_matching_origin_chat(self):
|
||||
job = {
|
||||
"deliver": "telegram",
|
||||
|
||||
@@ -13,7 +13,7 @@ def _would_warn():
|
||||
"SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
|
||||
"EMAIL_ALLOWED_USERS",
|
||||
"SMS_ALLOWED_USERS", "MATTERMOST_ALLOWED_USERS",
|
||||
"MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS",
|
||||
"MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS", "FEISHU_ALLOWED_USERS", "WECOM_ALLOWED_USERS",
|
||||
"GATEWAY_ALLOWED_USERS")
|
||||
)
|
||||
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any(
|
||||
@@ -22,7 +22,7 @@ def _would_warn():
|
||||
"WHATSAPP_ALLOW_ALL_USERS", "SLACK_ALLOW_ALL_USERS",
|
||||
"SIGNAL_ALLOW_ALL_USERS", "EMAIL_ALLOW_ALL_USERS",
|
||||
"SMS_ALLOW_ALL_USERS", "MATTERMOST_ALLOW_ALL_USERS",
|
||||
"MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS")
|
||||
"MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS", "FEISHU_ALLOW_ALL_USERS", "WECOM_ALLOW_ALL_USERS")
|
||||
)
|
||||
return not _any_allowlist and not _allow_all
|
||||
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,13 +29,18 @@ class TestHookRegistryInit:
|
||||
assert reg._handlers == {}
|
||||
|
||||
|
||||
def _patch_no_builtins(reg):
|
||||
"""Suppress built-in hook registration so tests only exercise user-hook discovery."""
|
||||
return patch.object(reg, "_register_builtin_hooks")
|
||||
|
||||
|
||||
class TestDiscoverAndLoad:
|
||||
def test_loads_valid_hook(self, tmp_path):
|
||||
_create_hook(tmp_path, "my-hook", '["agent:start"]',
|
||||
"def handle(event_type, context):\n pass\n")
|
||||
|
||||
reg = HookRegistry()
|
||||
with patch("gateway.hooks.HOOKS_DIR", tmp_path):
|
||||
with patch("gateway.hooks.HOOKS_DIR", tmp_path), _patch_no_builtins(reg):
|
||||
reg.discover_and_load()
|
||||
|
||||
assert len(reg.loaded_hooks) == 1
|
||||
@@ -48,7 +53,7 @@ class TestDiscoverAndLoad:
|
||||
(hook_dir / "handler.py").write_text("def handle(e, c): pass\n")
|
||||
|
||||
reg = HookRegistry()
|
||||
with patch("gateway.hooks.HOOKS_DIR", tmp_path):
|
||||
with patch("gateway.hooks.HOOKS_DIR", tmp_path), _patch_no_builtins(reg):
|
||||
reg.discover_and_load()
|
||||
|
||||
assert len(reg.loaded_hooks) == 0
|
||||
@@ -59,7 +64,7 @@ class TestDiscoverAndLoad:
|
||||
(hook_dir / "HOOK.yaml").write_text("name: bad\nevents: ['agent:start']\n")
|
||||
|
||||
reg = HookRegistry()
|
||||
with patch("gateway.hooks.HOOKS_DIR", tmp_path):
|
||||
with patch("gateway.hooks.HOOKS_DIR", tmp_path), _patch_no_builtins(reg):
|
||||
reg.discover_and_load()
|
||||
|
||||
assert len(reg.loaded_hooks) == 0
|
||||
@@ -71,7 +76,7 @@ class TestDiscoverAndLoad:
|
||||
(hook_dir / "handler.py").write_text("def handle(e, c): pass\n")
|
||||
|
||||
reg = HookRegistry()
|
||||
with patch("gateway.hooks.HOOKS_DIR", tmp_path):
|
||||
with patch("gateway.hooks.HOOKS_DIR", tmp_path), _patch_no_builtins(reg):
|
||||
reg.discover_and_load()
|
||||
|
||||
assert len(reg.loaded_hooks) == 0
|
||||
@@ -83,14 +88,14 @@ class TestDiscoverAndLoad:
|
||||
(hook_dir / "handler.py").write_text("def something_else(): pass\n")
|
||||
|
||||
reg = HookRegistry()
|
||||
with patch("gateway.hooks.HOOKS_DIR", tmp_path):
|
||||
with patch("gateway.hooks.HOOKS_DIR", tmp_path), _patch_no_builtins(reg):
|
||||
reg.discover_and_load()
|
||||
|
||||
assert len(reg.loaded_hooks) == 0
|
||||
|
||||
def test_nonexistent_hooks_dir(self, tmp_path):
|
||||
reg = HookRegistry()
|
||||
with patch("gateway.hooks.HOOKS_DIR", tmp_path / "nonexistent"):
|
||||
with patch("gateway.hooks.HOOKS_DIR", tmp_path / "nonexistent"), _patch_no_builtins(reg):
|
||||
reg.discover_and_load()
|
||||
|
||||
assert len(reg.loaded_hooks) == 0
|
||||
@@ -102,7 +107,7 @@ class TestDiscoverAndLoad:
|
||||
"def handle(e, c): pass\n")
|
||||
|
||||
reg = HookRegistry()
|
||||
with patch("gateway.hooks.HOOKS_DIR", tmp_path):
|
||||
with patch("gateway.hooks.HOOKS_DIR", tmp_path), _patch_no_builtins(reg):
|
||||
reg.discover_and_load()
|
||||
|
||||
assert len(reg.loaded_hooks) == 2
|
||||
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import gateway.run as gateway_run
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.session import SessionSource
|
||||
@@ -19,7 +20,7 @@ def _clear_auth_env(monkeypatch) -> None:
|
||||
"SMS_ALLOWED_USERS",
|
||||
"MATTERMOST_ALLOWED_USERS",
|
||||
"MATRIX_ALLOWED_USERS",
|
||||
"DINGTALK_ALLOWED_USERS",
|
||||
"DINGTALK_ALLOWED_USERS", "FEISHU_ALLOWED_USERS", "WECOM_ALLOWED_USERS",
|
||||
"GATEWAY_ALLOWED_USERS",
|
||||
"TELEGRAM_ALLOW_ALL_USERS",
|
||||
"DISCORD_ALLOW_ALL_USERS",
|
||||
@@ -30,7 +31,7 @@ def _clear_auth_env(monkeypatch) -> None:
|
||||
"SMS_ALLOW_ALL_USERS",
|
||||
"MATTERMOST_ALLOW_ALL_USERS",
|
||||
"MATRIX_ALLOW_ALL_USERS",
|
||||
"DINGTALK_ALLOW_ALL_USERS",
|
||||
"DINGTALK_ALLOW_ALL_USERS", "FEISHU_ALLOW_ALL_USERS", "WECOM_ALLOW_ALL_USERS",
|
||||
"GATEWAY_ALLOW_ALL_USERS",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
@@ -62,6 +63,32 @@ def _make_runner(platform: Platform, config: GatewayConfig):
|
||||
return runner, adapter
|
||||
|
||||
|
||||
def test_whatsapp_lid_user_matches_phone_allowlist_via_session_mapping(monkeypatch, tmp_path):
|
||||
_clear_auth_env(monkeypatch)
|
||||
monkeypatch.setenv("WHATSAPP_ALLOWED_USERS", "15550000001")
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
|
||||
session_dir = tmp_path / "whatsapp" / "session"
|
||||
session_dir.mkdir(parents=True)
|
||||
(session_dir / "lid-mapping-15550000001.json").write_text('"900000000000001"', encoding="utf-8")
|
||||
(session_dir / "lid-mapping-900000000000001_reverse.json").write_text('"15550000001"', encoding="utf-8")
|
||||
|
||||
runner, _adapter = _make_runner(
|
||||
Platform.WHATSAPP,
|
||||
GatewayConfig(platforms={Platform.WHATSAPP: PlatformConfig(enabled=True)}),
|
||||
)
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.WHATSAPP,
|
||||
user_id="900000000000001@lid",
|
||||
chat_id="900000000000001@lid",
|
||||
user_name="tester",
|
||||
chat_type="dm",
|
||||
)
|
||||
|
||||
assert runner._is_user_authorized(source) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthorized_dm_pairs_by_default(monkeypatch):
|
||||
_clear_auth_env(monkeypatch)
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
"""Tests for the WeCom platform adapter."""
|
||||
|
||||
import base64
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import SendResult
|
||||
|
||||
|
||||
class TestWeComRequirements:
|
||||
def test_returns_false_without_aiohttp(self, monkeypatch):
|
||||
monkeypatch.setattr("gateway.platforms.wecom.AIOHTTP_AVAILABLE", False)
|
||||
monkeypatch.setattr("gateway.platforms.wecom.HTTPX_AVAILABLE", True)
|
||||
from gateway.platforms.wecom import check_wecom_requirements
|
||||
|
||||
assert check_wecom_requirements() is False
|
||||
|
||||
def test_returns_false_without_httpx(self, monkeypatch):
|
||||
monkeypatch.setattr("gateway.platforms.wecom.AIOHTTP_AVAILABLE", True)
|
||||
monkeypatch.setattr("gateway.platforms.wecom.HTTPX_AVAILABLE", False)
|
||||
from gateway.platforms.wecom import check_wecom_requirements
|
||||
|
||||
assert check_wecom_requirements() is False
|
||||
|
||||
def test_returns_true_when_available(self, monkeypatch):
|
||||
monkeypatch.setattr("gateway.platforms.wecom.AIOHTTP_AVAILABLE", True)
|
||||
monkeypatch.setattr("gateway.platforms.wecom.HTTPX_AVAILABLE", True)
|
||||
from gateway.platforms.wecom import check_wecom_requirements
|
||||
|
||||
assert check_wecom_requirements() is True
|
||||
|
||||
|
||||
class TestWeComAdapterInit:
|
||||
def test_reads_config_from_extra(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
extra={
|
||||
"bot_id": "cfg-bot",
|
||||
"secret": "cfg-secret",
|
||||
"websocket_url": "wss://custom.wecom.example/ws",
|
||||
"group_policy": "allowlist",
|
||||
"group_allow_from": ["group-1"],
|
||||
},
|
||||
)
|
||||
adapter = WeComAdapter(config)
|
||||
|
||||
assert adapter._bot_id == "cfg-bot"
|
||||
assert adapter._secret == "cfg-secret"
|
||||
assert adapter._ws_url == "wss://custom.wecom.example/ws"
|
||||
assert adapter._group_policy == "allowlist"
|
||||
assert adapter._group_allow_from == ["group-1"]
|
||||
|
||||
def test_falls_back_to_env_vars(self, monkeypatch):
|
||||
monkeypatch.setenv("WECOM_BOT_ID", "env-bot")
|
||||
monkeypatch.setenv("WECOM_SECRET", "env-secret")
|
||||
monkeypatch.setenv("WECOM_WEBSOCKET_URL", "wss://env.example/ws")
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
assert adapter._bot_id == "env-bot"
|
||||
assert adapter._secret == "env-secret"
|
||||
assert adapter._ws_url == "wss://env.example/ws"
|
||||
|
||||
|
||||
class TestWeComConnect:
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_records_missing_credentials(self, monkeypatch):
|
||||
import gateway.platforms.wecom as wecom_module
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
monkeypatch.setattr(wecom_module, "AIOHTTP_AVAILABLE", True)
|
||||
monkeypatch.setattr(wecom_module, "HTTPX_AVAILABLE", True)
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
|
||||
success = await adapter.connect()
|
||||
|
||||
assert success is False
|
||||
assert adapter.has_fatal_error is True
|
||||
assert adapter.fatal_error_code == "wecom_missing_credentials"
|
||||
assert "WECOM_BOT_ID" in (adapter.fatal_error_message or "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_records_handshake_failure_details(self, monkeypatch):
|
||||
import gateway.platforms.wecom as wecom_module
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
class DummyClient:
|
||||
async def aclose(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(wecom_module, "AIOHTTP_AVAILABLE", True)
|
||||
monkeypatch.setattr(wecom_module, "HTTPX_AVAILABLE", True)
|
||||
monkeypatch.setattr(
|
||||
wecom_module,
|
||||
"httpx",
|
||||
SimpleNamespace(AsyncClient=lambda **kwargs: DummyClient()),
|
||||
)
|
||||
|
||||
adapter = WeComAdapter(
|
||||
PlatformConfig(enabled=True, extra={"bot_id": "bot-1", "secret": "secret-1"})
|
||||
)
|
||||
adapter._open_connection = AsyncMock(side_effect=RuntimeError("invalid secret (errcode=40013)"))
|
||||
|
||||
success = await adapter.connect()
|
||||
|
||||
assert success is False
|
||||
assert adapter.has_fatal_error is True
|
||||
assert adapter.fatal_error_code == "wecom_connect_error"
|
||||
assert "invalid secret" in (adapter.fatal_error_message or "")
|
||||
|
||||
|
||||
class TestWeComReplyMode:
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_uses_passive_reply_stream_when_reply_context_exists(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter._reply_req_ids["msg-1"] = "req-1"
|
||||
adapter._send_reply_request = AsyncMock(
|
||||
return_value={"headers": {"req_id": "req-1"}, "errcode": 0}
|
||||
)
|
||||
|
||||
result = await adapter.send("chat-123", "hello from reply", reply_to="msg-1")
|
||||
|
||||
assert result.success is True
|
||||
adapter._send_reply_request.assert_awaited_once()
|
||||
args = adapter._send_reply_request.await_args.args
|
||||
assert args[0] == "req-1"
|
||||
assert args[1]["msgtype"] == "stream"
|
||||
assert args[1]["stream"]["finish"] is True
|
||||
assert args[1]["stream"]["content"] == "hello from reply"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_file_uses_passive_reply_media_when_reply_context_exists(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter._reply_req_ids["msg-1"] = "req-1"
|
||||
adapter._prepare_outbound_media = AsyncMock(
|
||||
return_value={
|
||||
"data": b"image-bytes",
|
||||
"content_type": "image/png",
|
||||
"file_name": "demo.png",
|
||||
"detected_type": "image",
|
||||
"final_type": "image",
|
||||
"rejected": False,
|
||||
"reject_reason": None,
|
||||
"downgraded": False,
|
||||
"downgrade_note": None,
|
||||
}
|
||||
)
|
||||
adapter._upload_media_bytes = AsyncMock(return_value={"media_id": "media-1", "type": "image"})
|
||||
adapter._send_reply_request = AsyncMock(
|
||||
return_value={"headers": {"req_id": "req-1"}, "errcode": 0}
|
||||
)
|
||||
|
||||
result = await adapter.send_image_file("chat-123", "/tmp/demo.png", reply_to="msg-1")
|
||||
|
||||
assert result.success is True
|
||||
adapter._send_reply_request.assert_awaited_once()
|
||||
args = adapter._send_reply_request.await_args.args
|
||||
assert args[0] == "req-1"
|
||||
assert args[1] == {"msgtype": "image", "image": {"media_id": "media-1"}}
|
||||
|
||||
|
||||
class TestExtractText:
|
||||
def test_extracts_plain_text(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
body = {
|
||||
"msgtype": "text",
|
||||
"text": {"content": " hello world "},
|
||||
}
|
||||
text, reply_text = WeComAdapter._extract_text(body)
|
||||
assert text == "hello world"
|
||||
assert reply_text is None
|
||||
|
||||
def test_extracts_mixed_text(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
body = {
|
||||
"msgtype": "mixed",
|
||||
"mixed": {
|
||||
"msg_item": [
|
||||
{"msgtype": "text", "text": {"content": "part1"}},
|
||||
{"msgtype": "image", "image": {"url": "https://example.com/x.png"}},
|
||||
{"msgtype": "text", "text": {"content": "part2"}},
|
||||
]
|
||||
},
|
||||
}
|
||||
text, _reply_text = WeComAdapter._extract_text(body)
|
||||
assert text == "part1\npart2"
|
||||
|
||||
def test_extracts_voice_and_quote(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
body = {
|
||||
"msgtype": "voice",
|
||||
"voice": {"content": "spoken text"},
|
||||
"quote": {"msgtype": "text", "text": {"content": "quoted"}},
|
||||
}
|
||||
text, reply_text = WeComAdapter._extract_text(body)
|
||||
assert text == "spoken text"
|
||||
assert reply_text == "quoted"
|
||||
|
||||
|
||||
class TestCallbackDispatch:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("cmd", ["aibot_msg_callback", "aibot_callback"])
|
||||
async def test_dispatch_accepts_new_and_legacy_callback_cmds(self, cmd):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter._on_message = AsyncMock()
|
||||
|
||||
await adapter._dispatch_payload({"cmd": cmd, "headers": {"req_id": "req-1"}, "body": {}})
|
||||
|
||||
adapter._on_message.assert_awaited_once()
|
||||
|
||||
|
||||
class TestPolicyHelpers:
|
||||
def test_dm_allowlist(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(
|
||||
PlatformConfig(enabled=True, extra={"dm_policy": "allowlist", "allow_from": ["user-1"]})
|
||||
)
|
||||
assert adapter._is_dm_allowed("user-1") is True
|
||||
assert adapter._is_dm_allowed("user-2") is False
|
||||
|
||||
def test_group_allowlist_and_per_group_sender_allowlist(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(
|
||||
PlatformConfig(
|
||||
enabled=True,
|
||||
extra={
|
||||
"group_policy": "allowlist",
|
||||
"group_allow_from": ["group-1"],
|
||||
"groups": {"group-1": {"allow_from": ["user-1"]}},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert adapter._is_group_allowed("group-1", "user-1") is True
|
||||
assert adapter._is_group_allowed("group-1", "user-2") is False
|
||||
assert adapter._is_group_allowed("group-2", "user-1") is False
|
||||
|
||||
|
||||
class TestMediaHelpers:
|
||||
def test_detect_wecom_media_type(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
assert WeComAdapter._detect_wecom_media_type("image/png") == "image"
|
||||
assert WeComAdapter._detect_wecom_media_type("video/mp4") == "video"
|
||||
assert WeComAdapter._detect_wecom_media_type("audio/amr") == "voice"
|
||||
assert WeComAdapter._detect_wecom_media_type("application/pdf") == "file"
|
||||
|
||||
def test_voice_non_amr_downgrades_to_file(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
result = WeComAdapter._apply_file_size_limits(128, "voice", "audio/mpeg")
|
||||
|
||||
assert result["final_type"] == "file"
|
||||
assert result["downgraded"] is True
|
||||
assert "AMR" in (result["downgrade_note"] or "")
|
||||
|
||||
def test_oversized_file_is_rejected(self):
|
||||
from gateway.platforms.wecom import ABSOLUTE_MAX_BYTES, WeComAdapter
|
||||
|
||||
result = WeComAdapter._apply_file_size_limits(ABSOLUTE_MAX_BYTES + 1, "file", "application/pdf")
|
||||
|
||||
assert result["rejected"] is True
|
||||
assert "20MB" in (result["reject_reason"] or "")
|
||||
|
||||
def test_decrypt_file_bytes_round_trip(self):
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
plaintext = b"wecom-secret"
|
||||
key = os.urandom(32)
|
||||
pad_len = 32 - (len(plaintext) % 32)
|
||||
padded = plaintext + bytes([pad_len]) * pad_len
|
||||
encryptor = Cipher(algorithms.AES(key), modes.CBC(key[:16])).encryptor()
|
||||
encrypted = encryptor.update(padded) + encryptor.finalize()
|
||||
|
||||
decrypted = WeComAdapter._decrypt_file_bytes(encrypted, base64.b64encode(key).decode("ascii"))
|
||||
|
||||
assert decrypted == plaintext
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_outbound_media_rejects_placeholder_path(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
|
||||
with pytest.raises(ValueError, match="placeholder was not replaced"):
|
||||
await adapter._load_outbound_media("<path>")
|
||||
|
||||
|
||||
class TestMediaUpload:
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_media_bytes_uses_sdk_sequence(self, monkeypatch):
|
||||
import gateway.platforms.wecom as wecom_module
|
||||
from gateway.platforms.wecom import (
|
||||
APP_CMD_UPLOAD_MEDIA_CHUNK,
|
||||
APP_CMD_UPLOAD_MEDIA_FINISH,
|
||||
APP_CMD_UPLOAD_MEDIA_INIT,
|
||||
WeComAdapter,
|
||||
)
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
calls = []
|
||||
|
||||
async def fake_send_request(cmd, body, timeout=0):
|
||||
calls.append((cmd, body))
|
||||
if cmd == APP_CMD_UPLOAD_MEDIA_INIT:
|
||||
return {"errcode": 0, "body": {"upload_id": "upload-1"}}
|
||||
if cmd == APP_CMD_UPLOAD_MEDIA_CHUNK:
|
||||
return {"errcode": 0}
|
||||
if cmd == APP_CMD_UPLOAD_MEDIA_FINISH:
|
||||
return {
|
||||
"errcode": 0,
|
||||
"body": {
|
||||
"media_id": "media-1",
|
||||
"type": "file",
|
||||
"created_at": "2026-03-18T00:00:00Z",
|
||||
},
|
||||
}
|
||||
raise AssertionError(f"unexpected cmd {cmd}")
|
||||
|
||||
monkeypatch.setattr(wecom_module, "UPLOAD_CHUNK_SIZE", 4)
|
||||
adapter._send_request = fake_send_request
|
||||
|
||||
result = await adapter._upload_media_bytes(b"abcdefghij", "file", "demo.bin")
|
||||
|
||||
assert result["media_id"] == "media-1"
|
||||
assert [cmd for cmd, _body in calls] == [
|
||||
APP_CMD_UPLOAD_MEDIA_INIT,
|
||||
APP_CMD_UPLOAD_MEDIA_CHUNK,
|
||||
APP_CMD_UPLOAD_MEDIA_CHUNK,
|
||||
APP_CMD_UPLOAD_MEDIA_CHUNK,
|
||||
APP_CMD_UPLOAD_MEDIA_FINISH,
|
||||
]
|
||||
assert calls[1][1]["chunk_index"] == 0
|
||||
assert calls[2][1]["chunk_index"] == 1
|
||||
assert calls[3][1]["chunk_index"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_remote_bytes_rejects_large_content_length(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
class FakeResponse:
|
||||
headers = {"content-length": "10"}
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return None
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
async def aiter_bytes(self):
|
||||
yield b"abc"
|
||||
|
||||
class FakeClient:
|
||||
def stream(self, method, url, headers=None):
|
||||
return FakeResponse()
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter._http_client = FakeClient()
|
||||
|
||||
with pytest.raises(ValueError, match="exceeds WeCom limit"):
|
||||
await adapter._download_remote_bytes("https://example.com/file.bin", max_bytes=4)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_media_decrypts_url_payload_before_writing(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
plaintext = b"secret document bytes"
|
||||
key = os.urandom(32)
|
||||
pad_len = 32 - (len(plaintext) % 32)
|
||||
padded = plaintext + bytes([pad_len]) * pad_len
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
encryptor = Cipher(algorithms.AES(key), modes.CBC(key[:16])).encryptor()
|
||||
encrypted = encryptor.update(padded) + encryptor.finalize()
|
||||
adapter._download_remote_bytes = AsyncMock(
|
||||
return_value=(
|
||||
encrypted,
|
||||
{
|
||||
"content-type": "application/octet-stream",
|
||||
"content-disposition": 'attachment; filename="secret.bin"',
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
cached = await adapter._cache_media(
|
||||
"file",
|
||||
{
|
||||
"url": "https://example.com/secret.bin",
|
||||
"aeskey": base64.b64encode(key).decode("ascii"),
|
||||
},
|
||||
)
|
||||
|
||||
assert cached is not None
|
||||
cached_path, content_type = cached
|
||||
assert Path(cached_path).read_bytes() == plaintext
|
||||
assert content_type == "application/octet-stream"
|
||||
|
||||
|
||||
class TestSend:
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_uses_proactive_payload(self):
|
||||
from gateway.platforms.wecom import APP_CMD_SEND, WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter._send_request = AsyncMock(return_value={"headers": {"req_id": "req-1"}, "errcode": 0})
|
||||
|
||||
result = await adapter.send("chat-123", "Hello WeCom")
|
||||
|
||||
assert result.success is True
|
||||
adapter._send_request.assert_awaited_once_with(
|
||||
APP_CMD_SEND,
|
||||
{
|
||||
"chatid": "chat-123",
|
||||
"msgtype": "markdown",
|
||||
"markdown": {"content": "Hello WeCom"},
|
||||
},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_reports_wecom_errors(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter._send_request = AsyncMock(return_value={"errcode": 40001, "errmsg": "bad request"})
|
||||
|
||||
result = await adapter.send("chat-123", "Hello WeCom")
|
||||
|
||||
assert result.success is False
|
||||
assert "40001" in (result.error or "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_falls_back_to_text_for_remote_url(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter._send_media_source = AsyncMock(return_value=SendResult(success=False, error="upload failed"))
|
||||
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="msg-1"))
|
||||
|
||||
result = await adapter.send_image("chat-123", "https://example.com/demo.png", caption="demo")
|
||||
|
||||
assert result.success is True
|
||||
adapter.send.assert_awaited_once_with(chat_id="chat-123", content="demo\nhttps://example.com/demo.png", reply_to=None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_voice_sends_caption_and_downgrade_note(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter._prepare_outbound_media = AsyncMock(
|
||||
return_value={
|
||||
"data": b"voice-bytes",
|
||||
"content_type": "audio/mpeg",
|
||||
"file_name": "voice.mp3",
|
||||
"detected_type": "voice",
|
||||
"final_type": "file",
|
||||
"rejected": False,
|
||||
"reject_reason": None,
|
||||
"downgraded": True,
|
||||
"downgrade_note": "语音格式 audio/mpeg 不支持,企微仅支持 AMR 格式,已转为文件格式发送",
|
||||
}
|
||||
)
|
||||
adapter._upload_media_bytes = AsyncMock(return_value={"media_id": "media-1", "type": "file"})
|
||||
adapter._send_media_message = AsyncMock(return_value={"headers": {"req_id": "req-media"}, "errcode": 0})
|
||||
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="msg-1"))
|
||||
|
||||
result = await adapter.send_voice("chat-123", "/tmp/voice.mp3", caption="listen")
|
||||
|
||||
assert result.success is True
|
||||
adapter._send_media_message.assert_awaited_once_with("chat-123", "file", "media-1")
|
||||
assert adapter.send.await_count == 2
|
||||
adapter.send.assert_any_await(chat_id="chat-123", content="listen", reply_to=None)
|
||||
adapter.send.assert_any_await(
|
||||
chat_id="chat-123",
|
||||
content="ℹ️ 语音格式 audio/mpeg 不支持,企微仅支持 AMR 格式,已转为文件格式发送",
|
||||
reply_to=None,
|
||||
)
|
||||
|
||||
|
||||
class TestInboundMessages:
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_builds_event(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter.handle_message = AsyncMock()
|
||||
adapter._extract_media = AsyncMock(return_value=(["/tmp/test.png"], ["image/png"]))
|
||||
|
||||
payload = {
|
||||
"cmd": "aibot_msg_callback",
|
||||
"headers": {"req_id": "req-1"},
|
||||
"body": {
|
||||
"msgid": "msg-1",
|
||||
"chatid": "group-1",
|
||||
"chattype": "group",
|
||||
"from": {"userid": "user-1"},
|
||||
"msgtype": "text",
|
||||
"text": {"content": "hello"},
|
||||
},
|
||||
}
|
||||
|
||||
await adapter._on_message(payload)
|
||||
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
event = adapter.handle_message.await_args.args[0]
|
||||
assert event.text == "hello"
|
||||
assert event.source.chat_id == "group-1"
|
||||
assert event.source.user_id == "user-1"
|
||||
assert event.media_urls == ["/tmp/test.png"]
|
||||
assert event.media_types == ["image/png"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_preserves_quote_context(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter.handle_message = AsyncMock()
|
||||
adapter._extract_media = AsyncMock(return_value=([], []))
|
||||
|
||||
payload = {
|
||||
"cmd": "aibot_msg_callback",
|
||||
"headers": {"req_id": "req-1"},
|
||||
"body": {
|
||||
"msgid": "msg-1",
|
||||
"chatid": "group-1",
|
||||
"chattype": "group",
|
||||
"from": {"userid": "user-1"},
|
||||
"msgtype": "text",
|
||||
"text": {"content": "follow up"},
|
||||
"quote": {"msgtype": "text", "text": {"content": "quoted message"}},
|
||||
},
|
||||
}
|
||||
|
||||
await adapter._on_message(payload)
|
||||
|
||||
event = adapter.handle_message.await_args.args[0]
|
||||
assert event.reply_to_text == "quoted message"
|
||||
assert event.reply_to_message_id == "quote:msg-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_respects_group_policy(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(
|
||||
PlatformConfig(
|
||||
enabled=True,
|
||||
extra={"group_policy": "allowlist", "group_allow_from": ["group-allowed"]},
|
||||
)
|
||||
)
|
||||
adapter.handle_message = AsyncMock()
|
||||
adapter._extract_media = AsyncMock(return_value=([], []))
|
||||
|
||||
payload = {
|
||||
"cmd": "aibot_callback",
|
||||
"headers": {"req_id": "req-1"},
|
||||
"body": {
|
||||
"msgid": "msg-1",
|
||||
"chatid": "group-blocked",
|
||||
"chattype": "group",
|
||||
"from": {"userid": "user-1"},
|
||||
"msgtype": "text",
|
||||
"text": {"content": "hello"},
|
||||
},
|
||||
}
|
||||
|
||||
await adapter._on_message(payload)
|
||||
adapter.handle_message.assert_not_awaited()
|
||||
|
||||
|
||||
class TestPlatformEnum:
|
||||
def test_wecom_in_platform_enum(self):
|
||||
assert Platform.WECOM.value == "wecom"
|
||||
@@ -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"]
|
||||
@@ -4,10 +4,20 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
# tiktoken is not in core/[all] deps — skip estimation tests when unavailable
|
||||
_has_tiktoken = True
|
||||
try:
|
||||
import tiktoken # noqa: F401
|
||||
except ImportError:
|
||||
_has_tiktoken = False
|
||||
|
||||
_needs_tiktoken = pytest.mark.skipif(not _has_tiktoken, reason="tiktoken not installed")
|
||||
|
||||
|
||||
# ─── Token Estimation Tests ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@_needs_tiktoken
|
||||
def test_estimate_tool_tokens_returns_positive_counts():
|
||||
"""_estimate_tool_tokens should return a non-empty dict with positive values."""
|
||||
from hermes_cli.tools_config import _estimate_tool_tokens, _tool_token_cache
|
||||
@@ -26,6 +36,7 @@ def test_estimate_tool_tokens_returns_positive_counts():
|
||||
assert count > 0, f"Tool {name} has non-positive token count: {count}"
|
||||
|
||||
|
||||
@_needs_tiktoken
|
||||
def test_estimate_tool_tokens_is_cached():
|
||||
"""Second call should return the same cached dict object."""
|
||||
import hermes_cli.tools_config as tc
|
||||
@@ -60,6 +71,7 @@ def test_estimate_tool_tokens_returns_empty_when_tiktoken_unavailable(monkeypatc
|
||||
tc._tool_token_cache = None
|
||||
|
||||
|
||||
@_needs_tiktoken
|
||||
def test_estimate_tool_tokens_covers_known_tools():
|
||||
"""Should include schemas for well-known tools like terminal, web_search."""
|
||||
import hermes_cli.tools_config as tc
|
||||
|
||||
@@ -266,7 +266,8 @@ class TestResolveProvider:
|
||||
|
||||
def test_auto_does_not_select_copilot_from_github_token(self, monkeypatch):
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "gh-test-token")
|
||||
assert resolve_provider("auto") == "openrouter"
|
||||
with pytest.raises(AuthError, match="No inference provider configured"):
|
||||
resolve_provider("auto")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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})"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,6 +33,7 @@ def test_get_codex_model_ids_prioritizes_default_and_cache(tmp_path, monkeypatch
|
||||
assert "gpt-5.3-codex" in models
|
||||
# Non-codex-suffixed models are included when the cache says they're available
|
||||
assert "gpt-5.4" in models
|
||||
assert "gpt-5.4-mini" in models
|
||||
assert "gpt-5-hidden-codex" not in models
|
||||
|
||||
|
||||
@@ -64,7 +65,7 @@ def test_get_codex_model_ids_adds_forward_compat_models_from_templates(monkeypat
|
||||
|
||||
models = get_codex_model_ids(access_token="codex-access-token")
|
||||
|
||||
assert models == ["gpt-5.2-codex", "gpt-5.3-codex", "gpt-5.4", "gpt-5.3-codex-spark"]
|
||||
assert models == ["gpt-5.2-codex", "gpt-5.4-mini", "gpt-5.4", "gpt-5.3-codex", "gpt-5.3-codex-spark"]
|
||||
|
||||
|
||||
def test_model_command_uses_runtime_access_token_for_codex_list(monkeypatch):
|
||||
|
||||
@@ -150,11 +150,11 @@ class TestPluginsCommandDispatch:
|
||||
plugins_command(args)
|
||||
mock_list.assert_called_once()
|
||||
|
||||
@patch("hermes_cli.plugins_cmd.cmd_list")
|
||||
def test_none_falls_through_to_list(self, mock_list):
|
||||
@patch("hermes_cli.plugins_cmd.cmd_toggle")
|
||||
def test_none_falls_through_to_toggle(self, mock_toggle):
|
||||
args = self._make_args(None)
|
||||
plugins_command(args)
|
||||
mock_list.assert_called_once()
|
||||
mock_toggle.assert_called_once()
|
||||
|
||||
@patch("hermes_cli.plugins_cmd.cmd_install")
|
||||
def test_install_dispatches(self, mock_install):
|
||||
|
||||
@@ -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"]
|
||||
@@ -339,6 +339,16 @@ class TestTeePattern:
|
||||
assert dangerous is True
|
||||
assert key is not None
|
||||
|
||||
def test_tee_custom_hermes_home_env(self):
|
||||
dangerous, key, desc = detect_dangerous_command("echo x | tee $HERMES_HOME/.env")
|
||||
assert dangerous is True
|
||||
assert key is not None
|
||||
|
||||
def test_tee_quoted_custom_hermes_home_env(self):
|
||||
dangerous, key, desc = detect_dangerous_command('echo x | tee "$HERMES_HOME/.env"')
|
||||
assert dangerous is True
|
||||
assert key is not None
|
||||
|
||||
def test_tee_tmp_safe(self):
|
||||
dangerous, key, desc = detect_dangerous_command("echo hello | tee /tmp/output.txt")
|
||||
assert dangerous is False
|
||||
@@ -374,6 +384,30 @@ class TestFindExecFullPathRm:
|
||||
assert key is None
|
||||
|
||||
|
||||
class TestSensitiveRedirectPattern:
|
||||
"""Detect shell redirection writes to sensitive user-managed paths."""
|
||||
|
||||
def test_redirect_to_custom_hermes_home_env(self):
|
||||
dangerous, key, desc = detect_dangerous_command("echo x > $HERMES_HOME/.env")
|
||||
assert dangerous is True
|
||||
assert key is not None
|
||||
|
||||
def test_append_to_home_ssh_authorized_keys(self):
|
||||
dangerous, key, desc = detect_dangerous_command("cat key >> $HOME/.ssh/authorized_keys")
|
||||
assert dangerous is True
|
||||
assert key is not None
|
||||
|
||||
def test_append_to_tilde_ssh_authorized_keys(self):
|
||||
dangerous, key, desc = detect_dangerous_command("cat key >> ~/.ssh/authorized_keys")
|
||||
assert dangerous is True
|
||||
assert key is not None
|
||||
|
||||
def test_redirect_to_safe_tmp_file(self):
|
||||
dangerous, key, desc = detect_dangerous_command("echo hello > /tmp/output.txt")
|
||||
assert dangerous is False
|
||||
assert key is None
|
||||
|
||||
|
||||
class TestPatternKeyUniqueness:
|
||||
"""Bug: pattern_key is derived by splitting on \\b and taking [1], so
|
||||
patterns starting with the same word (e.g. find -exec rm and find -delete)
|
||||
@@ -606,3 +640,4 @@ class TestNormalizationBypass:
|
||||
dangerous, key, desc = detect_dangerous_command(cmd)
|
||||
assert dangerous is False
|
||||
|
||||
|
||||
|
||||
@@ -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() == []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,7 @@ from unittest.mock import patch
|
||||
|
||||
from tools.skill_manager_tool import (
|
||||
_validate_name,
|
||||
_validate_category,
|
||||
_validate_frontmatter,
|
||||
_validate_file_path,
|
||||
_find_skill,
|
||||
@@ -82,6 +83,22 @@ class TestValidateName:
|
||||
assert "Invalid skill name 'skill@name'" in err
|
||||
|
||||
|
||||
class TestValidateCategory:
|
||||
def test_valid_categories(self):
|
||||
assert _validate_category(None) is None
|
||||
assert _validate_category("") is None
|
||||
assert _validate_category("devops") is None
|
||||
assert _validate_category("mlops-v2") is None
|
||||
|
||||
def test_path_traversal_rejected(self):
|
||||
err = _validate_category("../escape")
|
||||
assert "Invalid category '../escape'" in err
|
||||
|
||||
def test_absolute_path_rejected(self):
|
||||
err = _validate_category("/tmp/escape")
|
||||
assert "Invalid category '/tmp/escape'" in err
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _validate_frontmatter
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -191,6 +208,29 @@ class TestCreateSkill:
|
||||
result = _create_skill("my-skill", "no frontmatter here")
|
||||
assert result["success"] is False
|
||||
|
||||
def test_create_rejects_category_traversal(self, tmp_path):
|
||||
skills_dir = tmp_path / "skills"
|
||||
skills_dir.mkdir()
|
||||
|
||||
with patch("tools.skill_manager_tool.SKILLS_DIR", skills_dir):
|
||||
result = _create_skill("my-skill", VALID_SKILL_CONTENT, category="../escape")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Invalid category '../escape'" in result["error"]
|
||||
assert not (tmp_path / "escape").exists()
|
||||
|
||||
def test_create_rejects_absolute_category(self, tmp_path):
|
||||
skills_dir = tmp_path / "skills"
|
||||
skills_dir.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
|
||||
with patch("tools.skill_manager_tool.SKILLS_DIR", skills_dir):
|
||||
result = _create_skill("my-skill", VALID_SKILL_CONTENT, category=str(outside))
|
||||
|
||||
assert result["success"] is False
|
||||
assert f"Invalid category '{outside}'" in result["error"]
|
||||
assert not (outside / "my-skill" / "SKILL.md").exists()
|
||||
|
||||
|
||||
class TestEditSkill:
|
||||
def test_edit_existing_skill(self, tmp_path):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -354,6 +354,78 @@ class TestErrorLoggingExcInfo:
|
||||
assert warning_records[0].exc_info is not None
|
||||
|
||||
|
||||
class TestVisionSafetyGuards:
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_non_image_file_rejected_before_llm_call(self, tmp_path):
|
||||
secret = tmp_path / "secret.txt"
|
||||
secret.write_text("TOP-SECRET=1\n", encoding="utf-8")
|
||||
|
||||
with patch("tools.vision_tools.async_call_llm", new_callable=AsyncMock) as mock_llm:
|
||||
result = json.loads(await vision_analyze_tool(str(secret), "extract text"))
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Only real image files are supported" in result["error"]
|
||||
mock_llm.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocked_remote_url_short_circuits_before_download(self):
|
||||
blocked = {
|
||||
"host": "blocked.test",
|
||||
"rule": "blocked.test",
|
||||
"source": "config",
|
||||
"message": "Blocked by website policy",
|
||||
}
|
||||
|
||||
with (
|
||||
patch("tools.vision_tools.check_website_access", return_value=blocked),
|
||||
patch("tools.vision_tools._validate_image_url", return_value=True),
|
||||
patch("tools.vision_tools._download_image", new_callable=AsyncMock) as mock_download,
|
||||
):
|
||||
result = json.loads(await vision_analyze_tool("https://blocked.test/cat.png", "describe"))
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Blocked by website policy" in result["error"]
|
||||
mock_download.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_blocks_redirected_final_url(self, tmp_path):
|
||||
from tools.vision_tools import _download_image
|
||||
|
||||
def fake_check(url):
|
||||
if url == "https://allowed.test/cat.png":
|
||||
return None
|
||||
if url == "https://blocked.test/final.png":
|
||||
return {
|
||||
"host": "blocked.test",
|
||||
"rule": "blocked.test",
|
||||
"source": "config",
|
||||
"message": "Blocked by website policy",
|
||||
}
|
||||
raise AssertionError(f"unexpected URL checked: {url}")
|
||||
|
||||
class FakeResponse:
|
||||
url = "https://blocked.test/final.png"
|
||||
content = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
with (
|
||||
patch("tools.vision_tools.check_website_access", side_effect=fake_check),
|
||||
patch("tools.vision_tools.httpx.AsyncClient") as mock_client_cls,
|
||||
pytest.raises(PermissionError, match="Blocked by website policy"),
|
||||
):
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(return_value=FakeResponse())
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
await _download_image("https://allowed.test/cat.png", tmp_path / "cat.png", max_retries=1)
|
||||
|
||||
assert not (tmp_path / "cat.png").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_vision_requirements & get_debug_session_info
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
+45
-9
@@ -18,6 +18,21 @@ from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Sensitive write targets that should trigger approval even when referenced
|
||||
# via shell expansions like $HOME or $HERMES_HOME.
|
||||
_SSH_SENSITIVE_PATH = r'(?:~|\$home|\$\{home\})/\.ssh(?:/|$)'
|
||||
_HERMES_ENV_PATH = (
|
||||
r'(?:~\/\.hermes/|'
|
||||
r'(?:\$home|\$\{home\})/\.hermes/|'
|
||||
r'(?:\$hermes_home|\$\{hermes_home\})/)'
|
||||
r'\.env\b'
|
||||
)
|
||||
_SENSITIVE_WRITE_TARGET = (
|
||||
r'(?:/etc/|/dev/sd|'
|
||||
rf'{_SSH_SENSITIVE_PATH}|'
|
||||
rf'{_HERMES_ENV_PATH})'
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Dangerous command patterns
|
||||
# =========================================================================
|
||||
@@ -26,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"),
|
||||
@@ -46,7 +61,8 @@ DANGEROUS_PATTERNS = [
|
||||
(r'\b(python[23]?|perl|ruby|node)\s+-[ec]\s+', "script execution via -e/-c flag"),
|
||||
(r'\b(curl|wget)\b.*\|\s*(ba)?sh\b', "pipe remote content to shell"),
|
||||
(r'\b(bash|sh|zsh|ksh)\s+<\s*<?\s*\(\s*(curl|wget)\b', "execute remote script via process substitution"),
|
||||
(r'\btee\b.*(/etc/|/dev/sd|\.ssh/|\.hermes/\.env)', "overwrite system file via tee"),
|
||||
(rf'\btee\b.*["\']?{_SENSITIVE_WRITE_TARGET}', "overwrite system file via tee"),
|
||||
(rf'>>?\s*["\']?{_SENSITIVE_WRITE_TARGET}', "overwrite system file via redirection"),
|
||||
(r'\bxargs\s+.*\brm\b', "xargs with rm"),
|
||||
(r'\bfind\b.*-exec\s+(/\S*/)?rm\b', "find -exec rm"),
|
||||
(r'\bfind\b.*-delete\b', "find -delete"),
|
||||
@@ -55,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)"),
|
||||
]
|
||||
|
||||
|
||||
@@ -221,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).
|
||||
@@ -236,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,
|
||||
@@ -316,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:
|
||||
|
||||
+102
-1
@@ -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()
|
||||
|
||||
@@ -372,7 +372,7 @@ Important safety rule: cron-run sessions should not recursively schedule more cr
|
||||
},
|
||||
"deliver": {
|
||||
"type": "string",
|
||||
"description": "Delivery target: origin, local, telegram, discord, slack, whatsapp, signal, matrix, mattermost, homeassistant, dingtalk, email, sms, or platform:chat_id or platform:chat_id:thread_id for Telegram topics. Examples: 'origin', 'local', 'telegram', 'telegram:-1001234567890:17585', 'discord:#engineering'"
|
||||
"description": "Delivery target: origin, local, telegram, discord, slack, whatsapp, signal, matrix, mattermost, homeassistant, dingtalk, feishu, wecom, email, sms, or platform:chat_id or platform:chat_id:thread_id for Telegram topics. Examples: 'origin', 'local', 'telegram', 'telegram:-1001234567890:17585', 'discord:#engineering'"
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
|
||||
@@ -289,7 +289,10 @@ def _run_single_child(
|
||||
|
||||
if interrupted:
|
||||
status = "interrupted"
|
||||
elif completed and summary:
|
||||
elif summary:
|
||||
# A summary means the subagent produced usable output.
|
||||
# exit_reason ("completed" vs "max_iterations") already
|
||||
# tells the parent *how* the task ended.
|
||||
status = "completed"
|
||||
else:
|
||||
status = "failed"
|
||||
|
||||
@@ -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]}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+61
-52
@@ -142,7 +142,7 @@ class ModalEnvironment(BaseEnvironment):
|
||||
# external services but can't modify the host's credentials.
|
||||
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(
|
||||
@@ -156,6 +156,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)
|
||||
|
||||
@@ -184,72 +196,69 @@ class ModalEnvironment(BaseEnvironment):
|
||||
self._app, self._sandbox = self._worker.run_coroutine(
|
||||
_create_sandbox(), timeout=300
|
||||
)
|
||||
# Track synced credential files to avoid redundant pushes.
|
||||
# Track synced files to avoid redundant pushes.
|
||||
# Key: container_path, Value: (mtime, size) of last synced version.
|
||||
self._synced_creds: Dict[str, tuple] = {}
|
||||
self._synced_files: Dict[str, tuple] = {}
|
||||
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:
|
||||
stat = hp.stat()
|
||||
file_key = (stat.st_mtime, stat.st_size)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
Mounts are set at sandbox creation, but credentials may be created
|
||||
later (e.g. OAuth setup mid-session). This writes the current file
|
||||
content into the sandbox via exec(), so new/updated credentials are
|
||||
available without recreating the sandbox.
|
||||
if self._synced_files.get(container_path) == file_key:
|
||||
return False
|
||||
|
||||
try:
|
||||
content = hp.read_bytes()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
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)}"
|
||||
)
|
||||
|
||||
async def _write():
|
||||
proc = await self._sandbox.exec.aio("bash", "-c", cmd)
|
||||
await proc.wait.aio()
|
||||
|
||||
self._worker.run_coroutine(_write(), timeout=15)
|
||||
self._synced_files[container_path] = file_key
|
||||
return True
|
||||
|
||||
def _sync_files(self) -> None:
|
||||
"""Push credential files and skill files into the running sandbox.
|
||||
|
||||
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
|
||||
from tools.credential_files import get_credential_file_mounts, iter_skills_files
|
||||
|
||||
mounts = get_credential_file_mounts()
|
||||
if not mounts:
|
||||
return
|
||||
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 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
|
||||
|
||||
# Skip if already synced with same mtime+size
|
||||
if self._synced_creds.get(container_path) == file_key:
|
||||
continue
|
||||
|
||||
try:
|
||||
content = hp.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Write via base64 to avoid shell escaping issues with JSON
|
||||
import base64
|
||||
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)}"
|
||||
)
|
||||
|
||||
_cp = container_path # capture for closure
|
||||
|
||||
async def _write():
|
||||
proc = await self._sandbox.exec.aio("bash", "-c", cmd)
|
||||
await proc.wait.aio()
|
||||
|
||||
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 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, command: str, cwd: str = "", *,
|
||||
timeout: int | None = None,
|
||||
stdin_data: str | None = None) -> dict:
|
||||
# Sync credential files before each command so mid-session
|
||||
# OAuth setups are picked up without requiring a restart.
|
||||
self._sync_credential_files()
|
||||
self._sync_files()
|
||||
|
||||
if stdin_data is not None:
|
||||
marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,6 +32,7 @@ from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -46,7 +47,7 @@ WRITE_DENIED_PATHS = {
|
||||
os.path.join(_HOME, ".ssh", "id_rsa"),
|
||||
os.path.join(_HOME, ".ssh", "id_ed25519"),
|
||||
os.path.join(_HOME, ".ssh", "config"),
|
||||
os.path.join(_HOME, ".hermes", ".env"),
|
||||
str(get_hermes_home() / ".env"),
|
||||
os.path.join(_HOME, ".bashrc"),
|
||||
os.path.join(_HOME, ".zshrc"),
|
||||
os.path.join(_HOME, ".profile"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import time
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TELEGRAM_TOPIC_TARGET_RE = re.compile(r"^\s*(-?\d+)(?::(\d+))?\s*$")
|
||||
_FEISHU_TARGET_RE = re.compile(r"^\s*((?:oc|ou|on|chat|open)_[-A-Za-z0-9]+)(?::([-A-Za-z0-9_]+))?\s*$")
|
||||
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
|
||||
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".3gp"}
|
||||
_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a"}
|
||||
@@ -128,6 +129,8 @@ def _handle_send(args):
|
||||
"mattermost": Platform.MATTERMOST,
|
||||
"homeassistant": Platform.HOMEASSISTANT,
|
||||
"dingtalk": Platform.DINGTALK,
|
||||
"feishu": Platform.FEISHU,
|
||||
"wecom": Platform.WECOM,
|
||||
"email": Platform.EMAIL,
|
||||
"sms": Platform.SMS,
|
||||
}
|
||||
@@ -198,6 +201,10 @@ def _parse_target_ref(platform_name: str, target_ref: str):
|
||||
match = _TELEGRAM_TOPIC_TARGET_RE.fullmatch(target_ref)
|
||||
if match:
|
||||
return match.group(1), match.group(2), True
|
||||
if platform_name == "feishu":
|
||||
match = _FEISHU_TARGET_RE.fullmatch(target_ref)
|
||||
if match:
|
||||
return match.group(1), match.group(2), True
|
||||
if target_ref.lstrip("-").isdigit():
|
||||
return target_ref, None, True
|
||||
return None, None, False
|
||||
@@ -280,6 +287,13 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from gateway.platforms.slack import SlackAdapter
|
||||
|
||||
# Feishu adapter import is optional (requires lark-oapi)
|
||||
try:
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
_feishu_available = True
|
||||
except ImportError:
|
||||
_feishu_available = False
|
||||
|
||||
media_files = media_files or []
|
||||
|
||||
# Platform message length limits (from adapter class attributes)
|
||||
@@ -288,6 +302,8 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
|
||||
Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH,
|
||||
Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH,
|
||||
}
|
||||
if _feishu_available:
|
||||
_MAX_LENGTHS[Platform.FEISHU] = FeishuAdapter.MAX_MESSAGE_LENGTH
|
||||
|
||||
# Smart-chunk the message to fit within platform limits.
|
||||
# For short messages or platforms without a known limit this is a no-op.
|
||||
@@ -351,6 +367,10 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
|
||||
result = await _send_homeassistant(pconfig.token, pconfig.extra, chat_id, chunk)
|
||||
elif platform == Platform.DINGTALK:
|
||||
result = await _send_dingtalk(pconfig.extra, chat_id, chunk)
|
||||
elif platform == Platform.FEISHU:
|
||||
result = await _send_feishu(pconfig, chat_id, chunk, thread_id=thread_id)
|
||||
elif platform == Platform.WECOM:
|
||||
result = await _send_wecom(pconfig.extra, chat_id, chunk)
|
||||
else:
|
||||
result = {"error": f"Direct sending not yet implemented for {platform.value}"}
|
||||
|
||||
@@ -777,6 +797,90 @@ async def _send_dingtalk(extra, chat_id, message):
|
||||
return {"error": f"DingTalk send failed: {e}"}
|
||||
|
||||
|
||||
async def _send_wecom(extra, chat_id, message):
|
||||
"""Send via WeCom using the adapter's WebSocket send pipeline."""
|
||||
try:
|
||||
from gateway.platforms.wecom import WeComAdapter, check_wecom_requirements
|
||||
if not check_wecom_requirements():
|
||||
return {"error": "WeCom requirements not met. Need aiohttp + WECOM_BOT_ID/SECRET."}
|
||||
except ImportError:
|
||||
return {"error": "WeCom adapter not available."}
|
||||
|
||||
try:
|
||||
from gateway.config import PlatformConfig
|
||||
pconfig = PlatformConfig(extra=extra)
|
||||
adapter = WeComAdapter(pconfig)
|
||||
connected = await adapter.connect()
|
||||
if not connected:
|
||||
return {"error": f"WeCom: failed to connect — {adapter.fatal_error_message or 'unknown error'}"}
|
||||
try:
|
||||
result = await adapter.send(chat_id, message)
|
||||
if not result.success:
|
||||
return {"error": f"WeCom send failed: {result.error}"}
|
||||
return {"success": True, "platform": "wecom", "chat_id": chat_id, "message_id": result.message_id}
|
||||
finally:
|
||||
await adapter.disconnect()
|
||||
except Exception as e:
|
||||
return {"error": f"WeCom send failed: {e}"}
|
||||
|
||||
|
||||
async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=None):
|
||||
"""Send via Feishu/Lark using the adapter's send pipeline."""
|
||||
try:
|
||||
from gateway.platforms.feishu import FeishuAdapter, FEISHU_AVAILABLE
|
||||
if not FEISHU_AVAILABLE:
|
||||
return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"}
|
||||
from gateway.platforms.feishu import FEISHU_DOMAIN, LARK_DOMAIN
|
||||
except ImportError:
|
||||
return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"}
|
||||
|
||||
media_files = media_files or []
|
||||
|
||||
try:
|
||||
adapter = FeishuAdapter(pconfig)
|
||||
domain_name = getattr(adapter, "_domain_name", "feishu")
|
||||
domain = FEISHU_DOMAIN if domain_name != "lark" else LARK_DOMAIN
|
||||
adapter._client = adapter._build_lark_client(domain)
|
||||
metadata = {"thread_id": thread_id} if thread_id else None
|
||||
|
||||
last_result = None
|
||||
if message.strip():
|
||||
last_result = await adapter.send(chat_id, message, metadata=metadata)
|
||||
if not last_result.success:
|
||||
return {"error": f"Feishu send failed: {last_result.error}"}
|
||||
|
||||
for media_path, is_voice in media_files:
|
||||
if not os.path.exists(media_path):
|
||||
return {"error": f"Media file not found: {media_path}"}
|
||||
|
||||
ext = os.path.splitext(media_path)[1].lower()
|
||||
if ext in _IMAGE_EXTS:
|
||||
last_result = await adapter.send_image_file(chat_id, media_path, metadata=metadata)
|
||||
elif ext in _VIDEO_EXTS:
|
||||
last_result = await adapter.send_video(chat_id, media_path, metadata=metadata)
|
||||
elif ext in _VOICE_EXTS and is_voice:
|
||||
last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)
|
||||
elif ext in _AUDIO_EXTS:
|
||||
last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)
|
||||
else:
|
||||
last_result = await adapter.send_document(chat_id, media_path, metadata=metadata)
|
||||
|
||||
if not last_result.success:
|
||||
return {"error": f"Feishu media send failed: {last_result.error}"}
|
||||
|
||||
if last_result is None:
|
||||
return {"error": "No deliverable text or media remained after processing MEDIA tags"}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"platform": "feishu",
|
||||
"chat_id": chat_id,
|
||||
"message_id": last_result.message_id,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": f"Feishu send failed: {e}"}
|
||||
|
||||
|
||||
def _check_send_message():
|
||||
"""Gate send_message on gateway running (always available on messaging platforms)."""
|
||||
platform = os.getenv("HERMES_SESSION_PLATFORM", "")
|
||||
|
||||
@@ -113,6 +113,31 @@ def _validate_name(name: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _validate_category(category: Optional[str]) -> Optional[str]:
|
||||
"""Validate an optional category name used as a single directory segment."""
|
||||
if category is None:
|
||||
return None
|
||||
if not isinstance(category, str):
|
||||
return "Category must be a string."
|
||||
|
||||
category = category.strip()
|
||||
if not category:
|
||||
return None
|
||||
if "/" in category or "\\" in category:
|
||||
return (
|
||||
f"Invalid category '{category}'. Use lowercase letters, numbers, "
|
||||
"hyphens, dots, and underscores. Categories must be a single directory name."
|
||||
)
|
||||
if len(category) > MAX_NAME_LENGTH:
|
||||
return f"Category exceeds {MAX_NAME_LENGTH} characters."
|
||||
if not VALID_NAME_RE.match(category):
|
||||
return (
|
||||
f"Invalid category '{category}'. Use lowercase letters, numbers, "
|
||||
"hyphens, dots, and underscores. Categories must be a single directory name."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _validate_frontmatter(content: str) -> Optional[str]:
|
||||
"""
|
||||
Validate that SKILL.md content has proper frontmatter with required fields.
|
||||
@@ -241,6 +266,10 @@ def _create_skill(name: str, content: str, category: str = None) -> Dict[str, An
|
||||
if err:
|
||||
return {"success": False, "error": err}
|
||||
|
||||
err = _validate_category(category)
|
||||
if err:
|
||||
return {"success": False, "error": err}
|
||||
|
||||
# Validate content
|
||||
err = _validate_frontmatter(content)
|
||||
if err:
|
||||
|
||||
+82
-16
@@ -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),
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ Provides speech-to-text transcription with three providers:
|
||||
Used by the messaging gateway to automatically transcribe voice messages
|
||||
sent by users on Telegram, Discord, WhatsApp, Slack, and Signal.
|
||||
|
||||
Supported input formats: mp3, mp4, mpeg, mpga, m4a, wav, webm, ogg
|
||||
Supported input formats: mp3, mp4, mpeg, mpga, m4a, wav, webm, ogg, aac
|
||||
|
||||
Usage::
|
||||
|
||||
@@ -60,7 +60,7 @@ COMMON_LOCAL_BIN_DIRS = ("/opt/homebrew/bin", "/usr/local/bin")
|
||||
GROQ_BASE_URL = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1")
|
||||
OPENAI_BASE_URL = os.getenv("STT_OPENAI_BASE_URL", "https://api.openai.com/v1")
|
||||
|
||||
SUPPORTED_FORMATS = {".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm", ".ogg"}
|
||||
SUPPORTED_FORMATS = {".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm", ".ogg", ".aac"}
|
||||
LOCAL_NATIVE_AUDIO_FORMATS = {".wav", ".aiff", ".aif"}
|
||||
MAX_FILE_SIZE = 25 * 1024 * 1024 # 25 MB
|
||||
|
||||
|
||||
+68
-2
@@ -39,11 +39,34 @@ from urllib.parse import urlparse
|
||||
import httpx
|
||||
from agent.auxiliary_client import async_call_llm, extract_content_or_reasoning
|
||||
from tools.debug_helpers import DebugSession
|
||||
from tools.website_policy import check_website_access
|
||||
|
||||
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:
|
||||
"""
|
||||
@@ -76,6 +99,28 @@ def _validate_image_url(url: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _detect_image_mime_type(image_path: Path) -> Optional[str]:
|
||||
"""Return a MIME type when the file looks like a supported image."""
|
||||
with image_path.open("rb") as f:
|
||||
header = f.read(64)
|
||||
|
||||
if header.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return "image/png"
|
||||
if header.startswith(b"\xff\xd8\xff"):
|
||||
return "image/jpeg"
|
||||
if header.startswith((b"GIF87a", b"GIF89a")):
|
||||
return "image/gif"
|
||||
if header.startswith(b"BM"):
|
||||
return "image/bmp"
|
||||
if len(header) >= 12 and header[:4] == b"RIFF" and header[8:12] == b"WEBP":
|
||||
return "image/webp"
|
||||
if image_path.suffix.lower() == ".svg":
|
||||
head = image_path.read_text(encoding="utf-8", errors="ignore")[:4096].lower()
|
||||
if "<svg" in head:
|
||||
return "image/svg+xml"
|
||||
return None
|
||||
|
||||
|
||||
async def _download_image(image_url: str, destination: Path, max_retries: int = 3) -> Path:
|
||||
"""
|
||||
Download an image from a URL to a local destination (async) with retry logic.
|
||||
@@ -115,11 +160,15 @@ async def _download_image(image_url: str, destination: Path, max_retries: int =
|
||||
last_error = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
blocked = check_website_access(image_url)
|
||||
if blocked:
|
||||
raise PermissionError(blocked["message"])
|
||||
|
||||
# Download the image with appropriate headers using async httpx
|
||||
# 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:
|
||||
@@ -131,6 +180,11 @@ async def _download_image(image_url: str, destination: Path, max_retries: int =
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
final_url = str(response.url)
|
||||
blocked = check_website_access(final_url)
|
||||
if blocked:
|
||||
raise PermissionError(blocked["message"])
|
||||
|
||||
# Save the image content
|
||||
destination.write_bytes(response.content)
|
||||
@@ -151,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
|
||||
|
||||
|
||||
@@ -257,6 +315,7 @@ async def vision_analyze_tool(
|
||||
# Track whether we should clean up the file after processing.
|
||||
# Local files (e.g. from the image cache) should NOT be deleted.
|
||||
should_cleanup = True
|
||||
detected_mime_type = None
|
||||
|
||||
try:
|
||||
from tools.interrupt import is_interrupted
|
||||
@@ -275,6 +334,9 @@ async def vision_analyze_tool(
|
||||
should_cleanup = False # Don't delete cached/local files
|
||||
elif _validate_image_url(image_url):
|
||||
# Remote URL -- download to a temporary location
|
||||
blocked = check_website_access(image_url)
|
||||
if blocked:
|
||||
raise PermissionError(blocked["message"])
|
||||
logger.info("Downloading image from URL...")
|
||||
temp_dir = Path("./temp_vision_images")
|
||||
temp_image_path = temp_dir / f"temp_image_{uuid.uuid4()}.jpg"
|
||||
@@ -289,10 +351,14 @@ async def vision_analyze_tool(
|
||||
image_size_bytes = temp_image_path.stat().st_size
|
||||
image_size_kb = image_size_bytes / 1024
|
||||
logger.info("Image ready (%.1f KB)", image_size_kb)
|
||||
|
||||
detected_mime_type = _detect_image_mime_type(temp_image_path)
|
||||
if not detected_mime_type:
|
||||
raise ValueError("Only real image files are supported for vision analysis.")
|
||||
|
||||
# Convert image to base64 data URL
|
||||
logger.info("Converting image to base64...")
|
||||
image_data_url = _image_to_base64_data_url(temp_image_path)
|
||||
image_data_url = _image_to_base64_data_url(temp_image_path, mime_type=detected_mime_type)
|
||||
# Calculate size in KB for better readability
|
||||
data_size_kb = len(image_data_url) / 1024
|
||||
logger.info("Image converted to base64 (%.1f KB)", data_size_kb)
|
||||
|
||||
+13
-1
@@ -351,6 +351,18 @@ TOOLSETS = {
|
||||
"includes": []
|
||||
},
|
||||
|
||||
"hermes-feishu": {
|
||||
"description": "Feishu/Lark bot toolset - enterprise messaging via Feishu/Lark (full access)",
|
||||
"tools": _HERMES_CORE_TOOLS,
|
||||
"includes": []
|
||||
},
|
||||
|
||||
"hermes-wecom": {
|
||||
"description": "WeCom bot toolset - enterprise WeChat messaging (full access)",
|
||||
"tools": _HERMES_CORE_TOOLS,
|
||||
"includes": []
|
||||
},
|
||||
|
||||
"hermes-sms": {
|
||||
"description": "SMS bot toolset - interact with Hermes via SMS (Twilio)",
|
||||
"tools": _HERMES_CORE_TOOLS,
|
||||
@@ -360,7 +372,7 @@ TOOLSETS = {
|
||||
"hermes-gateway": {
|
||||
"description": "Gateway toolset - union of all messaging platform tools",
|
||||
"tools": [],
|
||||
"includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk"]
|
||||
"includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu", "hermes-wecom"]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user