The macOS runner uses Bash 3.2, which rejects parameter case conversion.
Normalize the GHCR owner with portable tr and verify mixed-case input.
The macOS runner can clear setgid from a directory when chmod applies 2770.
Compare scratch permissions with the native chmod result while preserving all
bits the host accepts.
Tests: scripts/run_tests.sh tests/scripts/test_termux_build_driver.py tests/test_scratch_dir.py
Shell: bash -n scripts/termux/build_builder_image.sh
main carries 0.0.0, so comparing the tag against pyproject.toml refuses
every release. Admission reads the version from the -rc claim and checks
only that the commit is on main.
The committed version is a placeholder now; a build stamps the real one.
Dev installs report their distance from the highest reachable release tag,
so an -rc claim sitting on the same commit never becomes the version.
The version lives in the ref now, so the mirrors writer takes a tree and
rewrites a copy. The checkout it was invoked from stays at the placeholder,
and the bootstrap installer's Cargo.lock finally moves with the rest.
`release.py release --commit --bump` derives the next version, pushes an
annotated -rc tag as exactly that ref, and treats a dispatch that never
starts as an error. A commit behind an outstanding claim is refused, and
abandon deletes the draft while the claim tag stays as the burn mark.
The family is the published stable head, then outstanding -rc claims, then
the seed 0.21.4 when both are empty, so the first release is 0.21.5. CalVer
tags are excluded rather than sorted: v2026.9.21 is a valid three-component
version and would win every max(). A +canary identity compares equal to its
stable; the legacy -canary prerelease still sorts before its stable so feeds
published before the migration keep their order.
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.
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.
Fail ends the script with `exit 1`, which unwinds past the stage
dispatcher's try/catch, so the catch that frames failures as JSON never
ran for the installer's own fatal errors: a `-Stage repository -Json`
run whose clone failed printed the reason via Write-Host only and put
zero frames on stdout (verified on Windows: 0 frames before, 1 after).
Only thrown exceptions were framed.
Fail now emits the failure frame itself when running under -Stage -Json,
so every fatal path yields exactly one frame carrying the original
reason, matching install.sh's EXIT-trap framing.
scripts/releases/semver.STABLE_TAG accepted any-width majors, so
is_valid_version('2026.9.21') was True and docker.require_stable_tag /
stable.py / release.py admitted the legacy CalVer tags that
hermes_cli.source_releases and get_last_tag() already refused. A
workflow_call carrying GitHub's current 'latest' (v2026.9.21) would have
passed the docker publish gate.
hermes_cli.update_channel already owns the canary tag shape; it now owns
STABLE_TAG_RE too (three-digit major cap, no leading zeros, no suffix)
and every stable selector imports it. release.py drops its private
_SEMVER_TAG_RE + CalVer exclusion pair, which the capped major makes
redundant.
check_platform accepted Termux as plain Linux, so `curl | bash` on a
phone walked the source-install ladder: a glibc uv, a lock whose CPython
is the bundled bionic build with no Android wheels, and on-device sdist
builds. The signed APT package is the only supported Termux shape, so
detect Termux the way the runtime does (TERMUX_VERSION or the com.termux
PREFIX) and stop before any stage with `pkg install hermes-agent` and
the setup docs URL. --json surfaces the reason in the stage frame.
The clone publication step did `mv <staged>/tree "$INSTALL_DIR"`. When
INSTALL_DIR already existed as a non-git directory, mv moved the
checkout INSIDE it as INSTALL_DIR/tree and the stage reported success,
leaving later stages to read pm/lock.json from a directory that holds
the user's files instead of a checkout. An existing file made mv fail
with a generic "cannot publish" error.
Before staging the clone, refuse a destination that exists and is not a
Hermes checkout (non-empty dir, file, or symlink) and say what to do; an
empty directory is taken over so the checkout lands AT the path.
build_deb.sh wrote HERMES_DESKTOP_VARIANT=bundled, so the Termux stamp
carried payload=bundled and every 'bundled' reader treated the tree as
the repo/ of an Electron payload. hermes uninstall --data then called
resolve_bundle_layout on it and rejected the plan: the deb has no
enclosing app to protect, so data-only removal was impossible on Termux.
Add a 'runtime' variant to write_install_stamp.py for a sealed CLI
runtime with no desktop app around it. It stays sealed for the update
gate and channel identity (source_check, update_channel accept it next
to bundled/light) while is_bundled_payload keeps answering False, so
cleanup planning protects the APT-owned package tree the ordinary way
and never asks where the app is.
The runner's per-run temp roots live under a fixed `/var/tmp/hermes-pytest`, chosen for
real reasons (disk-backed, not hidden, short enough for AF_UNIX sun_path). But a fixed
literal in a world-writable sticky dir belongs to whoever creates it first: a root-owned
root — a container or system-service run — makes every later `makedirs`/`mkdtemp` there
fail with EPERM for every other user on the host, with no way back that does not need
root. That is exactly what happened on luna: /var/tmp/hermes-pytest is root:root 755, so
every local suite run by the login user died at `runner crashed: PermissionError(13)`
before collecting a single test.
Key the name by uid (with the same non-/var/tmp fallback), so no run can be blocked by
another user's leftovers. Invariant test: two uids never share a scratch root, and the
root is created under the expected parent.
Under both pwsh 7.6 and Windows PowerShell 5.1 on the lane `& python.exe ...` returns with
$LASTEXITCODE unset and no side effect while Start-Process sees a real exit code — the shape
PowerShell gives a non-console image. Narrate what the venv launcher is and whether the base
interpreter it points at behaves, so the next red run names the cause.
- test_source_build_env: the pwsh probe dumps the env through a script file, not
`-c 'import json, ...'` — pwsh 7.6 on the Windows lane re-quotes native argv and the
venv launcher receives a truncated -c body (`import` → SyntaxError, no stamp). Drop the
spawn-path diagnostics that answered that question. On nt prefer Windows PowerShell,
the shell install-e2e-windows-run.yml actually drives the asset with.
- test_update_fleet_completion: the obligation is host-scoped now; assert through
`_fleet_restart_obligation_armed()`, not the legacy per-home marker path.
- gateway-contract.generated.ts: regenerated (main's `npm run fix` sweep stripped the
eslint-disable line the generator emits).
On the Windows lane `& python ...` under pwsh 7.6.5 returns $?=True with $LASTEXITCODE unset and
no side effect: the child is not started. Exercise the three spawn paths so the next run says which
of them reach the interpreter.
The Windows lane runs the scriptblock (every narration line prints) but $LASTEXITCODE stays
unset after `& $env:PROBE_PYTHON`: the native call itself produced nothing. Log path existence,
run a bare child first, and print $? and the stamp presence right after the call.
- test_source_launcher_publication: launchd ProgramArguments are `/usr/bin/osascript -e` since #71206;
on Linux exec the child argv the script would spawn (minus the shell redirection tail).
- test_source_build_env: pwsh 7.6.5 on the Windows lane exits 0 without running the child; the probe
now traps any terminating error (exit 97 + stack) and narrates each step to stderr.
The Windows lane runs the -File probe to exit 0 with no stamp and no output. Log the shell,
PowerShell version and inputs to stderr, close stdin, and include the return code so the next
red run explains itself.
- test_source_build_env: Windows PowerShell 5.1 runs a multi-line -Command argument only up to
the first line break and exits 0; the probe is a -File script now (the child never ran, hence
the empty stamp).
- test_plugins_cmd_enable_disable_nested: `enable` no longer prompts for or writes an undeclared
allow_tool_override grant (09bcf17801); the test asserts that and uses --no-allow-tool-override
for the persisted False.
- test_stage_only: the directory hold opens with FILE_LIST_DIRECTORY, not access 0 — a
zero-access handle does not oppose the rename the repin performs.
- desktop-update/windows.ps1: the legacy-install retry re-sends the identical request
(--force included); the contract test compares both attempts.
- test_mint_launchers: the bootstrap imports hermes_cli.venv_sync/steward before
prepare_launch can return early for a fixture repo; copy them into the tree.
- test_source_build_env: when the pwsh child writes no stamp, fail with the child's
stdout/stderr instead of a bare FileNotFoundError (the Windows lane hides the cause).
- tests/conftest.py: `real_bash` fixture — the Windows runners resolve `bash` to System32's
WSL launcher (UTF-16 "no installed distributions", exit 1); prefer Git for Windows'. Used
by the setup-pin, install stage-frame and source-launcher shell tests.
- source-build-env.ps1: Test-Path before Remove-Item — under $ErrorActionPreference='Stop'
a missing identity variable aborted the try block before the child ran (Windows PS 5.1
raised where pwsh on Unix did not).
- desktop-update/windows.ps1: `--force` precedes the target arguments (the hand-off contract
test reads argv in that order).
- test_install_ps1_desktop_stage: assert the current contract — the shared completion tail
(source_completion.py --desktop) builds the products and -IncludeDesktop selects the desktop
product inside `products` rather than adding a stage. The test predated the completion-tail
refactor and had been red on the Windows lane since.
- test_windows_native_support: the restart watcher argv is `runtime_command` shaped
([python, -I, -c, bootstrap, pid, delay, ...]).
- test_mint_launchers: create the fixture repo's pm/ dir before copying pm/environments.py.
- test_browser_use_pm: console-script launchers report sys.argv[0] without `.exe`.
- test_update_stale_gateway_yield (from main): `_verify_fleet_after_update` has no
`node_failures` here (PM owns node).
- pm.environment: the `--no-install-package <name>` root read parses [project].name without
tomllib on the pre-3.11 bootstrap python (Docker arm64 stage_runtime). Both parsers proven
to agree on the real pyproject.
- windows-build-deps.ps1 / run_tests.sh: with DISTUTILS_USE_SDK, setuptools takes link.exe
from PATH; under a bash-hosted step Git for Windows' coreutils `link` shadowed MSVC's.
The MSVC linker directory now leads PATH (cl.exe was already found — this was the next
failure in the ruamel-yaml-clib build).
- tests/install/e2e-assets/source-build-env.ps1: clear the identity variables through the
env: drive — [Environment]::SetEnvironmentVariable(..., 'Process') on .NET/Unix does not
reach spawned children, so the stamp child still saw GITHUB_SHA. Red→green under nix pwsh.
- old-updater surface: warm_agent_browser_npx_cache is a permanent def in both facade and
sibling (the frozen surface names both); its compat pointer is retired, and the shims test's
__module__ check holds.
- tests re-seamed / de-faked: import guard tests use the probe_root fixture (CI has no
editable finder), the takeover child tree gets a hermes_constants stub, posix.sh hand-off
test pins HERMES_HOME (our script honours the ambient one), the two Windows-layout
PYTHONPATH tests are platforms("windows") (they fake `Lib/site-packages` on Linux; pm's
site_packages() is host-correct), setup-pin test picks Git bash over System32's WSL stub,
expose_cli's Windows test asserts the installer-convention convergence (the branch retired
"windows-installer-owned"), plugin-manifest satisfied-dep fixture uses a core dep (pyyaml is
gone), housekeeping test yaml imports go through hermes_yaml.
- apps/desktop main.ts: three imports restored in round 4 whose users main removed.
Merge fallout (my resolution errors, all caught by CI):
- hermes_cli/backup.py + gateway.py: `theirs` on those hunks re-imported clusters HEAD had
already moved to backup_restore.py / kept in the facade. backup.py loses the 349-line
duplicate (main's #110179 fix is ported into backup_restore._import_db_member); the
systemd service-unit cluster returns to gateway.py (PM's _prepare_service_launcher /
_pm_managed_node_dirs / _systemd_command have no home in main's extraction) with main's
utf-8-sig read. gateway_service_unit.py is dropped.
- gateway/run.py: main's plugin-update chore is not profile-scoped (the housekeeping
ordering test pins the scope/drain sequence).
- pyproject + 30 test files: `import yaml` -> `import hermes_yaml as yaml` (pm-clean has no
pyyaml); gateway/config._bundled_platform_manifest_name reads through hermes_yaml.
- tests re-seamed onto pm-clean's shape: residency admission (installed_engine),
supervisor child env (binary is a constructor argument), update import guard
(update_cmd_deps is gone; our probe already scrubs PYTHONPATH — both #115032 invariants
pass), shallow-count git responses (stash path asks `status --porcelain -z`); dropped
tests for retired code (_run_node_bootstrap/_ensure_tui_node, Windows resume demotion).
- tests/tools/test_local_env_blocklist.py: restore the two helpers the suite-reduction
commit dropped and the blocklist import.
Real fixes:
- pm: classify_uv_failure/ResolutionConflict move beside the uv runner (pm.environment,
stdlib-only). pm.workspace imports tomllib at module level and cannot load on the 3.10
bootstrap python that streams uv output in the Docker arm64 image.
- tools/browser_tool.warm_agent_browser_npx_cache: back as a permanent definition — it is on
the frozen old-updater surface, and the revert-scheduled compat pointer does not count.
- hermes_cli/memory_setup: the dashboard's pip row uses pm.environments.
running_from_selected_environment for installed vs restart_required.
- scripts/windows-build-deps.ps1: export DISTUTILS_USE_SDK/MSSdk so setuptools trusts the
primed MSVC environment instead of asking vswhere (`env -i` test runner on win32-arm64
compiling ruamel-yaml-clib); run_tests.sh forwards them.
- tests/pm/test_windows_build_deps.py: start the protocol test from a parent env without the
toolchain variables the runner job already exports.
- tests/conftest.py scrubs HERMES_BUNDLED_PLUGINS (Nix-wrapped hermes on the dev host);
tests/home_io_guard.py treats sys.path site-packages under the real home as the
interpreter's installation (PM-activated developer shell).
- tests-js: four `curly` lint errors from main's new scripts.
Branch semantics kept where main and PM disagree: update_cmd_deps.py,
constraints-termux.txt, the Electron update-api-check module and the
post-swap hand-off test stay deleted; the pending-fleet-restart catch-up
and the local_runtime tag/download ladder stay retired (PM owns engines).
Ported from main onto the branch's shape: profile_scoped_chore for the
auto-archive and plugin-update housekeeping chores, the local-runtime
cross-process boot lock and residency cap, the checkpoint tmp_pack sweep,
the cua daemon-liveness status probe, the remote-served Desktop update
flag (posix.sh / windows.ps1), sign-in for env-pinned remote gateways
(urlDisabled on RemoteSetupFields), the uvloop extra split (uvicorn
without [standard]), and the umask-scoping spawn test.
uv.lock regenerated with pm.build_env --lock-only; new utf-8 reads from
main switched to utf-8-sig (check-windows-footguns).
Two optional, submitter-controlled fields on a catalog entry feed the
entry's own page at /docs/plugins/<name>:
- `screenshots:` — up to 6 https URLs on GitHub hosts (same host rule as
`image`, so the site never fetches from third-party hosts and a raw URL
pinned to the sha is as immutable as the code).
- `readme: true` — the docs build renders the README from the PINNED
commit (raw.githubusercontent.com / gitlab.com raw at <sha>), never live
content, so what a user reads is what the reviewer read.
Validator rejects malformed values (admission), the loader parses and
drops off-host screenshots with a warning (client), and the extractor
emits `screenshots`, `readme`, `readmeUrl` and a `maintainerSlug` for the
author pages. Tests on all three.
A Desktop whose active connection is remote (SSH/remote/cloud, including the
registry primary) owns no local messaging gateway, yet the update hand-off
always ran `hermes update --gateway`. On hosts where launchd/service recovery
fails, the updater falls back to a detached local `gateway run --replace`;
with the same Telegram bot token as the remote VPS gateway, the two processes
compete for getUpdates and Telegram rejects one consumer, taking the
production bot offline (#117529).
Pass the ownership down the hand-off: globalRemoteActive() now adds
--no-gateway (posix) / -NoGateway (windows) when the Desktop is remote-served,
and both orchestrators drop --gateway from every update invocation (initial +
retry). The local-ownership default keeps --gateway exactly as before.
Keep the two tests that pin the user-facing contract (a file-backed
list bypasses discovery; `-` reads stdin). The mutual-exclusion and
unreadable-path error tests exercised argparse plumbing already covered
by the runner's flag-routing contracts and pushed the suite past the
salvage bar of two invariant tests per fix.