Commit Graph

1033 Commits

Author SHA1 Message Date
Teknium 28f2ea86e2 fix(desktop): make --setup-tcc-identity produce a VALID signing identity on modern macOS
Fixes the two live E2E blockers @ctaylor86 found on PR #77189 (macOS 26.3.1,
OpenSSL 3.6.3):
- retry the PKCS#12 export with -legacy when security import rejects the
  OpenSSL 3 default format ('MAC verification failed during PKCS12 import')
- trust the self-signed root for the codeSign policy (security
  add-trusted-cert -r trustRoot -p codeSign) — an imported-but-untrusted cert
  is invisible to find-identity -v and unusable by codesign
- gate success on find-identity -v -p codesigning (postcondition), and use
  the same -v probe for idempotency so an untrusted leftover cert is repaired
  instead of reported as done

Tests rewritten as stateful fakes (valid only after import+trust), plus new
coverage for the -legacy retry, trust failure, postcondition gate, and the
untrusted-cert repair path; sabotage-verified (reverting to the name-in-output
probe fails 4 tests). Docs: manual fallback now includes the Trust step.
2026-08-25 16:24:19 -07:00
CriptoGus eeb391d0b1 feat(desktop): add --setup-tcc-identity to keep macOS TCC grants across rebuilds
Adds a one-shot `hermes desktop --setup-tcc-identity` command that creates a
self-signed code-signing certificate in the login keychain (openssl +
security import), grants codesign access to it, writes
desktop.macos_signing_identity to config.yaml, and re-signs the packaged app
with a certificate-anchored Designated Requirement.

macOS persists permission grants (Full Disk Access, Accessibility, Files and
Folders, microphone) against the app's code-signing identity, not its path.
The default identifier-pinned ad-hoc signature is stable across rebuilds, but
a certificate-anchored identity is the strongest guarantee — the same
mechanism yabai/skhd rely on. Previously users had to create the certificate
manually in Keychain Access; this command automates the whole flow and is
idempotent (re-run after updates).

Docs: desktop.md TCC section now leads with the command, keeps manual steps.
Tests: 4 new — fresh cert creation path, idempotent reuse, non-macOS no-op,
cmd_gui early-exit before build.
2026-08-25 16:24:19 -07:00
Krzysztof Radzikowski 36f1423411 fix(update): gateway-only concurrent instances no longer abort hermes update (#37039)
On Windows, the pre-update concurrent-instance gate aborted with exit 2
whenever ANY other process held the venv hermes.exe shim — including the
gateway itself, which _pause_windows_gateways_for_update() stops moments
later and the post-update restart phase brings back. Users with a running
gateway were forced into a manual taskkill dance before every update.

The gate now filters gateway runtimes out of the abort list and proceeds
when nothing else is concurrent. Classification delegates to
_is_pausable_gateway -> gateway.status.looks_like_gateway_command_line
(the canonical shlex-tokenized, profile-selector-aware matcher shared by
the Desktop preflight exemption and the venv-holder guard fallback), so
the gate's exemption and the pause machinery cannot drift apart. Anything
not positively identified as a gateway — REPLs, dashboard, Desktop
backend children, gateway MANAGEMENT commands like 'gateway status',
unreadable cmdlines — still aborts exactly as before, and the abort
message now lists only the PIDs that are actually the user's problem.

Surgical reapply of PR #37039 by @damadorPL onto current main (the gate
moved from hermes_cli/main.py to hermes_cli/update_cmd.py in the main.py
decomposition); his substring classifier was replaced with the canonical
matcher, which also fixes the 'hermes gateway status' misclassification
flagged in review.

