Skip to content

feat: Hermes Admin Panel + Swarm Phase 1 + OpenSandbox lifecycle - #1

Merged
jyf2100 merged 378 commits into
mainfrom
feature/opensandbox-lifecycle
Apr 26, 2026
Merged

feat: Hermes Admin Panel + Swarm Phase 1 + OpenSandbox lifecycle#1
jyf2100 merged 378 commits into
mainfrom
feature/opensandbox-lifecycle

Conversation

@jyf2100

@jyf2100 jyf2100 commented Apr 25, 2026

Copy link
Copy Markdown
Owner

Summary

  • Hermes Admin Panel: Full-featured K8s management console (React 19 + FastAPI) — agent CRUD, config editing, log streaming, health monitoring, WeChat integration, template management, neon cyberpunk UI theme
  • Swarm Phase 1: Redis message bus, core runtime (messaging, circuit breaker, resilient client, exactly-once), admin panel integration (REST + SSE, Zustand, SwarmGuard), agent auto-registration
  • OpenSandbox lifecycle: K8s sandbox management, Open WebUI deployment, ingress configuration
  • Tests: 38 Playwright E2E + 47 swarm unit tests (all passing)

Test plan

  • Python: 47/47 swarm unit tests
  • E2E: 38/38 Playwright tests
  • K8s: 4 gateways + admin + Redis verified
  • Agent registration: auto-register in hermes:registry
  • Admin API: /swarm/agents and /swarm/metrics working
  • Pre-existing test_model_tools failures (same on main)

teknium1 and others added 30 commits April 11, 2026 17:16
The check_interval parameter on terminal_tool sent periodic output
updates to the gateway chat, but these were display-only — the agent
couldn't see or act on them. This added schema bloat and introduced
a bug where notify_on_complete=True was silently dropped when
check_interval was also set (the not-check_interval guard skipped
fast-watcher registration, and the check_interval watcher dict
was missing the notify_on_complete key).

Removing check_interval entirely:
- Eliminates the notify_on_complete interaction bug
- Reduces tool schema size (one fewer parameter for the model)
- Simplifies the watcher registration path
- notify_on_complete (agent wake-on-completion) still works
- watch_patterns (output alerting) still works
- process(action='poll') covers manual status checking

Closes #7947 (root cause eliminated rather than patched).
Add display.platforms section to config.yaml for per-platform overrides of
display settings (tool_progress, show_reasoning, streaming, tool_preview_length).

Each platform gets sensible built-in defaults based on capability tier:
- High (telegram, discord): tool_progress=all, streaming follows global
- Medium (slack, mattermost, matrix, feishu): tool_progress=new
- Low (signal, whatsapp, bluebubbles, wecom, etc.): tool_progress=off, streaming=false
- Minimal (email, sms, webhook, homeassistant): tool_progress=off, streaming=false

Example config:
  display:
    platforms:
      telegram:
        tool_progress: all
        show_reasoning: true
      slack:
        tool_progress: off

Resolution order: platform override > global setting > built-in platform default.

Changes:
- New gateway/display_config.py: resolver module with tier-based platform defaults
- gateway/run.py: tool_progress, tool_preview_length, streaming, show_reasoning
  all resolve per-platform via the new resolver
- /verbose command: now cycles tool_progress per-platform (saves to
  display.platforms.<platform>.tool_progress instead of global)
- /reasoning show|hide: now saves show_reasoning per-platform
- Config version 15 -> 16: migrates tool_progress_overrides into display.platforms
- Backward compat: legacy tool_progress_overrides still read as fallback
- 27 new tests for resolver, normalization, migration, backward compat
- Updated verbose command tests for per-platform behavior

