model_switch bound launch-profile turns to server._hermes_home, the home
frozen at import, and read <that home>/.env through launch_secret_scope.
The launch state.db handle already resolves the launch home at call time
(#112692: the patched _hermes_home when a test changed it, else the live
process home) precisely so a harness that re-homes the process after
import is honoured; the secrets path did not, so under a developer shell
with a custom HERMES_HOME every turn read the import-time home's .env —
a guarded root for the suite — and test_compute_host_turn_protocol ran
2 failed + 1 error under a bare pytest. Share one _launch_home() between
both paths.
Restore that file's _wait timeout to main's 5 s: the 20 s bump only
stretched the wait for a turn.end that the guard error had already made
impossible.
steward.read_install_stamp read <root>/install-stamp.json literally while
version_info._resolve_stamp_file honoured HERMES_INSTALL_ROOT. A Nix package
bakes its stamp outside the store's package dir, so `hermes --version` said
nix while sealed_steward said "unknown" and uninstall printed "managed by
unknown". steward.install_stamp_path now owns the location: the executing
tree resolves through pm.paths.install_root, any other tree is literal.
version_info._resolve_repo_dir fell back to the context-local profile home
for a value cached process-wide; the running code's identity is a process
fact, so it now reads the process home.
uv writes no __pycache__ (pip does), so every module of a fresh generation
was compiled in the foreground of the first user request that imported it
(#100461; main's f298911467 / d380651a9f were lost with lazy_deps).
--compile-bytecode on the sync covers the whole install, transitive deps
included. Also drops the stray copy of that rationale that a merge left
inside gateway/status.py::_posix_is_zombie.
update_stage rebuilt the home from HERMES_HOME with a literal ~/.hermes
fallback while update_lock resolves the same marker through
get_process_hermes_home(). Where the platform default differs (sudo invoker,
data-dir suffix) the two disagreed and the old-shim UI fallback found no
marker, leaving the hand-off window frozen. hermes_constants is stdlib-only,
so the lazy import keeps this module usable under -I -S -B.
prepare_launch treated "dependencies current" as "update finished", but the
sync commits the generation before the product builds and the maintenance
run. A crash between the two left a current install that never built anything
and never would. The tail is now owed by a pending marker written before the
sync and cleared after the tail succeeds, so a repeat launch finishes it.
That tail imports the application, whose entry point is this same function:
inside the launching process's update-lock claim (holder pid is our ancestor)
prepare_launch is a no-op, otherwise the marker recursed forever. The claim
also keeps `hermes update` from racing the launch-time completion.
Completion output goes to stderr: it runs in front of whatever the user
typed, which may be emitting machine-readable stdout. Metadata queries
(--version, -V, --help) skip completion entirely; they read no dependencies.
A completion that fails offline no longer exits 1 from hermes_bootstrap: the
previous generation is still selected (a failed sync commits nothing), so
warn, point at `hermes update`, and launch.
AGENTS.md showed three stacked platforms() decorators as the pattern —
exactly what the conftest rejects at collection. Show one marker per
test with the any-of form instead, and describe the lane selector as
resolving specs (posix reaches the macOS lane) rather than grepping for
the literal word, which is no longer how it works.
Remove the three MERGE-CHECK notes (AGENTS.md, hermes_cli/web_server.py)
left over from the origin/main merge; they were reviewer prompts, not
documentation of the code.
The OS lanes are marker-driven: list_os_marked_tests.py picks the files
a lane imports from their platforms() specs and the lane selects with
-m platforms. A test gated with skipif(sys.platform != "win32") is
therefore never imported on the Windows lane and skipped everywhere else
— it runs on no host. skipif(sys.platform == "win32") tests were merely
invisible to the lane bookkeeping, but the rule the tree now follows is
one host marker, never a bare skipif.
Mechanical mapping, semantics preserved: skip-on-Windows → "posix",
skip-off-Windows → "windows", skip-off-Linux → "linux", skip-on-macOS →
"not macos". The former skip reasons stay as trailing comments. A
non-host condition (os.geteuid() == 0) stays a separate skipif beside
the marker, spelled getattr(os, "geteuid", ...) so the decorator still
imports on Windows.
Where the conversion would stack two platforms() marks on one test (the
conftest rejects that at collection) the narrower mark wins:
- test_update_wedged_gateway: the class is already platforms("linux");
its per-test "needs UNIX sockets" marks were redundant and are gone.
- test_process_registry.TestSystemdCgroupIsolation: the class-level
skip-on-Windows moves onto the 11 methods that had no host mark; the
11 platforms("linux") methods keep theirs.
- test_file_ops_single_roundtrip: the two fifo tests drop their
platforms("linux") in favour of the module's "posix" (mkfifo exists on
macOS; both tests already skip when it does not).
- test_linux_desktop_entry / test_gateway_job_teardown_live: duplicate
or wider marks removed.
Twenty-five call sites told users to run
`python -c "from pm import sync_venv; sync_venv(['x'], explicit=True)"`
because `hermes pm install` only took package names. Add `--extra`
(repeatable; syncs the venv with the named extras and nothing else) and
pm.install_hint(extra), the single builder every site now uses, so the
advice stays correct when the command changes.
A cold PM runtime under allow_lazy_installs:false now reports the extra
the caller wanted and the command that provisions both, instead of a
bare "pm-runtime: not installed".
Patching CRON_DIR/JOBS_FILE alone selects the live-constants store in
_current_cron_store(), whose OUTPUT_DIR was still the import-time path;
five parametrizations then wrote under the real home and tripped
home_io_guard whenever HERMES_HOME was set at import.
Every update route now finishes through update_completion._complete_selected,
which restarted the whole fleet unconditionally -- including the "Already up to
date" route that main sent through the pending-restart catch-up. Net effect:
each cron tick and each profile's `hermes update` drained and re-killed the one
multiplexed gateway.
Port the catch-up path's two live guards into the completion tail:
- host_restart_already_completed(checkout sha): a sibling profile attaches to
the restart this host already stamped (#95294); the restart phase now stamps
it via mark_host_restart_completed.
- every planned runtime AND every live fleet row current at the checkout sha
(#117051, d6b0d37ece). Both are required: the live matrix lists gateways
only, so a planned serve still on pre-update code keeps the restart.
The already-current route also arms the host obligation with the checkout sha;
an SHA-less arm replaced the standing record and wiped the restarted proof.
assert is stripped under python -O, so a pm.ensure that recorded no
selected binary would have fallen through to str(None) as the npm/node
path. The four sites now raise pm.InstallError, which the surrounding
handlers already report.
ensure_import raises InstallError("restart Hermes to activate…") after a
SUCCESSFUL install, so the loop aborted on 'google' and never asked for
'google-chat'; the restart landed back on a still-missing extra. Both
are requested in one pass; the first failure is what the registry logs.
pm-runtime/generations/<uuid> trees were never collected: a kill -9 during
staging orphaned a venv permanently and every input change left the old
runtime behind. `hermes pm gc` now sweeps them under .prepare.lock with the
same lease model as application generations: entry points (worker.py,
launch.py) pin their own runtime, prepare_runtime marks new generations
lease-managed, and the collector removes unpublished stages outright,
superseded generations only once no worker holds a lease, and never a
pre-lease generation.
The adapter still honours PHOTON_NODE_BIN ahead of PM's pinned node, but
the pm hand-merge dropped it from optional_env, so the override became
undocumented and unreachable from setup.
dependency_homes() admitted every directory under profiles/, so a
.work.staging-* directory mid-publication, a tombstoned profile or a
marker-less side-effect dir could pull plugins into the shared venv.
Apply the canonical live-profile predicate (valid id, identity marker,
no tombstone) from hermes_constants, which pm already depends on, so
enumeration stays import-light and complete-union-or-error.
_daemon_subprocess_env copied dict(os.environ), which under multiplex is
the LAUNCH profile's: profile X's daemon started with the default
profile's HERMES_HOME and credentials. It now builds the child env with
served_profile_child_env(inherit_credentials=False) — the manager reads
the LLM keys from the profile's own 0600 env file, so the child needs no
credentials from us.
check_local_runtime spawned 'python -c import ... sentence_transformers'
(a torch cold start) from is_available(), unavailable_reason() and
initialize() on every session. The verdict is now kept per interpreter
path for the process; a reinstall publishes a new PM generation, so a
new path re-probes.
The passive source check moved into hermes_cli/source_check.py during the
pm/ rewrite, and with it the GitHub calls left the desktop — but the four
main fixes that lived on the desktop side (gh-CLI token fallback
c61a11474b, anonymous retry e79ba2d7f2, rate-limit headers
c7d4d32800, failure copy 66a13703b3) did not follow. `_request` sent no
Authorization, so every client behind one NAT/VPN exit shared the anonymous
60/hour budget, and `_branch_tip` collapsed every failure into "Could not
resolve the remote branch tip." — a 403 rate limit read as a Hermes bug.
hermes_cli/github_api.py owns the credential ladder (GITHUB_TOKEN, GH_TOKEN,
`gh auth token` cached per process, anonymous; a 401 on a token retries
anonymously) and the failure copy (rate limit with reset time and the
GITHUB_TOKEN remedy, 5xx as GitHub trouble, else the status/transport
error). `_branch_tip` returns that reason and check_for_updates puts it in
the message.
apps/desktop/electron/github-api-auth.ts had no caller left (the desktop no
longer talks to api.github.com) — removed with its test; the behaviour now
lives where the request is made.
pm.security_packages calls it from the Tirith package; a leading
underscore hides a cross-module contract. The consumer still owns the
signature semantics (pm/security_packages._SignedBinary), so the lazy
pm→tools→pm reference stays; only the name changes.
_git_origin_url spawned a bare 'git' while every other plugin git call
goes through _resolve_git_executable(); from a service with a minimal
PATH (or Windows without Git on PATH) the probe failed where install
and update succeed. No git at all now goes straight to the .git/config
parse.
The https-across-redirects gate accepted any http://127.0.0.1 / localhost
target so test servers work. That let a production https origin redirect
an unpinned model download (sha256 unset by catalog policy) to whatever is
bound on a local port, unverified. Loopback now requires the request to
have started on loopback; no test-only flag needed.
Members were keyed as hermes-plugin-<sha256(path)[:16]>, so the only
conflict text a user ever saw ("uv lock exited 1: …hermes-plugin-
88e1872941a051e3…") did not say which plugin to disable. The key is
now <sanitized dir name>-<hash>; the hash still keeps two same-named
plugins from different homes apart.
A second `hermes pm install` behind a long sdist build blocked with no
output for minutes. After 2s without the lock, print the lock path to
stderr, then keep waiting as before.
scripts/ci/list_os_marked_tests.py matched the literal lane word inside a
platforms() string, so 80 files gated platforms("posix") never reached the
macOS lane ("posix = linux or macOS" was false for CI), and "any" files
reached none. The selector now resolves specs the way the conftest gate
does (posix ⊇ linux+macos, any ⊇ all, "not X" admits the rest) and only
looks inside mark.platforms(...) calls, dropping a false positive whose
only "macos" was inside a generated-file string.
scripts/run_tests_parallel.py still grepped the retired linux_only/
macos_only/windows_only names, so its "N files SKIPPED on this host" note
had been silent since the migration. It now shares the selector's
resolver and names the spec and the lane(s) it runs on.
Restore the platforms("windows") mark that
test_suppress_platform_ver_console_stubs_syscmd_ver lost in the
windows_only migration (its docstring still declared it); it passed
vacuously on Linux and was deselected on the Windows lane.
MacStrategy.check() names its target by latestTag, never targetSha; the
notification gate accepted SHA-less results only for App Installer and
Microsoft Store, so a non-channel macOS build never toasted a background
update (manual/About checks still showed it). Recognise latestTag; git-style
checks without a target commit stay quiet.
_gc_store skipped every dot-dir, so the mkdtemp scratch a killed installer
left behind (a whole extracted python-build-standalone tree per crash)
was never removed. Scratch dirs live and die under the install lock gc
holds, so any that remain are orphans. .previous-* stays: it is the
restore point the next install of that entry verifies and consumes.
The saved update_url is what the gateway urlopen()s unattended every
check interval, and the feed decides which origin commit an update
selects. Install copied it into the row inside a try/except-pass with
no scheme check, so http:// (MITM to an old vulnerable commit),
file:// and ftp:// feeds were accepted.
One predicate (plugins_updates.https_update_url) is applied at install
(a refusal before any state exists — the manifest was already read, so
the re-read/except-pass is gone), in trust-update-url, and at
default_fetch itself so rows saved before this rule are refused too.
360c1ee836 + e6aa2e9fe4 were dropped in the merge: the request handler
denied 'fullscreen' and the check handler denied 'automatic-fullscreen', so
the native fullscreen button on <video controls> did nothing.
native-access-token.ts (22751c8fd9) survived the rewrite with no caller;
main.ts still ran the pre-fix open-coded ensureNativeAccessToken, so
concurrent callers raced /auth/native/refresh (each rotation invalidating
the other's winner) and a refresh landing after a login or logout could
resurrect the identity the user had just replaced. Wire the coordinator:
ensure() is its single-flight, the login handler orders itself with
beginLogin()/NativeAuthChangedError, and logout clears before awaiting
cookie I/O.
fetchJson/fetchPublicJson also lost httpStatusError (8d22781b1c) and threw
bare "<status>: <body>" errors, so readStatusCode() saw NaN on every REST
failure and neither the coordinator's dead-refresh-token check nor
isGatewayAuthRejection could recognise a 401. Restore the structured error.
Not ported here: the requestWithOauthFallback / rejectedAccessToken retry
integration from the same commit — a larger main.ts change, left for a
dedicated round.
before-build.mjs builds the msix-extensions.xml fragment and the hidden
CLI <Application> entries from string templates, interpolating the
display name and the payload-declared launcher stems raw. A value with
`&`, `<` or `"` would corrupt the manifest makeappx reads (an opaque
0x80080204 at best, a differently named alias at worst). Every
interpolated attribute now goes through xmlAttribute().
When runtime_lock times out, activate_dependencies read the selection and
leased it without the lock. An installer committing a new generation in
between left the reader holding a lease on an unselected tree while
importing from it; that tree is exactly what `hermes pm gc` removes once
the marker is a day old. Re-read the selection after leasing and move the
lease when it changed.
lease_generation now returns a release callable that also unlinks its
lease file, so one zero-byte file per hermes invocation no longer
accumulates under .leases/.
update_plugin published whatever the pulled pyproject/pip_dependencies
declared straight into the shared environment, while install/reinstall
ask "Prepare these with Hermes through PM now? [y/N]". Under
plugins.auto_apply that is unattended from the gateway.
Diff the declared install_requirements before vs staged: added ones go
through the same prompt (extracted as _consent_python_deps) when a
terminal user is present; the dashboard and auto-apply pass
interactive=False and get a refusal with nothing published — matching
the capability re-consent already in cmd_update.
Same seam rebuilds an accepted Node sidecar in the staged copy when
package.json/lock moved: publication swaps the whole tree, so a custom
pull carried a stale copied node_modules and a catalog re-pin dropped
it entirely.
shouldHoldBootProgressForReauth (8d22781b1c, #95701) survived the rewrite
with no caller, so updateBootProgress let a running:true phase or a sibling
attempt's cleared error from an in-flight boot lift the recovery overlay and
re-arm the renderer's retry loop — the Sign in button flicker the helper
exists to stop. Gate updateBootProgress on it; re-emits of the latched
failure still pass so the non-retryable verdict is never lost.
pre-update-backup-config.ts (4de06d1dbf) survived the rewrite with no
caller: the checkout strategy called preflightStateDb unconditionally, so a
user who turned the pre-update backup family off in config.yaml still got
an emergency state.db snapshot on every Desktop update. Gate the preflight
on `hermes config get updates.pre_update_backup` in the composition root;
an unreadable answer keeps the snapshot, as before.
batch-sign-binaries.mjs and electron-builder.config.cjs::windowsSigning
treat a missing AZURE_SIGN_* set as "sign nothing, warn" — right for a
fork or a local build, but a release-signing lane with the vars
unprovisioned would have published UNSIGNED installers with only a
console.warn in the log. The macOS leg already refuses to build without
CSC/Apple credentials under the publishing gate; the Windows legs now
do the same for the Azure vars, before any payload is built.
The previous commit dropped --verbose and broke the contract that native
build output streams live (tests/pm/test_environment_build.py). uv only
forwards backend output at DEBUG level, so keep --verbose and scope it with
RUST_LOG=uv_build_frontend=debug: the backend's own lines stream, the
interpreter/cache/reflink internals stay silent (2 lines on a no-op sync
instead of ~200 on both uv 0.11 and the pinned 0.12.3).