The bundled-Node bootstrap unpacked the nodejs.org tarball and stopped.
Node 26.5.1 bundles npm 11.17.0, one minor below the root package.json's
own `engines.npm` floor of >=12 — and .npmrc sets `engine-strict=true`,
so that is fatal rather than a warning:
npm error code EBADENGINE
npm error notsup Required: {"node":">=26.0.0","npm":">=12.0.0"}
npm error notsup Actual: {"node":"v26.5.1","npm":"11.17.0"}
A brand-new install died at the first `npm ci` with "Desktop workspace
npm install failed". CI never saw it because the workflows run an
explicit `npm i -g npm@12`; the Python update path recovers through
hermes_cli/npm_engine.py, but the installer path had no such rung.
_nb_ensure_bundled_npm_range() now upgrades the managed tree's npm into
range right after the tarball lands, mirroring upgrade_managed_npm():
- temp cwd, so the checkout's own .npmrc (engine-strict,
min-release-age) does not gate the upgrade meant to satisfy it;
- npm_config_min_release_age=0, which also neutralises a user ~/.npmrc;
- explicit --prefix at the managed tree, because
_nb_configure_npm_prefix writes prefix=~/.local into its etc/npmrc
and a bare `npm i -g` would install a second npm elsewhere while the
managed tree stayed stale.
The range is read out of package.json rather than duplicated, so the two
cannot drift, with HERMES_NPM_TARGET_RANGE as an override and a >=12.0.0
fallback for a stripped install tree. An already-in-range npm skips the
network round-trip. Best-effort: a failed upgrade warns with the manual
command and keeps the working Node, since npm_engine.py still covers the
EBADENGINE that follows.
Verified against a real tree provisioned by this bootstrap: node v26.5.1
/ npm 12.0.2, bin/npm and bin/npx still relative-symlinked into the
upgraded lib/node_modules/npm, the ~/.local/bin links resolving to 12.0.2
through the tree, and no stray second npm under ~/.local/lib.
Node 26 defines its own `localStorage` accessor on the global object,
which returns `undefined` unless the process was started with
`--localstorage-file` (hence the "localStorage is not available because
--localstorage-file was not provided" warning now printed by every
worker). In the jsdom environment `globalThis` IS the window, so that
accessor shadows jsdom's Storage and every `localStorage.getItem(...)` in
a test throws "Cannot read properties of undefined".
CI caught this on the Node 26 bump: `check:test:ui` failed with 22
errors across session.test.ts, terminals.test.ts, model-settings and
onboarding stores — all storage-backed. Reproduced locally against
nodejs_26 (12 failures in src/store/session.test.ts alone) before fixing.
vitest.setup.ts now installs a real in-memory Storage on both globalThis
and window when the global resolves to undefined, before any test module
reads it. Guarded on `typeof === 'undefined'` so Node < 26 and any future
runtime that provides a working Storage keep jsdom's own implementation.
Verified under nodejs_26: the full `--project ui` lane is 378 files /
3268 tests green (was 22 failures).
Two breaks from moving node_source to node:26, both proven against the
real image rather than inferred:
1. `COPY .../node_modules/corepack` failed with "not found". Node
unbundled corepack upstream, so node:26 ships only `npm` in
/usr/local/lib/node_modules (verified: `ls` in the pinned image lists
`npm` alone). Nothing in this repo needs it — no package.json declares
a `packageManager` and no build step shells out to yarn or pnpm — so
the COPY and its symlink are removed rather than replaced.
2. Hidden behind that failure: node 26's binary links against
`libatomic.so.1`, which node 22's did not, and bare debian:13.4
doesn't ship it. Without it every `node` invocation in the image dies
with "error while loading shared libraries: libatomic.so.1". Added
`libatomic1` to the existing apt layer, which runs well before the
node COPY so layer ordering and caching are unchanged.
Verified with a minimal probe image (debian:13.4 + the same two COPY
lines): node v26.5.1, npm 11.17.0, npx 11.17.0, uv 0.11.6 all execute.
`_append_node_dir_for_service()` had two bugs, both caught by
`test_system_unit_uses_target_user_home_not_calling_user`:
1. It crashed. `iter_hermes_node_dirs()` defaults to the *calling* user's
Hermes home, so under sudo it stats `/root/.hermes/node/bin` — which
raises `PermissionError` for a non-root caller rather than returning
False. An unreadable candidate dir means "skip this rung", not "kill
the generator", so the probe now swallows OSError.
2. Worse than the crash: had the stat succeeded, a `--system` unit
targeting alice would have baked *root's* managed Node into alice's
PATH. The generator now skips the managed-Node rung on the system
path and re-runs it after `_hermes_home_for_target_user()` resolves,
passing that home explicitly. Entries are prepended so the managed
Node still outranks the remapped shell-PATH entries, matching the
user-unit ordering.
The launchd generator is unaffected — it has no target-user remapping,
so the default home is already correct there.
Existing users who only ever launch Hermes (never re-run an installer)
kept their managed Node 22 tree forever: the heal path only fired for
*broken* trees, and a healthy 22 passes the --version probe. Now
"outdated" heals the same way "broken" does, on both sides of the mirror:
- hermes_constants.py: find_hermes_node_executable() checks
_managed_node_tree_outdated() (managed node major <
_HERMES_NODE_TARGET_MAJOR) and routes through the existing
once-per-process heal_hermes_managed_node(), which redownloads
latest-v26.x. When the heal fails (offline, download error) the
outdated-but-runnable tree is still returned — old Node beats no Node.
- scripts/lib/node-bootstrap.sh: _nb_managed_node_needs_heal() gains the
matching _nb_managed_node_outdated() rung, so heal_managed_node agrees
with the Python side.
This is the same shape as the managed-uv flow: resolve the managed
runtime, notice it can't satisfy the requirement, provision the right one
in place, fall back gracefully.
Tests (tests/test_hermes_constants.py): outdated tree triggers heal and
returns the upgraded binary; failed heal still serves the old tree; an
at-target tree never heals (heal stub raises).
Hermes now pins its toolchain to Node 26 everywhere. Every path that
installs, accepts, heals, or upgrades a Node runtime moves from the old
22-default / `^20.19 || >=22.12` floor to a single rule: Node >=26.
Installers:
- scripts/install.sh — NODE_VERSION=26; node_satisfies_build() collapses
the two-branch Vite floor to `major >= 26`; user-facing messages updated.
- scripts/install.ps1 — $NodeVersion=26; Test-NodeVersionOk likewise;
winget fallback switches OpenJS.NodeJS.LTS -> OpenJS.NodeJS (26 is
Current, not LTS — the LTS manifest would reinstall a too-old Node).
- Dockerfile — node_source stage node:22-bookworm-slim -> node:26 (digest
pinned, amd64 sha256:9e6f...bf73).
- nix/ was already on nodejs_26 (lib.nix, npm-12-0-2.nix); the checks.nix
wrapper check ratchets from `>= 20` to `>= 26`.
Heal/upgrade paths:
- scripts/lib/node-bootstrap.sh — HERMES_NODE_TARGET_MAJOR default 22->26
and HERMES_NODE_MIN_VERSION default 20->26, so heal_managed_node,
_nb_install_bundled_node, and the fnm/proto/nvm/brew rungs all target 26
and stop accepting an on-PATH Node below it. Both remain env-overridable.
- hermes_constants.py — _HERMES_NODE_TARGET_MAJOR fallback 22->26, which
drives the Windows heal path's latest-v26.x download.
Version gates:
- package.json engines.node >=20 -> >=26; apps/desktop engines
`^20.19.0 || >=22.12.0` -> `>=26.0.0`.
- CI setup-node: all five workflows 22 -> 26.
- Docs describing Hermes's own toolchain updated (windows-native, docker,
acp, nix-setup, contributing). Skill docs describing third-party tools'
own requirements are untouched.
Termux still installs via `pkg install nodejs` best-effort (nodejs.org
ships no Android tarballs); that path was never version-gated.
Verified: bash -n on both shell scripts, PowerShell AST parse of
install.ps1, latest-v26.x index resolves (node-v26.5.1), and the install
test suite — 18 tests across the 5 install/runtime test files — passes.
Hermes installs runtimes for itself — `uv` at `$HERMES_HOME/bin/uv`, Node
at `$HERMES_HOME/node` — and neither directory is on an arbitrary
process's PATH. Every `shutil.which("node"/"npm"/"npx"/"uv")` in Hermes's
own code therefore has two failure modes: the managed runtime is invisible,
so the caller reports "not installed" or degrades to a slower tier on a
machine that has exactly what it needed; and when a system copy also
exists, the one Hermes does not own wins.
Routed the Hermes-owned call sites through managed-aware resolvers:
- `agent/lsp/install.py`, `hermes_cli/dep_ensure.py`, `hermes_cli/main.py`
(`_make_tui_argv`), `hermes_cli/tools_config.py` (`_run_post_setup`) now
use `find_node_executable()`.
- `hermes_cli/tools_config.py::_pip_install` and `hermes_cli/setup.py`'s
vercel install use `ensure_uv()` (installing uv is in scope during setup,
and the Windows installer's `uv venv` does not seed pip, so the fallback
tier is "No module named pip"). `tools/lazy_deps.py` uses `resolve_uv()`
— a lookup, not a bootstrap, because it runs mid-turn for an optional
dependency and downloading a runtime as a side effect exceeds what the
caller asked for.
- `hermes_cli/gateway.py`: extracted `_append_node_dir_for_service()`,
shared by the systemd unit and launchd plist generators, which appends
the managed dirs before the PATH-resolved one. A service definition is
written once and survives reboots, so resolving a system Node that
happens to lead the installing shell's PATH bakes the wrong interpreter
in permanently. Managed dirs are profile-scoped, so each profile's unit
still names its own Node; the existing symlink-parent rule (don't
`.resolve()`) is preserved verbatim.
- `tools/environments/local.py`: the terminal tool's subshell PATH gains
the managed dirs, appended alongside the sane entries rather than
prepended — a tool the user deliberately put on their own PATH still
wins, and the managed one only fills a gap. This is also what makes the
bare `which("uv")` in `tools/env_probe.py` correct: that probe reports
the environment the *model* sees, and the model can only run what is on
that subshell's PATH.
`scripts/install.ps1`: the persisted User PATH update becomes
`Set-ManagedNodeFirstOnUserPath`, a move-to-front rather than an
add-if-missing. Installs made by an older install.ps1 already have the
managed dir in User PATH — at the tail, behind a system Node — and an
add-if-missing check sees it present and leaves that ordering in place
forever, so the users the bug hurt would never be repaired. Unrelated
entries keep their relative order (empty segments included; a trailing
`;` is legal and the installer's other PATH code preserves them),
duplicates collapse, and it writes only when the string actually changes.
Tests:
- `tests/test_managed_runtime_resolution.py` — AST guard that fails any
new bare `which()` for a managed runtime, with a short justified
allow-list and a companion test that fails when an allow-list entry goes
stale. Reading source is banned by AGENTS.md and this is the documented
exception: the property is "no call site anywhere spells it this way",
which no runtime seam can observe.
- `scripts/ci/test_install_ps1_path_migration.ps1` — behavioral, not a
source regex: it lifts the real `Set-ManagedNodeFirstOnUserPath` out of
install.ps1's AST and rewrites only the two registry calls into an
in-memory store, so the shipped split/dedupe/prepend/change-detection
logic executes for real. Not in the default lane (Linux runners have no
PowerShell host); runs under `pwsh`. 13/13 assertions pass.
Two paths let a pre-existing system Node win over the Hermes-managed one.
The desktop backend spawn built its managed-Node PATH entry as
`<home>/node/bin` only. That is the POSIX layout install.sh produces;
install.ps1 unpacks portable Node straight into `%LOCALAPPDATA%\hermes\node`
with node.exe at the root and no `bin\`. On Windows the entry therefore
pointed at a directory that does not exist, and the backend fell through to
whatever Node was already on PATH.
main.ts already had the correct platform-ordered list, behind a "keep this
in sync with iter_hermes_node_dirs()" comment on a second copy of the rule.
The two copies had drifted. Export the ordering from backend-env.ts and have
main.ts consume it so there is one source of truth on the Node side (the
Electron main process cannot import hermes_constants.py, so a mirror is
unavoidable — but one mirror, not two).
install.ps1 appended the node dir to the persisted User PATH instead of
prepending it. The session PATH was already prepended correctly, so this only
bit later processes: any shell opened after install, and a standalone
hermes-setup.exe run that inherits User PATH rather than a curated env, both
resolved a system Node ahead of the bundled one.
Not a bug, for the record: update.rs's prepend list omits the same Windows
root, but it inherits PATH from the desktop, which supplies the correct
entries — so it is redundant rather than broken, and no installer rebuild is
needed for this fix.
Tests: managed dirs lead with the platform-native layout while always
offering both shapes, empty without a home, and every managed dir outranks
the inherited PATH on darwin and win32. The three existing tests that pinned
`entries[1]` by index asserted the old single-dir shape and now assert the
relationship instead.
install.ps1 has no behavioral test here: CI has no PowerShell host, and
AGENTS.md bans source-reading tests (the neighbouring
test_install_ps1_node_path_for_npm.py predates that rule).
Live staging (2026-08-01, on a fresh instance where title generation
finally succeeded): the rename lane fired end to end, but the connector
declined the op with "discord egress declined: target not routed to an
onboarded tenant". The trace logs added earlier pinpointed it:
discord auto-thread rename: thread=... lane=relay new_title='...'
relay thread_rename declined ...: target not routed to an onboarded tenant
discord auto-thread rename result: thread=... applied=False
Root cause: the connector's routedEgressGuard resolves the owning tenant
from the outbound metadata's scope_id (guild) or user_id (author). The
adapter builds those via _with_scope(chat_id), reading per-chat caches
keyed by the PARENT channel chat_id learned at inbound. The relay rename
lane called rename_thread WITHOUT parent_chat_id, so chat_id defaulted to
the THREAD id — a key the caches never held — and the op shipped with no
discriminator. resolveTenant returned undefined and egress was declined
before the op ever reached the (now-durable) no-clobber guard.
This was the true terminal blocker: every earlier fix (send-result
feedback, registration/poll ordering, connector-owned guard, durable
Redis store) was correct but sat DOWNSTREAM of this egress-routing
decline, so none of them could take effect.
Fix: the relay lane passes parent_chat_id=source.chat_id (the relay
source's chat_id IS the parent channel; the thread came from send-result
feedback). _with_scope then resolves scope_id/user_id from the
parent-channel caches and the connector routes the op to the tenant.
Scoped to the relay lane only (use_connector_guard); the native lane
renames via the direct Discord API and needs no discriminator.
Tests: adapter-level — a rename passing parent_chat_id carries the cached
scope_id, one keyed on the thread id alone does not (the regression
shape); lane-level — the late-feedback test now asserts parent_chat_id
flows through as the parent channel. Relay suite 150 passed; ruff +
footguns clean.
Connector-compatible with the deployed egress guard; no gateway-gateway
change needed.
The sequential executor's KeyboardInterrupt handlers emitted a cancelled
post-tool-call event for the current tool, called agent.interrupt(), then
re-raised — WITHOUT appending a tool result message for the interrupted call
or any remaining calls in the batch. The assistant tool-call turn was left
with no matching tool results, a message-role alternation violation that
malforms the next provider request (relying on downstream repair passes to
patch it, which don't run on every path).
The cooperative-interrupt block (_interrupt_requested) and the concurrent
executor already emit a result for every call_id; this brings the two hard-
interrupt handlers into line via a shared _append_cancelled_tool_results
helper that appends a cancelled result for the current + remaining calls
before re-raising.
Verified live before/after (0 tool results -> 3 for a 3-call batch
interrupted on the first tool) and with a sabotage-checked regression test.
52 interrupt/executor tests pass.
Salvaged premise from #67065 (@webtecnica, issue #67027), reimplemented:
get_env_value() read os.environ first with no secret-scope check, so a
multiplexed profile turn could serve another profile's credential. Its
siblings get_env_value_prefer_dotenv and gateway.config._getenv were
already scope-aware.
Reimplementation note: the original diff called get_secret() but fell
through to os.environ on a scoped miss — re-opening the exact leak it
targeted (flagged by the sweeper review). This version delegates policy
fully to agent.secret_scope.get_secret (global vars pass through; scope
authoritative under multiplexing; legacy environ behavior when off;
UnscopedSecretError propagates fail-closed), then falls back to .env.
6 regression tests incl. the #67027 repro (envless profile + multiplexed
turn -> None, not the other profile's key); sabotage-verified RED on the
old implementation.
Salvaged from #51604 (@JoaoMarcos44, issue #51603): resolve_anthropic_token()
and run_oauth_setup_token() in agent/anthropic_adapter.py read
ANTHROPIC_TOKEN / CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY via bare
os.getenv(), bypassing agent.secret_scope — a cross-profile over-read in
multiplex mode. Every other provider routes through
runtime_provider._getenv -> get_secret; the adapter now does the same via
a local _getenv wrapper (identical to os.getenv when multiplexing is off,
scope-authoritative + fail-closed when on).
Dropped from the original PR: the cron scheduler hunks (superseded by
fdab380a1a which installs the per-job profile scope) and the unrelated
hermes_logging Windows hunk (scope creep).
Includes the PR's RED->GREEN scope-isolation test file (6 tests).
The salvaged cleanup (#75197) scrubbed every known Hermes key absent from
the profile .env — deleting user-shell-exported credentials
(export OPENAI_API_KEY=...) on every hermes invocation, a documented flow
the author's own failing test_dump_flags_shell_only_key_not_in_dotenv
confirmed. A child process cannot distinguish shell exports from
parent-process leakage, so the scrub now covers ONLY
_PROFILE_MANAGED_ENV_KEYS (ACP routing keys: HERMES_ACP_*,
HERMES_COPILOT_ACP_*, COPILOT_CLI_PATH, COPILOT_ACP_BASE_URL) —
the vector from #75141. Cross-profile credential isolation is owned at
read time by agent.secret_scope.get_secret.
Adds shell-export survival regression + a scope-invariant test that fails
if the scrub set is ever widened toward credential-shaped keys.
Align load_hermes_dotenv() with reload_env() so known Hermes env vars
absent from the active profile .env are removed from os.environ instead
of leaking from a parent process / other profile.
Register ACP-related keys (HERMES_ACP_AUTH_METHOD, HERMES_COPILOT_ACP_*,
COPILOT_CLI_PATH, COPILOT_ACP_BASE_URL) in _EXTRA_ENV_KEYS so they
participate in known-key cleanup.
This is the same isolation gap class as #68367 / #66930, but:
- Not Desktop-only spawn scrub — CLI/gateway restart inheritance
- Not Matrix/messaging auto-enable only — copilot-acp provider/ACP config
- Startup dotenv clear so *any* inheritance path is covered
Example: HERMES_ACP_AUTH_METHOD=cursor_login leaking into a Claude Code
ACP profile caused authenticate -> Internal error -> Discord
'model provider failed after retries'.
The npm 12 requirement (f88ed6c717) strands every system-Node install:
no shipping Node bundles npm >=12, engine-strict makes EBADENGINE fatal,
and the recovery in npm_engine.py refuses to touch a system npm — so
'hermes update' leaves the install in a mixed state (updated code, stale
Node deps, no TUI/web/desktop rebuild) with only a manual-fix hint.
Instead of modifying the user's toolchain (still never done), the
EBADENGINE recovery now provisions Hermes' own managed Node tree under
$HERMES_HOME/node — the same pinned-nodejs.org path install.sh and
install.ps1 use — upgrades THAT npm into the required range, and hands
the caller the managed npm for its single retry.
- hermes_constants.bootstrap_hermes_managed_node(): cross-platform
provisioning (POSIX via node-bootstrap.sh _nb_install_bundled_node,
Windows via the existing portable-zip download); reuses a healthy tree.
- node-bootstrap.sh: HERMES_NODE_SKIP_LINKS=1 skips the ~/.local/bin
node/npm/npx symlinks so the private tree never shadows the user's
own toolchain on PATH.
- maybe_repair_npm_engine() now returns the npm path to retry with
(managed-in-place upgrade or freshly provisioned runtime); both call
sites retry with the returned path and put the managed tree first on
PATH so npm lifecycle scripts resolve the managed node.
- Node-only mismatches on a foreign npm are now also recoverable (the
managed tree ships a supported Node); on a managed npm they still
correctly decline.
E2E (real download, temp HERMES_HOME): provisioned node v22.23.2,
upgraded bundled npm 10.9.4 -> 12.0.2, system npm byte-identical after,
no ~/.local/bin links re-pointed, healthy-tree reuse in 0.05s.
Three lower-severity core-tool robustness fixes from a targeted audit, each
reproduced live:
1. terminal_tool did not validate non-positive timeouts. 'timeout or default'
silently coerced 0 to the config default (0 can't mean 'no timeout'), and a
negative value is truthy so it flowed into 'deadline = now + timeout' and
fired an immediate '-Ns' timeout. Reject timeout <= 0 with a clear message.
2. fuzzy_find_and_replace accepted a whitespace-only old_string, which matches
trivially (blank line / run of spaces) and mass-replaces under replace_all
or raises an opaque ambiguity error. Reject it alongside the empty check.
3. The '/private/var/' sensitive-path prefix over-blocked ALL macOS temp-file
writes: , /tmp, and /var/folders realpath into /private/var/folders
on macOS (and paths are resolved through symlinks), and /private/var/tmp is
a normal temp dir. Narrowed to the genuinely-sensitive subtrees
(/private/var/db, /private/var/root); /etc and /private/etc stay blocked.
All verified with sabotage-checked regression tests. 85 terminal/fuzzy/file
tests pass; normal timeouts, legit replacements, and /var + /boot + /etc
blocking are unaffected.
Two V4A parse/validate bugs found in a core-tools audit, reproduced live:
1. CRLF patch body injected stray carriage returns. parse_v4a_patch split
on '\n' only, so a CRLF-encoded patch kept '\r' inside every HunkLine
and wrote mixed line endings into an LF file; the anchored Begin/End
markers could also fail to match because of the trailing '\r'. Strip a
trailing '\r' from each line at split time.
2. Move-then-Update of the same file was rejected. _validate_operations read
the UPDATE target from disk before the MOVE ran, so 'Move a->b' + 'Update
b' failed validation with 'b: file not found'. Added a small pending-move
overlay so UPDATE/DELETE/MOVE reads during validation see prior ops'
effects (moved-in destinations resolve, moved-away sources read as gone),
while a genuine 'destination already exists' conflict is still caught.
Both verified with sabotage-checked regression tests. 113 patch/fuzzy/file
tests pass.
Strategy 9 (context_aware, the last-resort fuzzy strategy used by
patch_replace, V4A UPDATE hunks, and skill_manage) had two serious flaws,
both reproduced live against current main:
1. CORRECTNESS: it accepted a block when >=50% of its lines were >=0.80
similar. A 2-line pattern with one real line and one garbage line matched,
silently deleting the non-matching line and persisting a wrong edit as
success. Now requires the first AND last lines to anchor-match and EVERY
non-blank pattern line to be >=0.80 similar — one garbage line disqualifies
the block.
2. PERFORMANCE: it scored every content window with per-line SequenceMatcher,
so every failed match paid O(file_lines x pattern_lines) — measured ~5.5s
for a single 40-line no-match on a 10k-line file, per hunk. The first/last
line anchor pre-filter skips non-candidate windows: same case now ~160ms
(34x faster).
Also gate replace_all: a similarity-based strategy (block_anchor,
context_aware) with multiple matches under replace_all would overwrite every
approximate block, not just exact ones. Now refused with a clear error
directing the caller to precise text.
All verified with sabotage-checked regression tests (fail against the old
50% logic). 158 file/patch/fuzzy tests pass; legit fuzzy edits (indent drift,
unique near-match) unaffected.
Two DATA-LOSS bugs in ShellFileOperations found in a core-tools audit,
each reproduced live against current main:
1. Non-UTF-8 file content silently corrupted on read->write. The terminal
env decodes stdout with errors='replace', so a latin-1/8859 file's bytes
arrive as U+FFFD before _is_likely_binary inspects them. U+FFFD is
'printable', so the >30%-non-printable check never flagged it, and the
agent would read the mojibake and write it back, permanently replacing the
original bytes. Fix: treat a sample containing U+FFFD as binary (read-only).
2. Writing through a symlink destroyed the link and orphaned the target. The
atomic temp-file + 'mv -f' swap replaced the symlink itself with a plain
file; the real target was never updated. Fix: resolve the link with
readlink -f/realpath first and recompute the temp dir from the resolved
target so the mv stays same-filesystem atomic. Broken links fall back to
the original path (no regression).
Both verified with sabotage-checked regression tests (fail without the fix).
Proper UTF-8 text (incl. non-ASCII) and plain-file writes are unaffected.
Two independent HIGH-severity correctness bugs found in a core-tools audit,
each reproduced live against current main:
1. Read-dedup was never evicted after a write on non-default tasks.
_invalidate_dedup_for_path looked up the read-tracker under the correct
task_id but resolved the path with _resolve_path(filepath) — which
DEFAULTS task_id='default'. The dedup cache is keyed by the task-resolved
absolute path, so for any task whose workspace cwd differs from the process
cwd (every -w worktree / Desktop / ACP session using relative paths) the
computed key never matched and the stale entry was never removed. A
read_file after a write_file/patch could then return the OLD content stub
when mtime coincided. Fix: pass task_id through.
2. A per-command workdir override permanently hijacked the session cwd.
The post-command dual-write unconditionally recorded env.cwd (stamped to
the transient workdir) into the durable session-cwd store, so every later
command that omitted workdir inherited the one-off directory — contradicting
the documented 'Working directory for this command' contract. Fix: skip the
session-cwd record when workdir was explicitly supplied.
Both verified with sabotage-checked regression tests (fail without the fix).
Users following abbreviated links guess /docs/quickstart and
/docs/installation and hit raw GitHub-Pages 404s — the real pages live
under /docs/getting-started/. Add client redirects for both.
Consumer-onboarding audit finding #1, Aug 2026.
Cancelling the API-key prompt mid-wizard (Enter → 'Cancelled.') let the
wizard continue through Terminal/Gateway/Tools and finish 'successfully'
with no model configured — the user exits believing they're set up, then
hits a broken chat.
_print_setup_summary() (called by every setup path: full, quick,
blank-slate, portal) now probes resolve_provider() and, when nothing is
configured, prints an unmissable warning with the two one-line fixes
(hermes model / hermes setup --portal).
Consumer-onboarding audit finding #7 (sev 4), Aug 2026.
Teknium review on #62947: os.environ.clear()/update around deferred
loaders is unsafe under concurrency and misses teams_pipeline's direct
adapter import.
Defer microsoft_teams binding in the Teams adapter, no-op
dotenv.load_dotenv while the SDK imports, keep api_server explicit
disable, and add SDK-import + load_gateway_config canaries.
Fixes#62935
microsoft-teams-apps calls load_dotenv(find_dotenv(usecwd=True)) at import time, which can pull a root-profile .env into every gateway process during plugin_entries() discovery and break profile secret isolation.
Snapshot/restore os.environ around deferred loaders, and honor explicit api_server enabled:false the same way _enable_from_env does for other platforms.
Fixes#62935
A completely unconfigured install previously booted into a working-looking
chat (banner showed model 'unknown'), accepted a message, spun ~30s, then
failed with 'Set OPENROUTER_API_KEY' — a provider the user never chose —
and never offered setup.
- HermesCLI.run() now probes provider readiness at startup (TTY only) and
offers the shared provider picker (hermes model flow, which fronts Quick
Setup / Nous Portal OAuth) when nothing is configured. Decline is
respected; picker state re-syncs into the live CLI so the next turn works
without a restart.
- New silent probe _runtime_credentials_ready(): no printing, no state
mutation; handles keyless local endpoints and callable bearer providers.
- The empty-api-key error is provider-aware: names the actual resolved
provider and points at 'hermes model' / 'hermes setup' instead of
hardcoding OPENROUTER_API_KEY.
- Banner: unconfigured installs render 'no model configured — run /model'
in red instead of the silent 'unknown' model slug.
Consumer-onboarding audit finding #2 (sev 5), Aug 2026.
After the test-suite prune, the Python slices (~2.3m each) are no longer
CI's critical path — Desktop E2E (5.2m, the longest job) and the Docker
build are, and both run on every python-lane PR even when the diff never
leaves tests/. Neither consumes the test suite: Playwright drives the
built app + hermes serve backend, and the image copies installed code.
New python_prod lane = python minus tests-only diffs. e2e-desktop and
docker gate on it; every pytest/lint lane keeps gating on python.
Fail-open contract preserved: .github/ changes and empty diffs set
python_prod=true, and runner infrastructure (scripts/run_tests.sh,
run_tests_parallel.py) is deliberately NOT tests-only since a bad
runner edit can mask real failures.
Replay over the last 231 main commits: 39 (17%) would skip both jobs,
cutting their critical path from ~8m to ~3m. E2E-verified through the
real script entrypoint (tests-only/prod/mixed/fail-open) + 83 tests/ci
green.
The ACP prompt path called set_session_vars(session_key=session_id, ...)
without passing session_id, so the HERMES_SESSION_ID ContextVar was bound
to its explicit "" default. Once the session-context machinery is engaged,
_inject_session_context_env treats an explicitly-bound "" as authoritative
and writes it to the child env — so subprocesses spawned during an ACP turn
got an empty HERMES_SESSION_ID instead of the session's own id.
Pass session_id through so child subprocesses carry the correct id.
Salvage of the ACP half of #53454 by @necoweb3 (the V4A-path half is
salvaged separately in the file-tools PR).
Co-authored-by: necoweb3 <sswdarius@gmail.com>
_normalize_managed_eol isolated line-ending churn from real edits by
diffing twice: all dirty files minus files still dirty under
--ignore-cr-at-eol. But 'git diff --name-only --ignore-cr-at-eol'
computes its file list from blob/stat differences BEFORE the CR filter
is applied, so it still lists CR-only files. On git 2.48.1 the two
name-only sets are therefore identical, _eol_only() is always empty, and
a managed Windows checkout gets pinned to core.autocrlf=false with the
whole CRLF tree left dirty — breaking the next 'git checkout' on update
(the exact failure this function exists to prevent).
Compute the real-edit set with 'git diff --numstat --ignore-cr-at-eol'
instead: numstat honors the CR filter (a CR-only file produces no
record), so eol-only files are correctly identified and cleared while
genuine edits are preserved. Pin core.quotepath=false so non-ASCII paths
parse. Verified at 1200 files: 1199 eol-only normalized, one real edit
preserved, autocrlf pinned only after the tree reads clean.
This was a pre-existing failure on main (test_update_eol_churn's
test_churn_across_more_files_than_fit_in_one_argv failed deterministically
on git 2.48.1), surfaced while landing unrelated file-tools PRs.
The remaining /model picker stall after the Copilot backoff fix: whenever
the 1h provider-models disk cache TTL (or the remote model-catalog manifest
TTL) lapsed mid-session, the next picker open blocked on 8-9 serial
/v1/models round-trips (~2-3s measured) plus the catalog manifest fetch
before rendering anything.
Model catalogs change on release timescales, not hourly — so both caches
now use stale-while-revalidate:
- cached_provider_model_ids(): an expired entry whose credential
fingerprint still matches is served immediately; a deduped daemon thread
re-fetches the live catalog and rewrites the disk cache for the next
open. Entries older than 7 days still block on a live fetch, credential
rotation still busts the entry, and force_refresh still bypasses SWR.
- model_catalog.get_catalog(): an expired disk manifest is served
immediately with an off-thread refresh; only a truly cold cache (no disk
copy) blocks on the network.
Measured picker payload build with deliberately-expired caches:
2.9s -> 0.93s (first open in process) / 0.06s (subsequent opens).
Combined with the Copilot fix (#76386): 7.3s -> ~0.06s for the common case.
patch_tool resolved V4A header paths against the task workspace for
locking, staleness, and reporting, but handed the original (often
relative) patch text to file_ops.patch_v4a — which re-resolved headers
against the backend env's own cwd. When the two diverge (the git-worktree
cwd bug), a relative header landed in a different directory than
everything the tool locked and reported: a silent wrong-file write.
Rewrite Update/Add/Delete/Move File headers to the resolved absolute
paths before apply, only for host-filesystem backends (container/remote
namespaces keep their own paths). Header patterns mirror patch_parser
(no-space ***Update File: form) and cover Move File: src -> dst.
Salvage of #53176 by @necoweb3, reimplemented onto current main (the
original branch predates the sensitive-path/Move-header extraction and
per-path locking now in patch_tool).
Co-authored-by: necoweb3 <sswdarius@gmail.com>
The boundary scan in parse_v4a_patch used substring matching, so a
content line mentioning "*** End Patch" (docs about the patch format,
nested patch text) truncated the patch, and "*** Begin Patch" in
content reset the start boundary — silently dropping already-parsed
operations while reporting success. Match only whole-line markers at
column 0, preserving the no-space "***Begin Patch" tolerance.
The Desktop client writes the SSH session token under $HOME/.hermes/desktop-ssh
(a literal ~/.hermes/desktop-ssh in apps/desktop/electron/remote-lifecycle.ts,
expanded against the account's $HOME), independent of HERMES_HOME and the active
profile. But _read_ssh_session_token_file validated it against
get_hermes_home()/desktop-ssh, which a non-default sticky profile re-homes to
<root>/profiles/<name>/desktop-ssh (and any custom HERMES_HOME points elsewhere).
relative_to() then rejects every token as "not under the desktop-ssh directory",
so SSH remote mode is broken under any non-default profile.
Anchor to Path.home()/.hermes/desktop-ssh so the validator matches the exact
directory the client writes to, across default, profile, and Docker layouts.
Adds profile / custom-root acceptance tests and a profile-local rejection test.
Fixes#69551.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The desktop lint rule added after the original PR bans mirroring reactive
values into refs via useEffect. Rework the setting to seed from draft-null
state, guard profile switches by stale-config identity, and derive the
save base + rollback value from the shared config record instead of
latestConfigRef/lastSavedRef/seededRef.
Adds a new config option terminal.font_family that lets users customize the
CSS font-family for the desktop app's embedded xterm.js terminal.
Previously the font was hardcoded in use-terminal-session.ts:
'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace
Now the value from config.yaml (terminal.font_family) is threaded through:
useHermesConfig → PersistentTerminal → TerminalTab → useTerminalSession
When font_family is empty or unset (default), the built-in fallback is used,
preserving backward compatibility. Users with Nerd Fonts installed (e.g.
CaskaydiaCoveNerdFont) can now set:
terminal:
font_family: 'CaskaydiaCoveNerdFont', 'JetBrains Mono', monospace
Closes: #terminal-font-config
The no-args /model picker calls list_authenticated_providers(), which walks
every provider through load_pool(). For copilot, _seed_from_singletons()
re-runs the raw-token -> API-token exchange on every pass. When the exchange
is rejected (HTTP 403: token not Copilot-entitled, revoked, org-blocked),
the transient-network retry loop slept ~4.5s (1.5s + 3.0s backoff) before
degrading to the raw token — and nothing cached the failure, so EVERY picker
open, provider discovery pass, delegation spawn, and dashboard credential
listing paid the full 4.5s again.
Measured on a machine with a 403-rejected gh token: /model picker payload
build went from 7.3s to 1.0s cold and 0.06s warm.
Fixes:
- Permanent HTTP rejections (401/403/404) skip the retry backoff entirely —
the loop exists for startup network races, not auth rejections.
- Negative cache keyed on token fingerprint: failed exchanges are not
re-attempted for 30min (auth rejection) / 60s (transient network error).
- Success and evict_cached_exchanged_token() both clear the negative-cache
entry, so the runtime stale-credential recovery path still forces a fresh
exchange.