Addresses community request for per-channel verbosity control (Guillaume Meyer,
Nathan Danielsen) — high verbosity on backchannel Telegram, low on customer-facing
Slack, none on email.
…(#7991)

* feat: component-separated logging with session context and filtering

Phase 1 — Gateway log isolation:
- gateway.log now only receives records from gateway.* loggers
  (platform adapters, session management, slash commands, delivery)
- agent.log remains the catch-all (all components)
- errors.log remains WARNING+ catch-all
- Moved gateway.log handler creation from gateway/run.py into
  hermes_logging.setup_logging(mode='gateway') with _ComponentFilter

Phase 2 — Session ID injection:
- Added set_session_context(session_id) / clear_session_context() API
  using threading.local() for per-thread session tracking
- _SessionFilter enriches every log record with session_tag attribute
- Log format: '2026-04-11 10:23:45 INFO [session_id] logger.name: msg'
- Session context set at start of run_conversation() in run_agent.py
- Thread-isolated: gateway conversations on different threads don't leak

Phase 3 — Component filtering in hermes logs:
- Added --component flag: hermes logs --component gateway|agent|tools|cli|cron
- COMPONENT_PREFIXES maps component names to logger name prefixes
- Works with all existing filters (--level, --session, --since, -f)
- Logger name extraction handles both old and new log formats

Files changed:
- hermes_logging.py: _SessionFilter, _ComponentFilter, COMPONENT_PREFIXES,
  set/clear_session_context(), gateway.log creation in setup_logging()
- gateway/run.py: removed redundant gateway.log handler (now in hermes_logging)
- run_agent.py: set_session_context() at start of run_conversation()
- hermes_cli/logs.py: --component filter, logger name extraction
- hermes_cli/main.py: --component argument on logs subparser

Addresses community request for component-separated, filterable logging.
Zero changes to existing logger names — __name__ already provides hierarchy.

* fix: use LogRecord factory instead of per-handler _SessionFilter

The _SessionFilter approach required attaching a filter to every handler
we create. Any handler created outside our _add_rotating_handler (like
the gateway stderr handler, or third-party handlers) would crash with
KeyError: 'session_tag' if it used our format string.

Replace with logging.setLogRecordFactory() which injects session_tag
into every LogRecord at creation time — process-global, zero per-handler
wiring needed. The factory is installed at import time (before
setup_logging) so session_tag is available from the moment hermes_logging
is imported.

- Idempotent: marker attribute prevents double-wrapping on module reload
- Chains with existing factory: won't break third-party record factories
- Removes _SessionFilter from _add_rotating_handler and setup_verbose_logging
- Adds tests: record factory injection, idempotency, arbitrary handler compat
…#8014)

* perf(ssh,modal): bulk file sync via tar pipe and tar/base64 archive

SSH: symlink-staging + tar -ch piped over SSH in a single TCP stream.
Eliminates per-file scp round-trips. Handles timeout (kills both
processes), SSH Popen failure (kills tar), and tar create failure.

Modal: in-memory gzipped tar archive, base64-encoded, decoded+extracted
in one exec call. Checks exit code and raises on failure.

Both backends use shared helpers extracted into file_sync.py:
- quoted_mkdir_command() — mirrors existing quoted_rm_command()
- unique_parent_dirs() — deduplicates parent dirs from file pairs

Migrates _ensure_remote_dirs to use the new helpers.

28 new tests (21 SSH + 7 Modal), all passing.

Closes #7465
Closes #7467

* fix(modal): pipe stdin to avoid ARG_MAX, clean up review findings

- Modal bulk upload: stream base64 payload through proc.stdin in 1MB
  chunks instead of embedding in command string (Modal SDK enforces
  64KB ARG_MAX_BYTES — typical payloads are ~4.3MB)
- Modal single-file upload: same stdin fix, add exit code checking
- Remove what-narrating comments in ssh.py and modal.py (keep WHY
  comments: symlink staging rationale, SIGPIPE, deadlock avoidance)
- Remove unnecessary `sandbox = self._sandbox` alias in modal bulk
- Daytona: use shared helpers (unique_parent_dirs, quoted_mkdir_command)
  instead of inlined duplicates

---------

Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
…tion (#7981)

Fixes #7952 — Matrix E2EE completely broken after mautrix migration.

- Replace MemoryCryptoStore + pickle/HMAC persistence with mautrix's
  PgCryptoStore backed by SQLite via aiosqlite. Crypto state now
  persists reliably across restarts without fragile serialization.

- Add handle_sync() call on initial sync response so to-device events
  (queued Megolm key shares) are dispatched to OlmMachine instead of
  being silently dropped.

- Add _verify_device_keys_on_server() after loading crypto state.
  Detects missing keys (re-uploads), stale keys from migration
  (attempts re-upload), and corrupted state (refuses E2EE).

- Add _CryptoStateStore adapter wrapping MemoryStateStore to satisfy
  mautrix crypto's StateStore interface (is_encrypted,
  get_encryption_info, find_shared_rooms).

- Remove redundant share_keys() call from sync loop — OlmMachine
  already handles this via DEVICE_OTK_COUNT event handler.

- Fix datetime vs float TypeError in session.py suspend_recently_active()
  that crashed gateway startup.

- Add aiosqlite and asyncpg to [matrix] extra in pyproject.toml.

- Update test mocks for PgCryptoStore/Database and add query_keys mock
  for key verification. 174 tests pass.

- Add E2EE upgrade/migration docs to Matrix user guide.
* feat: add `hermes backup` and `hermes import` commands

hermes backup — creates a zip of ~/.hermes/ (config, skills, sessions,
profiles, memories, skins, cron jobs, etc.) excluding the hermes-agent
codebase, __pycache__, and runtime PID files. Defaults to
~/hermes-backup-<timestamp>.zip, customizable with -o.

hermes import <zipfile> — restores from a backup zip, validating it
looks like a hermes backup before extracting. Handles .hermes/ prefix
stripping, path traversal protection, and confirmation prompts (skip
with --force).

29 tests covering exclusion rules, backup creation, import validation,
prefix detection, path traversal blocking, confirmation flow, and a
full round-trip test.

* test: improve backup/import coverage to 97%

Add 17 additional tests covering:
- _format_size helper (bytes through terabytes)
- Nonexistent hermes home error exit
- Output path is a directory (auto-names inside it)
- Output without .zip suffix (auto-appends)
- Empty hermes home (all files excluded)
- Permission errors during backup and import
- Output zip inside hermes root (skips itself)
- Not-a-zip file rejection
- EOFError and KeyboardInterrupt during confirmation
- 500+ file progress display
- Directory-only zip prefix detection

Remove dead code branch in _detect_prefix (unreachable guard).

* feat: auto-restore profile wrapper scripts on import

After extracting backup files, hermes import now scans profiles/ for
subdirectories with config.yaml or .env and recreates the ~/.local/bin
wrapper scripts so profile aliases (e.g. 'coder chat') work immediately.

Also prints guidance for re-installing gateway services per profile.

Handles edge cases:
- Skips profile dirs without config (not real profiles)
- Skips aliases that collide with existing commands
- Gracefully degrades if hermes_cli.profiles isn't available (fresh install)
- Shows PATH hint if ~/.local/bin isn't in PATH

3 new profile restoration tests (49 total).
Adds an optional focus topic to /compress: `/compress database schema`
guides the summariser to preserve information related to the focus topic
(60-70% of summary budget) while compressing everything else more aggressively.
Inspired by Claude Code's /compact <focus>.

Changes:
- context_compressor.py: focus_topic parameter on _generate_summary() and
  compress(); appends FOCUS TOPIC guidance block to the LLM prompt
- run_agent.py: focus_topic parameter on _compress_context(), passed through
  to the compressor
- cli.py: _manual_compress() extracts focus topic from command string,
  preserves existing manual_compression_feedback integration (no regression)
- gateway/run.py: _handle_compress_command() extracts focus from event args
  and passes through — full gateway parity
- commands.py: args_hint="[focus topic]" on /compress CommandDef

Salvaged from PR #7459 (CLI /compress focus only — /context command deferred).
15 new tests across CLI, compressor, and gateway.
The gateway startup path references RedactingFormatter without
importing it, causing a NameError crash when launched with a
verbosity flag (e.g. via launchd --replace).

Fixes #8044

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tions (#8107)

After compression, models (especially Kimi 2.5) would sometimes respond
to questions from the summary instead of the latest user message. This
happened ~30% of the time on Telegram.

Root cause: the summary's 'Next Steps' section read as active instructions,
and the SUMMARY_PREFIX didn't explicitly tell the model to ignore questions
in the summary. When the summary merged into the first tail message, there
was no clear separator between historical context and the actual user message.

Changes inspired by competitor analysis (Claude Code, OpenCode, Codex):

1. SUMMARY_PREFIX rewritten with explicit 'Do NOT answer questions from
   this summary — respond ONLY to the latest user message AFTER it'

2. Summarizer preamble (shared by both prompts) adds:
   - 'Do NOT respond to any questions' (from OpenCode's approach)
   - 'Different assistant' framing (from Codex) to create psychological
     distance between summary content and active conversation

3. New summary sections:
   - '## Resolved Questions' — tracks already-answered questions with
     their answers, preventing re-answering (from Claude Code's
     'Pending user asks' pattern)
   - '## Pending User Asks' — explicitly marks unanswered questions
   - '## Remaining Work' replaces '## Next Steps' — passive framing
     avoids reading as active instructions

4. merge-summary-into-tail path now inserts a clear separator:
   '--- END OF CONTEXT SUMMARY — respond to the message below ---'

5. Iterative update prompt now instructs: 'Move answered questions to
   Resolved Questions' to maintain the resolved/pending distinction
   across multiple compactions.
On servers with broken or unreachable IPv6, Python's socket.getaddrinfo
returns AAAA records first. urllib/httpx/requests all try IPv6 connections
first and hang for the full TCP timeout before falling back to IPv4. This
affects web_extract, web_search, the OpenAI SDK, and all HTTP tools.

Adds network.force_ipv4 config option (default: false) that monkey-patches
socket.getaddrinfo to resolve as AF_INET when the caller didn't specify a
family. Falls back to full resolution if no A record exists, so pure-IPv6
hosts still work.

Applied early at all three entry points (CLI, gateway, cron scheduler)
before any HTTP clients are created.

Reported by user @29n — Chinese Ubuntu server with unreachable IPv6 causing
timeouts on lobste.rs and other IPv6-enabled sites while Google/GitHub
worked fine (IPv4-only resolution).
…opic context (#8187)

Rewrite the cronjob tool's 'deliver' parameter description to strongly
guide models toward omitting the parameter (which auto-detects origin
including thread/topic). The previous description listed all platform
names equally, inviting models to construct explicit targets like
'telegram:<chat_id>' which silently drops the thread_id.

New description:
- Leads with 'Omit this parameter' as the recommended path
- Explicitly warns that platform:chat_id without :thread_id loses topics
- Removes the long flat list of platform names that invited construction

Also adds diagnostic logging at two key points:
- _origin_from_env(): logs when thread_id is captured during job creation
- _deliver_result(): warns when origin has thread_id but delivery target
  lost it; logs at debug when delivering to a specific thread

Helps diagnose user-reported issue where cron responses from Telegram
topics are delivered to the main chat instead of the originating topic.
The TUI transition (4970705, f83e86d) replaced stacked per-tool history
lines with a single live-updating spinner widget. While the spinner
provides a nice live timer, it removed the scrollback history that
users relied on to see what the agent did during a session.

This restores stacked tool progress lines in 'all' and 'new' modes by
printing persistent scrollback lines via _cprint() when tools complete,
in addition to the existing live spinner display.

Behavior per mode:
- off: no scrollback lines, no spinner (unchanged)
- new: scrollback line on completion, skipping consecutive same-tool repeats
- all: scrollback line on every tool completion
- verbose: no scrollback (run_agent.py handles verbose output directly)

Implementation:
- Store function_args from tool.started events in _pending_tool_info
- On tool.completed, pop stored args and format via get_cute_tool_message()
- FIFO queue per function_name handles concurrent tool execution
- 'new' mode tracks _last_scrollback_tool for dedup
- State cleared at end of agent run

Reported by community user Mr.D — the stacked history provides
transparency into what the agent is doing, which builds trust.

Addresses user report from Discord about lost tool call visibility.
The interrupt mechanism for regular text messages (non-commands) during
active agent runs relied on a single async polling task
(monitor_for_interrupt) with no error handling. If this task died
silently due to an unhandled exception, stale adapter reference after
reconnect, or any other failure, user messages sent during agent
execution would be queued but never trigger an actual interrupt — the
agent would continue running until it finished naturally, then process
the queued message.

Three improvements:

1. Error handling in monitor_for_interrupt(): wrap the polling body in
   try/except so transient errors are logged and retried instead of
   silently killing the task.

2. Fresh adapter reference on each poll iteration: re-resolve
   self.adapters.get(source.platform) every 200ms instead of capturing
   the adapter once at task creation time. This prevents stale
   references after adapter reconnects.

3. Backup interrupt check in the inactivity poll loop: both the
   unlimited and timeout-enabled paths now check for pending interrupts
   every 5 seconds (the existing poll interval). Uses a shared
   _interrupt_detected asyncio.Event to avoid double-firing when the
   primary monitor already handled the interrupt. Logs at INFO level
   with monitor task state for debugging.
…ternal credentials (#8224)

Users whose credentials exist only in external files — OpenAI Codex
OAuth tokens in ~/.codex/auth.json or Anthropic Claude Code credentials
in ~/.claude/.credentials.json — would not see those providers in the
/model picker, even though hermes auth and hermes model detected them.

Root cause: list_authenticated_providers() only checked the raw Hermes
auth store and env vars. External credential file fallbacks (Codex CLI
import, Claude Code file discovery) were never triggered.

Fix (three parts):
1. _seed_from_singletons() in credential_pool.py: openai-codex now
   imports from ~/.codex/auth.json when the Hermes auth store is empty,
   mirroring resolve_codex_runtime_credentials().
2. list_authenticated_providers() in model_switch.py: auth store + pool
   checks now run for ALL providers (not just OAuth auth_type), catching
   providers like anthropic that support both API key and OAuth.
3. list_authenticated_providers(): direct check for anthropic external
   credential files (Claude Code, Hermes PKCE). The credential pool
   intentionally gates anthropic behind is_provider_explicitly_configured()
   to prevent auxiliary tasks from silently consuming tokens. The /model
   picker bypasses this gate since it is discovery-oriented.
- Add rebrand_text() that replaces OpenClaw, Open Claw, Open-Claw,
  ClawdBot, and MoltBot with Hermes (case-insensitive, word-boundary)
- Apply rebranding to memory entries (MEMORY.md, USER.md, daily memory)
- Apply rebranding to SOUL.md and workspace instructions via new
  transform parameter on copy_file()
- Fix moldbot -> moltbot typo across codebase (claw.py, migration
  script, docs, tests)
- Add unit tests for rebrand_text and integration tests for memory
  and soul migration rebranding
Remove auto-archival from hermes claw migrate — not its
responsibility (hermes claw cleanup is still there for that).

Skip MESSAGING_CWD when it points inside the OpenClaw source
directory, which was the actual root cause of agent confusion
after migration. Use Path.is_relative_to() for robust path
containment check.

Salvaged from PR #8192 by opriz.
Co-authored-by: opriz <opriz@users.noreply.github.com>
Add a 'tip of the day' feature that displays a random one-liner about
Hermes Agent features on every new session — CLI startup, /clear, /new,
and gateway /new across all messaging platforms.

- New hermes_cli/tips.py module with 210 curated tips covering slash
  commands, keybindings, CLI flags, config options, tools, gateway
  platforms, profiles, sessions, memory, skills, cron, voice, security,
  and more
- CLI: tips display in skin-aware dim gold color after the welcome line
- Gateway: tips append to the /new and /reset response on all platforms
- Fully wrapped in try/except — tips are non-critical and never break
  startup or reset

Display format (CLI):
  ✦ Tip: /btw <question> asks a quick side question without tools or history.

Display format (gateway):
  ✨ Session reset! Starting fresh.
  ✦ Tip: hermes -c resumes your most recent CLI session.
…onsumed output via wait/poll/log (#8228)

When the agent calls process(action='wait') or process(action='poll')
and gets the exited status, the completion_queue notification is
redundant — the agent already has the output from the tool return.
Previously, the drain loops in CLI and gateway would still inject
the [SYSTEM: Background process completed] message, causing the
agent to receive the same information twice.

Fix: track session IDs in _completion_consumed set when wait/poll/log
returns an exited process. Drain loops in cli.py and gateway watcher
skip completion events for consumed sessions. Watch pattern events
are never suppressed (they have independent semantics).

Adds 4 tests covering wait/poll/log marking and running-process
negative case.
…tructured content together

Add content-aware splitting to compact mode: short chat-like exchanges
(2-6 short lines without headings/lists/quotes) get separate message
bubbles for a natural chat feel, while structured content (tables,
headings with body, numbered lists) stays in a single message.

Cherry-picked from PR #7587 by bravohenry, adapted to the compact/legacy
split_per_line architecture from #7903.
- Add gosu for runtime privilege dropping from root to hermes user
- Support HERMES_UID/HERMES_GID env vars for host mount permission matching
- Switch to debian:13.4-slim base image
- Use uv venv instead of pip install --break-system-packages
- Pin uv and gosu multi-stage images with SHA256 digests
- Set PLAYWRIGHT_BROWSERS_PATH to /opt/hermes/.playwright so build-time
  chromium install survives the /opt/data volume mount
- Keep procps for container debugging

Based on work by m0n5t3r in PR #5811. Stripped to hardening-only
changes (non-root, virtualenv, slim base); matrix deps, fonts, xvfb,
and entrypoint playwright download deferred to follow-up.
The slim image drops packages that may be needed at runtime.
Keep the full Debian base for compatibility.
Add lesser-known power-user tips covering:
- BOOT.md gateway startup automation
- Cron script attachment for data collection pipelines
- Prefill messages for few-shot priming
- Focus topic compression (/compress <topic>)
- Terminal exit code annotations and auto-retry
- Automatic sudo password piping
- execute_code built-in helpers (json_parse, shell_quote, retry)
- File loop detection and staleness warnings
- MCP sampling and dynamic tool discovery
- Delegation heartbeat and ACP child agents (Claude Code)
- 402 auto-fallback in auxiliary client
- Container mode, HERMES_HOME_MODE, subprocess HOME isolation
- Ctrl+C 5-tier priority system
- Browser CDP URL override and stealth mode
- Skills quarantine, audit log, and well-known protocol
- Per-platform display overrides, human delay mode
- And many more deep-cut features
…(#8231)

* fix: list all available toolsets in delegate_task schema description

The delegate_task tool's toolsets parameter description only mentioned
'terminal', 'file', and 'web' as examples. Models (especially smaller
ones like Gemma) would substitute 'web' for 'browser' because they
didn't know 'browser' was a valid option.

Now dynamically builds the toolset list from the TOOLSETS dict at import
time, excluding blocked, composite, and platform-specific toolsets.
Auto-updates when new toolsets are added.

Reported by jeffutter on Discord.

* chore: exclude moa and rl from delegate_task toolset list
- Add openai/openai-codex -> openai mapping to PROVIDER_TO_MODELS_DEV
  so context-length lookups use models.dev data instead of 128k fallback.
  Fixes #8161.

- Set api_mode from custom_providers entry when switching via hermes model,
  and clear stale api_mode when the entry has none. Also extract api_mode
  in _named_custom_provider_map(). Fixes #8181.

- Convert OpenAI image_url content blocks to Anthropic image blocks when
  the endpoint is Anthropic-compatible (MiniMax, MiniMax-CN, or any URL
  containing /anthropic). Fixes #8147.
… (#8209)

The previous wording ('If one clearly matches') set too high a threshold,
and 'If none match, proceed normally' was an easy escape hatch for lazy
models. Now:

- Lowered threshold: 'matches or is even partially relevant'
- Added MUST directive and 'err on the side of loading' guidance
- Replaced permissive closer with 'only proceed without if genuinely none
  are relevant'

This should reduce cases where the agent skips loading relevant skills
unless explicitly forced.
Reject non-URL values (e.g. shell commands typed by mistake) in the
base URL prompt during provider setup. Previously any string was saved
as-is to .env, breaking connectivity when the garbage value was used
as the API endpoint.

Adds http:// / https:// prefix check with a clear error message.
The custom-endpoint flow already had this validation (line 1620);
this brings the generic API-key provider flow to parity.

Triggered by a user support case where 'nano ~/.hermes/.env' was
accidentally entered as GLM_BASE_URL during Z.AI setup.
…suming it

The monitor_for_interrupt() and backup interrupt checks were calling
get_pending_message() which pops the message from the adapter's queue.
This created a race condition: if the agent finished naturally before
checking _interrupt_requested, the pending message was permanently lost.

Timeline of the race:
1. Agent near completion, user sends message
2. Level 1 guard stores message in adapter._pending_messages, sets event
3. monitor_for_interrupt() detects event, POPS message, calls agent.interrupt()
4. Agent's run_conversation() was already returning (interrupted=False)
5. Post-run dequeue finds nothing (monitor already consumed it)
6. result.get('interrupted') is False so interrupt_message fallback doesn't fire
7. User message permanently lost — agent finishes without processing it

Fix: change all three interrupt detection sites (primary monitor + two
backup checks) from get_pending_message() (pop) to
_pending_messages.get() (peek). The message stays in the adapter's queue
until _dequeue_pending_event() consumes it in the post-run handler,
which runs regardless of whether the agent was interrupted or finished
naturally.

Reported by @_SushantSays — intermittent message loss during long
terminal command execution, persisting after the previous fix (73f970f)
which addressed monitor task death but not this consumption race.
…gging (#8276)

After /model switches the model (both picker and text paths), the cached
agent's config signature becomes stale — the agent was updated in-place
via switch_model() but the cache tuple's signature was never refreshed.
The next turn *should* detect the signature mismatch and create a fresh
agent, but this relies on the new model's signature differing from the
old one in _agent_config_signature().

Evicting the cached agent explicitly after storing the session override
is more defensive — the next turn is guaranteed to create a fresh agent
from the override without depending on signature mismatch detection.

Also adds debug logging at three key decision points so we can trace
exactly what happens when /model + /retry interact:
- _resolve_session_agent_runtime: which override path is taken (fast
  with api_key vs fallback), or why no override was found
- _run_agent.run_sync: final resolved model/provider before agent
  creation

Reported: /model switch to xiaomi/mimo-v2-pro followed by /retry still
used the old model (glm-5.1).
jyf2100 added 8 commits April 19, 2026 17:01
- index.css: add Tailwind v4 @theme directive (all colors were transparent)
- AdminLayout.tsx: fix layout to h-screen flex column for proper scrolling
- admin-api.ts: handle Pydantic 422 array error format (was crashing React)
- CreateAgentPage.tsx: add MiniMax and Kimi LLM provider configs
- CreateAgentPage.tsx: fix layout with min-h-0 for scrollable content
- dist/: rebuild frontend with all fixes
- ingress.yaml: add proxy timeout annotations for long K8s operations
- docker-compose.yml: configuration updates
- entrypoint-merged.sh: merged admin + gateway entrypoint script
- Fix CustomObjectsApi missing api_client in get_pod_metrics()
- Add get_node_metrics() to K8sClient for node-level metrics
- Add _parse_cpu() and _parse_memory() helpers for K8s quantity parsing
- Populate ResourceUsage in list_agents() via get_resource_usage()
- Calculate cpu_usage_percent/memory_usage_percent in get_cluster_status()
…, logging

- C1: Add metrics.k8s.io pods/nodes permissions to ClusterRole in rbac.yaml
- C2: Parallelize resource usage fetch in list_agents() with asyncio.gather
- H2: get_agent_detail now calls get_resource_usage() instead of empty default
- M6: get_agent_detail passes conditions to _resolve_status() for failed state
- H4: Replace silent except:pass in _read_stream_sync with logger.warning
- Remove hostNetwork: true from admin deployment — use ClusterIP + Ingress
- Remove dnsPolicy: Default — let K8s use default ClusterFirst
- Move _ingress_lock from class attribute to __init__ instance attribute
  to prevent RuntimeError across event loops
Dark purple backgrounds (#0d0221), neon pink (#ff2a6d) + cyan (#05d9e8)
accents, Orbitron + Exo 2 typography, glass-effect sidebar layout with
mobile drawer, staggered page animations, SVG arc gauges, and
prefers-reduced-motion support. All functionality and i18n preserved.
… components

- Extract getApiError() to utils.ts, replacing 9 repeated error extraction patterns
- Merge YamlEditor/SoulEditor into shared TextConfigEditor component (-60 lines)
- Add --color-bar-track CSS token, replace 7 hardcoded rgba() values
- Replace inline spinners in SettingsPage with LoadingSpinner component
- Fix statusOrder import: DashboardPage imports directly from utils.ts
- Remove unused Tabs.tsx component
- Remove stale AdminApiError imports from cleaned files
…d data persistence

- Add WeChat QR login/unbind/status endpoints and frontend components
- Display agent display_name from K8s annotations on cards and detail page
- Add POST /agents/{id}/api-key endpoint for full key reveal with audit logging
- Implement data persistence: templates (dual-layer read), resource limits (JSON file)
- Add Anthropic-compatible api_mode support in config rendering
- Add Anthropic compat provider with base_url handling
- Fix clipboard copy on HTTP deployments with execCommand fallback
- Use hmac.compare_digest for timing-safe admin key comparison
- Add Playwright E2E test infrastructure with mock API fixtures
@github-actions

Copy link
Copy Markdown

⚠️ Supply Chain Risk Detected

This PR contains patterns commonly associated with supply chain attacks. This does not mean the PR is malicious — but these patterns require careful human review before merging.

⚠️ WARNING: base64 encoding/decoding detected

Base64 has legitimate uses (images, JWT, etc.) but is also commonly used to obfuscate malicious payloads. Verify the usage is appropriate.

Matches (first 20):

1250:+                return base64.b64decode(secret.data["api_key"]).decode("utf-8")
4156:+    b64 = base64.b64encode(buf.getvalue()).decode()
40612:+        b64 = base64.b64encode(raw).decode("ascii")
41127:+        computed = base64.b64encode(mac.digest()).decode("utf-8")
42049:+        self.key = base64.b64decode(encoding_aes_key + "=")
42061:+            cipher_text = base64.b64decode(encrypt)
42103:+            return base64.b64encode(encrypted).decode("utf-8")
42653:+        aes_key_for_api = base64.b64encode(aes_key.hex().encode("ascii")).decode("ascii")
74852:+    return base64.b64encode(mac.digest()).decode("utf-8")
76883:+        expected_aes = base64.b64encode(fake_hex_key.encode("ascii")).decode("ascii")
90580:+        tar_data = base64.b64decode(payload)
90749:+        tar_data = base64.b64decode(payload)
92560:+            audio_data=base64.b64encode(audio_content).decode()
92585:+            audio_data=base64.b64encode(b"data").decode()
92601:+            audio_data=base64.b64encode(b"data").decode()
92617:+            audio_data=base64.b64encode(b"data").decode()
92632:+            audio_data=base64.b64encode(b"data").decode()
92658:+            audio_data=base64.b64encode(b"data").decode()
92673:+            audio_data=base64.b64encode(b"data").decode()
92693:+            audio_data=base64.b64encode(b"audio").decode()

⚠️ WARNING: exec() or eval() usage

Dynamic code execution can hide malicious behavior, especially when combined with base64 or network fetches.

Matches (first 20):

40069:+            proc = await asyncio.create_subprocess_exec(
55312:+    "Quick commands support two types: exec (run shell command directly) and alias (redirect to another command).",
90504:+def _wire_async_exec(env, exec_calls=None):
90608:+        exec_calls, _, _ = _wire_async_exec(env)
90625:+        exec_calls, _, _ = _wire_async_exec(env)
90669:+        _, run_kwargs, _ = _wire_async_exec(env)
94546:+            self._sandbox.process.exec(quoted_mkdir_command(parents))
94977:+            parent_check = self._exec(
94981:+                ls_result = self._exec(
95027:+        result = self._exec(cmd_sorted, timeout=60)
95037:+            result = self._exec(cmd_plain, timeout=60)
102807:+  while ((match = pattern.exec(text)) !== null) {
107771:+  while ((match = regex.exec(snippet)) !== null) {

⚠️ WARNING: Outbound network calls (POST/PUT)

Outbound POST/PUT requests in new code could be data exfiltration. Verify the destination URLs are legitimate.

Matches (first 10):

20917:+            with urllib.request.urlopen(req, timeout=5) as resp:
49954:+    with urllib.request.urlopen(req, timeout=30) as resp:
49990:+    with urllib.request.urlopen(req, timeout=30) as resp:
56769:+        with urllib.request.urlopen(req, timeout=20) as resp:
59578:+        with urllib.request.urlopen(req, timeout=15) as r:
60364:+        with urllib.request.urlopen(req, timeout=15) as r:
60424:+        with urllib.request.urlopen(url, timeout=10) as r:
80298:+            "hermes_cli.debug.urllib.request.urlopen",

⚠️ WARNING: Install hook files modified

These files can execute code during package installation or interpreter startup.

Files:

hermes_cli/memory_setup.py
hermes_cli/setup.py
tests/hermes_cli/test_setup.py

Automated scan triggered by supply-chain-audit. If this is a false positive, a maintainer can approve after manual review.

jyf2100 added 11 commits April 25, 2026 10:15
Addresses 11 CRITICAL and 23 HIGH issues from five-expert review
of the swarm collaboration design. Covers sync/async bridge,
message transport redesign, exactly-once semantics, Redis deployment
strategy, connection management, circuit breaker, frontend interaction
specs, Zustand state management, and SSE real-time transport.
Consolidate original design v1.0 and technical supplementary document
(addressing 11 CRITICAL + 23 HIGH review issues) into a single v2.0
design document. Removes the separate supplementary file.

Key additions: sync/async bridge (3-thread architecture), message
transport redesign (Streams + Pub/Sub dual layer), exactly-once semantics
(5-layer defense), Redis deployment (3-phase), connection management,
circuit breaker + graceful degradation, frontend interaction specs,
Zustand state management, SSE real-time transport, visual design spec.
… client, and tool integration

Implements the Swarm Phase 1 backend: Redis connection management, health
checks, exactly-once task delivery via Streams+Pub/Sub, circuit breaker for
fault tolerance, graceful degradation to standalone mode, and the swarm_tool
bridge for the agent loop's three-thread architecture.

47 tests covering all modules.
Redis 7-alpine with redis-exporter sidecar, ConfigMap (AOF, maxmemory
384mb allkeys-lru), Secret template, local PV/PVC, and NetworkPolicy
restricting ingress to hermes-agent namespace.
Pydantic models, authenticated routes for agent registry/health/metrics,
SSE stream with one-time token auth, and swarm router mounted on admin app.
…stores

SwarmGuard feature flag, RedisHealthCard, SwarmOverviewPage with agent
grid/stats, swarm-sse.ts with exponential backoff reconnect, Zustand
stores for registry and events. Full zh/en i18n coverage.
Playwright config and swarm.spec.ts with 3 tests (agent cards, Redis
health, capability guard redirect). Updated dist build output.
2810-line plan with 27 tasks across 3 groups (Redis infrastructure,
core runtime, admin panel), plus hermes admin panel PRD spec.
- CLAUDE.md: architecture, conventions (auth, i18n, SSE, API layer),
  testing guide, common pitfalls
- scripts/check-admin.sh: CI checks (i18n sync, console.log audit,
  TypeScript, build, auth patterns, bare except, secrets, E2E count)
- scripts/admin-hooks.sh: Claude Code PostToolUse hooks (tsc,
  console.log, i18n sync, auth pattern)
- scripts/pre-commit.sh: git pre-commit hook for security patterns
- Add redis[hiredis] to backend requirements.txt
- Copy swarm/ package into Docker image (needed by swarm_routes.py)
- Fix redis PV node affinity (roc-epyc, not hermes-node)
- Add imagePullPolicy: Never to redis-exporter sidecar
- Update frontend build artifacts
@github-actions

Copy link
Copy Markdown

⚠️ Supply Chain Risk Detected

This PR contains patterns commonly associated with supply chain attacks. This does not mean the PR is malicious — but these patterns require careful human review before merging.

⚠️ WARNING: base64 encoding/decoding detected

Base64 has legitimate uses (images, JWT, etc.) but is also commonly used to obfuscate malicious payloads. Verify the usage is appropriate.

Matches (first 20):

1406:+                return base64.b64decode(secret.data["api_key"]).decode("utf-8")
4612:+    b64 = base64.b64encode(buf.getvalue()).decode()
48101:+        b64 = base64.b64encode(raw).decode("ascii")
48616:+        computed = base64.b64encode(mac.digest()).decode("utf-8")
49538:+        self.key = base64.b64decode(encoding_aes_key + "=")
49550:+            cipher_text = base64.b64decode(encrypt)
49592:+            return base64.b64encode(encrypted).decode("utf-8")
50142:+        aes_key_for_api = base64.b64encode(aes_key.hex().encode("ascii")).decode("ascii")
83214:+    return base64.b64encode(mac.digest()).decode("utf-8")
85245:+        expected_aes = base64.b64encode(fake_hex_key.encode("ascii")).decode("ascii")
99608:+        tar_data = base64.b64decode(payload)
99777:+        tar_data = base64.b64decode(payload)
101588:+            audio_data=base64.b64encode(audio_content).decode()
101613:+            audio_data=base64.b64encode(b"data").decode()
101629:+            audio_data=base64.b64encode(b"data").decode()
101645:+            audio_data=base64.b64encode(b"data").decode()
101660:+            audio_data=base64.b64encode(b"data").decode()
101686:+            audio_data=base64.b64encode(b"data").decode()
101701:+            audio_data=base64.b64encode(b"data").decode()
101721:+            audio_data=base64.b64encode(b"audio").decode()

⚠️ WARNING: exec() or eval() usage

Dynamic code execution can hide malicious behavior, especially when combined with base64 or network fetches.

Matches (first 20):

47558:+            proc = await asyncio.create_subprocess_exec(
62801:+    "Quick commands support two types: exec (run shell command directly) and alias (redirect to another command).",
99532:+def _wire_async_exec(env, exec_calls=None):
99636:+        exec_calls, _, _ = _wire_async_exec(env)
99653:+        exec_calls, _, _ = _wire_async_exec(env)
99697:+        _, run_kwargs, _ = _wire_async_exec(env)
103574:+            self._sandbox.process.exec(quoted_mkdir_command(parents))
104005:+            parent_check = self._exec(
104009:+                ls_result = self._exec(
104055:+        result = self._exec(cmd_sorted, timeout=60)
104065:+            result = self._exec(cmd_plain, timeout=60)
112001:+  while ((match = pattern.exec(text)) !== null) {
116965:+  while ((match = regex.exec(snippet)) !== null) {

⚠️ WARNING: Outbound network calls (POST/PUT)

Outbound POST/PUT requests in new code could be data exfiltration. Verify the destination URLs are legitimate.

Matches (first 10):

23427:+            with urllib.request.urlopen(req, timeout=5) as resp:
57443:+    with urllib.request.urlopen(req, timeout=30) as resp:
57479:+    with urllib.request.urlopen(req, timeout=30) as resp:
64258:+        with urllib.request.urlopen(req, timeout=20) as resp:
67300:+        with urllib.request.urlopen(req, timeout=15) as r:
68086:+        with urllib.request.urlopen(req, timeout=15) as r:
68146:+        with urllib.request.urlopen(url, timeout=10) as r:
88660:+            "hermes_cli.debug.urllib.request.urlopen",

⚠️ WARNING: Install hook files modified

These files can execute code during package installation or interpreter startup.

Files:

hermes_cli/memory_setup.py
hermes_cli/setup.py
tests/hermes_cli/test_setup.py

Automated scan triggered by supply-chain-audit. If this is a false positive, a maintainer can approve after manual review.

jyf2100 added 4 commits April 26, 2026 06:24
…line

Connect swarm collaboration to the agent lifecycle:
- run_agent.py: AIAgent._init_swarm() reads swarm config, creates
  ResilientSwarmClient with graceful degradation, wires swarm_tool
- admin backend: render_config_yaml() generates swarm: section,
  CreateAgentRequest accepts swarm_enabled/capabilities/max_tasks
- gateway/run.py: bridges swarm config to SWARM_* env vars
- K8s deployments: inject SWARM_REDIS_URL and K8S_DEPLOYMENT env vars
  so agents can identify themselves and connect to Redis
capabilities is already a list after json.loads(profile_json), calling
json.loads() again on a list raises TypeError, silently skipping agents.
- SwarmGuard redirects to "/" which maps to "/admin" URL, not root "/"
- Use regex matcher for Connected text to support i18n variations
@github-actions

Copy link
Copy Markdown

⚠️ Supply Chain Risk Detected

This PR contains patterns commonly associated with supply chain attacks. This does not mean the PR is malicious — but these patterns require careful human review before merging.

⚠️ WARNING: base64 encoding/decoding detected

Base64 has legitimate uses (images, JWT, etc.) but is also commonly used to obfuscate malicious payloads. Verify the usage is appropriate.

Matches (first 20):

1415:+                return base64.b64decode(secret.data["api_key"]).decode("utf-8")
4643:+    b64 = base64.b64encode(buf.getvalue()).decode()
48140:+        b64 = base64.b64encode(raw).decode("ascii")
48655:+        computed = base64.b64encode(mac.digest()).decode("utf-8")
49577:+        self.key = base64.b64decode(encoding_aes_key + "=")
49589:+            cipher_text = base64.b64decode(encrypt)
49631:+            return base64.b64encode(encrypted).decode("utf-8")
50181:+        aes_key_for_api = base64.b64encode(aes_key.hex().encode("ascii")).decode("ascii")
83358:+    return base64.b64encode(mac.digest()).decode("utf-8")
85389:+        expected_aes = base64.b64encode(fake_hex_key.encode("ascii")).decode("ascii")
99752:+        tar_data = base64.b64decode(payload)
99921:+        tar_data = base64.b64decode(payload)
101732:+            audio_data=base64.b64encode(audio_content).decode()
101757:+            audio_data=base64.b64encode(b"data").decode()
101773:+            audio_data=base64.b64encode(b"data").decode()
101789:+            audio_data=base64.b64encode(b"data").decode()
101804:+            audio_data=base64.b64encode(b"data").decode()
101830:+            audio_data=base64.b64encode(b"data").decode()
101845:+            audio_data=base64.b64encode(b"data").decode()
101865:+            audio_data=base64.b64encode(b"audio").decode()

⚠️ WARNING: exec() or eval() usage

Dynamic code execution can hide malicious behavior, especially when combined with base64 or network fetches.

Matches (first 20):

47597:+            proc = await asyncio.create_subprocess_exec(
62850:+    "Quick commands support two types: exec (run shell command directly) and alias (redirect to another command).",
99676:+def _wire_async_exec(env, exec_calls=None):
99780:+        exec_calls, _, _ = _wire_async_exec(env)
99797:+        exec_calls, _, _ = _wire_async_exec(env)
99841:+        _, run_kwargs, _ = _wire_async_exec(env)
103718:+            self._sandbox.process.exec(quoted_mkdir_command(parents))
104149:+            parent_check = self._exec(
104153:+                ls_result = self._exec(
104199:+        result = self._exec(cmd_sorted, timeout=60)
104209:+            result = self._exec(cmd_plain, timeout=60)
112145:+  while ((match = pattern.exec(text)) !== null) {
117109:+  while ((match = regex.exec(snippet)) !== null) {

⚠️ WARNING: Outbound network calls (POST/PUT)

Outbound POST/PUT requests in new code could be data exfiltration. Verify the destination URLs are legitimate.

Matches (first 10):

23466:+            with urllib.request.urlopen(req, timeout=5) as resp:
57492:+    with urllib.request.urlopen(req, timeout=30) as resp:
57528:+    with urllib.request.urlopen(req, timeout=30) as resp:
64307:+        with urllib.request.urlopen(req, timeout=20) as resp:
67361:+        with urllib.request.urlopen(req, timeout=15) as r:
68147:+        with urllib.request.urlopen(req, timeout=15) as r:
68207:+        with urllib.request.urlopen(url, timeout=10) as r:
88804:+            "hermes_cli.debug.urllib.request.urlopen",

⚠️ WARNING: Install hook files modified

These files can execute code during package installation or interpreter startup.

Files:

hermes_cli/memory_setup.py
hermes_cli/setup.py
tests/hermes_cli/test_setup.py

Automated scan triggered by supply-chain-audit. If this is a false positive, a maintainer can approve after manual review.

@jyf2100 jyf2100 changed the title feat: Hermes Admin Panel with K8s multi-instance management feat: Hermes Admin Panel + Swarm Phase 1 + OpenSandbox lifecycle Apr 26, 2026
- Auth key sync: both main.py and swarm_routes.py now read from app.state
- SPA path traversal: add resolve() + containment check on static file serving
- WeChat credentials: chmod 0o750/0o640 instead of 0o777/0o666
- Redis URL logging: sanitize password before logging in _init_swarm
- ResilientSwarmClient: recover from STANDALONE mode on successful heartbeat
- Admin key fallback: add warning log for plaintext file persistence
- I18n context: memoize provider value to prevent unnecessary re-renders
@github-actions

Copy link
Copy Markdown

⚠️ Supply Chain Risk Detected

This PR contains patterns commonly associated with supply chain attacks. This does not mean the PR is malicious — but these patterns require careful human review before merging.

⚠️ WARNING: base64 encoding/decoding detected

Base64 has legitimate uses (images, JWT, etc.) but is also commonly used to obfuscate malicious payloads. Verify the usage is appropriate.

Matches (first 20):

1415:+                return base64.b64decode(secret.data["api_key"]).decode("utf-8")
4664:+    b64 = base64.b64encode(buf.getvalue()).decode()
48162:+        b64 = base64.b64encode(raw).decode("ascii")
48677:+        computed = base64.b64encode(mac.digest()).decode("utf-8")
49599:+        self.key = base64.b64decode(encoding_aes_key + "=")
49611:+            cipher_text = base64.b64decode(encrypt)
49653:+            return base64.b64encode(encrypted).decode("utf-8")
50203:+        aes_key_for_api = base64.b64encode(aes_key.hex().encode("ascii")).decode("ascii")
83387:+    return base64.b64encode(mac.digest()).decode("utf-8")
85418:+        expected_aes = base64.b64encode(fake_hex_key.encode("ascii")).decode("ascii")
99794:+        tar_data = base64.b64decode(payload)
99963:+        tar_data = base64.b64decode(payload)
101774:+            audio_data=base64.b64encode(audio_content).decode()
101799:+            audio_data=base64.b64encode(b"data").decode()
101815:+            audio_data=base64.b64encode(b"data").decode()
101831:+            audio_data=base64.b64encode(b"data").decode()
101846:+            audio_data=base64.b64encode(b"data").decode()
101872:+            audio_data=base64.b64encode(b"data").decode()
101887:+            audio_data=base64.b64encode(b"data").decode()
101907:+            audio_data=base64.b64encode(b"audio").decode()

⚠️ WARNING: exec() or eval() usage

Dynamic code execution can hide malicious behavior, especially when combined with base64 or network fetches.

Matches (first 20):

47619:+            proc = await asyncio.create_subprocess_exec(
62872:+    "Quick commands support two types: exec (run shell command directly) and alias (redirect to another command).",
99718:+def _wire_async_exec(env, exec_calls=None):
99822:+        exec_calls, _, _ = _wire_async_exec(env)
99839:+        exec_calls, _, _ = _wire_async_exec(env)
99883:+        _, run_kwargs, _ = _wire_async_exec(env)
103760:+            self._sandbox.process.exec(quoted_mkdir_command(parents))
104191:+            parent_check = self._exec(
104195:+                ls_result = self._exec(
104241:+        result = self._exec(cmd_sorted, timeout=60)
104251:+            result = self._exec(cmd_plain, timeout=60)
112187:+  while ((match = pattern.exec(text)) !== null) {
117151:+  while ((match = regex.exec(snippet)) !== null) {

⚠️ WARNING: Outbound network calls (POST/PUT)

Outbound POST/PUT requests in new code could be data exfiltration. Verify the destination URLs are legitimate.

Matches (first 10):

23488:+            with urllib.request.urlopen(req, timeout=5) as resp:
57514:+    with urllib.request.urlopen(req, timeout=30) as resp:
57550:+    with urllib.request.urlopen(req, timeout=30) as resp:
64329:+        with urllib.request.urlopen(req, timeout=20) as resp:
67383:+        with urllib.request.urlopen(req, timeout=15) as r:
68169:+        with urllib.request.urlopen(req, timeout=15) as r:
68229:+        with urllib.request.urlopen(url, timeout=10) as r:
88833:+            "hermes_cli.debug.urllib.request.urlopen",

⚠️ WARNING: Install hook files modified

These files can execute code during package installation or interpreter startup.

Files:

hermes_cli/memory_setup.py
hermes_cli/setup.py
tests/hermes_cli/test_setup.py

Automated scan triggered by supply-chain-audit. If this is a false positive, a maintainer can approve after manual review.

@jyf2100
jyf2100 merged commit fa9097c into main Apr 26, 2026
5 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.