Commit Graph

14240 Commits

Author SHA1 Message Date
kshitijk4poor cd6585abf8 refactor(process-registry): fold kill_started_since into kill_all via exclude_ids
kill_started_since duplicated kill_all's collect-under-lock/kill-outside-lock
loop line for line; it is now a thin delegate through new kill_all kwargs
(exclude_ids, source, consume_output). Public signatures unchanged — existing
callers and test monkeypatch seams keep working. kill_process's docstring now
names the deliberate consume_output=True exception for abandoned-turn reaping
so the deviation isn't 'fixed' later.
2026-08-02 14:23:55 +05:30
kshitijk4poor 8f0f55eaac fix(api-server): epoch-gate abandoned-run reaps and cover the /v1/runs sibling
The API server intentionally lets concurrent runs share a client-provided
session_id (= process task_id), so the SSE-disconnect reap could kill a
process a still-live concurrent run spawned after the disconnecting run's
baseline — the same stale-reaper bug class the gateway path gates via
run_generation.

- Per-task-id run epochs (monotonic counter): each run claims the epoch at
  publish; a reaper holding a superseded epoch declines to kill. A missing
  entry (the run's own clear pruned it) still reaps, so the leak fix isn't
  silently disabled.
- _publish_turn_process_ownership / _clear_turn_process_ownership helpers
  replace the copy-pasted marker set/clear blocks, so attribute names and
  epoch bookkeeping can't drift between surfaces.
- /v1/runs — the third own-lifecycle surface — now records ownership and
  reaps on POST /v1/runs/{id}/stop and on server-side SSE cancellation,
  closing the remaining sibling paths of #76115.
2026-08-02 14:23:55 +05:30
kshitijk4poor eb4772ec2f fix(gateway): guard empty task_id reaps and prefer the finished worker's result
Two follow-ups from review of the salvaged fix:

- _reap_gateway_turn_processes now returns 0 for a blank task_id.
  ProcessSession.task_id defaults to empty for sessionless callers, so a
  blank turn id would match (and kill) every unrelated empty-task process
  instead of the turn's own.

- The asyncio poll loop checks executor completion BEFORE the watchdog's
  timeout flag. When both race in the same window, the completed run has
  already persisted its real reply to session history; surfacing the
  'agent inactive' diagnostic would contradict the stored transcript.
  This matches _abandon_timed_out_gateway_turn's own worker-done-wins
  tiebreak.
2026-08-02 14:23:55 +05:30
joaomarcos 1b886822de test(gateway): cover the run_generation guard and API-server disconnect reap
dbbb10d39 shipped without direct test coverage for its own new logic
— the same gap teknium's review flagged on the competing PR. Close it:

- _reap_gateway_turn_processes: skips when is_still_current() is
  False, proceeds when True, fails open (reaps) if the check itself
  raises rather than silently disabling the leak fix.
- _abandon_timed_out_gateway_turn: still marks the turn abandoned
  (interrupt fires) even when the reap itself is skipped.
- api_server._reap_disconnected_agent_processes: reaps the
  baseline-diff for an owned turn, no-ops when the agent never
  recorded ownership markers.
- APIServerAdapter._run_agent: markers are populated with the right
  task_id/baseline during the turn and cleared once it completes,
  closing the same race window fixed in gateway/run.py for this
  separate agent-lifecycle surface.
2026-08-02 14:23:55 +05:30
joaomarcos 80e4fb5995 fix(gateway): reap only the background processes an abandoned turn created
An agent turn can spawn a long-running background subprocess (e.g.
`next build`) and later be abandoned via inactivity timeout, /stop,
/new, or a client disconnect. Before this fix the gateway interrupted
the agent loop but never touched the subprocess: it kept running
inside the gateway's cgroup, unbounded, until memory pressure starved
the event loop and made every platform/cron look hung (#76115).

The process registry already knew how to kill a process tree — the
missing piece was per-turn ownership: nothing distinguished a process
that predates the turn (must survive), a process the turn started and
finished successfully (must survive), and a process an abandoned turn
left running (must be reaped).

- tools/process_registry.py: snapshot_running_ids() captures a turn's
  starting baseline; kill_started_since() reaps only IDs created after
  it, scoped to one task_id.
- gateway/turn_context.py: TurnContext carries process_task_id +
  process_baseline so the timeout/interrupt paths can reach them.
- gateway/run.py: baseline is snapshotted right before the turn's
  executor task starts; the inactivity-timeout path and the explicit
  /stop|/new|disconnect interrupt path both reap via the same helper.
  A daemon-thread watchdog backs up the asyncio-based timeout poll,
  since a starved event loop is exactly the failure mode this bug
  causes. The turn's own worker clears its ownership markers the
  instant it finishes, closing a race where a /stop landing right
  after normal completion could reap a background process the turn
  deliberately left running.

Related but insufficient on their own: #37454 (cgroup ExecStopPost
reaper only fires on service restart) and #68915 (orphaned-pipe
grandchild detection, a registry bug not a turn-lifecycle gap).
Neither ties process cleanup to turn abandonment.
2026-08-02 14:23:55 +05:30
kshitijk4poor 0cd26ce9a5 refactor(cron): log the heartbeat ceiling stop + test it
- logger.warning when the 6h ceiling stops the heartbeat (matches the
  delegate_task stale-stop precedent) so the eventual watchdog reap is
  explainable from logs instead of silent
