Vite inlines every VITE_* variable into the renderer and NODE_ENV selects its mode, but the
desktop receipt recorded only sources and prepared inputs. After `VITE_PERF_PROBE=1 npm run
build`, the next plain build reused (and productCurrent certified) the renderer that still
carries the perf probe; a NODE_ENV change had the same gap.
The desktop build inputs now record the renderer environment (VITE_* plus NODE_ENV, unset
read as vite's production default), so both rendererCurrent and productCurrent require it to
match. Reported by Greptile on #135092.
Skipping stage-native-deps on an admitted receipt kept a tree staged without a piece forever:
get-windows (or its win32 x64 binding, which only the native installer inside staging can fetch),
the macOS get-windows helper, or the host HUD helper (no X11 toolchain on Linux). The receipt
identity cannot see the host fix that would complete it.
The receipt now records `degraded` from the staged tree itself (nativeTreeComplete: every piece
this host can produce for the target is present), and build.mjs reuses only a complete one
(nativeInputsComplete). Gaps no host fix closes — win32-arm64 has no get-windows prebuild, a
cross-target pack never builds the helpers — do not count. Judging the tree rather than each
builder's return value covers soft-fail paths that still return a path (the win32 binding).
* fix(install-e2e): tolerate the update-result consume race and held lock files
Two HEAD -> NEXT legs of the stable-release matrix went red for harness reasons,
not product ones.
macOS (app-driven update): source-update-observer listed
.hermes-update-result.json and then read it. The relaunched app consumes
(unlinks) that file on boot, so the read could land after the unlink and throw
ENOENT, which launch-from-spec reported as a failed update although the update
receipt said success. A vanished file is now "not there", the same as before it
was listed.
Windows (user-state verify): verify-user-state hashed every judged file. The
gateway's cron ticker holds an msvcrt byte-range lock on cron/.tick.lock, so
open() raised PermissionError and the upgrade was reported as changing user
state. Proven on a real Windows host: base raises Errno 13, the fix records the
lock as a sentinel. A lock file is held by a live process and is never user
content, so it is recorded without being read. Any other unreadable entry now
surfaces as a ScanError that names the path instead of a bare errno.
* test(install-e2e): an unreadable user file still fails the scan with its path
Skipping lock files must not let a user file the verifier cannot read pass
silently. The test makes a real mode-000 file under memories/ and asserts
snapshot_home raises ScanError naming the path, with the PermissionError kept
as the cause. It goes red when the OSError -> ScanError wrapper is removed.
The smoke driver created .desktop-smoke-home inside the predicted Hermes
home before checking that the home was empty. Bundles with stamped env
that retained the explicit HERMES_HOME failed before Electron launched.
Resolve and admit the predicted home before any sandbox writes. Keep the
existing-profile refusal and provider seeding intact. Extend the seeding
contract for onboarding defaults and forced home clears.
Verified the old failure and fixed launch boundary on native Windows ARM64
with real driver imports and filesystem writes. Full package install/chat
acceptance still needs a fresh hosted smoke run.
processStartTime read `ps -o lstart` in local time and rebuilt it with a
hand-written month table. In the hour that repeats when DST ends, local
01:30 names two instants and the reconstruction picked the first, putting a
backend started in the second 3600 s off its ledger create_time, so a valid
host backend was rejected. ps now renders lstart with TZ=UTC0 and the value
is parsed as GMT with Date.parse (V8 accepts the exact `ps` format,
including the double-spaced day), which also drops the month table.
appVouchesForBackend tested `ownership !== 'host-ledger'`, so a backend
object that never had its ownership established (the field was optional;
untyped .mjs callers can build one) received the ancestry waiver. The
waiver's premise is ancestry, so it must be stated: localBackendProcess now
returns a BackendProcess whose `ownership` is required, and the waiver
requires `=== 'child'`.
Moves the app-ancestry evidence predicate (process evidence unreadable,
not a ledger-attached host backend, app reported the same tree) and its
comment into a named helper. Same expression, same short-circuit order,
evaluated at the same point; behaviour unchanged. Brings
assertBackendOrigin's CC back under its main cap (24 -> 19).
A host-ledger backend is not an Electron descendant, so the app-reported
root no longer stands in for its tree when process evidence is unreadable
(Windows). Ledger records must match the live process's start time
(create_time, 2 s like the product), and the ledger is read at the machine
root the app resolves for the home, where the product writes it.
The reuse receipt check in node-deps.mjs accepted a tree where every locked
directory existed but package contents had been stripped (partial cleanup or
snapshot restore). npm trusts the hidden lockfile and never repairs such
entries, so every 'hermes update' skipped npm ci, the TUI build died on the
missing esbuild manifest, and the stale receipt kept the loop closed (#128935).
Probe each locked entry's package.json — the same file product builders
resolve tools through — so a gutted tree falls back to a real npm ci.
publishDirectory moved a finished product into place with one renameSync.
On Windows an antivirus scanner opens every freshly written file, so the
rename onto build/native-deps (right after hud-modifier-monitor.exe is
compiled) or hermes_cli/web_dist failed EPERM and threw away a build that
took minutes.
retryHeld retries EPERM/EACCES/EBUSY for about 3s and raises every other
code at once. All three publication renames use it, as does the HUD
helper's staging rename. The previous product's backup now sits beside
the output instead of inside withProduct's scratch tree, so a rollback
that also fails no longer has its only copy deleted by scratch cleanup;
the error names where it is.
Co-authored-by: dskwe <6286305+dskwe@users.noreply.github.com>
Co-authored-by: juliorobertoalvarez <39749815+juliorobertoalvarez@users.noreply.github.com>
Co-authored-by: bighamx <10262931+bighamx@users.noreply.github.com>
Co-authored-by: lzmuhioin <1814826+lzmuhioin@users.noreply.github.com>
Four sequential node --check children (plus a spawnSync that must fail
finding a bogus binary) exceed vitest's 5s default on a cold run; the test
needs a 120s ceiling so it measures behaviour, not machine speed.
Opening the checkout in Finder mid-build lets .DS_Store, AppleDouble ._*
sidecars or .localized land inside a hashed input dir (apps/desktop, apps/shared,
assets, scripts/build). treeHash then sees a new input and recordProduct throws
"Build inputs changed during compilation; retry the build", so `hermes update`
rebuilds the desktop app, fails closed, and never prints Update complete.
Finder metadata is never a build input, so skip it as a baseline in treeHash —
covering source, output and prepared trees uniformly rather than only the source
predicate. A real source change is still detected and still names nothing new to
hash, so clean checkouts (CI) hash byte-identically.
The mid-compile guard test drives recordProduct directly: a clock-only restamp
must not throw, while a commit, payload or tag racing in must still fail it.
The pre-build gate tests cover the two ways a bundle could be left inconsistent:
a restamp after the build makes productCurrent report not current, and the
receipt records the clock the output baked rather than the live one, so a
restamp landing between the bake and the record is caught too. A receipt written
before the clock was recorded is documented as self-healing, and a missing or
unparsable stamp still hashes to a stable distinct value instead of throwing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first test reproduces the issue's own shape: a second build restamps the
stamp mid-compile, and with the whole-file hash the build dies on "inputs
changed". A commit swapped in mid-compile must still throw, so the passing case
cannot be an artefact of a guard that stopped guarding.
The second drives buildDesktop with only `builtAt` moved: the receipt stays
current and the rebuild succeeds, while commit/payload/tag still invalidate. The
third covers the fallback: a missing and an unparsable stamp hash to distinct,
stable values instead of throwing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The freshness regression wrote assets/.localized into a hashed web input
dir the fixture never created, so the assertion exercised a
missing->directory transition of the assets input rather than the
junk-in-an-existing-dir case it meant to cover: treeHash goes from
'assets\0missing\0' to 'assets\0', the hash flips, and productCurrent
returns false. Seed assets/logo.svg before buildWeb so assets is an
established hashed dir at baseline; the later .localized is then skipped
by macMetadata and the hash stays byte-identical, keeping assets coverage
in the junk-metadata check.
Reported by @kokhlo.
* refactor(desktop): split clarify-tool.tsx into a clarify/ folder
Pure moves, no behaviour change. Parsing, the question-card core
(shell, choice rows, question block), the delivery watchdog, and each
pending/settled card get their own file so the core can be reused.
* feat(clarify): one questions[] shape with per-question status and one outcome
The clarify tool now takes only questions=[{question, choices?, multi_select?}].
A wrong shape is a tool error that names the right one, so a model that
sends the old top-level question/choices corrects itself on the next call.
Every result has the same shape on every surface: each response carries
status (answered, skipped, unanswered) with user_response null unless
answered, and the result carries one outcome (submitted, cancelled,
timed_out, undelivered) plus an optional surface-written notice. The
timeout and cancel sentences that used to pose as the user's answer are
gone, and the compressor reads status instead of matching their prefixes.
The callback contract is callback(questions) -> {answers, outcome, notice?}
(None = skipped, missing qid = unanswered). tui_gateway settles a batch the
same way for the last lock, a cancel, an interrupt and the deadline, and
clarify.lock keeps a null answer as a skip. A multi-select answer that is
not a JSON array counts as one typed ("Other") answer. The tool-row preview
reads the first question.
* feat(clarify): every surface asks through the one question card
CLI: the batch panel is the only panel; Enter on an empty field skips a
question, Ctrl+C cancels and keeps the locked answers, the deadline returns
timed_out. -q and -z return undelivered with a no-user notice.
Ink TUI: the single-question prompt is gone; the card supports multi-select
(Space or a digit toggles, Enter locks, typed Other text joins the picks),
an empty submit skips, Esc cancels, and "Batch" names are dropped.
Desktop: the single-question card is deleted and its keyboard handling moved
into the one card (arrows, letters and digits, auto-advance, Enter picks then
confirms). Confirm enables at one answer; blank questions lock null; Skip and
a composer message cancel; the settled card shows No answer for unanswered
questions. The bots room card follows the same contract.
Messaging: one card per question with a "Reply skip to skip" line in every
locale; timeouts and delivery failures map to timed_out and undelivered.
* fix(clarify): cancelled for a released messaging card, labels win over the skip word
A prose reply to a messaging choice card, /new and session end released
the wait with "", which read as timed_out. They now resolve with a
CANCELLED marker and the tool gets outcome cancelled.
A typed reply that matches a choice label ("Skip") now resolves to that
choice; the skip word applies only when no choice matches.
The bots room card sends the picked labels as they are; the tool already
strips the recommendation label. Unused OUTCOMES and a new docstring and
comment are removed.
* fix(tui): re-editing a multi-select answer keeps its picks
The Ink card stored a multi-select answer as display text ("A, B"), so
revisiting the question put the whole string into Other and re-locked it
as one item. The answers map now keeps the raw JSON array, the card
restores the picks on revisit, and only the display lines format it.
Comments that earlier edits reworded are restored to their original
words, minus the phrases the change made wrong. The compute-host clarify
lock accepts None for a skipped question.
* test(clarify): align three checks with the one-answer confirm and the regenerated keys
The desktop Cmd+Enter test now expects a send with one answer (the blank
question locks null), the compressor test expects the single-answer summary
the code gives, and locales/_keys.desktop.json is regenerated for the
removed and added clarify strings.
* test(tui): the one-question card header is singular
The --icons case found the compile command with endsWith('/scripts/build/desktop.mjs'),
which never matches the path.join-built argument on Windows, so the case failed with
TypeError instead of asserting. Match both separator styles.
Fixes https://github.com/NousResearch/hermes-agent/issues/125139
The bundler rewrite predates #113247, and merging it back kept main.ts as
the esbuild entry. entry.ts, which picks the ozone platform and relaunches
before main loads, was no longer bundled: the WSLg Wayland relaunch never
ran. Bundle entry.ts again and run the built electron-main.mjs in a test
to prove the relaunch ships.
A red scheduled run blocked nothing and told nobody. install-e2e-red.yml
runs after every scheduled "Install & Update E2E" run: red opens one issue
labelled install-e2e-red (or rewrites the open one's body in place) with the
red legs grouped by failure class and linked; the first green run closes
it. No per-run comment, never a second issue.
It is its own workflow_run workflow because install-e2e.yml is also called
by stable-release.yml with read-only permissions, and a nested job asking
for issues: write would fail that call at startup. workflow_dispatch with a
dry-run default previews the change for any run id.
install-e2e.yml only ran on the clock, so nothing in front of a merge
installed a release and updated it on a real OS. A pull_request trigger,
path-filtered to the install/update surface (derived from 60 days of
update/install/pm commits), runs the new `pr` route of
generate-e2e-matrix.mjs with only the newest release tag sampled:
linux installer-script -> hermes-update (newest release -> PR)
linux installer-script -> hermes-update (PR -> NEXT)
windows installer-script -> hermes-update (PR -> NEXT)
macos installer-script -> hermes-update (newest release -> PR)
The bundle-manifest validation job is skipped on PRs (bundled legs need
dispatch-only manifests). The full matrix stays on schedule and release.
The receipt key hashes the npm version. Cover the transition from the
child-probe lane to the manifest lane: an install completed under the
probed version must still be recognized as complete once the version is
read from npm's package.json (same string for standard npm), and that
reuse must hold when `--version` can no longer spawn — the #123933 lane
on a Windows Job Object — without running npm ci again. A changed npm
version still invalidates the receipt. Runs without symlinks so Windows
CI exercises it too.
Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
npm_execpath can point through a symlink, and the manifest sits beside
the resolved CLI, never beside the link: resolve the realpath before
looking for package.json. Layouts without a readable manifest now fall
back to the pre-fix child probe instead of aborting with ENOENT.
Move the regression test to tests-js/node-deps.test.mjs (the module's
own suite) and cover the symlinked-execpath and fallback lanes there.
Since the questions[]-only schema (#95907) every single question is a
one-entry batch on both the tool args and the gateway wire, yet no test
exercised that shape (called out in #98645). Lock the behavior down at
both layers:
- unit: a one-entry batch renders the batch card (not a blank/spinner
single card) both with the wire already parked and when the request
lands after the tool row, and answers with the qid-keyed lock
- e2e: a SINGLE_BATCH trigger drives the real chain (composer -> gateway
-> agent -> clarify tool -> clarify.request -> renderer) through mount,
pick, confirm, and settle for questions.length === 1
The e2e mock's trigger routing also learns to scope its has-tool-result
guard to the answering turn's own question text: the existing any-tool-
result check would false-positive once a second scripted clarify shares
the conversation history.
Adapted to main: the mock server now lives in tests-js/scripts, and a batch
confirm answers with clarify.lock.
User installs failed with 'resvg-py is missing' because the web/desktop
source builds rendered icons on whatever python was on PATH. The default
brand outputs are now committed; source_build, apps/desktop build.mjs and
the npm/docusaurus pre-hooks consume them directly. Flavored release
bundles (canary/commit) still render into their own product dir.
icons-freshness-check now regenerates and fails on any byte diff.
setup-pm-cache.test.mjs compared `if:` expressions as literal strings and
indexed `build-${platform}-${arch}-{release,commit}` by id. So the per-arch
split, or rewording a gate, broke it while the cache contract still held.
- A small evaluator for the Actions expression subset. Gates are asserted by
how they behave:
- The exact-key wheel save runs only for callers with extras or a test
environment, and never together with the rolling restore.
- The prune runs after a failure but not on cancel.
- The upload needs a successful prune.
- Desktop build legs are the jobs that use the desktop-build-cache action,
keyed by matrix target and cache-mode. A new check requires a write leg and
a read leg for every native target. The write leg runs only for tag builds;
the read leg runs for commit and channel builds.
- Candidate saves are evaluated: they run after a successful prepare of a
trusted dispatch (a tag build, or a main push for the payload). They do not
run on a failed prepare, a cancel, commit or channel builds, pull requests,
other branches, or a foreign ref.
- Selection gates are found by SELECTED_BUILD_SUCCEEDED. Each needs validate
plus one write and one read leg of a single target, and refuses a failed
admission. The selection logic and the "only gates consume legs" invariant
are covered in tests/ci/test_desktop_bundle_smoke.py.
The current source checker reports updateAvailable with behind null when GitHub
compare cannot count staged commits; the app-update predicate demanded an integer
behind and refused every HEAD->NEXT leg. Require behind > 0 only for the historical
shape without updateAvailable.
v2026.6.19's DMG bootstrap runs the same install.sh that writes .install_method,
so its macOS leg saw a dirty tree. Share the Linux driver's guarded exclude through
source-driver.sh and apply it before every app-driven macOS update.
Packaged Electron rejects NODE_OPTIONS --require, so the preload never reached the Desktop source check. Wrap only the E2E installation launcher and route its source-check call to the real staged Git main with an explicit branch; other launcher calls remain unchanged. Keep production channel resolution fail-closed.
The HEAD -> NEXT Windows legs take every git off PATH so the product must
provision its own; readInstallationCommit spawned bare `git` and died with
spawnSync git ENOENT before the chat checkpoint could judge the product.
route already means "which legs run"; a leg name is the most specific
route. Presets keep their meaning (all, the linux trio, windows-desktop,
macos-desktop, the bundled three); any other value selects legs by name,
so a leg name, a fragment of one, or the job name GitHub shows
("<leg> / e2e") runs just those legs. A route that selects nothing fails
instead of producing a green empty run.
The generator is now the one interpreter of route for source legs: the
linux/windows/macos jobs run when it selected legs for them, replacing
three hand-kept preset lists. Dispatch route becomes a string input (a
choice cannot take a leg name) and reaches the gen step through env, never
interpolated into the script.