The previous test patched ssl.create_default_context globally with a bare
SSLContext that has zero CA certs. Both verify_ca_bundle() and the macOS
fallback got the same mocked context, so the test verified nothing useful:
both paths produced empty get_ca_certs() and the assertion that no
exception escaped was vacuously satisfied.
Only mock the fallback call (no cafile) — let the certifi call hit the
real SSL stack and fail with SSLError on the broken PEM. The mock
fallback returns a context with load_default_certs() so the test now
verifies the real scenario: broken certifi → SSLConfigurationError,
macOS system trust store → success.
Also pads the broken PEM past the 1 KB size guard so the size check
doesn't short-circuit before ssl.create_default_context(cafile=...) runs.
Reported by @liuhao1024 in PR review.
A stale certifi CA bundle after a partial `hermes update` used to crash
the agent on the first outbound HTTPS call with a raw traceback and
trap the gateway in a retry loop.
This patch:
* Adds `agent/errors.py` with a typed `SSLConfigurationError`
* Adds `agent/ssl_guard.py` with a `verify_ca_bundle()` pre-flight
that asserts the bundle exists, is non-trivial in size, and can build
a working SSLContext. On macOS, it falls back to the system trust
store when the bundle is empty but the system store is healthy
(covers corporate proxies / MDM setups).
* Wires the guard into `run_agent.py` and `gateway/run.py` right
after the `hermes_bootstrap` import, inside a try/except so a bug
in the guard itself can never prevent startup.
* Adds a `SSL / CA Certificates` section to `hermes_cli doctor` so
users can detect the failure with one command.
* Adds unit tests covering the healthy, missing, empty, skip-env, and
macOS-fallback paths.
* Adds an RCA document describing the failure mode and the recovery
path (`pip install -e .`).
When the bundle is broken the user sees:
\u26a0\ufe0f SSL certificate bundle issue detected.
Run: pip install -e .
`HERMES_SKIP_SSL_GUARD=1` disables the check for sandboxed
environments that ship their own trust store.
A notify_on_complete background process (the agent's terminal tool, proc_*) only
reached the TUI as the AGENT'S NARRATION — the completion is fed to the model as a
synthetic prompt (_run_prompt_submit) and the gateway emitted only message.start +
the reply, never the completion itself. So the OpenTUI card mechanism had nothing
to render and the synthetic turn read as a context-less line.
Additive fix (glitch-approved relaxation of the no-core rule for this one emit):
new _emit_process_completion_card() fires a `notification.show` (text "<cmd> exited
<code>", level info/warn by exit code, kind process.complete, key proc:<id>) at the
two completion-delivery sites, just before the agent turn. The OpenTUI engine
renders it as a distinct inline card (P1) + an OSC ping; Ink treats it as a notice.
Completion events only (watch matches skipped); call-site dedup → one card per exit.
No existing behavior changes — the agent turn still happens.
Gateway suite green (321 passed incl. 5 new TestProcessCompletionCard cases).
* fix(desktop): jump-to-approval pill for off-screen approvals
A blocked approval's only response surface is the inline Run/Reject bar on
the pending tool row. When that row is scrolled out of view the session looks
stalled with no visible action. Surface a composer-anchored "Approval needed"
pill only when an approval is pending AND its inline bar is scrolled away;
clicking scrolls the bar back into view. Preserves the deliberate inline (not
modal) approval design — the pill never duplicates the approve/reject controls.
The inline bar mirrors its own viewport visibility via IntersectionObserver
(tracks scroll/resize/layout) and registers a scroll-into-view handler the pill
fires, mirroring the existing thread-scroll jump-button bridge.
Supersedes #45828.
* fix(desktop): morph jump-to-bottom into approval prompt; drop scroll bridge
Collapse the separate "jump to approval" pill into the existing
scroll-to-bottom control: when scrolled away from the bottom while an approval
is pending, it relabels to "Approval needed". A parked approval's inline
Run/Reject bar is always the bottom-most content, so the existing
scroll-to-bottom action lands the user right on it — one control, no collision.
This also fixes the layout corruption from the first cut: the pill called
native el.scrollIntoView(), which scrolls every scrollable ancestor including
the overflow:hidden chat shell containers. Those have no scrollbar to scroll
back and don't remount on session switch, so the composer stayed shoved and
the breakage persisted across sessions. Reusing requestScrollToBottom() (the
use-stick-to-bottom path) only touches the one designated scroll container.
Removes the now-unused approval-scroll store + IntersectionObserver wiring.
Three tests covering: a stale .bak poisoning a failed update's move/restore, an orphaned .bak misread as a user deletion, and a partially written dest blocking restore-on-failure. All three fail on current main without the fix.
Refs #44942
Recover an orphaned .bak before classification (interrupted updates no longer read as user deletions), clear a stale .bak before shutil.move (replace, not nest), and clear a partial dest before restore so restore-on-failure actually runs.
Fixes#44942
tools/approval.py already denies tee/redirection writes to every
_SENSITIVE_WRITE_TARGET (~/.ssh/*, ~/.netrc/.pgpass/.npmrc/.pypirc, shell
rc files, ~/.hermes/config.yaml/.env) via the DANGEROUS_PATTERNS tee/`>`
rules, but cp/mv/install were only paired for _SYSTEM_CONFIG_PATH (/etc) and
the project-relative env/config target. So `cp evil ~/.ssh/authorized_keys`
(SSH-key implant / persistence), `cp creds ~/.netrc`, and `cp evil ~/.bashrc`
(login-time command injection) auto-approved while the equivalent tee/`>`
forms were denied — an unpaired write deny is theater (same rationale as
#14639 / commit 4e9d886d, which paired the terminal side for
~/.hermes/config.yaml writes but did not touch these cp/mv/install verbs on
the broader sensitive set).
Add one (cp|mv|install) DANGEROUS_PATTERNS entry reusing the existing
_SENSITIVE_WRITE_TARGET fragment, anchored via _COMMAND_TAIL so it fires on
the destination (last arg) only: reading OUT of a sensitive path
(`cp ~/.ssh/config /tmp/x`) stays auto-approved. Description differs from the
system-config cp entry so the two keep distinct approval keys (no silent
cross-approval). Additive — does not subsume the /etc or project-config rules.
Adds TestSensitiveCopyMovePattern: 5 positive cases (ssh authorized_keys,
ssh private key via mv, netrc via install, bashrc, ~/.hermes/config.yaml) +
2 negative guards (copy FROM ssh, unrelated copy). The ssh/netrc/bashrc
positives fail on main and pass on this branch; the negatives stay green
both ways.
Carry forward focused follow-ups from PR #45741: treat PTB's raw Bot API 10.1 response shapes safely, recognize real missing-endpoint errors, preserve link preview settings on rich sends, and lock the rich limit to Telegram's character-based cap.
Large paste and Ctrl+A → Delete froze the composer for seconds — both routed
through Chromium's contenteditable editing pipeline (~O(n²) on multiline DOM).
- insertPlainTextAtCaret: Range + text/<br> fragment (paste path)
- deleteSelectionInEditor: range.deleteContents for non-collapsed Backspace/Delete
- Shared composerSelectionRange helper; both flush via flushEditorToDraft
Profiled live (47 KB / 122 paragraphs): paste 4474 ms → 13 ms; select-delete
1304 ms → 4 ms. Collapsed-caret deletes still native.
* fix(desktop): accept slash command on space at command stage
Pressing space on a no-arg slash command (e.g. /hermes-agent) fell
through to the arg-completion stage and dead-ended on "No matches"
instead of inserting the directive. Space now mirrors Tab/Enter while
the command name is still being typed: no-arg commands commit the chip,
arg-taking commands expand to their options step.
* fix(desktop): suppress arg popover for no-arg slash commands
Committing a no-arg command (`/hermes-agent `) re-detected the chip+space
as an arg query and re-opened the popover on "No matches". The arg-stage
menu now only opens when the command actually takes args.
* fix(desktop): polish slash arg completion (space/tab/click + typed args)
Unify Enter/Tab/Space accept of the highlighted item at both the command
and arg stages: no-arg commands commit a chip, arg commands expand to
options, and an arg option commits the full `/cmd arg` chip. A fully-typed
arg (which the backend completer drops from suggestions) now commits on
Space/Tab via the verbatim text instead of dead-ending, and the "No
matches" empty state is suppressed past a command's name. Space stays
slash-only so @ mentions keep a literal space.
The salvaged fix's two regression tests mock adapter.handle_message, so
they only assert the pre-claimed sentinel is set/cleaned around a stub —
they never drive the real dispatch chain. Add a full-path test that
exercises _schedule_resume_pending_sessions -> _guarded_handle_message ->
adapter.handle_message -> _process_message_background -> _handle_message
and asserts the resumed session's agent runs EXACTLY ONCE: not zero (the
pre-claim must not self-bounce the resume into a queued no-op) and not
twice (the duplicate-agent bug #45456 the fix targets). Also assert no
leaked sentinel and no orphaned pending event after the drain settles.
Tighten the _guarded_handle_message docstring: on current main the real
sentinel is taken over inside _handle_message (not _process_message_background),
and note the `is _AGENT_PENDING_SENTINEL` guard only releases the slot we
ourselves placed, never one a live run owns.
When the gateway restarts and auto-resumes an interrupted session, an
inbound message arriving in the window between `asyncio.create_task()`
and the task's first await could spin up a second AIAgent for the same
session. Both agents would then process messages concurrently,
producing interleaved duplicate responses (#45456).
Fix: set `_AGENT_PENDING_SENTINEL` in `_running_agents` immediately
after the "already running" check, before creating the task. This
closes the race window — any inbound message sees the slot as occupied
and queues behind the auto-resume.
A `_guarded_handle_message` wrapper ensures the pre-claimed sentinel is
always released, even if `handle_message` raises before reaching
`_process_message_background` (whose `finally` block handles normal
cleanup).
(cherry picked from commit 85150c976bcd067d96900dbf85a4616bb4851e1c)
Bedrock Converse rejects non-default sampling parameters for Opus 4.7 and 4.8 with a ValidationException. Reuse the Anthropic-native sampling-param guard in the Bedrock kwargs builder so those models omit temperature/topP while older Claude and non-Claude models keep existing behavior.
Includes the stop-sequence regression from the parallel fix to ensure stopSequences still pass through for restricted Opus models.
Co-authored-by: Tranquil-Flow <tranquil_flow@protonmail.com>
I conflated two "background" concepts. In hermes, /bg (aliases /background, /btw)
launches a background PROMPT (prompt.background → background.complete), and the
`bg: N` badge counts in-flight prompt tasks — but P3 hijacked /bg + bg: for the
OS-process registry and never handled background.complete (so completions were
silent). Corrected:
- /bg <prompt> now launches a background prompt (Ink parity): prompt.background,
echoes "bg <id> started", tracks the task in store.bgTasks.
- background.complete → drops the task + renders a distinct inline completion
CARD with the result (the missing completion notification; a completion-ish
kind also fires the OSC desktop ping).
- `bg: N` badge counts store.bgTasks (in-flight background prompts), not OS procs.
- the OS-process panel moved off /bg to /processes (+ /procs); its header count
uses runningCount. Dropped the ambient agents.list poll — the badge is now
event-driven and the panel fetches on open.
Gate green; new store test for background.complete (card + badge decrement).
/simplify pass over this session's diff (4 cleanup agents → applied the high-value
findings):
- reuse: extract the duplicated truncRight/truncLeft (statusBar/agentsDashboard/
backgroundPanel) to logic/truncate.ts; export DONE_STATUSES/procIsRunning from
backgroundActivity and drop backgroundPanel's re-declared copy.
- simplification: remove dead/speculative exports (upsertNotification,
clearNotificationsByKey, BackgroundRun) — the campaign models notifications as
Message rows, not a notifications array — and drop runningCount's always-empty
`runs` param; collapse notificationDispatcher's always-true `card` field into a
plain notificationOsc(): TermNotification | undefined.
- efficiency: the bg-process poll now idles at 30s (was a flat 8s) and tightens to
8s only when something is running — most sessions have zero background processes.
- altitude: the `⚡ agents` chip joins the statusSegments width-ladder (was an inline
width gate) so all bar segments share one drop policy; refreshed the stale
statusBar header (bg: is wired now, not "reserved").
Net −95 LOC. Gate green. Skipped (noted): statusColor merge (divergent domains),
the pushNotification double-clone (necessary — guards the Solid aliasing footgun),
moving the notification-card dispatch out of messageLine, and the Message-row model
(deliberate "inline in transcript" design).
Input-zone density (rpiw): the agents tray kept a persistent collapsed line under
the composer (`⚡ N agents running — ↓ to inspect`), stacking with the status bar +
composer. The running count now lives in a status-bar `⚡ N` chip (next to bg:/mcp:),
and the tray renders NOTHING when collapsed — one fewer persistent line under the
transcript. The tray stays mounted + focusable, so composer-Down still hands focus
over and expands it into the rows (focus-routing tests unchanged + green).
Gate green; agentsTray tests repointed from the removed tray line to the chip; the
Down/Esc/printable focus-routing coverage is intact.
The OS process registry (the qxpe "Claude Code background process" gap) had NO
surface in the TUI. Now:
- /bg (aliases /background, /jobs) opens a Background Processes panel listing the
registry from agents.list — per-process command + uptime + status, running
count, and a single STOP-ALL action (x → process.stop; the gateway exposes
kill_all only, so there's no per-row kill — noted in the panel).
- the reserved status-bar `bg: N` badge (A) now shows the running-process count,
fed by a slow (8s) scoped poll of agents.list so it stays live with the panel
closed; hidden at zero.
Background *runs* are intentionally NOT duplicated here — they're already the
resume picker's active-sessions tab; this panel targets the process registry,
the actual gap. All TUI-only (agents.list + process.stop already exist). Gate
green; new backgroundPanel.test (parse + list + running-count + empty state).
The agents dashboard (rplj pain) dumped each subagent's full multi-line prompt
into the master list, wrapping it into a wall of text and squeezing the trace.
Now each master row is ONE line: status + a width-budgeted truncated goal + model.
The detail pane still shows the full goal (the inspect half) and renders the
activity as a TYPED transcript instead of flat lines: SubagentInfo.trace is now
TraceEntry[] ({kind:'start'|'tool'|'progress'|'summary'}), drawn with per-kind
glyph+color (▶ start / ⚡ tool accent / · progress muted / ✓ summary green).
No foregrounding (kept subagent UX unchanged — inspection only, per the brainstorm).
TUI-only. Gate green; new agentsDashboard.test.tsx asserts one-line truncation +
typed-trace render; store.test trace assertion updated to the typed shape.
The TUI now consumes notification.show/clear gateway events (it dropped them
before — they leaked into the transcript as plain model-output-looking lines,
the qxpe pain). They render as a distinct, level-tinted inline card (role
'notification'): gold ◆ for info, amber for warn, red for error — clearly chrome,
not the agent. Important ones (error/warn/'complete'|'done'|'finish' kinds) also
fire the EXISTING focus-gated OSC desktop notification via termChrome.
Shared substrate (pure, unit-tested) for the rest of the campaign:
- logic/backgroundActivity.ts — parse notification.show/agents.list payloads,
dedupe-by-id upsert, clear-by-key, runningCount (badge, used in P3).
- logic/notificationDispatcher.ts — card-always + OSC-when-important decision.
- store: notification.show → pushNotification (distinct clones to avoid Solid
createStore reference-aliasing), notification.clear → drop matching cards,
lastNotification → OSC seam (terminalChrome).
All TUI-layer; builds only on events/RPCs the gateway already emits. Gate green
(737 tests incl. new unit + frame coverage); verified live in tmux.
Three Tier-1 fixes from live use:
- bare `/` hydrates the full command menu again (reverses F1's "name char
first" gate). The lead-token grammar still rejects `/abs/path` (F2) and a
`/ ` trailing-space is still not arg-completion on an empty name.
- `!cmd` shell mode now reads unmistakably: the composer glyph flips ❯ → `$`
in the alert (warn) color and an amber "shell mode — Enter runs this in your
shell (no model turn)" note rides the slot the slash/path dropdown would use
(they never coexist). New optional `brand.shellPrompt` ($), skin-overridable.
- the transcript scrollbox reserves a 1-cell right gutter (contentOptions
paddingRight) so the vertical scrollbar no longer paints OVER hard-width
content — markdown table / code-block right borders were clipped under it.
Gate green (714 tests); F1/F2/F7/F8 slash specs + the slashMenu frame test
updated to the new bare-/ behavior. Verified live in tmux.
The dashboard's public /api/status liveness endpoint is in PUBLIC_API_PATHS
and bypasses dashboard auth, yet it returned absolute hermes_home,
config_path, env_path, the gateway PID, and the internal gateway health URL.
That exceeds the shape its own allowlist documents as public ("version,
gateway state, active session count, and the dashboard auth-gate shape. No
bodies, no session content, no secrets"), leaking deployment recon to any
unauthenticated caller on a network-exposed (gated) bind.
Withhold host-local detail unless the bind is loopback / --insecure, where
the dashboard is local-only and the caller is already inside the trust
envelope -- the same split should_require_auth draws. The NAS liveness probe
and the auth-gate badge are unaffected.
Adds invariant tests for both modes (gated withholds, loopback keeps).
Keep the own-policy fail-closed hardening from PR #45444, but still trust WeCom groups.<id>.allow_from because the adapter already checked that sender allowlist before dispatching to gateway auth.
Own-policy adapters (WhatsApp, WeCom, Weixin, QQBot, Yuanbao) default dm_policy/group_policy to "open", which forwards every sender. The gateway's adapter-trust shortcut in _is_user_authorized blanket-trusted those platforms when no env allowlist was set, so an operator who enabled one with only credentials authorized the entire external network -- the fail-open SECURITY.md section 2.6 forbids ("an allowlist is required for every enabled network-exposed adapter").
Trust the adapter only when its effective policy for the chat type is an actual "allowlist" restriction (the case #34515 was protecting). "open"/"pairing"/anything else falls through to default-deny, where {PLATFORM}_ALLOW_ALL_USERS / GATEWAY_ALLOW_ALL_USERS and the pairing flow remain the explicit opt-ins.
Old Office formats (.xls, .doc, .ppt) were missing from the
SUPPORTED_DOCUMENT_TYPES dict in gateway/platforms/base.py while their
newer counterparts (.xlsx, .docx, .pptx) were included.
Sending an .xls file via Telegram triggers 'Unsupported document type'
and the file is silently dropped instead of being cached and forwarded
to the agent.
Add the three legacy MIME types so these files are handled the same way
as their modern equivalents.