Re-lands #129296, which merged into onboarding/next and never reached main. 0.2.0 declares the Blender app, so Hermes checks Blender is installed (5.1+ on Windows and macOS) before it installs the plugin, and the onboarding plugins card can preselect Blender when it is present.
The native smoke only ran inside desktop-bundled-release, and it checked out
its test scripts at the same commit as the artifact it fetched. Changing a
test therefore meant a new commit, and a new commit meant a full signed
build (about an hour per platform) just to rerun the smoke.
Add workflow_dispatch: `sha` picks the staged artifact to fetch, and the
scripts come from the ref the dispatch runs on. Release callers are
unchanged.
The smoke's CLI inherited the driver's HERMES_HOME / HERMES_RUNTIME_DIR /
HERMES_PYTHON, so PM built the plugin's environment on the driver's own
Python under C:\a\_temp\setup-pm and never touched the packaged
interpreter: the bug could not show. Clear HERMES_*/UV_*/PYTHON* and the
driver's tool dirs from PATH in the basic-user batch, and fail if the
install log still names the driver's PM home.
Enabling a plugin with Python dependencies, and `hermes pm install`, failed
in the packaged Windows app: uv lock/sync/pip check exit 101 with "Unable to
create process using ...\venv\Scripts\python.exe".
uv queries a venv by running its Scripts\python.exe. That redirector lives
outside the package and CreateProcess's the interpreter named by
pyvenv.cfg `home`. 6a031bf1b3 moved generations onto the packaged
interpreter, so `home` names a file under WindowsApps that a process without
package identity may not start (exit 101 is the redirector's
RC_CREATE_PROCESS). Restore the verified writable copy of the pinned Python
in the user store as the build interpreter for sealed Windows payloads.
venv_command (also from 6a031bf1b3) stays: Hermes still enters those venvs
on the packaged interpreter, so its own children keep package access to the
bundled git, rg and PM worker.
The installed-package smoke only chatted, so nothing exercised PM building
a dependency generation inside the package. After the chat it now installs
a local plugin that declares a Python dependency through the package's CLI
execution alias with --enable --yes-deps and requires `plugins list` to show
it enabled.
It runs under a Safer NORMALUSER token at medium integrity: GitHub runners
are administrators, and Administrators can start files in a package
directory without package identity, which a normal user cannot. A probe
that the token is refused the packaged python.exe keeps the check honest.
`_kill_tree` SIGKILLs the per-file pytest process group, which misses any
child that left the group with `start_new_session=True` (an auto-started
`gateway run`, a setsid server). Those are reparented to init and run
forever: one host accumulated 215 orphan gateways (~52 GB RSS, up to 21 h
old), and every `hermes update` listed each as "another Hermes home — left
alone".
After every attempt the runner now SIGKILLs any process whose environment
still points into that attempt's unique temp root (PYTEST_DEBUG_TEMPROOT /
TMPDIR, or a HOME / HERMES_HOME a fixture created under it). The root is
unique per attempt, so the match names only that attempt's processes.
Linux-only (/proc); the process-group kill stays the fallback elsewhere.
Pull requests and pushes to main no longer run any E2E suite: ci.yaml's detect gate
forces every e2e* lane off unless the run is a stable-release run (inputs.release) or a
manual dispatch, whatever the diff, the update-path classifier or the run-e2e label
selects. That covers the Python e2e and e2e-upgrade jobs, the Windows E2E and Windows
install + update journey in tests-os, and the Desktop core/update suites.
install-e2e.yml drops its pull_request (path-filtered `pr` route) and 12-hourly
schedule triggers; it runs from the stable-release gate (workflow_call) or by dispatch.
live-providers drops its nightly schedule and keeps the release-tag push + dispatch.
Why: a one-row change to left_core_migration.py held a PR for 40 minutes on the
Windows install + update E2E; the suites cost most of the larger-runner minutes and
the release gate already runs all of them strictly.
FadeScroll tracked clipped edges in React state, with a scroll handler and a
resize observer, then rebuilt an inline mask on every change. Move that to a
`scroll-fade-y` utility: the mask's stops are scaled by two registered
<number> properties that the scroller's own scroll position animates
(animation-timeline: scroll(self y)), so an edge only fades while content is
clipped behind it and a list that fits is never dimmed. No listener, no state;
a browser without scroll-driven animations shows no fade.
From each edge inward the mask is `pad` of fully hidden content, then `fade`
of gradient, both optional props. The onboarding apps list sets pad to 1.5rem
so the gradient starts at the chips instead of spending itself on the empty
padding at the box edge. edgeMask/scrollEdges stay for the profile switcher.
The connector/plugin chip picker is height-capped, so the last rows were
clipped with no hint there was more. Wrap the grid in the existing
FadeScroll, which already fades whichever edge has content clipped behind
it and leaves a list that fits untouched.
The spotify toolset (plugins/spotify) and its loopback-PKCE login (hermes_cli/auth_spotify.py)
now live in NousResearch/hermes-spotify, installed from the plugin catalog. Same pattern as the
Home Assistant move (fb9fc8a3dd): a LEFT_CORE row installs the plugin for every home that was
using it (a Spotify login in auth.json, or the spotify toolset in platform_toolsets) on
`hermes update` / first start; the toolset name, tool names, schemas, env vars and stored login
(providers.spotify) are unchanged, so nobody re-authenticates.
The one user-visible change is the login command: plugins cannot extend `hermes auth`, so the
plugin registers `hermes spotify login|status|logout`. LeftCoreFeature gains a `cli` field and
moved_command_hint() turns `hermes auth [status|logout] spotify`, and `hermes spotify` while the
plugin is not loaded, into a line naming the new command and the install/enable step for this
home. The dashboard's "needs login" hint names the new command too.
Core drops the Spotify auth constants/dispatch, the CONFIGURABLE_TOOLSETS row and post-setup
step, the core `spotify` entries in the shared-metrics toolset enums, its tests, and the docs
(en + zh-Hans) now point at `hermes plugins install spotify` / `hermes spotify login`.
Its description named core's `hermes auth spotify`; the pinned sha imports core's
plugins.spotify.client, which the Spotify core move removes. A known_issues line points
at the upstream fix (cruzlxyz/hermes-spotify-desktop#2) until the pin can move to it.
Moves the temp-home guard (`temp_home_in_service_definition`,
`refuse_temp_home_service_write`) and the systemd install's ownership
resolution (`unit_belongs_to_caller`) out of the gateway.py facade into
the owner sibling, so the facade shrinks instead of growing. Narrows the
plist read's catch-all to the errors plistlib raises. Temp-home tests
move with their code into test_gateway_service_owner.py.
Trims the salvaged ownership guard to the part that closes the bug: a
refresh or install never rewrites a unit/plist that pins another
HERMES_HOME (--force-unit-path is the explicit repoint).
Dropped the new `gateway install` admission gate for homes outside the
platform-default tree. It refused every custom HERMES_HOME (Docker's
/opt/data, any non-default root) on first install unless the user knew
the new flag, which is a behaviour flip for legitimate installs; it also
needed a bypass monkeypatch in five unrelated test files. Tests cut to
the two invariants (foreign home refused, own stale unit refreshed).
Two homes can resolve to one gateway service name: `<root>/profiles/<name>`
and `~/.hermes/profiles/<name>` both take `hermes-gateway-<name>`
(`ai.hermes.gateway-<name>` on launchd). Every gateway boot, start and
restart calls `refresh_systemd_unit_if_needed` / `refresh_launchd_plist_if_needed`,
which rewrote whatever definition sat at that name with no check of the
HERMES_HOME it pins. A scratch/E2E gateway started from a custom root
therefore repointed the real install's unit at the scratch directory; the
real gateway failed on its next restart (200/CHDIR once the scratch dir was
cleaned up). `systemd_install --force` and `launchd_install --force` did
the same. Uninstall already refused a unit pinning another home
(`_systemd_unit_belongs_to_current_home`); the writers did not.
- hermes_cli/gateway_service_owner.py (new sibling): `pinned_home()` reads
the HERMES_HOME a unit or plist pins; `definition_belongs_to_home()`
refuses a write from any other home; `refuse_foreign_home_install()`
gates `gateway install` for a home outside the platform-default tree.
- Writers refuse a definition pinned to another home: the systemd and
launchd refreshes (boot/start/restart) and both installers, `--force`
included. The system-scope check runs on the caller's own home, before
`systemd_unit_is_current` adopts the unit's home into os.environ (else
the check compares the unit with itself); a sudo-stripped caller with no
HERMES_HOME still acts for the unit's pinned home, as before.
- `gateway install` refuses a HERMES_HOME outside the account's Hermes
tree that owns no bare service name and has no service installed yet: a
service registered for a scratch home outlives the run.
- New `gateway install --force-unit-path` overrides both refusals (an
explicit repoint of the definition at this home).
- Refused installs exit 1, so `gateway install` no longer goes on to start
the other home's service; the setup wizard reports the refusal and
continues, as ensure_gateway_service already did.
- Existing tests whose sandbox HERMES_HOME is a tmp dir opt in to the
admission check (5 files, one monkeypatch each).
Tests: tests/hermes_cli/test_gateway_service_owner.py, 18 tests. On
e36a818033 (sources reverted, tests kept): 11 failed, 7 passed (foreign
unit/plist rewritten on refresh and --force, refused install starts the
service, scratch install proceeds, no --force-unit-path). With this change:
18 passed. test_colliding_profile_names_share_a_unit pins the premise on
the real name resolution.