The Hermes-managed Node tree at %HERMES_HOME%\node is destructively
rewritten while the desktop app's Node processes execute from it:
the Node-26 heal did shutil.rmtree + move, the EBADENGINE repair ran
npm install --global --prefix into the tree, and install.ps1's
Test-Node did Remove-Item + Move-Item. Windows rejects those writes
with PermissionError: [WinError 5] on npm.cmd.
- _heal_managed_node_windows: stage the fully-downloaded tree in a
sibling node.new-* dir, then rename-swap (live tree -> node.old-*,
staged -> node). The live tree is never deleted before its
replacement is ready, so an interrupted heal cannot gut it; a
refused rename is the OS-level in-use signal and defers (returns
None) instead of forcing the write.
- heal_hermes_managed_node: an in-use deferral does not record the
once-per-process attempt, so the heal retries once the tree is free.
- managed_node_tree_in_use: cheap psutil pre-check (Windows only) that
avoids pointless 30-50MB re-downloads in long-lived processes.
- upgrade_managed_npm: defer the in-place npm self-upgrade while the
tree is in use, with a notice.
- install.ps1: Test-ManagedNodeInUse guard around Update-ManagedNpm and
the Test-Node install branch, which now rename-swaps instead of
delete-then-move.
An in-use-but-outdated tree keeps serving the old runnable Node (old
Node beats no Node), and every npm resolution re-evaluates the heal, so
the upgrade applies automatically on the next update with the app
closed.
Everything Browser Use is now managed by Hermes: the canonical binary
is the one install_cli() provisions into HERMES_HOME/bin, and every
resolution and provisioning site prefers it.
- _find_cli(): probe order flipped to managed bin -> PATH ->
user-level tool dir (then uvx across the same order). A user's own
uv tool install can no longer shadow the Hermes-managed copy with a
drifted version; side installs only matter when we have nothing.
- install_cli(): a browser-use on PATH no longer short-circuits the
install — only the managed copy does, so selecting any backend
provisions the copy Hermes controls and updates.
- _ensure_browser_use_cli() (hermes tools): drops its own PATH check
and always delegates to install_cli(), the single owner of the
managed-copy policy.
- install.sh / install.ps1: same short-circuit fix — only
HERMES_HOME/bin/browser-use counts as installed.
Follow-up to #86240 and #86320: with every non-Camofox backend
selection installing the CLI, managed-first closes the remaining
version-drift/shadowing class instead of guarding single sites.
Tests updated to pin the new contract: managed beats PATH and
user-local; PATH install does not satisfy install_cli; helper always
delegates. E2E-verified precedence chain with real files and a real
degraded-PATH install attempt.
`uv pip install -e .` has to replace the console-script shims, so
_quarantine_running_hermes_exe must first rename the running hermes.exe out
of the way. That rename fails whenever any child process spawned from that
hermes.exe is still alive: on Windows a child inherits a handle on the parent
image. It is the inherited handle, not the trampoline, that pins the file --
killing the child makes the identical rename succeed, and the shim flavour
(uv trampoline vs distlib launcher) makes no difference.
The updater spawns such children itself (npx cache warm, memory-provider
refresh -- hindsight-api runs as a daemon with --idle-timeout 300 and outlives
the step that started it), so this presents as a race rather than a hard
failure: the same hand-off succeeds on one run and dies on the next. Step 2's
shim-unlock preflight cannot catch it, because the shim genuinely is unlocked
at that moment; the pinning child appears later, during the update.
When the rename loses that race, _schedule_replace_on_reboot is the last
resort -- and MOVEFILE_DELAY_UNTIL_REBOOT writes to HKLM, so it needs
elevation. A Desktop-driven update is not elevated, so it returns
ERROR_ACCESS_DENIED, `uv pip install -e .` exits 2, and the ZIP fallback
repeats the identical sequence. The desktop build stage is then never reached
while the pre-build clean has already removed apps/desktop/release, leaving an
install whose Start Menu shortcut points at a Hermes.exe that no longer exists.
Running the same code as `python.exe -m hermes_cli.main update` puts the
inherited handles on python.exe, which uv never has to replace.
posix.sh is deliberately untouched: unlinking a running executable is legal
there, so the equivalent call is harmless.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Windows transcripts were ZERO bytes: ts-prefix.ps1 formatted with
{0:D2}, but Floor() returns a double and the D specifier is
integer-only - it threw per line, and under the driver's relaxed EAP
every line errored into the void. {0:00} fixes it (custom numeric
format works on doubles). Reproduced the exact pipeline locally
(empty file + Format specifier invalid), verified the fix produces
prefixed merged stdout+stderr with exit code intact. That is also
why the log timeline never auto-synced: there was nothing in the
files to sync.
The GitHub artifact URL 307s to /suites/... server-side and strips
the ?zip= query param. The player now reads the zip URL from a
#zip= HASH param (client-side, survives the redirect) with ?zip=
as fallback; the hash path was verified in a real browser against a
real leg zip (auto-fetch + boot).
Per ethie's design, one player artifact for the whole run: new
leg-player job uploads playback.html (archive:false) before the
matrix legs, the report job needs it, and each ran cell gets TWO
links - 📼 to the player with #zip=<that leg's logs zip> and ⬇️ to
the raw zip. Per-leg player uploads removed from all three run
workflows.
Debugged against run 31635036702 (real jobs + artifacts replayed
through the renderer). Two naming realities the renderer ignored:
1. upload-artifact with archive:false IGNORES the name: input and
registers the artifact under the FILE's basename - every leg's
player artifact is called 'playback.html' (all the same blob). The
renderer now uses any 'playback.html' artifact for the player half
of the 📼 link instead of install-e2e-player-<leg_id>.
2. The posix arms append -<sha> to the logs artifact name at upload
(install-e2e-logs-<leg_id>-<sha>), while windows does not. Match
by prefix instead of exact name.
Also fixed the pass/fail summary counters, which compared cells with
=== against '✅' - the appended reel link made every ran cell
count as neither passed nor failed (the summary said 0 passed while
the table was full of checks).
Replayed against the real run: 31 passed, 25 failed, 149 skipped,
24 📼 links on ran cells (both outcomes), none on skips.
* fix(install): fail when Node dependencies cannot install (#85297)
The POSIX installer converted root and TUI npm failures into warnings, then
printed a dependency-success message and reached the installation-complete
banner with a zero exit status. This left consumers with no usable
node_modules while reporting success.
Treat both required npm installs as fatal: log an error, restore tracked
lockfile churn, return status 1, and propagate the failure from the monolithic
and node-deps stage callers. Successful installs, Termux and missing-Node
skips, missing-manifest skips, and optional Playwright/Browser Use/Computer
Use best-effort behavior remain unchanged. The fix is limited to the POSIX
installer; the PowerShell installer is outside this issue's scope.
Focused and adjacent installer tests passed (32), with bash syntax,
py_compile, and diff checks clean. The broader installer family had 90 passes,
one unrelated pre-existing failure, and two skips; the full suite was
environment-limited by missing dependencies. CodeRabbit, iterative deep
security/compatibility reviews, and final confidence security/compatibility
reviews were clean against the final diff.
Fixes#85297
* fix(install): require npm alongside node in check_node (#77003)
A stray `node` symlink without a sibling `npm` (leftover from a node
version manager) made check_node report "Node.js found"; every later
npm install then failed and the desktop build died with an opaque
"Node.js / npm unavailable". Node now only counts as found when npm
resolves on the same PATH, with an explicit "stray node symlink?" branch
that falls through to the Hermes-managed Node (which bundles npm).
The overlapping success-log honesty half of the original PR is subsumed
by the previous commit, which makes a failed npm install fatal rather
than conditionally-logged; the behavioral tests there cover it, so this
commit keeps only the check_node PATH-gate assertions.
Fixes#77003.
Co-authored-by: criptogus <criptogus@users.noreply.github.com>
---------
Co-authored-by: Eugeniusz Gilewski <egilewski@egilewski.com>
Co-authored-by: CriptoGus <128640021+criptogus@users.noreply.github.com>
Co-authored-by: criptogus <criptogus@users.noreply.github.com>
* fix(install): time-box the Windows node-deps stage so a stalled npm or Playwright install can't hang setup forever
scripts/install.sh has bounded this same work with run_with_timeout
"$NODE_DEPS_TIMEOUT" (600s default) since #39219, but install.ps1 never got
the guard: Install-NodeDeps ran both `npm install` and `npx playwright
install chromium` unbounded. A stalled registry fetch or a wedged Chromium
archive extraction (#76222, #84614) froze the installer indefinitely -- one
user left it running 12+ hours overnight before asking for help.
Route both invocations through _Invoke-NativeWithTimeout: cmd.exe launches
the native command with its output merged to a log, the parent polls with a
wall-clock deadline and tails new log lines to the console each tick (the
live progress that makes a 3-minute download distinguishable from a hang),
and on timeout taskkill /T /F kills the real process tree and returns 124 --
the same convention as coreutils timeout and bash's run_with_timeout.
Wait-Job was rejected for this: jobs swallow live output and Stop-Job leaves
the npm child running. Windows PowerShell 5.1-safe throughout.
Timeouts surface as a warning with the log path, a note that re-running the
installer resumes (stages are idempotent), and the NODE_DEPS_TIMEOUT env
override for slow links -- mirroring bash.
Fixes#76222.
Closes#84614.
Supersedes #76303.
Co-authored-by: JonthanaHanh <JonthanaHanh@users.noreply.github.com>
* fix(installer): roll stage timers over to hours so an overnight stall doesn't read as "744 hours"
formatElapsed rendered a running stage as m:ss with unbounded minutes: a
node-deps stage left hanging overnight showed "744:38", which the user who
reported the hang understandably read as 744 hours. formatDuration
(completed stages) had the same unbounded-minutes shape.
Move both formatters into src/lib/format.ts (pure, no React) and add the
hour rollover: h:mm:ss live, "Xh Ym" completed. tests-js pins the shapes,
including 744m38s -> 12:24:38.
---------
Co-authored-by: JonthanaHanh <JonthanaHanh@users.noreply.github.com>
Choosing Computer Use should be a config flip, not a hunt for
'hermes computer-use install'. Three provisioning rungs:
- install.sh / install.ps1 pre-install cua-driver (best-effort,
non-fatal, time-boxed at 660s above the upstream installer's 600s
lock window; --skip-computer-use / -SkipComputerUse to opt out;
Termux and unwritable-/Applications skipped cleanly)
- PUT /api/tools/toolsets/{name} (dashboard + desktop toggle) spawns
the background 'hermes tools post-setup cua_driver' action when the
toolset is enabled while the binary is missing — previously the
toggle 'saved' but the tool never appeared in the schema because
check_computer_use_requirements() couldn't find the binary
- hermes tools interactive flow already installed via
_toolset_needs_configuration_prompt/_POST_SETUP_INSTALLED (unchanged)
Docs: computer-use.md enabling section rewritten around the new flow;
installation.md documents --skip-computer-use.
ensure_browser() (install.sh) and Install-AgentBrowser (install.ps1)
are reached only via the explicit --ensure browser / -Ensure browser
on-demand mode, itself only triggered by an actual browser-tool call's
lazy-install fallback or `hermes acp --setup-browser`. agent-browser
already resolves via npx in that same fallback before ever reaching
these scripts, so eagerly npm-installing a second, separately
version-pinned copy here was redundant and an extra credential/
supply-chain surface for a path npx already covers. Chromium
acquisition for this on-demand path is now deferred entirely to
_maybe_autoinstall_chromium's existing lazy fallback. camofox's
install and system-browser detection/configuration are unaffected.
install.ps1 also drops the now-dead -SkipChromium switch, confirmed
unused at its one call site.
Each leg uploads playback.html as a single-file artifact (archive:
false) before the driver runs, so it exists even on failure. The
results chart now links every leg that RAN (pass or fail, not skip)
to its player with ?zip= pointing at that leg's logs artifact.
Leg<->artifact mapping: the generator mints a leg_id per matrix entry
(sanitized matrix name, exported legId()), every run workflow names
its artifacts install-e2e-{player,logs}-<leg-id>, and the report job
feeds the run's artifact name->id list to the results renderer, which
rebuilds the leg id from the parsed job name. GitHub does not link
jobs to artifacts, so the deterministic name is the join key.
Empirical finding: GitHub artifact downloads are auth-gated (the
download URL 307s to /suites/... which is 404 anonymous), so a
locally-opened player page cannot fetch the zip cross-origin. The
player now degrades gracefully: ?zip= fetch failure renders a real
download link for the zip (a normal click carries the user's session)
plus a drag-and-drop / file-picker path, and no-param opens as a pure
drop target. Verified in a real browser against a real artifact URL.
Verified: generator emits leg_id, results renderer emits
✅/❌ [📼](...?zip=...) only on ran cells, npm run check PASS,
install tests 36/36, strict tsc PASS, actionlint x4 PASS.
Skipped cells split into their reason: pre-desktop (a desktop-surface
method against a tag that predates apps/desktop) vs TODO (declared,
no driver arm yet). The report job passes pick-releases' annotated
tags into --format results; the shared methodNeedsDesktop() is the
same predicate the plan chart uses, so plan and results agree about
what pre-desktop means. Without --tags the renderer keeps the flat
skip label (backward compatible).
Verified against run 31579084845 real job list: 82 legs, 44 skips
labeled correctly.
* 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 '--'.
macos gains the desktop-installer@latest install method: the website's
Hermes-Setup.dmg (verified live), mounted with hdiutil and its app
binary run DIRECTLY - an open-launched app inherits none of the git
redirect env, so direct exec is what keeps the isolation honest while
staying the same binary and first-launch flow.
install-e2e-macos-run.yml takes the windows shape: one workflow, one
inner job per driver arm, native skips. Arm 1 delegates script installs
to the shared OS-agnostic run workflow; arm 2 stages, installs from the
dmg, and drives both app-update methods through launch-from-spec.mjs -
open-app-update launches the installed .app (the double-click surface,
env via Playwright), hermes-desktop-app-update captures the product's
own hermes desktop spawn. Both end on sha asserts, never version
strings.
windows-desktop-gui-e2e.ps1 and windows-installer-script-e2e.ps1 fold
into tests/install/windows-e2e.ps1 with orthogonal -InstallMethod and
-Route axes: the install phase dispatches on one, the update phase on
the other, and shared workroot state carries how OLD landed - so any
implemented update method can follow any implemented install method.
Implementing a new pair is now a driver function plus a gate edit,
never a new job.
The run workflow collapses to ONE inner job whose if: is the
implemented-pairs table. Newly cheap pairs go live with the merge:
desktop-installer@latest -> hermes-update / installer-script /
installer-script+desktop / hermes-desktop-app-update
installer-script(+desktop) -> hermes-desktop-app-update
installer-script+desktop -> open-app-update (the -IncludeDesktop
install registers real Start Menu / Desktop shortcuts)
Only desktop-installer@latest as an UPDATE method stays a declared
TODO. scripts/windows_e2e_harness.ps1 executes the parse/parameter/
dispatch checks under pwsh before any Windows runner spins up.
Playwright must own the spawn (it needs the inspection pipe), but
hermes desktop is not just build+launch - stamp checks, integrity
gates, sandbox fixups, and a constructed child environment. So the
driver intercepts the product's own launch: a sitecustomize.py on
PYTHONPATH (opt-in via HERMES_E2E_CAPTURE_LAUNCH) wraps subprocess.run,
captures argv/cwd/env at the spawn site, and fakes success instead of
spawning; launch-from-spec.mjs then _electron.launch-es exactly that
spec and clicks Settings -> About -> Update now. Completion is product
state, not a Playwright event: the handoff result file or the checkout
reaching the expected sha (source installs write no result file).
Ships with the driver, so it works unchanged on every sampled OLD ref
- no product flag, no pre-flag fallback split. Both launch shapes are
matched (npm exec electron / packaged exe under apps/desktop/release);
npm BUILD calls pass through untouched. Exit 0 without a capture fails
the leg: a version that never reached its launch must not pass.
Probe-the-probe: scripts/launch_capture_probe.sh runs control rows
(no opt-in, non-launch argv) and both treatment shapes - all green
locally. Gate flips on the shared run workflow for linux/macos;
windows adopts the same path with the driver restructuring.
The one-liner with its desktop stage opted in (--include-desktop /
-IncludeDesktop) is a real install kind, distinct on both sides:
on windows the stage builds Hermes.exe AND registers Start Menu /
Desktop shortcuts - a second path to a hand-launchable app - while
on linux/macos it builds into the checkout and registers no OS
entry point.
Declared on every OS and driven by both script drivers: the drivers
pass the flag through (hard failure if the ref predates it - the
tag-has-desktop gate already skips pre-desktop tags upstream) and
assert the built app exists under apps/desktop/release afterwards.
The run-workflow gates run +desktop pairs only on desktop-bearing
tags; app-update pairs from +desktop installs stay declared TODOs.
The desktop app has two launch paths, so app-update becomes two
methods. open-app-update starts the app from the OS entry point the
desktop installer created (the installed exe / the .app), so it exists
only where a desktop installer does. hermes-desktop-app-update starts
the app via hermes desktop, which every install method provides on
every OS that ships the desktop app - on linux it is the only app
surface, since no desktop installer or packaged artifact exists there.
Both variants are desktop-surface methods on every OS, so the
tag_has_desktop annotation moves from windows-only to every matrix
entry, install-e2e-run.yml grows the input, and the plan chart marks
pre-desktop cells on all OSes.
The windows GUI arm's implemented pair renames to open-app-update;
every other new combination is a declared TODO that natively skips.
generate-e2e-matrix.mjs grows --format results: reads the run's own
job list as NDJSON {name, conclusion} on stdin (per-leg conclusions
are NOT reachable through needs - a matrix job collapses to one
aggregate result) and re-renders the plan chart with each cell's
outcome. Legs are recognized by the exact name shape buildMatrices
mints, so unrelated jobs fall out; duplicate leg names (one windows
job per driver arm, only one runs) merge by significance - real
outcomes beat skips, failures beat successes. A final report job
(if: always, needs all three OS jobs) appends the chart to its step
summary via gh api with the default token.
Verified against two real runs: 31536931863 renders 11 passed / 0
failed / 54 skipped all-green; 31557865241 (the pre-EAP-fix run)
renders its 4 real failures + cancellations over the sibling arm's
skips.
generate-e2e-matrix.mjs grows --format results: reads the run's own
job list as NDJSON {name, conclusion} on stdin (per-leg conclusions
are NOT reachable through needs - a matrix job collapses to one
aggregate result) and re-renders the plan chart with each cell's
outcome. Legs are recognized by the exact name shape buildMatrices
mints, so unrelated jobs fall out; duplicate leg names (one windows
job per driver arm, only one runs) merge by significance - real
outcomes beat skips, failures beat successes. A final report job
(if: always, needs all three OS jobs) appends the chart to its step
summary via gh api with the default token.
Verified against two real runs: 31536931863 renders 11 passed / 0
failed / 54 skipped all-green; 31557865241 (the pre-EAP-fix run)
renders its 4 real failures + cancellations over the sibling arm's
skips.
generate-e2e-matrix.mjs grows --format markdown: one row per
{os, install -> update} combination, one column per starting tag,
appended to GITHUB_STEP_SUMMARY by the expand job. Cells mark
dispatched legs; run-vs-grey stays the run workflows' call, so the
only special cell is pre-desktop (the one annotation the plan owns).
JSON mode unchanged.
The fake Internet (bubblewrap + slirp4netns + MITM proxy +
upload-pack shim, 883 lines across dev-sandbox.sh, stage2-run.sh,
proxy.py, ssh-shim.sh, openssl.cnf, install-update-e2e.sh) existed to
isolate install.sh's network. The GIT_CONFIG_GLOBAL insteadOf redirect
the windows driver introduced does the same job with a gitconfig file
and works on any OS, so:
* install-e2e-run.yml now runs tests/install/installer-script-e2e.sh
directly on the bare runner - no sandbox deps, no userns sysctls -
and takes a runner input;
* the macos matrix calls the SAME workflow on macos-latest, deleting
install-e2e-macos-run.yml: installer-script -> installer-script /
hermes-update flip from grey to live, app-update pairs stay TODO
inside the shared gate;
* install.sh is no longer curl'd through a fake CA - each leg runs
the copy from the ref a user of that version actually executed;
* scripts/dev-sandbox.sh becomes the minimal isolation sandbox from
ab6b9492f (separate HERMES_HOME / Electron userData / app name,
same CLI surface: --persistent, --from, --delete), keeping its
.hermes-sandbox dir name so gitignore and docs hold;
* nix/sandbox.nix drops the bwrap/proxy closure and keeps only the
Electron runtime LD_LIBRARY_PATH the desktop app needs.
Verified: nix build .#sandbox + smoke run (isolated HERMES_HOME
created, ephemeral cleanup), shellcheck/bash -n on both scripts,
actionlint on all three workflows, and the new driver ran the full
v0.20.2 -> HEAD hermes-update pass locally before this commit.
The Browser Use CLI became the default browser backend, but nothing
provisioned it: users without uv/uvx (field report from DongyangHe on
macOS) silently fell back to the built-in browser tools with no notice.
- install_cli() in tools/browser_use_cli.py: uv tool install browser-use
via the managed uv (bootstrapped on demand), linked into
$HERMES_HOME/bin (UV_TOOL_BIN_DIR)
- _find_cli() now also probes $HERMES_HOME/bin for browser-use/uvx —
Hermes' managed uv is not on the user's PATH
- hermes tools post_setup actually installs (Camofox standard) instead
of printing instructions
- install.sh / install.ps1 provision the CLI at install time
(best-effort, non-fatal, honors --skip-browser)
- CLI startup shows a one-line notice (24h rate-limited) when the
default backend downgraded to the built-in tools
Per review the unions were overcomplicated. Install methods are now
just: installer-script (the platform one-liner - curl | bash on
linux/macos, irm | iex on windows), desktop-installer, and
packaged-app (declared, unused). Update methods are every install
method (re-run it over the existing install) plus hermes-update and
app-update. desktop-installer-rerun, desktop-app, curl-bash, and
irm-iex are gone as ids; the windows driver's ValidateSet, switch
arms, and both run workflows' gates renamed to match. tsc --checkJs
clean; generator output re-verified (4 linux / 16 windows / 6 macos
legs for 2 tags).
Per review: the method/version vocabulary is now closed TYPE unions
(@ts-check + jsdoc typedefs - InstallerVersion, InstallMethod,
UpdateMethod - checked with tsc --checkJs, which rejects a SPEC entry
outside the unions; verified by corrupting a copy: 6 errors) instead
of runtime KNOWN_METHODS/ALLOWED_VERSIONS sets. validateEntry and
routeWants are deleted with all the paranoia: the generator always
emits every OS matrix and the dispatch route filter moved to plain
job-level ifs in install-e2e.yml, where the OS jobs already live.
secondUpdate is typed never[] so declaring one is a type error until
a leg implements it. Anything types cannot catch is self-evident on
the next CI run.
Revert the hardcoded 16-job experiment: the combination spec belongs
in scripts/sandbox/generate-e2e-matrix.mjs (restored), not copy-pasted
YAML blocks. What survives from the experiment:
* leg names carry everything - 'os: install -> update (tag -> HEAD)' -
generated per entry, since slash-joined names are all the graph
renders;
* pick-releases annotates each tag ({ref, desktop}) and the generator
threads tag_has_desktop onto windows entries, so the windows run
workflow still gates pre-desktop tags without a probe job;
* the per-OS run workflows are untouched: single job, static 'e2e'
name, native skip gates own all capability knowledge.
install-e2e.yml is one generate job + three per-OS matrix fanouts.
Generator shape (4/16/12 legs for 2 tags), annotation threading, and
all six error paths verified; all four workflows pass actionlint;
driver parses clean pure-ASCII.
GitHub only draws matrix boxes for the PRIMARY workflow's matrices;
everything inside a called workflow flattens into slash-joined names.
The generator + per-tag sub-workflow therefore bought no structure in
the graph and hid the support matrix in a script.
Invert it: install-e2e.yml now declares one job per {os,
install-method -> update-method} combination (2 linux + 8 windows +
6 macos - same 16 the generator produced, verified by inventory
before/after), each a matrix over the picked release tags. The graph
now renders one titled box per combination whose legs read
'... from vX' - the tag axis inside the combo axis. The per-OS run
workflows are unchanged: they own capability knowledge and natively
skip unimplemented method pairs and pre-desktop tags.
install-e2e-tag.yml and generate-e2e-matrix.mjs are deleted; adding a
method is now adding one job block here, implementing one is flipping
the run workflow's gate.
Remove all capability knowledge from the combination generator: no
IMPLEMENTED table, no skipped matrix, no per-OS special cases. It now
only declares and expands - every {os, install-method, update-method}
combination is dispatched to its OS's run workflow, and each run
workflow natively skips (grey, job-level if on the method inputs) the
pairs its driver cannot run yet:
* install-e2e-run.yml gains install-method/update-method inputs,
gates on the supported pairs (curl-bash -> hermes-update/curl-bash),
and maps the method id to the sandbox script's --route internally;
* install-e2e-macos-run.yml is new - all pairs skip until a macOS
driver exists, and implementing one flips its job-level if;
* install-e2e-windows-run.yml already worked this way;
* install-e2e-skip.yml is deleted - nothing special-cases macOS
anymore, so the tag workflow is three identical OS fanouts.
Structure is now uniformly matrix(tag) -> matrix(combination) ->
run-or-skip, with capability knowledge living only next to each
driver. Generator shape/route filters/error paths re-verified; all
five workflows pass actionlint.
Two structural changes to the combination fanout:
1. Tags become the OUTER axis, as a sub-graph per starting version:
install-e2e.yml fans a plain matrix over the picked tags into a new
per-tag reusable workflow (install-e2e-tag.yml), which runs the
combination generator for that one tag and fans out one job per
{os, install-method, update-method}. The Actions graph now reads
'from vX -> windows: install -> update' per leg. Nothing is
hardcoded in the workflows: the tag workflow calls the generator
itself.
2. Native skips move to the point that owns the capability knowledge:
macOS combos (no driving workflow exists) grey out in the tag
workflow via install-e2e-skip.yml, untouched by the tag axis; ALL
windows combos dispatch to install-e2e-windows-run.yml, which takes
install-method/update-method inputs and natively skips the pairs
its driver cannot run yet - so implementing a windows method is a
change in the run workflow + driver only. The driver's -Route ids
now match the generator's method ids verbatim.
Generator output shape, route filters, and all error paths re-verified
locally; all four workflows pass actionlint; driver re-parses clean
pure-ASCII.
Replace the hand-enumerated update/installer/windows-desktop jobs with
a support-matrix generator (scripts/sandbox/generate-e2e-matrix.mjs).
The spec declares every {os, install-method, update-method} combination
a user could be on; generate-matrix expands it and fans out ONE JOB PER
COMBINATION:
* linux combos (curl-bash install x hermes-update/curl-bash rerun)
drive install-e2e-run.yml, still multiplied by the sampled release
tags from pick-releases;
* the windows combo (desktop-installer@latest -> desktop-app) drives
install-e2e-windows-run.yml - the real GUI flow;
* every declared-but-unimplemented combo (all of macOS, the remaining
windows methods) becomes its own visible skipped job, so the
coverage gap is enumerable from the Checks tab and implementing one
is a one-line move into IMPLEMENTED.
Strictness carried into the generator: method ids validate against a
closed set, installer 'versions' arrays only allow 'latest' until a
versioned archive exists, secondUpdate must stay empty until a chained
second-update leg is implemented, and unknown spec keys/routes throw.
Expansion, route filtering, empty-matrix gating, and all seven error
paths verified locally; the dispatch route choice keeps its exact
previous semantics (all/both/update/installer/windows-desktop).
Round 4 of helix4u's review — the durable fallback is now real:
- Result protocol gains `manual`: an ok result the user still must act
on (reopen the app, reinstall the GUI package, fix the sandbox helper).
Both orchestrators set it on every DONE_NOTE/downgrade path; the Desktop
consumer surfaces manual results in a real dialog on next boot instead
of a log line — the browserless-Linux disappearance now ends at a
visible dialog, worst case one boot later. Older result files without
the field parse as manual:false (covered).
- notify ladder verifies EXECUTION, not existence: zenity/kdialog must
survive their first second (an instant death means no display and falls
through); the no-surface case is an explicit best-effort contract whose
guaranteed channel is the result dialog.
- mac DONE_NOTE + failed relaunch of the kept/rolled-back bundle is no
longer swallowed (`|| true` dropped): the durable message carries both
facts.
- launch/gate matrices assert `manual` in the result JSON; consumer
round-trip tested in handoff-result.test.ts.
gille's round 3:
- cd into the install root FAILS CLOSED (set -u without set -e let a
failed cd continue hermes update in the caller's tree -- the exact
wrong-tree class the correction exists to kill). Honest result, exit 3.
- A supplied mac relaunch target that is missing is a REJECTED launch ->
manual downgrade; the launch matrix asserts the downgrade instead of
codifying the old false success. A mac swap-failure DONE_NOTE now still
relaunches the kept/rolled-back bundle before publishing manual.
- notify_fallback: every rung falls through on EXECUTION failure (a
notify-send that can't reach D-Bus no longer eats the message), mac
gets osascript (present on every macOS -- Safari-only machines have no
chromium shim), and the no-surface terminal case is an explicit logged
contract: the result file carries the outcome to the next boot.
- update:repro:fresh passes --non-interactive explicitly (prompt_yes_no
falls back to /dev/tty, so </dev/null was not equivalent).
gille's round-2 review: the terminal lifecycle claimed outcomes the
launch hadn't delivered yet.
- posix finish() reorders: outcome -> durable result+marker -> LAUNCH
WITH ACCEPTANCE -> terminal event. mac acceptance is open's exit code
(launchd rejects broken bundles loudly); linux verifies the setsid
child is still alive 1.5s after spawn, so an instant exec failure
downgrades to a held 'manual' state + truthful result instead of a
vanished 'done'. Gated skew/manual outcomes publish a real 'manual'
event (new third shim state -- still zero logic in the page).
- Renderer-free linux recovery: when no chromium-family browser exists,
manual/error outcomes fire notify-send/zenity/kdialog best-effort so a
gated non-relaunch is never a silent disappearance.
- windows.ps1 mirrors the contract: Start-DesktopRelaunch returns
verified acceptance (WMI pid alive / fallback process alive; dying
before the window appears counts as failure), and the finally block
downgrades to Show-ManualFinale + rewritten result when the launch
didn't land. Error path still relaunches after showing itself.
- repro.sh launch / npm run update:repro:launch: real-orchestrator
matrix for instant-exit relaunch downgrade and skew-message surfacing.
- posix.sh cds into the install root before hermes update (found by the
sandboxed behind-repro: parts of the update resolve the mutated tree
from cwd, which is the Desktop's cwd -- it updated the DEVELOPER'S
checkout while reporting success against the sandbox).
The posix orchestrator inherited the Desktop's cwd, and parts of the
update pipeline resolve the tree they mutate from the working directory
-- the sandboxed behind-repro caught it updating the DEVELOPER'S primary
checkout (cwd at spawn time) while reporting success against the
sandbox. cd "$INSTALL_ROOT" before running hermes update, matching the
cwd:updateRoot contract of the deleted in-app path. Verified: rerun
leaves the outside checkout untouched (reflog clean).
repro.sh fresh used a --no-interactive flag install.sh doesn't have;
non-TTY stdin (</dev/null) + --skip-setup is the real non-interactive
contract.
Address helix4u's review:
- finish() now delivers the outcome BEFORE publishing it: mac bundle swap
and the linux relaunch gate run first, then the result file, marker
removal, and the shim event -- the app launch itself goes last so it
can't race the result write. A gated/skewed linux install (AppImage/
deb/rpm, broken sandbox helper) surfaces its message in the result file
AND holds the shim window open with it instead of closing on a false
'Opening Hermes...'.
- mac swap is transactional with a checked rollback; a failed install
restores the previous bundle and the result says so (exit 7 when even
rollback fails). Failed 'open' rewrites the result truthfully.
- linux gate is an exact port of the deleted update-relaunch.ts logic:
anchored path-segment match on <root>/apps/desktop/release/linux-unpacked,
chrome-sandbox absent = namespace build = fine, present = root+setuid
required, with the real opt-outs (ELECTRON_DISABLE_SANDBOX, --no-sandbox
among replayed args, or the Desktop vouching) instead of the invented
HERMES_DESKTOP_NO_SANDBOX. collectRelaunchArgs/sandboxFallbackFromEnv
live in updater-process.ts again; the Desktop passes filtered launch
args (after --) and --relaunch-cwd so a deep-link or --no-sandbox
launch survives the update.
- result/status JSON strings are escaped (git permits '"' in branch
names) and the result write is atomic (tmp + rename).
- coverage: resolvePosixScriptHandoff + ported helpers in
updater-process.test.ts (19 pass); repro.sh gate / npm run
update:repro:gate asserts the whole gate matrix and round-trips a
hostile branch name through the result JSON.
scripts/desktop-update/repro.sh drives the real code paths against a
disposable HERMES_HOME under /tmp: shim/shim-fail (UI dry runs), fresh
(literal install.sh), behind N (rewound checkout driven forward by the
orchestrator), error (broken venv -> abort + result file). Exposed as
npm run update:shim / update:shim:fail / update:repro:* from
apps/desktop.
scripts/desktop-update/posix.sh is the mac/linux twin of windows.ps1:
the Desktop spawns it detached and QUITS; it waits the app out, runs
plain hermes update (retry-once across the update boundary, truthful
desktop-rebuild completion), swaps/relaunches the .app bundle (mac) or
the release/*-unpacked binary when its sandbox helper is launchable
(linux), writes .hermes-update-result.json, and drives the same shim.
Repo-owned, so every update refreshes the code that drives the next one.
resolvePosixScriptHandoff mirrors the Windows resolver (with the
flat-path fallback covering the scripts/ reorg skew).
scripts/desktop-update.ps1 moves to scripts/desktop-update/windows.ps1 (a
compat forwarder stays at the old path for one asar/checkout skew cycle)
and gains the shim: scripts/desktop-update/ui.html rendered in a
chromeless Edge app window, fed done|error over a loopback /progress
endpoint. The page is #75895's hand-off screen ported verbatim (Fourier
Flow loader, one title, one line, OS light/dark, charcoal dark seeds);
failure is the terse card pointing at hermes debug share. The WinForms
card stays as the no-Edge fallback, same shape.
Salvaged from the web-shell spike: TcpListener runspace server, Edge
--app spawn with throwaway profile, degradation ladder, -SelfTestUi.
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
The hand-off script's WinForms window was a 720x420 dashboard: streaming
log box, wide marquee, warning label. Updating is a wait, not a dashboard
-- it is now the same shape as the other update surfaces (#75895): a fixed
280x320 panel, marquee loader, one title, one static line, following the
OS light/dark theme (charcoal #232323 seeds, never brand blue).
Failure gets a terse finale instead of a wall of log: 'Failed to update' +
'Run "hermes debug share" in a terminal to send a report' + Close (held
max 5 minutes, then the relaunched Desktop re-surfaces the result banner
as before). The result-json message points at debug share too.
With nothing streamed to the window, the per-line stdout pump is gone:
Invoke-HermesStep drains both pipes async (no deadlock on chatty children,
no frozen marquee on quiet ones) and writes full output to the hand-off
log afterwards, where hermes debug share picks it up.
The publisher read the PR number from the CI run's pull_requests
payload. GitHub keeps that payload empty for fork runs, so the job
printed 'No pull request is associated' and stopped on every fork PR.
Resolve the PR from the run's head owner, branch, and SHA instead.
The SHA match skips runs that a newer push superseded.
A fork PR also has no CI review comment, because the live poller
skips forks. The publisher now logs this and exits clean instead of
raising; the evidence stays in the workflow artifact.
Two consecutive Windows E2E runs hung inside dependency install until
the job timeout: 31449642122 65 minutes in uv pip install .[all],
31453853006 43 minutes in the SQLite-repair uv sync (RUST_LOG=uv=debug
made THAT run hang earlier and with more stderr - the tell).
Root cause: scripts/desktop-update.ps1 redirects both child pipes but
only pumps stdout while the child runs; stderr is ReadToEnd()'d after
exit. uv and pip write progress to stderr. Once that pipe hits the
~64KB buffer, uv blocks on write, hermes update blocks on uv, the
hand-off blocks on hermes update: deadlock. Slower stderr producers
survive by finishing before the buffer fills, which is why the linux
sandbox never sees this.
Fix both sides of the class:
- managed_uv.py candidate sync + main.py _run_install_with_heartbeat:
merge stderr into stdout (the pipe that IS drained). This arm heals
EXISTING installs, whose old hand-off script drives the NEW python
after the git reset.
- desktop-update.ps1: drain stderr concurrently via ReadToEndAsync so
future bases never block regardless of what a child writes there.
_run_logged_subprocess and _run_npm_install_deterministic already
merge or capture both pipes; the two fixed sites were the only update-
path spawns that redirect stderr without draining it live.
Main (7537de9e7) moved most of the vulnerable locked versions, but some
fixes live only in the lockfiles and some advisories stayed open. This
commit closes the rest:
website/package.json gets durable overrides for js-yaml 4.3.1,
dompurify 3.4.13, mermaid 11.16.1, and tar 7.5.22. The root workspace
gets the same tar override, which moves the tar 6.2.1 copies under
get-windows and @mapbox/node-pre-gyp past twelve open advisories.
Without an override, a reinstall can pull an old transitive copy back
in.
image-size <=2.0.2 has two infinite-loop DoS advisories and no fixed
release upstream. An override points it at @nous-research/image-size
2.0.3, our maintained fork of the real repo. The OSV scanner resolves
the aliased fork cleanly, so no ignore entries are needed.
The photon sidecar moves @opentelemetry/core to 2.10.0. The
whatsapp-bridge gets a body-parser 1.20.6 override, so the lockfile-only
fix from main cannot regress on reinstall.
website/.npmrc gets matching min-release-age exclusions for the fix
releases that are less than two weeks old.
electron stays at 40.10.2. The 41.x fix for GHSA-9f4c-93c8-jc8g brings
back the install failure that bb8280b75 reverted: install.js in 40.10.3+
extracts with an MSVC native binding, which fails on Windows machines
without the VC++ Redistributable. Upstream tracks this in
electron/electron#52481, with no fix released.
The poller job set GITHUB_RUN_ID in env: to point at the CI run.
The Actions runner sets the GITHUB_* defaults itself and ignores
the override. Thus the poller read its own run id and watched
itself. Its own run stays in_progress while the poller runs, so
runs_all_completed() was never true. The comment froze at
'waiting for jobs to start' and the job burned its full 3000s
timeout on every PR.
Rename the variable to CI_RUN_ID. Also drop the GITHUB_REPOSITORY
override — it was a no-op for the same reason, and the runner
default already holds the correct value.
The live comment poller inferred completion from the job list. An empty
job list looks the same as a finished run: GitHub has not spawned the
jobs yet, so nothing is pending, and the poller posted a final
"all good!" comment and exited.
The run status is now the authoritative signal. collect_run_jobs()
returns whether the CI run and every watched sibling run report
status=completed, and the loop exits only when no job is pending AND
all runs are complete. While a run is still queued or in progress with
no visible jobs, the comment shows "waiting for jobs to start" instead
of a final banner.
the markers from the previous commit skip off-host. without a host to
run them on, every marked test is a silent skip. this commit adds the
hosts.
- tests-os.yml runs -m macos_only on macos-latest and -m windows_only
on windows-latest. ci.yml requires both lanes in all-checks-pass.
- a lane fails on pytest exit code 5 (zero tests selected). a renamed
marker cannot produce a green job that ran nothing.
- each lane repeats 'not integration' because a command-line -m
replaces the addopts filter.
- scripts/ci/list_os_marked_tests.py selects which files each lane
imports. -m filters after collection, and collection imports every
module. without this helper, one unrelated ImportError on the
foreign host fails a job whose own tests passed. the helper exits
non-zero when a marker matches no file, and writes bytes with
explicit lf so windows crlf translation cannot corrupt the bash
file list. it has its own tests in tests/ci/.
- the local runner now reports the skipped count and prints a note:
macos_only/windows_only tests were skipped on this host, and this
ci lane runs them. a green local run on linux no longer reads as
coverage of the other hosts.
- the runner default job count is now #cpu, not #cpu*2.
The CI run stayed in progress until its last job ended. Two advisory jobs
set that time: the review-comment poller (40 minutes) and the Docker image
build (45 minutes). Neither job was required to merge.
GitHub refuses `gh run rerun` on a run that is in progress. Thus a reviewer
who added the `ci-reviewed` label had to wait for the two slow jobs, and
label-rerun.yml carried a 2100-second wait loop for this reason. The fast
required jobs were ready long before.
Each slow job now runs in its own workflow:
- docker.yml owns its `pull_request` trigger and does its own change
detection. The new `detect` job runs the same composite action with the
same condition that ci.yml applied, so a tests-only PR still skips the
build. The `workflow_call` trigger is gone.
- ci-review-comment.yml starts on `workflow_run` when CI starts. It reads
the workflow and the scripts from the default branch, which is the trust
boundary that the old job got from its `ref: default_branch` checkout.
The poller reads job results through the API, so it can report on a run
that it does not belong to. `WATCH_WORKFLOWS` names sibling workflows for
the same commit, and `select_watched_runs` keeps the newest run for each
name. Thus the comment still shows the Docker results. The list is
newline-separated, because a workflow name can contain a comma.
The poller always exits 0 now. It reports on the CI run from a different
run, so a failed CI job is not a failure of the poller. The CI run has its
own gate for that.
Also correct a parse error in label-rerun.yml. STATUS came from the already
truncated RUN_ID, so its value was the run id and never "completed". Thus
the wait branch always ran.
ci.yml no longer needs `packages: write`, because the image build has left.