Ubuntu CI (and other distro builds) ship a sqlite3 shell compiled without
the sqlite_dbpage virtual table that .recover requires, so PATH presence
alone let the lane attempt and fail with 'no such table: sqlite_dbpage'.
find_sqlite3_cli() now probes .recover on a scratch DB once; the test skip
gate uses the same probe, and the no-CLI guidance names the capability
requirement.
Fixes#80205: when one ordered rowid-edge probe failed,
_salvage_rowid_bounds() substituted the whole SQLite rowid domain and
_copy_table_salvage() burned the 10,000-query budget bisecting a
synthetic tail that could not contain rows, silently omitting readable
boundary rows (field case: message 76882 of 76882). Two-part fix:
* _probe_populated_edge(): gallop outward from the surviving edge with
doubling offsets; a clean 'no rows beyond X' probe caps the domain in
O(log range) queries instead of exhausting the budget on it.
* exact-key singleton salvage: a one-row range scan must advance the
cursor past the hit into the damaged sibling page to prove exhaustion,
which discards the already-produced row; 'WHERE rowid = ?' stops at
the hit, recovering the boundary row exactly like sqlite3 .recover.
* the strict-path refusal now points users at --allow-partial.
New last-resort lane for --allow-partial when the sessions/messages
table schemas themselves are unreadable (previously a hard refusal even
though page-level salvage recovers the rows fine). If a sqlite3 CLI is
on PATH, shell out to '.recover --ignore-freelist' into a scratch
lost_and_found DB, then heuristically map rows back into a fresh
SessionDB-schema database (hermes_cli/session_lost_and_found.py):
classification keyed on nfield counts + sentinel columns (session ids
matching ^\d{8}_\d{6}_, roles in user/assistant/tool/system, known
source strings), covering the current 54-col sessions layout, the
52-col historical layout, a 14-col legacy identity-only salvage,
rowid-alias messages rows and 18-col session_model_usage rows. Missing
parent sessions are stubbed (children are never deleted for FK
cleanup), FTS is rebuilt at the end, and output is labeled BEST-EFFORT
everywhere. Without the CLI the error names the sqlite3 requirement
with actionable guidance. Mirrors a successful manual recovery of a
real corrupt state.db (2026-08-12), and this lane was validated against
that preserved file: 32 sessions / 7 messages / 4 usage rows mapped,
integrity_check ok, opens via SessionDB.
Also fixes#72291: the source-fingerprint 'bundle changed while it was
being copied' error now enumerates that the parent interactive CLI
session itself counts as a Hermes process and suggests a fresh shell or
an immutable snapshot.
Tests use real physical page corruption (flipped b-tree/schema header
bytes), skip the CLI-dependent path cleanly when sqlite3 is absent, and
keep the mapper unit tests binary-independent via a synthetic
lost_and_found DB. Sabotage-verified: reverting the fixes makes the
regression tests fail with the exact field failure shape.
Follow-up to the state.db PASSIVE checkpoint salvage (PR #84277,
#45383/#80255/#44795): the kanban dispatcher's periodic explicit
checkpoint still used TRUNCATE on the shared kanban.db. The dispatch
flock only serializes dispatchers — CLI kanban commands in other
processes write to the same board without it, so the TRUNCATE races
live writers exactly like the state.db close() path did.
Switch it to PASSIVE and bound the -wal file with
journal_size_limit=8MiB set at connection init (SQLite trims the file
on the writer's natural post-checkpoint reset), since PASSIVE never
truncates.
tests/hermes_cli/test_kanban_db_repair.py updated to assert PASSIVE
and reject TRUNCATE. Remaining TRUNCATE call sites are test fixtures
operating on private temp DBs (sole opener), which is the legitimate
use.
Hook queries now lazy-discover plugins (delivery parity, #64178). The
relay direct-runtime tests build a bare PluginManager to prove zero
plugins are involved; mark it discovered so the parity path doesn't
populate it from the real plugin tree mid-test.
Salvaged from PR #64188 (@Bartok9), re-reviewed against the #64229
ownership ledger (landed in #84923).
Delivery parity (survived):
- Module-level invoke_hook/invoke_middleware/has_hook/has_middleware
lazily run plugin discovery via _delivery_manager(), so surfaces that
never import model_tools (dashboards, TUI slash workers, query mode,
cron, gateway platform events) deliver plugin callbacks instead of
silently dropping them (#50776, #67597, #67890, #50937).
- _delivery_manager() joins any in-flight background discovery first and
tolerates test doubles that monkeypatch get_plugin_manager().
Symmetric force-reload (survived):
- agent/shell_hooks.py gains re_register_config_hooks(); the force branch
of discover_and_load() calls it after a successful sweep, restoring
config.yaml shell hooks that the ledger-driven unload wiped but cannot
restore (they are config-owned, not plugin-owned) (#60036).
- unload(plugin=None) now sweeps pre-ledger _plugin_tool_names entries
out of the process-global tools.registry, mirroring the platform-name
sweep that already existed, so zombie tools cannot survive a force
reload in long-lived pre-ledger processes (#60050).
Superseded by the ownership ledger (dropped from #64188):
- _unload_global_plugin_registrations() bulk tool/platform teardown —
the ledger's reverse-order handle disposal with previous-entry
restoration covers it more precisely.
- tools.registry/platform_registry displaced-entry LIFO restore stacks —
the ledger's restore_registration() identity-checked previous-entry
restoration made them redundant.
- Discovery serialization lock + double-checked singleton — main already
has _discovery_lock on every discover/unload path and a keyed,
lock-guarded per-home manager cache (#24714 concern is covered).
Fixes tracked under #64178 (#50776, #60036, #60050, #24714, #67798,
#50937, #67597, #67890, #31480 — #31480 already handled on main by
_parse_hooks_block warn+suggest).
Part A — pre_command observer hook (observer-first per #64182 ground rule 3):
- New VALID_HOOKS event `pre_command`: fires when a recognized slash command
is about to be dispatched, BEFORE the handler runs, on both surfaces:
- CLI: cli.py process_command (right after alias resolution)
- Gateway: gateway/run.py _handle_message cold-path canonical dispatch
- Payload: surface ('cli'|'gateway'), command (canonical), alias_used,
args_raw, session_key, platform. Return values IGNORED in v1; a plugin
returning a directive-shaped dict gets a debug log so future
block/rewrite adopters are discoverable (#64231 taxonomy).
- Deliberately NOT fired on the gateway running-agent intercept path
(/stop, /approve, busy_policy dispatch during an active run): those are
control-plane escape hatches on an in-flight run and must stay outside
plugin observation/veto reach.
- fire_pre_command_hook() helper never raises, so broken plugin infra can
never break command dispatch.
Part B — ctx.call_mcp (capability-gated, default-off, ground rule 4):
- PluginContext.call_mcp(server, tool, arguments, timeout=30): synchronous,
callable from plugin hooks/tools, routes through the EXISTING native MCP
client machinery (tools.mcp_tool._make_tool_handler: background loop,
trust-tier gates, circuit breaker, reconnect) — never a parallel client.
- Gate: plugins.entries.<id>.mcp_allowlist (list of server names).
Absent key / unreadable config / non-list value => default-deny.
Unlisted server raises PermissionError naming the exact config key.
TODO seam left for the #64228 declared-capability model.
- Bounded: timeout clamped to 1-600s and forwarded to the MCP loop call;
results capped at 64KB with truncation marker; stable
{ok, result|error, structuredContent?, truncated?} envelope.
Tests (transport mocked, no live MCP servers):
- tests/hermes_cli/test_pre_command_hook.py: both surfaces fire, canonical
alias reporting (/exit->quit, /q->queue), hook-before-handler ordering,
control-plane exclusion, hook failure non-fatal, observer-only directive
handling.
- tests/hermes_cli/test_plugin_call_mcp.py: default-deny (absent entry,
unreadable config, non-list, '*'), allowlist enforced per-server,
denied calls never touch transport, timeout forwarding/clamping,
result truncation, error/structuredContent envelopes.
Docs: hooks.md shipped-catalog row for pre_command; plugins.md
"Calling MCP servers from plugins" section with the security note.
Closes#64204
Extends the salvaged #64229 ledger (PR #76490) to cover the registries
added on main since the PR was cut, and lands the remaining Phase 0
lifecycle pieces:
- register_system_prompt_section and register_approval_transport now
record ownership handles, so unload/force-reload removes plugin
system prompt sections and approval transports too
- ctx.on_unload(callback): plugin cleanup callbacks run through the
reverse-order ledger walk, exception-isolated
- ctx.spawn_task(coro): supervised background asyncio tasks tracked in
the ledger and cancelled on unload
- document the #65593 multi-profile constraint on the ledger (keyed per
manager/(hermes_home, plugin_id); identity-conditional restores) with
a TODO(#64178) for full profile keying of remaining global slots
Part of #64229; prerequisite for #64178.
Give plugins a first-class, namespaced pub/sub event bus so plugin↔plugin
interaction is a declared, testable contract instead of ad-hoc imports.
Closes#64164 (sub-issue 03/14 of the plugin-interface expansion epic #64182).
Additive-only: when no plugin calls emit/subscribe, behavior is unchanged.
Interface (on PluginContext):
- `ctx.emit(event, payload=None) -> int` publishes to subscribers and returns
the count invoked. The namespace is FORCED to the plugin's own registry key
(`manifest.key or manifest.name`): pass only the bare event name, delivered
as `<key>:<event>`. Fail-closed — any name containing `:` (a `hermes:`
reserved-core prefix, a foreign `other:` namespace, or an own-colon'd name)
is rejected with a ValueError + logged warning naming the plugin.
- `ctx.subscribe(full_event, callback)` registers an ordered listener for a
fully-qualified `<plugin>:<event>`. Subscribing is unrestricted (any plugin
may listen to any published event); only emitting is namespace-gated.
Delivery mirrors invoke_hook: registration-order iteration, per-callback
try/except isolation (one raising subscriber never breaks delivery to the
rest), payload passed as `cb(**payload)`. A per-thread depth counter caps
re-entrant emits at 8 — mutually-emitting plugins terminate cleanly with one
logged warning, never an infinite loop or RecursionError.
Discoverability: optional advisory `emits:`/`listens:` manifest fields (no
manifest-v2 dependency; not enforced) are parsed and surfaced by a new
`hermes plugins show <name>` (alias `info`) command. `get_plugin_subscriptions()`
module accessor mirrors `get_plugin_auxiliary_tasks()`.
Tests (tests/hermes_cli/test_plugin_event_bus.py, 22): two-plugin delivery +
listener count; forced namespace (delivered as `b:ping`); spoof rejection
(parametrized `hermes:x` / foreign / own-colon'd / `:x` / `x:` / empty) with
no delivery; name-fallback when key empty; per-callback isolation; recursion
cap termination + warning; manifest emits/listens parsed (present/absent/from
yaml); module accessor; `plugins show` output. `pytest test_plugin_event_bus.py
test_plugin_auxiliary_tasks.py` → 37 passed. I independently re-verified the
namespace rejection and recursion-cap termination outside the test suite.
Note: the reserved-name gate rejects any `:`-containing input rather than a
bare-name denylist — a bare `core_event` is allowed and delivered under the
plugin's own namespace. Say the word if a reserved bare-name list is wanted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013b1XyXitAxV7phGmKWigJX
Static machine-readable community plugin index with fuzzy search and
index-resolved installs, mirroring the Skills Hub catalog pattern
(fetch → HERMES_HOME/cache with 24h TTL → bundled seed fallback).
- hermes_cli/plugin_index.py: index fetch/cache/seed chain, fuzzy
search (name/description/tags/author + typo tolerance), capability
filter, bare-name resolution. Canonical URL overridable via
plugins.index_url config key.
- hermes_cli/data/plugin_index.json: bundled seed (offline fallback +
format reference) with 5 real ecosystem plugins, each pinned to an
exact commit SHA.
- hermes plugins search [term] [--json] [--capability] [--refresh]:
Rich table or JSON output, offline-safe, with an explicit
'indexed ≠ audited' footer.
- hermes plugins install <name>: bare names (no slash, no URL scheme)
resolve through the index to owner/repo[/subdir] @ pinned ref and
hand off to the existing install path (ref wired through the #82029
exact-ref support). Ambiguous names list candidates and exit;
explicit owner/repo and Git URL installs are untouched, and an
explicit --ref always beats the index pin.
- Docs: discovery section in user-guide plugins.md (format, submission
workflow via PR to hermes-plugin-index, security framing) and
reference/cli-commands.md rows.
- Tests: tests/hermes_cli/test_plugin_index_search.py (38 tests, no
live network) covering parsing, search, remote→cache→seed fallback,
TTL, install resolution/ambiguity/passthrough, and --json output.
Additive plugin.yaml v2 fields (all optional; v1 manifests unchanged forever):
- manifest_version: manifest FILE-FORMAT version (absent = 1). Deliberately
split from api_version per the round-2 design correction. Newer-than-
supported versions load with a warning, unknown fields ignored.
- api_version: runtime plugin API generation the plugin targets (integer).
- requires_plugins: advisory inter-plugin deps ({id, version_range?}).
Missing dep = warn + still load (ctx.has_plugin() runtime probe added).
Load ORDER is dependency-respecting: graphlib topological sort, stable
alphabetical tiebreak; cycles warn and fall back to alphabetical.
- python_dependencies: declared pip requirements — VALIDATED AND SURFACED
ONLY (loader warning + install-time printout + doctor checks with a pip
install hint). Never auto-installed: the isolation design for the install
seam (#15220) is an explicitly deferred follow-up per the round-2 review.
- config_schema: JSON-schema-ish description of plugins.entries.<id>.settings
keys; validated at load, mismatches are actionable warnings naming the key
and expected type — never load failures.
- Formalized metadata: license, homepage, tags.
- Unknown manifest fields warn-don't-fail (debug-level for v1 manifests).
- hermes plugins doctor gains v2 checks: future manifest_version, invalid
api_version, dep declarations, unpinned/missing python_dependencies,
unknown config_schema types.
- Docs: manifest v2 reference table in the developer-guide plugins index,
including the explicit pip-seam isolation deferral and the note that
#64166 packs build on these fields.
- Tests: tests/hermes_cli/test_plugin_manifest_v2.py (19 tests) covering v1
regression, v2 parse, unknown-field warn, dep order, cycle fallback,
config_schema warnings, and the surfaced-not-installed pip seam.
Unify the scattered per-plugin trust gates into one declared, diffable
capability model with an install/update-time consent flow. Consent +
audit over host API surfaces — explicitly NOT a sandbox.
New module hermes_cli/plugin_capabilities.py:
- Canonical CAPABILITY_REGISTRY mapping each capability id 1:1 to an
EXISTING enforcing gate (no capability minted without a surface):
tools.override -> allow_tool_override
llm.provider_override -> llm.allow_provider_override
llm.model_override -> llm.allow_model_override
llm.agent_id_override -> llm.allow_agent_id_override
llm.profile_override -> llm.allow_profile_override
llm.task_override -> llm.allow_task_override
- plugin_capability_granted(plugin_id, capability): canonical check —
granted set OR deprecated legacy allow_* key; fail closed on unknown
ids and any unreadable/corrupt consent state; emits checked_by audit
log lines on every decision.
- record_consent() persists plugins.entries.<id>.granted_capabilities +
capabilities_consent {hash, granted_at} and mirrors grants into the
legacy keys so existing enforcement sites keep working unchanged.
- capability_set_hash / pending_capabilities / declared_set_changed
power the update-time re-consent diff.
Wiring:
- plugin.yaml manifest field `capabilities:` parsed into
PluginManifest.capabilities (unknown ids dropped with a warning).
- hermes plugins install: consent screen (one Y/n) when the manifest
declares capabilities; non-interactive installs proceed with
capabilities ungranted (fail closed).
- hermes plugins update: when the new version declares capabilities the
granted set lacks (hash diff), the additions are surfaced and require
re-consent — an update can never silently widen access.
- hermes plugins enable: consent screen replaces the standalone
tool-override prompt for capability-declaring plugins.
- hermes plugins capabilities [<id>]: declared vs granted per plugin,
flags grants held via deprecated legacy keys.
- PluginContext.has_capability() probing API so plugins degrade
gracefully; _tool_override_allowed migrated to the canonical
plugin_capability_granted path (reference migration; legacy
allow_tool_override still honored).
Tests: tests/hermes_cli/test_plugin_capabilities.py (38 tests) —
declaration parsing, consent grant/persist, update re-consent on added
capability, fail-closed on missing/corrupt state, legacy-gate backward
compat, consent CLI flow (grant / decline / non-interactive).
Docs: user-guide plugins.md consent section (with explicit not-a-sandbox
warning) + developer-guide plugin authoring capability note.
Salvages the intent of PR #37976 (@coygeek — require renewed review
before plugin updates), scoped to capability diffs.
Part of #64182.
Main's helper no longer takes a platform kwarg (real-host layout since the
sys.platform-fake removal); mark the five password-store tests linux_only/
macos_only per the don't-fake-the-host policy, and stub the Linux desktop-entry
registration those cmd_gui runs now reach.
Users who install Hermes and then restore a backup (hermes import) ended
up with bot tokens and cron jobs fully registered but nothing running
them: the setup wizard's service-install prompt lived at the end of the
Messaging Platforms section, so skipping messaging (the normal case on a
box whose tokens arrive with the import afterward) skipped the service
entirely, and run_import never touched the service layer at all.
A platform-less gateway is already a supported mode (gateway/run.py runs
the cron scheduler and picks platforms up as tokens appear), so there is
no reason to gate the service on messaging config — or to ask at all.
- hermes_cli/gateway.py: new ensure_gateway_service() — prompt-free,
never-raising install+start of the user-scope service (systemd /
launchd / Scheduled Task), no-op in containers and on hosts without a
service manager, refuses to pile onto conflicting user+system units.
- hermes_cli/setup.py: setup_gateway() service block now runs
unconditionally (zero platforms included) and auto-installs instead of
prompting; restart-on-config-change keeps its prompt. Quick-setup and
migrated-config paths that skip the messaging section now call
ensure_gateway_service() so they can no longer skip the service.
- hermes_cli/backup.py: run_import() ends by installing/starting the
service when none is running, with a manual fallback hint on failure.
- tests: new tests/hermes_cli/test_ensure_gateway_service.py (9 cases)
+ 3 run_import wiring tests; existing backup tests get an autouse
fixture so they never touch the host's real service manager.
Add voice.submit_mode=direct|draft without model-refine hooks or callbacks. Validate the config, preserve direct-submit compatibility, render editable drafts in the Ink composer, and document both locales.
Co-authored-by: BELIVIN MEDIA <212580280+KarateWilly@users.noreply.github.com>
Salvage the plugin-owned static prompt idea from PR #51589 into the constrained #64167 contract: stable IDs, deterministic placement, bounded fail-open rendering, and full-prompt resume recovery without new session columns.
Co-authored-by: Topher Ross <biz@topherross.com>
Address teknium1 review on #64189:
- Re-pull gate now delegates to each source's is_enabled(cfg) via the
registry contract, so a plugin source with custom activation logic is
honored (previously only secrets.<name>.enabled was checked).
- Add BUILTIN_SOURCE_NAMES to the registry so plugin-vs-bundled is a
single source of truth instead of a hard-coded set at the call site.
- Reconcile docs: rewrite the timing :::note to describe both the
post-discovery re-pull and the remaining import-time limitation, and
cross-link the first-process bootstrap section.
- Tests: real SecretSource subclasses, custom is_enabled activation
(positive + negative), is_enabled-raises skip, builtin-only no-op,
and a discovery-registration end-to-end re-pull check.
After plugins register SecretSource backends, reset the env-loader cache
and re-run load_hermes_dotenv when an enabled plugin secret source is
configured. Closes the first-process bootstrap gap where import-time env
load stale-outs plugin vaults (tommck / Community ask). Fail-open, no-op
without plugin sources.
Docs: first-process bootstrap timing on secret-source plugin guide.
Tests: unit coverage for noop / enabled re-pull / discover hook.
Part of #64182 plugin-interface expansion.
* fix(windows): SSH ControlMaster gating + stop hijacking the user's python
Two Windows environment-integrity fixes:
1. tools/environments/ssh.py (#73927): Windows OpenSSH has no
Unix-domain-socket ControlMaster support, so unconditionally passing
ControlPath/ControlMaster/ControlPersist failed EVERY tool call on a
Windows-hosted ssh terminal backend with 'getsockname failed: Not a
socket'. Gate the three multiplexing options behind a module-level
_SSH_MULTIPLEX = (os.name != 'nt'); the scp upload path is gated the
same way. On Windows the backend now works without connection pooling
(each command a fresh connection); POSIX behavior is unchanged. The
teardown 'ssh -O exit' is naturally inert because the socket never
exists on Windows.
2. scripts/install.ps1 (#83797): the installer put the whole
venv\Scripts directory on the user PATH, which contains python.exe /
pythonw.exe / pip.exe and so silently hijacked the 'python' command in
every terminal on the machine — unrelated projects started resolving
python to Hermes' runtime interpreter. Now copy only the launchers
(hermes.exe, hermes-acp.exe) into a dedicated $InstallDir\bin and put
THAT on PATH. Existing installs are migrated: the legacy venv\Scripts
entry is stripped from the user PATH on the next install/update. The
new bin dir is under $InstallDir (…\hermes-agent), which the uninstall
PATH sweep already matches via its \hermes-agent marker.
Updated the stale hermes_cli/update_cmd.py docstring that described the
old venv\Scripts-on-PATH layout.
Tests: SSH ControlMaster gating pinned both directions (multiplex on →
flags present; off → absent but BatchMode/StrictHostKeyChecking retained).
install.ps1 parses clean via the PowerShell AST parser.
* docs: update windows-native install docs for the bin\ launcher layout
CI (test_windows_native_docs) pins the docs and installer to the same
PATH layout. The #83797 fix moved the PATH entry from venv\Scripts to a
dedicated $InstallDir\bin holding only the hermes launchers, so update
the Windows-native guide to match: PATH-after-install section, the
install-steps list, the directory-layout table, the Get-Command
verification line, and the 'command not found' pitfall. Test now asserts
the bin\ layout and guards against a regression back to venv\Scripts on
PATH.
* fix: keep install.ps1 pure ASCII (PowerShell 5.1 codepage safety)
The two comments I added in the #83797 PATH-hijack fix used em-dashes,
tripping tests/test_install_ps1_ascii_only.py — Windows PowerShell 5.1
reads a BOM-less .ps1 in the system ANSI codepage (not UTF-8), so a
non-ASCII byte can misdecode into a stray quote and desync the parser
(issues #66994/#67000). Replace the em-dashes with ASCII '--'.
* fix: make verify_on_stop opt-in everywhere (default False, not auto)
The verify-on-stop nudge was already judged more noise than signal: the
v31 migration flips existing installs off, the v32 migration catches the
baked-in literal-true population, and the docs tell users to 'treat off
as the effective default and opt in explicitly'. But DEFAULT_CONFIG still
shipped the "auto" sentinel, so exactly one population kept getting the
nudges: fresh installs (and any config missing the key), where "auto"
resolves ON for CLI/TUI/desktop surfaces. Live symptom: repeated
'[System: You edited code ... run verification]' interruptions the user
never asked for and had to hunt down in source to disable.
- DEFAULT_CONFIG: agent.verify_on_stop "auto" -> False (opt-in).
- verify_on_stop_enabled(): missing/unrecognized value now falls back
OFF instead of surface-aware; explicit "auto" still selects the
legacy surface-aware behavior, explicit bools unchanged, and the
HERMES_VERIFY_ON_STOP env override is untouched.
- No migration needed: v31/v32 already normalized existing installs,
and this only changes the merged default for configs without the key.
- Docs updated; default-path E2E test now asserts OFF, plus a new
missing-value regression test. Also added the standard win32 skip
marker to the symlink-based temp-dir test (pre-existing Windows
failure, same class as tests/cron/test_cron_script.py).
* test: update config goldens — verify_on_stop=False is now stripped as default
With the DEFAULT_CONFIG flip to False, the migration-write invariant
(_persist_migration / save_config strip_defaults) no longer materialises
verify_on_stop: false to disk unless the user explicitly set the key:
- V20 floor fixture (agent: {} on disk): v31's write is stripped —
agent stays {} and load_config() supplies False at read time.
- V12 floor fixture (explicit verify_on_stop: true on disk): the key is
a user-set path, so the v32 flip stays materialised as false.
- Partial-write and _persist_migration regressions now assert the key is
absent from disk and (for the merge case) that the merged view still
resolves False.
Behavior verified with a one-shot migrate_config run against both
fixture shapes.
* fix(gateway): pass live adapters to cron fire webhook's fire_due
The Chronos fire webhook (/api/cron/fire) called
provider.fire_due(job_id, adapters=None, loop=loop), so every
externally-triggered fire delivered through the standalone path even
with a live gateway in-process. E2EE platforms and relay-fronted
logical platforms (whose ONLY send path is the live relay adapter — no
native credential exists on the box) failed every external fire with
"platform 'X' not configured/enabled", while the same job delivered
fine under the built-in ticker (gateway/run.py passes runner.adapters).
Resolve the runner (self.gateway_runner → app['gateway_runner'] →
_gateway_runner_ref(), the same chain the drain check uses) and forward
its adapters. No runner → adapters=None, preserving the historical
standalone path byte-identically.
Note: does not by itself fix Fly-hosted scale-to-zero deployments where
NAS's callback lands on the DASHBOARD process (internal_port 9119) —
_fire_cron_job_for_profile there has no gateway runner in-process. That
topology needs a separate fire handoff (design pending).
* fix(cron): dashboard forwards Chronos fires to the gateway (503 when unreachable)
The dashboard's /api/cron/fire executed cron jobs in the DASHBOARD
process via _fire_cron_job_for_profile with adapters=None. On hosted
deployments (Fly proxy exposes only the dashboard's port) that made
every managed-cron fire deliver through the standalone send path, which
cannot serve relay-fronted logical platforms (their only sender is the
live relay adapter in the gateway process — no native credential exists
on the box) or E2EE rooms. It also ran the whole agent turn inside the
dashboard: wrong process for memory/session ownership and fire-claim
attribution.
Restore the invariant that the GATEWAY owns cron execution:
- Dashboard route: after verifying the NAS JWT and resolving the job's
profile, FORWARD the fire to the gateway api_server's own
/api/cron/fire on loopback, NAS bearer preserved (the gateway
re-verifies the JWT — defense in depth, no new trust link), and pass
the gateway's response through. Gateway unreachable → 503 so NAS
retries per the Chronos contract (non-2xx = retryable; the store CAS
de-dupes the eventual double fire). Deliberately NO local-execution
fallback.
- Endpoint resolution mirrors gateway/config.py's api_server load order
per target profile (config.yaml extra.port → API_SERVER_PORT from
process env or the profile's .env → 8642), with /p/<profile>/ prefix
routing under multiplex.
- docker/stage2-hook.sh: generate a strong API_SERVER_KEY into .env on
first boot when absent (never overwrites an operator value), so the
loopback api_server passes its startup guard on hosted images. The
fire route itself is NAS-JWT-authed; the key gates the rest of the
api_server surface. The listener binds 127.0.0.1 by default and the
Fly service exposes only the dashboard port.
- _fire_cron_job_for_profile kept but deprecated (late-binding seam
compatibility); no route calls it.
- docs/chronos-managed-cron-contract.md: document the two-hop inbound
topology and the 503-retry semantics.
Depends on the previous commit (fire webhook passes live adapters to
fire_due) — together they make NAS→dashboard→gateway fires deliver over
relay end to end.
* fix(cron): read the profile api_server port via the canonical config loader
CI guard test_config_read_guard flagged the new _gateway_fire_endpoint
for a raw yaml.safe_load of the profile's config.yaml — the exact drift
class the guard exists to kill (raw reads miss the managed-scope
overlay, ${ENV_VAR} expansion, and root-model normalization).
Read through load_config() under a HERMES_HOME override scoped to the
target profile instead (the same pattern the deprecated
_fire_cron_job_for_profile uses for its store scope), and pull the port
with cfg_get. Test updated to stub load_config rather than write a raw
config.yaml.
* fix(gateway): only messaging platforms count for the scale-to-zero arm gate
The stage2 hook now generates API_SERVER_KEY for every Docker container,
and key presence force-enables the api_server platform. The scale-to-zero
arm gate counted every enabled platform, so the loopback api_server
listener made messaging_is_relay_only_or_absent False on every hosted
instance — silently disarming the feature (the not-armed log would show
enabled platforms=['relay','api_server']).
The arm gate and the not-armed logger now share one helper that filters
to enabled MESSAGING platforms, excluding LOCAL/API_SERVER/WEBHOOK —
the same non-messaging exclusion set _connect_platforms already uses.
A genuinely enabled direct-socket platform (Discord/Telegram) still
disarms. Two of the three new tests fail without this fix.
get_env_value/load_config read through the shared os.environ mirror that
save_env_value writes, so a reader-based assertion cannot prove which
profile's store actually received the write. Read the two profiles'
config.yaml and .env directly instead, and cover the credential path.
The custom-endpoint REST handlers ran bare load_config/save_config, so
every add/activate/delete landed in the process-level default profile
regardless of which profile the desktop settings UI was targeting. A
provider added under a non-default profile silently went to default:
visible only in default-bound sessions, absent everywhere else, and
un-addable to another profile without hand-editing its config.yaml.
Scope all four handlers (list/upsert/activate/delete) to the requested
profile via _config_profile_scope, matching /api/config, and spread the
active profile into the four hermes.ts wrappers alongside their existing
validateCustomEndpoint sibling.
_spawn_gateway_restart() now calls _reap_unsupervised_gateway_orphans()
before spawning a new `hermes gateway restart` child. On desktop-app
restart the old serve exits but its gateway child gets reparented to
launchd (PPID=1) and keeps its platform connection alive. The new
serve then spawns a fresh gateway, resulting in two live gateways
racing the same connection.
The reap was already implemented for the CLI restart path (#75936) but
the dashboard's _spawn_gateway_restart path was not covered.
Fixes#77276
Same bug class as the PKCE clear-shape mismatch, sibling call paths:
clear_session_cookies() and clear_sso_attempt_cookie() emitted
__Host-/__Secure- name deletions without the Secure attribute. RFC
6265bis prefix rules make such a Set-Cookie invalid — browsers reject
the header outright — so the __Host- session cookie deletions on
logout were silently ignored on HTTPS origins (masked in practice by
the 15-minute access-token TTL, but logout did not actually delete
the cookies it claimed to).
Extract one _clear_cookie_variants() helper used by all three clear
functions: prefixed variants always carry the attributes their name
demands (Secure, and Path=/ for __Host-), while the bare-name deletion
mirrors the corresponding setter's shape so it works on plain-HTTP
origins too. Adds a contract test pinning the deletion shapes.
The SameSite=None change left clear_pkce_cookie() deleting every name
variant with `SameSite=None; Secure` unconditionally. Over loopback
HTTP the setter emits a bare-name cookie with `SameSite=Lax` and no
`Secure` — a Secure deletion on a plain-HTTP origin can be ignored by
the browser, leaving a stale PKCE cookie behind after callback/logout.
Thread use_https from the request into clear_pkce_cookie() and derive
both the set and clear attribute shapes from one helper (_pkce_attrs)
so they cannot drift apart again. The __Host-/__Secure- variants keep
`Secure; SameSite=None` regardless of origin — those names require
Secure to be valid at all and only ever exist on HTTPS origins.
Adds contract tests pinning the full Set-Cookie shape on both origins
for set and clear, and updates the dashboard cookie documentation
(which still claimed all cookies are SameSite=Lax).
On Desktop serve startup, reap orphan gateway processes (PPID=1) left
behind by a previous serve session that exited abnormally. This prevents
the old and new gateways from racing for the same QQ WebSocket
credential, which splits messages across parallel session trees (#77276).
Production incident: the orphan reap killed a legitimate SSH remote backend
started by another client machine. Its process sat at ppid 1 with the same
cmdline shape as a genuine orphan, and the exclusion list only covered THIS
app instance's children — ownership by OTHER clients was invisible.
The reap now treats every backend.lock.json under ~/.hermes/desktop-ssh/*/
as an ownership claim: lock payloads are schema-validated (mirroring
remote-lifecycle.ts) and their PIDs are excluded both before the scan and
re-checked after it (defense in depth against a lock written mid-scan).
Regression tests cover the exact incident shape: a lock-owned PID and a
genuine orphan with identical process shapes — only the orphan is reaped.
Also: fold the new single-field `runtime` config category into `agent`
(_CATEGORY_MERGE) and fix an env leak in the serve-startup test
(HERMES_SERVE_HEADLESS restored via monkeypatch) so the combined suites
run green in any order.
launchd starts children with soft nofile=256; hermes gateway start rewrites the plist and previously stripped any manually-added SoftResourceLimits, silently reintroducing EMFILE crashes under load. The plist generator now embeds the configured runtime.nofile_soft_limit so the persisted service definition and the in-process floor share one knob.