- new mutation-checked test: past the ceiling the heartbeat stops while
  the job still completes
- clearer assertion messages (surface res on failure)
2026-08-02 14:04:52 +05:30
kshitijk4poor 8fd1a68106 refactor(cron): harden the run heartbeat (review follow-ups)
- heartbeat loop continues past a raising activity callback instead of
  silently stopping (matches delegate_task / touch_activity_if_due
  swallow-and-continue semantics) — one transient error must not drop
  watchdog protection for the rest of a long job
- hard 6h elapsed ceiling so a wedged job under HERMES_CRON_TIMEOUT=0
  (unlimited child watchdog) cannot mask the gateway watchdog forever
- public get_activity_callback() accessor in tools/environments/base.py
  instead of importing the private _get_activity_callback cross-module
- tests: deterministic heartbeat test (event-gated, no timing sleep),
  no-callback test now asserts the thread is truly never created, new
  exception-survival guard; dead started event removed
- fix comment: delegate_task heartbeat cadence is 30s, not 10s
2026-08-02 14:04:52 +05:30
webtecnica 2314abcbb0 fix(cron): run job without blocking the calling turn (#76502) 2026-08-02 14:04:52 +05:30
kshitijk4poor 840fb55a8a fix(auth): enrich device-auth timeout at the source to cover the dashboard poller
/simplify-code review found _poll_for_token has a second caller:
web_server._nous_poller (dashboard/desktop device login), which surfaces
str(e) as the UI error_message — so wrapping only in
_nous_device_code_login left the dashboard showing the bare timeout.

Move the enrichment into _poll_for_token's deadline raise so every
caller inherits the guidance, and drop the now-redundant try/except
wrap in the CLI login. Add a source-level regression test driving the
real poll loop (authorization_pending stub client) to the deadline.
2026-08-02 13:32:41 +05:30
HexLab fbf26e3845 fix(auth): actionable CAPTCHA-aware guidance on Nous device-auth timeout
A bare 'Timed out waiting for device authorization' gives the user
nothing to act on. The most common cause is Portal sign-in failing in
the opened browser tab (including the server-side CAPTCHA loop from
issue #20605), so point at the Portal login page and the hermes portal
retry command.

Salvaged from PR #75290 by @HexLab98 (timeout-guidance kernel only).
The URL-rewrite portion of that PR was dropped: the live Portal has no
/device route (verified 404 with a real user_code), so rewriting the
manage-subscription verification URL would break login entirely.
Guidance text reworded to reference only real URLs.
2026-08-02 13:32:41 +05:30
kshitijk4poor 881ac52423 fix: widen category guard — hybrid skill-dir nesting and file collisions
Follow-up to the salvaged #76000 guard:
- refuse installing a skill INTO an existing skill directory (hybrid
  skill-plus-category dirs whose later update/uninstall rmtree would
  destroy the nested skill — sibling case of #75983)
- refuse a stray regular file at the install path with the caller's
  ValueError contract instead of an uncaught NotADirectoryError
- regression tests: nested-only category (skills at depth >= 2),
  category-inside-skill, file collision
2026-08-02 13:31:16 +05:30
x7peeps 75e85ef6ba fix(tool/skills): refuse to overwrite category bucket during skill install (issue #75983)
Fix #75983

## 根因分析

hermes skills install <url> --name <name> 在安装技能时,如果目标路径(即
<skills_dir>/<name>)已存在,会无条件调用 shutil.rmtree 删除该目录。当
<name> 碰巧与用户手动创建的类别目录(category bucket)同名时,rmtree 会
删除整个类别目录及其下所有无关技能,造成静默的、不可逆的数据丢失。

lock.json 的已有检查仅追踪通过 hub 安装的技能,不覆盖用户手动创建的目录。

## 修复方式

在 install_from_quarantine() 的 rmtree 之前,增加类别桶保护逻辑:
1. 如果 install_dir 已存在且是目录,但不包含顶层 SKILL.md(说明不是技能目录)
2. 检查该目录下是否包含其他技能子目录(含 SKILL.md 的子目录)
3. 如果是,则抛出 ValueError 拒绝安装,列出受影响的技能名称
4. 如果不是(空目录或仅含非技能文件),则允许继续(与原有行为一致)

这样既保护了用户的类别桶不被意外删除,又不影响正常技能目录的覆盖安装。

## 回归测试

新增 3 个测试用例:
- test_install_from_quarantine_rejects_category_bucket_overwrite:
  验证包含技能的类别桶被拒绝覆盖,且内部技能文件完好
- test_install_from_quarantine_allows_existing_skill_overwrite:
  验证已存在的技能目录(含 SKILL.md)仍可被覆盖安装
- test_install_from_quarantine_allows_empty_category_dir:
  验证空目录仍可被正常安装覆盖
2026-08-02 13:31:16 +05:30
Christopher fc61608a17 fix(security): isolate explicit Docker passthrough snapshots 2026-08-02 00:36:03 -07:00
Christopher 7138b9587a fix(security): scope passthrough env to routed profile 2026-08-02 00:36:03 -07:00
Teknium 52308ff455 fix(env): seed .op.env bootstrap into cold-profile hydration
The salvaged hydrate_profile_secret_sources (#74549) seeded its
profile-local env from <home>/.env only, but the documented 1Password
bootstrap flow puts OP_SERVICE_ACCOUNT_TOKEN in the gitignored
<home>/.op.env (mirrored from load_hermes_dotenv). A cold profile using
that flow still failed 1Password hydration — the one unaddressed item
from the sweeper review on #74549. Seed .op.env via setdefault so .env
values win; never touches os.environ. Two regression tests.
2026-08-02 00:11:50 -07:00
x7peeps 4f4ea9a6de fix(whatsapp): route WHATSAPP_* env reads through secret scope for multiplex profiles
Fix #75349

Root cause:
Under multiplex_profiles, secondary profiles run inside
_profile_runtime_scope which installs a per-profile secret scope via
set_secret_scope.  The WhatsApp adapter (and the shared
WhatsAppBehaviorMixin + Cloud API adapter) read WHATSAPP_MODE,
WHATSAPP_DM_POLICY, etc. via raw os.getenv(), bypassing the secret
scope.  Since os.environ doesn't contain secondary profile .env values,
the bridge silently falls back to 'self-chat' and rejects all inbound
messages with self_chat_mode_rejects_non_self.

Fix:
- Add _wenv() helper in adapter.py that reads WHATSAPP_* vars through
  get_secret() (agent.secret_scope), which honors the active scope.
- Replace all os.getenv('WHATSAPP_*') calls in adapter.py,
  whatsapp_common.py, and whatsapp_cloud.py with get_secret()-based
  equivalents.
- Inject resolved WHATSAPP_* values into the bridge subprocess
  environment so the Node.js bridge (which reads process.env) sees the
  profile's own configuration.

Changes:
- plugins/platforms/whatsapp/adapter.py: 37 lines (+ helper, bridge_env
  injection, 2 os.getenv→_wenv)
- gateway/platforms/whatsapp_common.py: 13 lines (6 os.getenv→_get_wsecret)
- gateway/platforms/whatsapp_cloud.py: 21 lines (9 os.getenv→_get_wsecret)
- New regression test: 6 test cases covering scope isolation, fallback,
  and cross-profile non-leakage.
2026-08-02 00:11:50 -07:00
Bao 6ab390a476 fix(gateway): hydrate cold profile secret sources 2026-08-02 00:11:50 -07:00
tachyon-r 3d9a146d81 fix(browser): scope Camofox session identity 2026-08-02 00:11:50 -07:00
tachyon-r 76cf19fee1 fix(tools): isolate model tools by multiplex profile 2026-08-02 00:11:50 -07:00
sergioperezcheco 153442dd5b fix(matrix): honor profile secret scope for recovery key under multiplex
The Matrix adapter read MATRIX_RECOVERY_KEY via os.getenv, so under
gateway.multiplex_profiles every profile resolved the default profile's
key. That produced "recovery key verification failed: Key MAC does not
match" and broke E2EE for secondary profiles (#69090).

Route the read through agent.secret_scope.get_secret, which honors the
active profile's scope, with an os.getenv fallback for an unscoped read
under multiplex (default-profile startup loop) — mirroring the Slack
app-token pattern (#59739). Applied to both the startup verification
site and the status diagnostic.

Fixes #69090
2026-08-02 00:11:50 -07:00
Jonny Kovacs 651c5160b7 fix(gateway): run manual /compress under the profile secret scope
Multiplexed gateways resolve credentials through the fail-closed
per-profile secret scope (agent/secret_scope.py, Workstream A): any
get_secret() read outside a set_secret_scope(...) block raises
UnscopedSecretError. The agent turn installs the scope via _run_agent's
profile-scoping wrapper, but slash-command dispatch does not — so manual
/compress reached provider resolution unscoped and every invocation on a
gateway.multiplex_profiles: true deployment failed with:

  Manual compress failed: get_secret('OPENROUTER_BASE_URL') called with
  no profile secret scope active while multiplexing is on.

Same bug class as the cron scheduler (#57692) and the /v1/runs agent
path — an un-migrated call site the fail-closed design is meant to catch.

Two changes, both required:

- _handle_compress_command becomes a profile-scoping wrapper around the
  existing handler (renamed _handle_compress_command_inner), mirroring
  _run_agent: gated on multiplex_profiles, resolves the source profile's
  home and runs the whole handler inside _profile_runtime_scope. Covers
  the coroutine-side read (_resolve_session_agent_runtime).
- The compressor call switches from a bare loop.run_in_executor(None, …)
  to the existing _run_in_executor_with_context helper, so the scope
  contextvar survives the thread hop into _compress_context, where the
  aux-client provider resolution reads credentials.

Single-profile gateways take the pass-through branch — zero behavior
change (pinned by test).

Tests: 2 added (scoped read inside the executor under fail-closed
multiplexing reproduces the field failure pre-fix; single-profile
pass-through). 162 gateway compress/multiplex-scope tests green.
2026-08-02 00:11:50 -07:00
kshitij 077317a0f6 simplify: collapse aux config reads, trim comments, use load_config_readonly
- Merge _aux_free_only() + _aux_openrouter_model() into single
  _aux_openrouter_settings() that reads config once via
  load_config_readonly (avoids double deepcopy).
- Remove 15-line block comment and 5-line inline comment that
  restated what the code already says.
- Trim module docstring from 10 lines to 3.
- Update test patches to target load_config_readonly.
2026-08-02 12:19:04 +05:30
webtecnica c19c63d9e6 fix(agent): make auxiliary auto-chain fallback configurable and free-only (#75803) 2026-08-02 12:19:04 +05:30
dsad f86693c2f9 fix(tui): fail closed when prompt.submit cannot persist history truncation
Desktop edit / regenerate / restore-checkpoint send truncate_before_user_ordinal
on prompt.submit. The handler rewrote session['history'] first, then called
replace_messages, and on failure only printed to stderr and still started the
turn. When the durable write fails, in-memory history is already truncated
and history_version is bumped while state.db still holds the pre-edit tail.
The agent flush is append-only for history-dict identities, so the new
exchange is appended on top of the 'undone' turns — durable zombie history.

Fix: persist first; only then mutate memory. On failure return 5008 and
leave memory/DB unchanged.

Based on #72876 by @necoweb3. Adapted: the handler moved from
tui_gateway/server.py to tui_gateway/methods_prompt.py since the PR's base.
2026-08-02 12:18:40 +05:30
kshitij 582606f176 test(tts): reconcile test file with main and add regression tests
Start from main's 13 tests (renamed test_openai_available_reflects_key
to test_openai_available_reflects_audio_key_resolution, added 4 new
tests for xai oauth, elevenlabs secret resolver, openai configured
key, stream cap). Append 12 new regression tests from PR #71084 for
the prefetch pipeline, PCM misalignment, and PortAudio resilience.
Patch platform.system in stream-path tests for main's macOS guard.
2026-08-02 12:08:29 +05:30
kshitijk4poor 88a629b9dc refactor(agent): share the cache_ttl disable predicate across init and stub paths
Follow-ups from review of #76113:
- Extract cache_ttl_means_disabled() as the single disable-synonym
  predicate; agent_init and prompt_caching_disabled_from_config both use
  it so the two detection sites can no longer drift (drift would recreate
  the #76085 bug class).
- Mirror _run_reference's not-None injection guard in
  aggregate_moa_context (stamping None was a harmless no-op copy).
- Replace a vacuous trailing test assertion with the intended
  input-non-mutation check; drop a stray blank line.
- Add a predicate-parity regression test (unknown TTL values keep
  caching enabled, matching historical agent_init semantics).
2026-08-02 11:52:59 +05:30
686f6c61 8ee51747fe fix(agent): consolidate cache-disable stubs with blank_cache_policy_stub
Absorb the useful deltas from the parallel #76121 approach: a single
blank_cache_policy_stub factory so _cache_disabled cannot be left off
hand-rolled SimpleNamespaces, and pin the live agent disable onto MoA
advisor fan-out and one-shot aggregate_moa_context decoration so those
paths track conversation state rather than a fresh config re-read.

Keeps the earlier tri-state prepared-aggregator no-agent fix. Adds
factory and synthesis/advisor regressions.

Coordinates with #76121 / #76085.

Co-authored-by: JoaoMarcos44 <87440198+JoaoMarcos44@users.noreply.github.com>
2026-08-02 11:52:59 +05:30
686f6c61 8e1a351e82 fix(agent): preserve None cache_disabled when MoA has no agent
Prepared-aggregator facades built via __new__ lack _agent. Accessing
self._agent raised inside the planner try and bool-coercion of a missing
snapshot forced False, suppressing config fallback for cache_ttl=off.
Pass a tri-state value and add a no-agent/config-off regression.
2026-08-02 11:52:59 +05:30
686f6c61 67db87009e test(agent): drop unused pytest import from cache-disable tests
Avoid F401 from ruff/pyflakes on the #76085 regression file.
2026-08-02 11:52:59 +05:30
686f6c61 8a29703a1b fix(agent): honor prompt_caching.cache_ttl=off on stub policy paths
Blank SimpleNamespace stubs used by MoA decoration and
plan_cache_sections_for_destination never set _cache_disabled, so
anthropic_prompt_cache_policy re-injected cache_control markers after
operators turned caching off. Stamp the disable onto those stubs from
an explicit flag or the live config, and pass the agent flag from the
MoA aggregator path.

Fixes #76085
2026-08-02 11:52:59 +05:30
fangliquanflq f5ca0e2f0b fix(gateway): honor empty WhatsApp allow_from over env grants
Select allowlist source by config key presence so allow_from: [] does not fall through to WHATSAPP_* env carriers on Baileys or Cloud.
2026-08-02 11:50:05 +05:30
fangliquanflq b35f219aed fix(gateway): apply WhatsApp identity aliases to cloud pairing revoke
Extend phone/JID alias matching to whatsapp_cloud and treat a removed
allowlist env key as empty so sole-entry revoke cannot revive a stale
adapter snapshot.
2026-08-02 11:50:05 +05:30
fangliquanflq 810c8777e1 fix(gateway): preserve WhatsApp allowlist config precedence on live checks
Track which source seeded the DM allowlist so live intake does not let a
stale env carrier override explicit config, while env-seeded adapters still
reread pairing mutations.
2026-08-02 11:50:05 +05:30
fangliquanflq ddfc6342ad fix(gateway): revoke WhatsApp sole allowlist entry without restart
Clear live adapter _allow_from on pairing revoke and re-check DM
allowlist authz so sole-entry removal takes effect without restart.
2026-08-02 11:50:05 +05:30
Gille 58e85f4314 fix(browser): replace expired cloud sessions 2026-08-02 11:18:41 +05:30
Brooklyn Nicholson 63ff4b87b6 fix(install): sync the lockfile engines mirrors with the manifests
`hermes desktop` still failed with EBADENGINE demanding Node >=26 after
#76562, on a machine whose `apps/desktop/package.json` already said
`^20.19.0 || >=22.12.0`. #76562 fixed the manifest but not its mirror in
`package-lock.json`, and `npm ci` reads engines from the lockfile:

    package.json  apps/desktop -> {'node': '^20.19.0 || >=22.12.0'}
    package-lock  apps/desktop -> {'node': '>=26.0.0'}      <- what gated

Chasing that exposed a second, pre-existing problem: the floor #76562
declared was too generous. Running the real `npm ci` against the whole
workspace on Node 22.21.1 fails on a transitive dependency —

    npm error notsup Not compatible with your version of node/npm:
      react-router@8.3.0
    npm error notsup Required: {"node":">=22.22.0"}

react-router 8.3.0 (a direct dependency of both `apps/desktop` and `web`)
declares `>=22.22.0`, which is tighter than Vite's `^20.19 || >=22.12` and
excludes all of Node 20. So `>=20.0.0` promised support the tree cannot
deliver: an install on Node 20 or early 22 passed the installer's gate and
then died inside `npm ci` on someone else's package.

All four engine declarations now state the floor the dependency tree
actually has, `>=22.22.0`: root `package.json`, `apps/desktop/package.json`,
and both of their `package-lock.json` mirrors. The installer gates move with
them (`node_satisfies_build` in install.sh, `Test-NodeVersionOk` in
install.ps1) so a too-old system Node is replaced with the managed one
*before* npm runs, and the failure a user does see names hermes-agent rather
than a transitive package. NODE_VERSION stays 22 — latest-v22.x is 22.23.2,
comfortably above the floor.

The invariant test gains the case that would have caught the mirror drift on
its own: the desktop assertion now pins the tightest floor a dependency
actually declares, and the managed-runtime check compares majors, since
install.sh fetches latest-v{major}.x rather than {major}.0.0.

Verified with real `npm ci --dry-run` over the full workspace:
- node 22.23.2 (what install.sh provisions) -> 1258 packages
- node 26.5.1                               -> 1189 packages
- node 22.21.1 (below the floor)            -> EBADENGINE naming
  hermes-agent, i.e. our own manifest, not react-router
2026-08-01 23:28:28 -05:00
Brooklyn Nicholson c51c92a218 fix(update): survive a hermes_constants cached from before the update
`hermes update` aborted its managed-Python runtime repair with an error
that reads like a contradiction:

    ⚠ Managed Python runtime repair skipped: cannot import name
      'venv_python_path' from 'hermes_constants'
      (/home/teknium/.hermes/hermes-agent/hermes_constants.py)

The named file does contain the symbol. The module in memory does not.
main.py imports hermes_constants from the OLD checkout, `git pull` then
replaces that file on disk, and the freshly-pulled managed_uv runs its
lazy `from hermes_constants import venv_python_path` against the module
object Python already cached in sys.modules — the pre-upgrade one. The
ImportError reports the path of the new file, so it looks like the symbol
is missing from a file that plainly has it.

Same update-boundary class already documented on `_UvResult` for the
ensure_uv() arity skew. It fires on the first update from any release
older than 83314ca38, which introduced the symbol.

Both lazy call sites now reload the module from disk on ImportError:

- hermes_cli/managed_uv.py::_venv_python — the reported path
- hermes_cli/gateway.py::get_python_path — same flaw, same fix; a gateway
  restarted mid-update hits it identically

Reload rather than a local fallback on purpose. Recomputing the layout
inline would hand-roll `Scripts`/`bin` a second time — exactly what #76105
deduped into venv_bin_dir()/venv_python_path(), and what
test_no_open_coded_venv_layout_remains_in_hermes_cli bans. Reloading fixes
the actual problem (a stale module) and keeps one owner for the layout.

hermes_cli/update_cmd.py imports the symbol at module scope, which is a
different failure mode (the module fails to import at all) and is already
covered by the installer's retry-once for the update-boundary crash.

Tests reproduce the stale-module state by deleting the attribute from the
imported hermes_constants: recovery resolves through the reloaded shared
helper (asserted via a sentinel, so an open-coded copy cannot pass), and
the normal path never reloads. Against origin/main's managed_uv they fail
with the exact reported ImportError.
2026-08-01 23:06:47 -05:00
Brooklyn Nicholson 8e08a4a16e fix(install): restore installs — engines floor no shipping toolchain can meet
Fresh installs and `hermes update` both fail at the first `npm ci`:

    npm error code EBADENGINE
    npm error notsup Required: {"node":">=26.0.0","npm":">=12.0.0"}
    npm error notsup Actual:   {"node":"v24.15.0","npm":"11.12.1"}

`.npmrc` sets engine-strict=true, so `engines` is a hard gate on every
install. The floor was raised to npm >=12 — but no Node release bundles
npm 12: Node 26 ships 11.17.0, 24 ships 11.16.0, 22 ships 10.9.8. The
requirement is unsatisfiable by any stock toolchain, so the installer
provisions a Node and is immediately unable to install with it.

engines.npm becomes `<11.10.0 || >=11.17.0`. That still excludes the band
the strictness was actually for: npm 11.10-11.16 honor `min-release-age`
but ignore `min-release-age-exclude`, both set in .npmrc, so they apply the
14-day gate to packages we exempted. Verified rather than assumed — npm
11.12.1 fails `ETARGET ... vite@8.2.0 with a date before 7/18/2026` while
11.17.0 installs it.

engines.node returns to >=20.0.0 and the toolchain floor to Node 22.
Nothing in the tree needs 26: Vite 8.2.0 declares `^20.19.0 || >=22.12.0`
and Electron 40 declares `>=12.20.55`. Requiring 26 force-migrated every
working install for no dependency reason. apps/desktop drops to Vite's own
floor for the same reason; the desktop bundle builds clean on Node 22.

install.sh gained a second gate: a system Node was accepted on version
alone, so a machine with Node 24 + its bundled npm 11.16.0 (the bad band)
passed the check and then failed `npm ci`. npm_supports_npmrc() now rejects
that band and installs the managed Node instead.

CI, Docker and nix are hermetic and keep pinning Node 26 / npm 12 — they
provision their own toolchain, and both satisfy the relaxed range.

tests/test_engines_satisfiable.py encodes the invariants that would have
caught this: the npm floor must be met by an npm some shipping Node
bundles, the node floor by the runtime install.sh provisions, the desktop
floor by its own build toolchain, and the lockfile mirror must match.
Restoring the broken values fails 5 of them with the reason stated.

Verified end-to-end (real downloads, temp HERMES_HOME):
- fresh install: managed node v22.23.2 / npm 10.9.8 -> npm ci, 209 packages
- existing managed tree (v22.22.3 / npm 10.9.8) -> npm ci, 209 packages
- node 26.5.1 + bundled npm 11.17.0 -> npm ci, 208 packages
- system npm 11.12.1 (the reported case) -> EBADENGINE, recovery provisions
  a managed tree and retries green
- apps/desktop `npm run build` on Node 22 -> dist built, assert passes
2026-08-01 22:56:52 -05:00
Brooklyn Nicholson 8de9c16b7c Merge branch 'main' into ethie/bundled-node-path-windows-layout
#76499 landed the npm floor as >=11.17.0 on a Node >=20 baseline. This
branch takes the other half of the same constraint: the vendored Node 26
tree now installs npm 12 into itself, so the toolchain satisfies the
stricter floor rather than the manifest relaxing to meet the tarball.

Resolved package.json + package-lock.json to node >=26.0.0 / npm >=12.0.0
and refreshed npm_engine.py's illustrative range to match. website/'s
mirror keeps #76499's >=11.17.0 — it is not a root workspace and builds
on its own Node.
2026-08-01 21:27:26 -05:00
Ben Barclay a041526efe feat(gateway): key Discord auto-thread sessions on prospective_thread_id (#76513)
Live staging (2026-08-02): only the FIRST auto-thread in a channel got an
auto-title/rename. Root cause is a grouping-model mismatch — the connector
auto-threads per message (each channel message spawns its own thread), but the
gateway keyed sessions per PARENT CHANNEL, so every message after the first
reused the first message's already-titled session; auto-title short-circuited
and the rename lane never fired for later threads.

Intended model: a channel message INITIATES a session, the thread CONTINUES it.
A Discord thread created from a message reuses that message's id as the thread
id, so the connector can tell us the thread id at inbound (before the thread
exists). The paired connector change stamps it as source.prospective_thread_id;
this keys the session on it:

- SessionSource.prospective_thread_id (new field; to_dict/from_dict + the relay
  ws_transport inbound source build read it off the wire).
- build_session_key: effective_thread_id = thread_id or prospective_thread_id.
  The channel-initiating message (no thread_id, carries prospective) and the
  later follow-ups that arrive IN that thread (real thread_id ==
  prospective_thread_id) now produce the SAME key. A real thread_id always
  wins. The chat_type slot is normalized to "thread" when keying on a
  prospective id so the initiating "group"/"channel" event byte-matches the
  follow-up "thread" event. Prospective-thread sessions are shared across
  participants like any thread (not per-user).

Net effect: each distinct channel message is its own session/thread and gets
its own title + rename; follow-ups inside a thread continue that session with
full history. Additive and inert until the connector sends the field, so
non-relay and pre-deploy behaviour is byte-identical.

Tests: initiate-then-continue share one session; distinct channel messages get
distinct sessions; real thread_id wins over prospective; prospective sessions
shared across participants. Session suite 59 passed; relay suite green; ruff +
footguns clean.

Paired: gateway-gateway stamps prospective_thread_id per auto-threading instance.
2026-08-01 18:42:53 -07:00
ethernet b13148d354 feat(runtime): heal outdated managed Node trees up to the target major
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).
2026-08-01 21:17:51 -04:00
ethernet 25d0bcd424 fix(runtime): resolve Hermes-managed Node and uv before bare PATH
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.
2026-08-01 21:17:51 -04:00
Brooklyn Nicholson aaa6a97378 fix(managed_uv): keep project uv config on the candidate locked sync
The SQLite runtime repair staged its replacement environment with
`uv sync --extra all --locked --no-config`, and managed_python_env also
exports UV_NO_CONFIG=1. Both drop `[tool.uv]` from pyproject.toml —
including `exclude-newer = "14 days"`, which uv.lock was generated with.

uv 0.12 treats the missing setting as a resolver change, re-resolves, and
then refuses to write under `--locked`:

  Resolving despite existing lockfile due to removal of global exclude newer
  error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.

So every repair attempt failed at the dependency-sync gate and reported
"replacement environment did not pass dependency and import smoke tests",
leaving vulnerable-SQLite installs stuck on journal_mode=DELETE with a
guaranteed-failure warning on each `hermes update`.

Drop `--no-config` from the sync argv and pop UV_NO_CONFIG from its env.
Interpreter provisioning keeps both: only the sync has to agree with the
lockfile the project shipped.
2026-08-01 20:02:43 -05:00
Ben Barclay 3f497e2b4f fix(gateway): relay thread-rename must carry the parent-channel discriminator (#76465)
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.
2026-08-01 17:08:29 -07:00
Teknium 38c09e5d73 fix(tool-executor): emit tool results on hard interrupt to keep alternation
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.
2026-08-01 16:42:57 -07:00
webtecnica ed1170cd8b fix(config): make get_env_value scope-aware — the last scope-blind credential reader
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.
2026-08-01 16:42:51 -07:00
joaomarcos 18e0683bfc fix(auth): route anthropic adapter credential reads through the profile secret scope
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).
2026-08-01 16:42:51 -07:00
Teknium fe5a718c4e fix(env): narrow startup env scrub to profile-managed ACP keys
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.
2026-08-01 16:42:51 -07:00
keepConcentration 61b2fa7937 fix(env): strip export prefix in dotenv key scan for cleanup (review fix) 2026-08-01 16:42:51 -07:00
keepConcentration 968b66338c fix(env): clear inherited Hermes keys missing from profile .env (ACP leak)
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'.
2026-08-01 16:42:51 -07:00