Co-authored-by: Hermes <hermes@nousresearch.com>
2026-08-25 14:48:51 -07:00
Teknium 76d8f876f3 fix(cli): reconcile scoped-closure and reduced-lockfile freshness checks
Merge follow-up on top of the salvaged commits: fold the npm>=10 reduced
hidden-lockfile comparison (intersection of non-null fields) and the
annotation-field exclusions into a single entries_differ() helper, keeping
the workspace-closure scoping intact.
2026-08-25 12:09:08 -07:00
andyst-dev def7bdc638 fix(cli): don't re-run npm install on every TUI launch with npm>=10 reduced hidden lockfile
_tui_need_npm_install compared every field of the root package-lock.json
against node_modules/.package-lock.json. npm>=10/11 writes a reduced hidden
lockfile that omits declarative fields (version/dependencies/dev) and adds
extraneous, so nearly every package looked 'changed'; workspace link entries
("link": true, paths outside node_modules/) are never materialized by the
partial --workspace install. Both made the check return True forever, so
hermes --tui re-ran npm install (and dirtied package-lock.json) on every
launch (#84617).

Compare only the keys both sides record with non-null values (resolved,
integrity, ...), ignore workspace link entries and non-node_modules paths in
the missing-entry check, and treat extraneous as an npm runtime annotation.
Real skew (lockfile bumped while node_modules is behind) is still detected.
2026-08-25 12:09:08 -07:00
qazasdsdqaza 4d66def307 fix(tui): prevent spurious npm install on every launch
_tui_need_npm_install() compared ALL fields between
package-lock.json and .package-lock.json, treating npm's
intentionally-stripped metadata (version, license, engines,
dependencies, etc.) as real skew.  This caused 'Installing
TUI dependencies…' + rebuild on every launch.

Two changes:
1. Compare only the *intersection* of fields between the
   two lockfiles — a field present in the root lock but
   absent in npm's hidden actualized lock is a normal npm
   artefact, not a real dependency change.
2. Add , , ,
   to _NPM_LOCK_RUNTIME_KEYS — these boolean annotations
   are written non-deterministically between the two locks
   and never indicate a real dependency skew.

Real version/dependency changes still change /
, which are present in both locks and caught by
the intersection comparison.

Fixes: 347 false-positive lockfile mismatches → 0.
2026-08-25 12:09:08 -07:00
PRATHAMESH75 a96bad8e71 fix(cli): scope TUI npm-install closure to all selected workspaces
On Termux the launch install also selects ui-tui's child packages/*
workspaces (include_child_workspaces=True), so npm installs each child's
devDependencies. The freshness closure only followed devDependencies for
the ui-tui workspace itself, so a devDependency unique to a selected child
was dropped from the closure and a genuine missing package slipped past
_tui_need_npm_install.

Derive the closure from every workspace the install path selects, following
devDependencies for each. _npm_lock_workspace_closure now accepts the set of
selected workspace keys (dev-included roots); _tui_selected_workspace_keys
mirrors _make_tui_argv (ui-tui, plus child packages/* on Termux). Adds a
child-workspace-devDependency regression test (installs on Termux, ignored
off Termux) plus a closure-level dev-scope test.
2026-08-25 12:09:08 -07:00
PRATHAMESH75 0c47cd5260 fix(cli): scope TUI npm-install check to the ui-tui workspace closure
_tui_need_npm_install compared the full multi-workspace root
package-lock.json against the hidden .package-lock.json, but the launch
install is scoped with npm install --workspace ui-tui and only writes the
ui-tui dependency closure. Every dep belonging solely to another workspace
(apps/desktop, web, ...) was therefore reported as missing, so the check
returned True and printed "Installing TUI dependencies..." on every launch.

Restrict the comparison to the ui-tui workspace's dependency closure,
computed from the root lock's packages map (following npm's node-resolution
walk and workspace symlinks). Standalone / own-lockfile layouts and any
case where the workspace can't be located fall back to the full comparison,
so drift on a genuine ui-tui dependency is still detected.

Fixes #66978
2026-08-25 12:09:08 -07:00
Teknium a75ea37dc5 feat: browser snapshots drop LLM summarization — truncate-and-store like web_extract; auxiliary.web_extract slot removed
web_extract stopped using an auxiliary LLM long ago (deterministic
truncate-and-store), but browser snapshots still routed oversized
accessibility trees through the auxiliary web_extract model, keeping a
dead-looking aux slot alive across every config/picker surface.

- tools/browser_tool.py: remove _extract_relevant_content and
  _get_extraction_model; oversized snapshots always truncate at line
  boundaries, store the full tree to cache/web, and append a read_file
  pointer (element refs beyond the cut live in the file)
- tools/browser_camofox.py: same — no LLM path
- Remove auxiliary.web_extract slot: config_defaults (removal note, same
  pattern as session_search/PR #27590), cli.py defaults + env bridge,
  gateway/run.py bridged keys, hermes config display, hermes model picker,
  dashboard REST slots, desktop + web AUX_TASKS, i18n labels (en/zh/
  zh-hant/ja/ar)
- Docs: env-vars, configuration, fallback-providers, browser + zh-Hans
  mirrors (web-search zh-Hans was stale on the old LLM pipeline — synced
  to truncate-and-store truth)
- Tests updated: aux bridge uses approval slot, browser tests assert the
  LLM path is gone and stored files are secret-redacted
2026-08-24 20:11:18 -07:00
Brooklyn Nicholson b595fcd5e1 fix(desktop): keep the Linux HUD clickable and recoverable
X11 cannot restore a window that has ignored the mouse, so stay solid
there. Native Wayland keeps click-through via the cursor poll. Add
desktop.ozone_platform_hint so COSMIC users can opt into XWayland for
always-on-top, and a layout reset that restores the default size.

Co-authored-by: Codex Metatron <47930664+BlakeB254@users.noreply.github.com>
Co-authored-by: DeseretSaint <202557515+DeseretSaint@users.noreply.github.com>
Co-authored-by: Shawn Wang <32839114+enwaiax@users.noreply.github.com>
2026-08-24 06:48:10 -05:00
liuhao1024 8d17060249 fix(cli): hard-exit the Windows update hand-off child once work is durable
The re-exec'd venv child spawned by
_reexec_dependency_sync_off_windows_shim completes every update step —
the receipt records success / "completed at command boundary" — but then
hangs in interpreter shutdown on a leftover non-daemon thread, freezing
the PowerShell window for minutes after "Update complete!". On the
hand-off path only (HERMES_UPDATE_REEXEC=1), after the receipt is
finalized, the update lock released, and stdio restored, flush and
os._exit(code) instead of unwinding — the same treatment #79040's cron
workaround applies. SystemExit codes (including early refusals)
propagate to the hard exit; real exceptions keep the normal raise path
so tracebacks still print. Non-hand-off invocations are untouched: the
marker env is set solely when the shim spawns the child.

Fixes #93581
2026-08-24 03:21:28 -07:00
Teknium 93bf6f7225 fix(cli): fail closed on empty fleet probe across all pre-update liveness signals (#93406)
The #93410 guard keyed on (restarted_services or killed_pids), which never
fires on Windows: _pause_windows_gateways_for_update /
_resume_windows_gateways_after_update populate neither list, so a healthy
resumed Windows gateway still yielded zero fleet rows and exit 0.

Hoist the decision into _fleet_probe_expected_runtimes(), keyed on every
pre-update liveness signal:
- restarted_services / killed_pids (POSIX restart bookkeeping)
- _pre_restart_gateway_pids non-empty or None (unreadable pre-state,
  same fail-closed contract as _restart_phase_failure_is_incomplete, #78574)
- pre-update plan inventoried >=1 runtime
- Windows pause/resume token carries profiles or unmapped entries

Gate the 2.0s settle sleep on the same condition so a resumed Windows
gateway gets its settle window before the probe. The guard keys only on
zero-rows-despite-expected-runtimes; non-empty snapshots (including
'unknown'-state rows) are still judged solely by print_fleet_version_matrix.

Regression tests cover: empty snapshot + plan runtimes -> incomplete;
empty snapshot + genuinely idle -> success; Windows-resume token path ->
fail-closed + settle sleep wiring.

Builds on RelaxJonh's #93410. Fixes #93406
2026-08-24 03:21:18 -07:00
fangliquanflq 694550e486 fix(cli): derive top-level value flags from parser
(cherry picked from commit f2e5a1388115615fafde49a5f2144e5855a81d89)
2026-08-24 03:20:37 -07:00
fyzanshaik 8345effc4e fix(cli): support reliable setup menu navigation
Decode Ghostty/Kitty enhanced selection and cancellation keys, make setup cancellation terminal, and add cross-terminal previous-step navigation to setup and model flows.

Refs #92833
2026-08-24 15:45:52 +05:30
e-macgregor d3df14a7e3 fix(dashboard): secure loopback public URL proxy mode 2026-08-23 19:55:17 -07:00
Teknium 65c58651b0 feat: review slot appears in every aux-model picker (desktop, dashboard, CLI)
Follow-up to #93339: the auxiliary.review slot existed in config but was
missing from every model-picker surface, so users could only set the
review model by hand-editing config.yaml.

- hermes_cli/web_server.py: review in _AUX_TASK_SLOTS (REST allowlist,
  stale-aux warning sweep)
- hermes_cli/main.py: review in _AUX_TASKS (hermes model aux picker)
- apps/desktop model-settings.tsx + all 5 i18n locales (en/ja/zh/
  zh-hant/ar): review slot with label/hint
- web/src/pages/ModelsPage.tsx: review row in dashboard Models page
- tests: registry-sync test pinning review across DEFAULT_CONFIG,
  _AUX_TASKS, and _AUX_TASK_SLOTS (curator pattern)
- docs: aux-task table in fallback-providers.md (en) + zh-Hans mirrors
  of fallback-providers and the delegation /review section missed in
  #93339
2026-08-23 18:22:39 -07:00
Teknium 5c1a304ce8 fix: derive the pinned interpreter's Scripts dir via venv_bin_dir (#76105 lint)
The salvaged _interpreter_scripts_dir hand-rolled the Scripts/bin layout,
which the AST lint-test in test_update_zip_two_phase forbids — route it
through the canonical hermes_constants.venv_bin_dir instead, with the
interpreter's own dir as fallback for non-venv layouts.
2026-08-23 02:16:33 -07:00
mrmixx-max 33e813da17 fix(update): address review on stale-VIRTUAL_ENV pin
- _is_uv_command: detect 'python -m uv'/'python -m uvx' and launcher
  wrappers, not just a uv basename (review: naive check missed module form)
- _insert_python_pin: never duplicate a caller-supplied --python (review:
  last-wins ambiguity)
- _interpreter_scripts_dir: when pinning to sys.executable on Windows with
  no project venv, quarantine the running interpreter's Scripts dir so the
  hermes.exe shims uv rewrites are actually unlocked (review: quarantine
  path diverged from pinned interpreter)
- tests: rewritten to repo English convention; added python -m uv,
  --python-guard and Windows quarantine-target cases (5 total)
2026-08-23 02:16:33 -07:00
mrmixx-max 13f9d18e33 fix(update): pin uv installs to the running interpreter when VIRTUAL_ENV is stale
When Hermes is installed via pip / site-packages (e.g. the Windows
installer), PROJECT_ROOT is the interpreter's site-packages directory and
PROJECT_ROOT/venv is never created. The update and interrupted-install
recovery paths still set VIRTUAL_ENV=PROJECT_ROOT/venv, so uv fails with
'Failed to inspect Python interpreter from active virtual environment'
before installing anything — leaving the install partially updated.

Detect the nonexistent VIRTUAL_ENV in the shared dependency-install helper
and pin uv to the running interpreter (uv pip install --python
sys.executable) instead, matching the fix already applied to lazy-deps
(#83335) and the ZIP update path (#71510).
2026-08-23 02:16:33 -07:00
hsearcy 503d863fcd fix(install): never strand hermes.exe when a Windows update fails
On Windows the updater renames the live `hermes*.exe` shims aside
(`hermes.exe.old.<unix-ms>`) so uv can write replacements. When that quarantine
succeeds but the install then fails, the recovery path could leave the install
with no `hermes` on PATH at all — unrecoverable in place, because the command
that would repair it IS `hermes update` (#75584).

Restoring a quarantined shim happens at three sites: the updater, the
early-recovery installer, and the startup sweep's orphan rescue. Each was a
single un-retried rename whose OSError was swallowed in silence, while the
OUTBOUND quarantine rename already retried a lock. That is backwards — a failed
quarantine merely aborts an update, a failed restore removes `hermes` from
PATH — and the two sites that had messages had already drifted apart.

- `_early_recovery.restore_quarantined_shims()` is now the single
  implementation: retry ladder, one recovery message, returns the pairs it
  could not restore. It lives in the stdlib-only module that both `main` and
  `_install_repair` already import, so the layers cannot drift again. A pair is
  not a failure when the original reappeared or the quarantine file vanished —
  two processes sweeping the same orphan must not produce a spurious error.

- `_cleanup_quarantined_exes` unlinked every `*.exe.old.*` on each invocation.
  When the original shim was already missing, that .old file was the ONLY
  surviving copy — deleting it converted a one-rename recovery into a full
  reinstall. It now rescues the orphan through the shared helper instead, and
  leaves anything inside a 15-minute grace window alone so it cannot destroy a
  concurrent update's in-flight quarantine.

- Ordering is by the PARSED `.old.<unix-ms>` stamp, not the raw filename.
  Lexicographic ordering only tracks recency while every stamp shares a digit
  width; a stray `.old.999` sorts above a 13-digit epoch-ms stamp and would be
  the copy rescued onto the live shim name.

- Names whose suffix does not parse as int-ms are not ours: never rescued,
  never deleted. The sweep should not destroy files whose provenance it cannot
  establish, and they are not produced by the quarantiner.

The stamp is read from the filename rather than st_mtime because `rename`
preserves the original shim's mtime, which records when uv wrote the shim —
days earlier, in general — not when it was quarantined. A regression test pins
that distinction.

Messages go to stderr: the sweep runs on EVERY hermes invocation and
`hermes acp` speaks JSON-RPC on stdout.

Scope note: `_quarantine_running_hermes_exe` is deliberately byte-identical to
main here. Why the outbound rename fails in the first place (the launcher
holding its own image without FILE_SHARE_DELETE) is #88121's subject; this is
the net underneath, covering the case where quarantine SUCCEEDS and the install
dies afterwards. The two touch disjoint functions and can merge in either order.

Reproduced and verified on Windows 11 (26200), Python 3.11.15: stranded the
shims, confirmed a normal `hermes` invocation now rescues the orphan instead of
deleting it, and confirmed an exhausted rescue prints the recovery command.
16 new tests; 35 pass across the four quarantine suites.
2026-08-23 01:47:26 -07:00
686f6c61 4ccc4b6931 fix(update): skip Windows gateway cold-start when Desktop owns lifecycle
Vestigial autostart is not proof the user wants a standalone gateway
run. When Desktop currently supervises this install's control plane,
the updater must not spawn a competing messaging daemon. Serve is not
treated as gateway-equivalent.
2026-08-22 21:36:44 -07:00
Teknium c9c44d0df9 Merge pull request #92636 from NousResearch/fix/windows-launcher-managed-bin
fix(windows): stage hermes launchers in the managed binary dir, not the git checkout
2026-08-22 20:58:00 -07:00
Teknium 83864c0b5d fix(update): a contended venv is never mutated — failed shim quarantine now refuses instead of warning (#87331)
The #87331 remaining half: when hermes.exe (or a sibling shim) could not
be renamed aside, the updater printed a warning and ran the installer
anyway — which died partway on the same locks and stranded the venv
between versions.

- _run_quarantined_install gains strict_quarantine: any shim whose
  rename failed every retry aborts BEFORE the install command runs
  (successful renames rolled back), raising ShimQuarantineError.
- The update dependency sync passes strict_quarantine=True. The update
  boundary turns the error into a refusal: defer via the
  update-incomplete marker, exit 2 (recorded as refused by the receipt
  net), never ZIP-fallback. Post-sync repair installs keep warn-and-try
  (their venv is already mutated; refusing buys nothing).
- The recovery installer (_install_repair._run_install_cmd) is strict
  unconditionally: marker survives, next launch retries after the
  holder exits.
- Live Windows E2E for the wine2e lane: a real child holds hermes.exe
  without FILE_SHARE_DELETE (the exact field lock shape), strict path
  refuses with zero installer invocations, releases roll back, and the
  same path proceeds once the holder exits.

Sabotage-verified: reverting the strict wiring makes both fail-closed
tests fail.
2026-08-22 19:30:10 -07:00
emozilla fe95ed3930 Merge origin/main: reconcile with PR #92092 (in-checkout launcher restore)
PR #92092 fixed the same vanished-launcher bug by restoring copies into
the legacy in-checkout hermes-agent\bin from the update tail. That
location is what this branch removes: untracked files there are swept
by the update autostash on every cycle (restore/sweep treadmill, plus a
parked stash entry per update under --keep-stash), and unconditional
exe copies break on relocatable venvs ('uv trampoline failed to
canonicalize script path'). This branch's managed-binary-dir layout
supersedes both mechanisms, so the merge resolves to it:

- drop _sync_windows_cli_launchers and its _ensure_acp_launcher call
  (Windows staging/repair lives in ensure_windows_bin_launchers at
  process start and migrate_windows_bin_path in the update tail);
  _ensure_acp_launcher is a Windows no-op again
- keep #92092's genuinely better installer semantics: staging stays in
  a dedicated Install-HermesCommandLaunchers function that throws
  BEFORE any PATH mutation when the required launcher cannot be staged
  and verified -- previously Set-PathVariable could put an empty dir on
  PATH and still print 'hermes command ready'. Reworked for this
  branch's layout: caller passes the destination ($HermesHome\bin),
  launcher form follows the venv (exe copy vs .cmd delegator), and the
  verify step accepts either form
- rework #92092's AST-lifted PowerShell test for the new function
  signature, keeping its fail-before-PATH-mutation assertions and
  adding relocatable-venv form-selection coverage
- drop tests/hermes_cli/test_windows_cli_launcher_repair.py (pinned the
  superseded in-checkout mechanism; equivalent and broader coverage
  lives in tests/hermes_cli/test_ensure_windows_bin_launchers.py)
2026-08-22 22:16:38 -04:00
emozilla 679e9cd294 fix(windows): stage hermes launchers in the managed binary dir, not the git checkout
The installer staged the hermes/hermes-acp launcher copies at
hermes-agent\bin -- inside the git working tree -- and put that dir on
the user PATH (#84452). The update command's pre-pull autostash
(git stash push --include-untracked) swept those untracked, unignored
copies off disk, and once the desktop updater stopped re-applying
stashes (--keep-stash, 5dd221d442) nothing restored them: `hermes`
stopped resolving in every new terminal on every desktop-updated
install.

Move the canonical launcher home to the managed binary dir
(%LOCALAPPDATA%\hermes\bin, next to the managed uv) -- outside the
checkout, where no git operation can ever touch it. The dir is
per-machine and shared by every profile, so all anchoring uses
get_default_hermes_root(), never HERMES_HOME (which points inside
profiles\<name> under `hermes -p`).

The copy design also had a second latent break: managed-uv rebuilds
create relocatable venvs, and a relocatable venv's exe trampoline
resolves relative to its own location -- a copy outside venv\Scripts
dies with 'uv trampoline failed to canonicalize script path'. Launcher
form now depends on the venv (lockstep in install.ps1 and
_install_repair.py): exe copy for normal venvs, a .cmd delegator
invoking the in-venv exe by absolute path for relocatable ones. Either
form counts as present, so pre-rebuild exe copies are left alone.

Delivery to the existing fleet, per cohort:

- already-broken installs cannot run the CLI, so an import-time heal in
  hermes_cli.main (ensure_windows_bin_launchers) re-stages missing
  launchers when the desktop app spawns its backend -- the one channel
  that still reaches them. Gates fail toward inaction: canonical dir
  only for the managed clone, legacy hermes-agent\bin only while the
  user PATH still resolves through it (some pre-managed-uv installs
  have no hermes\bin PATH entry; the legacy re-stage is what fixes
  those). Staging-name + os.replace keeps concurrent process starts
  from tearing a launcher; the helper never raises.
- healthy old-layout installs migrate in the update tail
  (migrate_windows_bin_path): stage canonical launchers, verify them
  BEFORE touching the registry, prepend hermes\bin to the user PATH,
  strip the legacy entries (hermes-agent\bin and venv\Scripts, #83797),
  preserving REG_EXPAND_SZ and raw %VARS%. The legacy dir's files stay
  on purpose -- configs holding absolute launcher paths keep working;
  only the sweepable PATH resolution route goes.
- fresh installs get the new layout from install.ps1 directly.

/bin/ is gitignored so the one update that DELIVERS this fix cannot
sweep pre-migration launchers a final time under the old rules; the
gitignore line, the legacy re-stage branch, and the update-tail call
are transition machinery with a named expiry once the fleet has
migrated.

Also rewrites _ensure_acp_launcher's stale Windows paragraph to match
(raw docstring fixes its invalid \S escape) and updates the Windows
native docs to the new layout, with a docs<->installer parity test.
2026-08-22 13:38:56 -04:00
Teknium bbbc50acc2 fix: hermes update no longer strands non-interactive updates on a parked branch with unmerged commits
A clean checkout parked on a feature branch now always switches to the
update target. Unmerged commits are safe on the branch (git checkout
never discards committed work) and get a loud 'kept' notice naming the
branch, count, and the checkout command to resume the work. Previously
the update hard-skipped with exit 1 — a dead end for the desktop update
button, gateway /update, and cron, which have no way to resolve a skip.

Dirty trees (uncommitted changes) still skip loudly, and the
updates.auto_switch_parked_branch: false opt-out still pins the branch.
2026-08-21 15:23:49 -07:00
Teknium 0aecadc17c feat(update): hermes update --plan — read-only fleet inventory + plan phase in every update
Phase 2 core slice of #91277: the updater now knows WHAT it is operating
on before it mutates anything.

- hermes_cli/update_inventory.py (new): side-effect-free runtime
  inventory — install kind via detect_install_method (git / docker / nix
  / apt, updatable-in-place or not, with the correct external update
  command for image/package-managed installs), all profiles, every live
  gateway with its supervisor (systemd / launchd / manual via the
  fleet-wide _get_service_pids), running code_sha/code_version from the
  #91283 gateway_state.json stamps, and the restart mechanism each
  runtime will get.
- hermes update --plan: prints the plan and exits; runs BEFORE the
  docker/nix refusal gates so image-managed installs get a useful
  'not updatable in place + right command' report instead of a bare
  refusal. Read-only, safe on a live fleet.
- Every real update run now records the pre-update plan in its receipt
  ('plan' key) and prints a one-line fleet summary, so post-mortems can
  compare what the update SAW against what it did.
- Docs: updating.md (--plan section + receipts/fleet-check section),
  cli-commands.md (flag row + receipts behavior bullet).
- 11 tests: two-profile fleet classification, docker not-in-place,
  dead-PID exclusion, PID-file fallback dedupe, all-probes-fail
  never-raises, JSON round-trip for the receipt, print output shapes,
  receipt integration.
2026-08-21 04:23:13 -07:00
Teknium 40b4a3bfe1 fix(update): every begun update receipt is now persisted — command-boundary finalization
Review on #91283: begin_update_receipt() fires early in _cmd_update_impl,
but finalization only existed on the success/ZIP/CalledProcessError
paths. Early sys.exit paths (Windows concurrent-instance preflight,
venv-holder refusal, head-pinned no-op, fetch failure) terminated with
the receipt started but never written — losing exactly the refused/
failed runs the receipt matters most for.

- update_receipt.py: finalize_pending_update_receipt(exit_code,
  stop_reason) — boundary safety net; maps exit 2 → 'refused', other
  non-zero → 'failed'; records exit_code + stop_reason. Exactly-once by
  construction (singleton popped in finalize_update_receipt), so runs
  the inner paths already finalized are untouched.
- main.py cmd_update: SystemExit/BaseException/else arms around
  _cmd_update_impl persist any still-open receipt with the real exit
  code, then re-raise unchanged. Future early exits are covered without
  per-site finalize patches.
- 5 regression tests incl. end-to-end through the real cmd_update
  wrapper (exit 2 preserved, outcome 'refused', stop reason recorded,
  singleton cleared, exactly one receipt file).
2026-08-21 03:44:55 -07:00
Rudraksh Chahal 28a9b6c565 feat(providers): add OpenCode Free provider with keyed auth and opencode User-Agent
Adds an OpenCode Free provider plugin. Free model discovery uses models.dev
(cost.input == 0 AND status != "deprecated"), matching opencode CLI's exact
filter logic.

The free tier requires a real account API key and throttles third-party
clients by User-Agent:

- With OPENCODE_FREE_API_KEY configured, the key is sent as a Bearer token
  and requests identify as "opencode/latest".
- Without a key, the keyless fallback strips the SDK's always-injected empty
  Authorization header and still sends the opencode User-Agent.
- The credential resolver no longer blanks OPENCODE_FREE_API_KEY
  unconditionally (the stale keyless-tier assumption), and credential-pool
  exhaustion no longer surfaces the misleading "Set OPENCODE_FREE_API_KEY"
  message.

Co-authored-by: Jean-François <jfm@laposte.net>
Signed-off-by: Rudraksh Chahal <131520192+rudrakshchahal@users.noreply.github.com>
2026-08-21 00:24:32 -07:00
Teknium 044acf2bf7 fix(update): gateway auto-restart no longer dies on stale cached modules after the pull
`hermes update` runs in the pre-pull interpreter. The auto-restart phase
imports freshly-pulled gateway source, which resolves sibling imports
against the OLD sys.modules cache — so any update where an already-cached
module gained a new export ImportErrored the whole phase and left the
gateway serving pre-update code (2026-08-20 field failure: new gateway.py
needs cli_output.line_input, cached cli_output predates it).

Class fix replacing the per-symptom _UPDATE_RUNTIME_RELOAD_MODULES
approach: _purge_stale_hermes_modules() evicts every cached module under
the Hermes package prefixes (hermes_cli/gateway/tools/tui_gateway/agent)
right before the restart phase, so later lazy imports rebuild a
self-consistent module graph from the updated checkout. The updater's own
executing modules are exempt (purging them buys nothing; reload-in-place
is the unsafe op, and we never reload). Root-segment check spares
prefix-lookalike packages. Best-effort, never raises.

5 new tests incl. an end-to-end repro of the field failure shape
(stale module missing symbol -> ImportError -> purge -> import resolves).
2026-08-20 05:02:36 -07:00
Teknium 5dd221d442 feat: desktop updates no longer re-apply local source edits (--keep-stash)
The desktop updater ran `hermes update --yes`, which auto-restored any
uncommitted source-tree edits onto the freshly updated checkout. On dirty
from-source installs this silently carried local modifications across every
update and could break the rebuilt app (field report: Windows update handoff
leaving the app 'crashed').

New `hermes update --keep-stash`: local changes are still autostashed so the
update can proceed, but are never re-applied — they stay parked in git stash
with printed recovery guidance. Both desktop handoff scripts (windows.ps1,
posix.sh) now pass it, probing `update --help` first so older installed
backends without the flag keep working. Failure paths are unchanged (stash
preserved, no restore); updates.non_interactive_local_changes: discard still
wins.

Tests: park/restore/failure-path coverage incl. a sabotage-verified
regression test; docs updated.
2026-08-20 04:54:14 -07:00
Teknium 95fa814269 feat(process): positive process identity — spawn tags, machine spawn ledger, Windows job-object self-attach
Every long-lived Hermes process is now positively identifiable so reapers
never have to guess lineage from PPID archaeology or cmdline shape:

- hermes_cli/process_identity.py (new): HERMES_SPAWN tag build/parse,
  spawn-ledger.json self-registration keyed on (pid, create_time) — PID
  reuse cannot forge the pair — with #89298-style corrupt-file quarantine,
  and a kill-on-close job-object self-attach (BREAKAWAY_OK preserved for
  the existing CREATE_BREAKAWAY_FROM_JOB escape hatches).
- serve/dashboard (web_server.py) and the gateway entry point register
  themselves at startup and attach to the job; Desktop legacy
  HERMES_PARENT_PID/winms marker reused as spawner identity so lineage
  works with every Desktop version.
- Desktop stamps HERMES_SPAWN on backend spawns (parent-process-identity.ts).
- hermes update gets a positive-identity rung ahead of the heuristic ones:
  _ledger_reapable_backend_pids reaps holders the ledger PROVES are orphaned
  backends (purpose reapable + recorded spawner provably dead) in ANY update
  context. Ledger-unknown holders fall through to the existing rungs.

22 new tests, sabotage-verified.
2026-08-20 04:47:38 -07:00
Teknium d5edb66199 fix(update): reap leaked serve backends during a GUI hand-off instead of dead-ending the venv sync (Windows)
Field incident (2026-08-20): a Windows Desktop update hand-off
(update --yes --gateway --force) left a swarm of per-profile serve
backends (mr-tester, probe-inherit, turqoise, clippy, maroon, …) holding
cryptography/_rust.pyd. Some still had a live parent (the tearing-down
Electron process, or the venv launcher->worker two-hop chain mid-exit),
so the strict orphan-only reap (_orphaned_desktop_backend_pids, which
bails the instant ANY holder has a live parent) disqualified the whole
set and the venv-holder guard dead-ended. The user saw a ~12-minute hang,
force-closed, and the half-done state stranded bot sessions.

New rung: _handoff_reapable_backend_pids reaps surviving Hermes
serve/dashboard backends from this venv — live parent or not — but ONLY
in the hand-off context the caller gates on: args.gateway AND the
update-incomplete marker present AND no live hermes.exe shim. In that
window nothing legitimate supervises or respawns a serve backend (the
Desktop tree-kills its backends and parks any relaunch behind the marker,
#50238), so a surviving backend is a leak, not a race. A non-backend
holder (operator REPL, stray script) still disqualifies the whole set;
psutil-unavailable returns None (keep refusing). Wired as the final rung
before the existing dead-end, after the orphan-only reap.
2026-08-20 04:14:42 -07:00
brooklyn! 6d85d790e4 fix(update): hand off only the dependency sync, not the whole update (#90240)
`hermes update` on Windows detached on every run, including the
`Already up to date!` no-op that never touches the venv. emozilla hit
the visible half: the shim exits, PowerShell takes the console back,
and a child prints the result under a fresh prompt — it reads as a
frozen update. The invisible half is worse: the hand-off sat ahead of
the fetch, so it also carried off the stash and branch-switch
questions, which #90205 then had to answer by closing stdin. Nobody
who mods Hermes got asked about their local changes again.

The shim lock is real and the child is still required — a launcher
holds venv\Scripts\hermes.exe open without FILE_SHARE_DELETE for the
whole command, so the quarantine rename is refused and uv fails with
os error 32. A parent that waits deadlocks against the handle it is
itself holding, and Windows has no exec to escape with.

But that lock only binds one step. Move the hand-off to the dependency
sync boundary, beside the native-module deferral that solves the same
"this process holds a file the sync must replace" problem — and for
the reason that placement already exists (#86735: a preflight ahead of
the fetch re-bricked the flow it was meant to protect). Everything
before the sync now runs foreground in the user's console: the
preflight, the stash question, the branch switch, git pull. An
up-to-date run never hands off at all.

Deferring to the next launch cannot substitute here the way it does
for a mapped .pyd: every future `hermes` launch is also the shim, so
the marker would defer forever. The child re-runs the update to keep
the node/web/lazy-refresh tail, and takes the sync it was spawned for
rather than the up-to-date early return.
2026-08-20 03:43:00 -07:00
Teknium e69b8e561d feat: consolidate 'hermes version' into 'hermes --version', remove the subcommand
'hermes --version' (and -V) now prints the full version report — banner
version line with upstream SHA, install directory, authoritative install
method, Python and OpenAI SDK versions, and update status — making the
separate 'hermes version' subcommand redundant. The subcommand is removed.

- _startup_fast.print_fast_version_info() is now THE canonical version
  printer: static lines print instantly from stdlib probes, then the
  banner label, install-method resolver, and update check lazy-import
  after the first line is on screen (each degrades gracefully).
- main.py _print_version_info() delegates to it (used by /version in the
  CLI chat surface and the --version flag path); the old duplicate
  implementation is deleted.
- hermes_cli/subcommands/version.py removed; parser wiring, subcommand
  sets, console-engine extraction entry, and tests updated. Hermes
  Console keeps a 'version' command wired to the shared printer.
- Termux fast paths now include update status too (previously
  check_updates=False).
- Docs/i18n, CONTRIBUTING, SECURITY, and nix checks updated to
  'hermes --version'.
2026-08-20 03:05:46 -07:00
Teknium 76653a8eba fix(sessions): prune/archive spare pinned sessions by default (data loss) 2026-08-20 01:46:20 -07:00
Teknium f309f92d30 feat: hermes worktree list/prune — attended reclaim for accumulated worktrees and merged branches
The startup pruner is deliberately conservative (unattended, pre-banner),
so real installs accumulate what it can never touch: trees preserved for
untracked-only scratch, and orphaned local branches beyond the two
auto-generated prefixes it deletes. A measured multi-agent box: 35 trees /
15GB / 244 local branches, 120 of them fully merged.

New attended surface (hermes_cli/worktree_gc.py + worktree_cmd.py):
- hermes worktree list — audit every tree: age, size, verdict, reason,
  plus deletable-branch count
- hermes worktree prune [--dry-run|--trees-only|--branches-only]
- /worktree prune [--dry-run] — same engine in-session; never touches the
  session's own active tree
- startup escalation: one WARNING when .worktrees/ exceeds 10 trees or
  5GB, naming the reclaim commands (silence is how boxes hit 15GB)

Safety invariants (shared with the startup pruner via cli.py primitives):
tracked modifications and unique unpushed commits never deleted at any
age; live-locked trees untouched; branch deletion gated on worktree
removal success; untracked-only scratch ARCHIVED to
~/.hermes/archive/worktree-prune/ before its tree is reaped.

Branch GC is content-gated, not name-gated: any local branch fully merged
or git-cherry patch-equivalent upstream is safe to delete (rebase merges
rewrite SHAs, so --merged alone misses the dominant leak); unique-commit,
checked-out, protected, and stale-base (>50 ahead) branches are kept.
Classification is parallel (8 workers) — 244 branches audit in ~64s live.

git timeouts degrade to keep (returncode 124) instead of crashing the
audit — live-verified failure on a 746MB .git repo.

16 behavior-contract tests against real git fixtures; live dry-run on the
production repo: 12 trees reclaimable, 120 branches deletable, 0 false
positives among kept trees.
2026-08-19 23:55:20 -07:00
Teknium 258410a184 fix(cli): first launch banner shows the seeded skill catalog, not "No skills installed" 2026-08-19 19:44:42 -07:00
GarlicGo 466665282b fix(cli): Fix oneshot skills preload 2026-08-19 19:41:52 -07:00
Teknium d0132b5821 fix(cli): give every wizard free-text prompt arrow-key and Ctrl+A/E editing
Widens #90327's line_input() to the whole bug class: all 46 bare input()
free-text prompt sites across the setup wizards (model_setup_flows, setup,
config, gateway, auth, auth_commands, plugins_cmd, skills_hub, bundles,
setup_whatsapp_cloud, main) now route through line_input(), and the shared
cli_output.prompt() / setup.prompt() helpers do too — so every CLI wizard
gets cursor editing, not just the custom model prompt.

Redirected stdin and missing prompt_toolkit keep builtin input() behavior.
E2E: real PTY with raw escape bytes through line_input, cli_output.prompt,
and setup.prompt (arrows + Ctrl+A/E edit correctly); redirected-stdin
fallback verified.
2026-08-19 19:34:36 -07:00
Teknium 803397ecc3 feat: wall-clock run budget — wrap-up injection at 80% and deadline-scaled stale timeouts (agent.run_budget_seconds / --run-budget) 2026-08-19 16:32:17 -07:00
Brooklyn Nicholson ae6c973fc7 fix(update): run the Windows update hand-off unattended
The re-exec'd child inherits the console, so sys.stdin.isatty() still reported
a terminal and the update asked its local-changes question. By then the parent
shim had exited and the shell had taken the console back, so the prompt could
not be answered and the update sat there forever — worse than the lock it
replaced, because nothing recovers without closing the window.

Spawn the child with stdin closed. It then takes the same path the gateway and
Desktop updates take: honour updates.non_interactive_local_changes, which
stashes by default so nothing is lost, and keep going without asking.
2026-08-19 13:41:44 -05:00
Brooklyn Nicholson a9eb99d172 fix(update): stop deferring shim renames to next boot
MOVEFILE_DELAY_UNTIL_REBOOT was the quarantine's last resort, and it is worse
than doing nothing. It writes to HKLM, so a non-elevated update — every
Desktop-driven one, and most terminal ones — gets ERROR_ACCESS_DENIED and
reports nothing. When it does succeed it frees nothing for the install
running right now, and the queued operation outlives that update: at the next
boot it moves aside whatever sits at the shim path, including a shim a later
repair just wrote.

Drops the fallback and sweeps entries older versions queued, matching only
our own <shim> -> <shim>.old.<stamp> pairs so unrelated installers keep
theirs.

Salvaged from #88121 by @fangliquanflq.
2026-08-19 13:14:40 -05:00
Brooklyn Nicholson 867ab54e20 fix(update): re-run Windows updates off the console shim
`hermes update` launched as venv\Scripts\hermes.exe can never finish on
Windows. The launcher runs the interpreter with the shim as its script and
holds it open without FILE_SHARE_DELETE for the whole command, so the
quarantine rename is refused and uv fails to replace hermes.exe with
os error 32 — every time, with no Desktop, gateway or AV involved. The
concurrent-instance preflight cannot catch it because it excludes this
process and its ancestors by design.

Detect the shim from both the process ancestry and this process's own launch
paths (argv[0], __main__.__file__, the spec origin — the runpy/zipapp launch
puts <shim>\__main__.py there), intersected with the project venv's shims so
an unrelated hermes.exe never matches. When it matches, re-run the same
argv as `venv\Scripts\python.exe -m hermes_cli.main ...` and return, which
releases the shim before the child installs anything.

The hand-off sits ahead of the update lock so the child claims the marker
itself rather than adopting one the parent immediately releases, and any
failure falls through to the previous in-process behaviour with the manual
command printed.

Refs #88838, #89599, #86093
2026-08-19 13:13:09 -05:00
Brooklyn Nicholson 7a94b1fbf7 fix(update): resolve the project venv as venv or .venv
`uv venv` writes `.venv` while our installers write `venv`, and every venv
lookup in the update/repair paths hardcoded `venv`. On a `.venv` install
`_venv_scripts_dir()` returned None, so the Windows shim-lock preflight, the
quarantine, and the console-script verification all silently skipped
themselves — the update walked straight into the failure they exist to catch.

Adds `hermes_constants.project_venv_dir()` as the single resolver and routes
both `_venv_scripts_dir()` implementations plus the two VIRTUAL_ENV call
sites through it.

Refs #79542
2026-08-19 13:11:35 -05:00
Teknium 4903993cdd test: enforce -q/--query-file exclusivity at parse time
The subprocess-based exclusivity test invoked hermes_cli.main in the CI
environment where startup exits 1 before the manual guard runs. Enforce
the conflict in argparse itself (mutually exclusive group, exit 2 at parse
time) and test the parser directly; the manual guard stays for programmatic
namespace fills.
2026-08-18 22:31:32 -07:00
Teknium 1190825652 fix(bot-mode): DM protocol no longer shell-interpolates message bodies
The Bot Mode teammate-DM protocol told agents to inline the message into a
double-quoted shell argument: quotes truncated the body and $(...)/backticks
executed on the sender's machine. The protocol now writes the message to a
temp file and delivers it via a new 'hermes chat --query-file' flag (or '-'
for stdin); 'hermes peer dm' already accepted stdin and the peer recipe now
uses it. No shell pass touches the body at any point.

Supersedes the tool-based approach in #89077 — same bug, fixed with a CLI
flag + protocol rewrite instead of a new model tool.

Co-authored-by: mehmetkr-31 <mehmetkr-31@users.noreply.github.com>
2026-08-18 22:31:32 -07:00
ethernet d5a9c2ba6c feat(nix): home-manager module, shared with the NixOS module
Hermes is an agent for one person. The credentials, the memory, the
sessions and the cron jobs all belong to that person. But the only
declarative path was a NixOS system service. Issue #9056 asks for the
user-level equivalent. 25 public Nix configurations already write one by
hand, and several of them copy nix/nixosModules.nix and edit the systemd
part.

This module is not a second copy of that file. The code that both modules
share moves into nix/moduleCommon.nix:

  - the options
  - the renderers for config.yaml, .env and the documents
  - the activation body
  - the command lines of the processes

nixosModules.nix keeps only the parts that need root. Those parts are the
service user, stateDir, addToSystemPackages, container mode and tmpfiles.
The file goes from 1008 lines to 666.

`services.hermes-agent` is now the same option set on both modules. A
NixOS example works on Home Manager without a change, and an option added
one time appears on both.

The Home Manager module is different only where it must be. It uses
systemd.user.services on Linux and launchd.agents on Darwin. It uses
home.activation and not system.activationScripts. It sets HERMES_HOME
directly, with the default ~/.hermes, so an existing directory continues
to work. It uses the modes 0600 and 0700, because the state has one user
and does not need the group-shared umask of the NixOS module. It does not
support container mode, which needs root and the Docker socket.

The change also makes four corrections that apply to both modules:

- backend.mode runs `hermes serve` or `hermes dashboard`. Both modules
  had only the gateway. But Hermes Desktop and the web dashboard connect
  to a different process, so six of the configurations in public repos
  add a second unit by hand. serve and dashboard are one entry point with
  one flag of difference, and you can run only one of them. Thus the
  option is an enum. The NixOS module asserts against container mode with
  a backend, and does not make a unit that cannot start.

- hermesHomeFiles installs files into HERMES_HOME. The `documents` option
  installs into the working directory, which is correct for AGENTS.md but
  wrong for SOUL.md and memories/. Hermes reads those files from
  HERMES_HOME, in agent/prompt_builder.py:2095. A SOUL.md in `documents`
  made a workspace file that Hermes never loaded as the identity. The
  documentation said this in prose, but two directory diagrams showed the
  opposite. This change corrects both. A key in either option can now
  contain subdirectories.

- `documents` needs an explicit `workingDirectory`. The default of that
  option is bad on both modules. It is the home directory of the user on
  Home Manager, and ${stateDir}/workspace on NixOS. A user who declares
  workspace files without a directory therefore gets a place that the
  user did not select. The place is also different on each module. The
  modules now refuse that combination.

  The test is on the priority of the option and not on its value. An
  option that nothing sets keeps the priority of its own default, and
  each definition from a user is stronger. Thus a directory with the same
  text as the default still counts as a selection, and so does a
  mkDefault. A comparison of values detects neither case.

- Each activation writes .env again from a base in the Nix store, and
  does not add to the file that exists. Thus a second activation cannot
  put the same secret in the file two times, and a removed
  environmentFile goes away. environmentFiles keeps the type `listOf
  str` and not `path`, so Nix cannot copy a sops-nix or agenix path into
  the Nix store, which all users can read.

- HERMES_MANAGED and the .managed marker now hold the name of the system
  that manages the install. Thus a refusal says "managed by home-manager"
  and not "managed by NixOS", and `hermes update` gives the Nix guidance
  for both shapes. The CLI does not print a rebuild command for each
  system. It names the owner, and the user knows their own tool. A bare
  `true` and an empty marker still mean NixOS, so this does not change an
  existing install.

Verification. Six new checks, all built:

  nixos-module           evaluates the module with evalModules and the
                         NixOS module list. It asserts both units, one
                         HERMES_HOME, and that the module refuses
                         container mode with a backend.
  home-manager-module    evaluates the module with the
                         homeManagerConfiguration function of
                         home-manager. The process assertions run against
                         systemd units on Linux and launchd agents on
                         Darwin.
  module-option-parity   asserts that each shared option is on both
                         modules, and that the two exclusion lists name
                         only options that exist.
  env-file-assembly      runs the real .env script and checks the
                         contents, the mode, that a second run gives the
                         same bytes, and that a removed file goes away.
  workspace-files-need-a-directory
                         checks that the module refuses `documents`
                         without a directory, and accepts a directory
                         that has the same text as the default.
  service-argv           runs each command line that the modules build
                         through the real parser of the CLI, with one
                         sentinel flag added, and requires that argparse
                         refuses only the sentinel.

`nix flake check` passes, with 21 checks in total.

The CLI branches that treat an install as a Nix install move to one
helper, is_nix_install_method. Four call sites in main.py, web_server.py,
update_cmd.py and doctor.py tested the literal set {"nix", "nixos"}, and
each one missed home-manager. recommended_update_command asks the managed
state before the code-scoped stamp again, because a managed install can
carry a stale stamp that names an update path the managed guard refuses.
The metrics contract gets a home-manager bucket, so a Home Manager
install does not report as unknown.

Each check was mutation-probed. 22 faults were injected, and the checks
caught all 22:

  - a lost --no-open
  - a backend that runs the gateway
  - an overwritten config.yaml
  - documents in the wrong directory
  - a different HERMES_HOME on the two processes
  - a lost HERMES_HOME export
  - a missing backend unit
  - a removed assertion
  - an .env file that grows at each activation
  - an install that reports NixOS
  - an empty .managed marker
  - an option on the NixOS module only
  - a stale entry in an exclusion list
  - a renamed subcommand
  - an unknown flag
  - the workspace-files assertion always passes
  - the assertion compares values instead of priorities
  - an off-by-one that lets an untouched default through
  - the assertion also fires for hermesHomeFiles
  - a mkDefault no longer counts as a selection
  - the Home Manager module stops wiring the assertion
  - the NixOS module stops wiring the assertion

The 16 Python tests in tests/hermes_cli/test_managed_install_shapes.py
were probed the same way. 8 faults were injected and 8 were caught.

These tests fail on this tree. They fail in the same way on the stashed
HEAD, and they have no relation to Nix:

  - test_git_probe_tree_kill.py (2 tests)
  - test_update_import_guard.py (1 test)
  - test_telegram_media_read_timeout.py (2 tests)
  - test_teams.py (a collection error)

Closes #9056

# Conflicts:
#	hermes_cli/main.py
#	hermes_cli/update_cmd.py
#	hermes_cli/web_server.py
2026-08-18 20:42:06 -04:00
Teknium 72b7c6c8d1 fix(models): keep discovery sentinels out of the user-facing models mapping
PR #67934 marked auto-discovered catalogs by writing two sentinel keys
INSIDE the user-facing ``models`` mapping of custom provider entries:
``__discovered_model_catalog__`` (written by
_save_discovered_models_to_config) and ``__explicit_model_allowlist__``
(injected by _normalize_custom_provider_entry). Every consumer of that
mapping — pickers, selectors, gateway/agent readers, and the user's own
config.yaml — had to know to filter those keys, and any site that
didn't listed them as phantom model IDs (``__discovered_model_catalog__``
showing up as a selectable "model"). The v11→v12 config migration and
the ACP session-state test caught exactly that leak on main.

Replace the in-mapping sentinels with a single entry-level flag:

- ``models_discovered: true`` now sits next to ``models``/``base_url``
  on the provider entry; the models mapping stays a clean
  ``{model_id: metadata}`` dict with no reserved keys.
- _save_discovered_models_to_config writes the new shape and refreshes
  catalogs it previously discovered (entry-level flag or legacy
  sentinel) instead of treating them as user-curated metadata.
- _normalize_custom_provider_entry no longer injects
  ``__explicit_model_allowlist__``; a dict-shaped models mapping counts
  as an explicit allowlist exactly when the entry is NOT marked
  models_discovered.
- _models_config_is_allowlist takes the discovered flag as a parameter
  (new helper _entry_models_discovered resolves it, including the
  legacy in-mapping sentinel); all call sites updated
  (model_switch.py, model_setup_flows.py, acp_adapter/server.py).
- Backward compat, no config version bump: configs written by a
  pre-fix Hermes (sentinels inside models) still read correctly —
  ``__discovered_model_catalog__: true`` is treated as
  models_discovered, both sentinel keys are stripped from model
  listings, and the next discovery save migrates the entry to the
  clean shape. Covered by a new regression test.

Also restore ``except Exception:`` on the pre-existing guards this PR
had narrowed to specific exception tuples (the resolve_runtime_provider
fallback in switch_model, the picker discovery/cache guards in
list_authenticated_providers, _get_model_config_dict, and
_credential_fingerprint). Those guards were intentionally broad on
main — a failed resolution or probe must degrade to the fallback path,
never crash the model switch. Guards the PR introduced for its own new
probe code keep their authored tuples.

The ACP new_session payload also goes back to
probe_current_custom_provider=False, matching the contract main's
test_new_session_returns_authenticated_cross_provider_model_state pins
(session opens must not block on live-probing the current custom
endpoint).
2026-08-18 14:26:16 -07:00
Vadelma fa1bb88e3e feat(models): propagate native discovery across selectors
Co-authored-by: Taneli Mielikäinen <taneli.mielikainen@iki.fi>
2026-08-18 14:26:16 -07:00