sync: rebase PAI patches onto upstream main (2026-05-27) - #9
Conversation
PR NousResearch#30136 review item O6: test_container_restart.py used fixed `time.sleep(8)` calls after `docker restart` to wait for the cont-init reconciler to finish. Fixed sleeps are slow when the event happens fast and false-fail when the event happens slow. Replace with two polling helpers: * `_wait_for_path(container, path, kind='f' | 'd', deadline_s=...)` — generic `test -f/-d` poller. Returns True on success, False on timeout; callers assert with a clear message. * `_wait_for_reconcile_log_mention(container, profile, ...)` — the reconciler's per-profile log line is the canonical signal that the cont-init reconcile has finished for that profile. Poll on it instead of a sleep that hopes 8 seconds is enough. The fixture-level setup wait is similarly migrated: it now polls for `profile=default` in the boot log (every container always gets a default-slot entry per item I1) and raises a clear timeout error from the fixture if the container never finishes cont-init — much better diagnostics than a mid-test KeyError. The remaining `time.sleep()` calls are all internal interval_s between probe attempts; no fixed wait points left.
PR NousResearch#30136 review item O7: the plan doc was 3,191 lines — 5x the size of any other plan in docs/plans/ and the largest reference document in the repo. With the implementation shipped, most of that content is either: * The phase-by-phase TDD walkthrough (~2,800 lines): now canonical in the PR commit log (`git log a957ef0..a6f7171`). * The v2/v3 re-validation preambles: artifacts of the planning process, no longer load-bearing. * The full Open Questions deliberations with options A/B/C laid out: collapsed into the Decision Log. * The Rollout Plan and Estimated Timeline: history. Trim to ~430 lines covering what readers actually need going forward: the goal, architecture, scope, key design decisions (D1–D9), risk register (now including the three risks surfaced in PR review — `_s6_running` detection, svscanctl FIFO perms, supervise control FIFO perms), the decision log including the post-merge additions, and the verification checklist (now all boxes ticked). Header now reads 'Status: shipped' and points at the PR. The git history preserves the full v3 plan for anyone who needs it.
Second migration of an existing built-in platform adapter after Discord (PR NousResearch#30591) — follows the same shape established by IRC / Teams / LINE / Google Chat / SimpleX and the playbook in `references/platform-plugin-migration.md`. Advances the umbrella refactor in NousResearch#3823. Matches Discord's parity bar — adapter under `plugins/platforms/mattermost/` with the standard `__init__.py` / `adapter.py` / `plugin.yaml` shell, `register(ctx)` entry point, **no back-compat shim** at the old import path, and full parity for all five hooks Discord uses plus the `apply_yaml_config_fn` hook (mattermost is the second consumer of NousResearch#25443 after Discord): * `standalone_sender_fn` — out-of-process cron delivery via Mattermost REST API. Picks up the thread_id + media_files capabilities the legacy `_send_mattermost` lacked (parity with Discord's `_standalone_send`). * `setup_fn` — interactive `hermes setup gateway` wizard. * `apply_yaml_config_fn` — translates `config.yaml` `mattermost:` keys (`require_mention`, `free_response_channels`, `allowed_channels`) into `MATTERMOST_*` env vars (replaces the hardcoded block in `gateway/config.py`). * `is_connected` — declares connection state from `MATTERMOST_TOKEN` + `MATTERMOST_URL`. * `check_fn` — verifies aiohttp is installed and both required env vars are set. * plus `allowed_users_env`, `allow_all_env`, `cron_deliver_env_var`, `max_message_length` (4000 — Mattermost practical limit), `emoji`, `required_env`, `install_hint`. Files ----- * `gateway/platforms/mattermost.py` (873 LOC) → `plugins/platforms/mattermost/adapter.py` (git rename, R071) + appended `register()` block, hook helpers, and `_standalone_send` with media upload + thread_id support. * New `plugins/platforms/mattermost/{__init__.py, plugin.yaml}` with `requires_env` / `optional_env` declarations covering MATTERMOST_URL, MATTERMOST_TOKEN, MATTERMOST_ALLOWED_USERS, MATTERMOST_ALLOW_ALL_USERS, MATTERMOST_HOME_CHANNEL, MATTERMOST_REPLY_MODE, MATTERMOST_REQUIRE_MENTION, MATTERMOST_FREE_RESPONSE_CHANNELS, MATTERMOST_ALLOWED_CHANNELS. * `gateway/config.py`: delete 17-LOC `mattermost_cfg` YAML→env bridge (moved into plugin's `_apply_yaml_config`). * `gateway/run.py::_create_adapter`: delete `Platform.MATTERMOST elif` — replaced by the existing generic plugin-registry-first dispatch. * `tools/send_message_tool.py`: delete `_send_mattermost` (22 LOC) + `Platform.MATTERMOST elif` in `_send_to_platform` — the `else` branch already routes plugin platforms through `_send_via_adapter`, which hits the registry's `standalone_sender_fn`. * `hermes_cli/setup.py`: delete `_setup_mattermost` (44 LOC) — replaced by the plugin's `interactive_setup`. * `hermes_cli/gateway.py`: delete `_PLATFORMS["mattermost"]` dict entry (3 LOC) — plugin's `setup_fn` is dispatched via the plugin path in `_configure_platform`. * Consumer rewrite: 5 test files (test_mattermost.py, test_media_download_retry.py, test_send_multiple_images.py, test_stream_consumer.py, test_ws_auth_retry.py) get `gateway.platforms.mattermost` → `plugins.platforms.mattermost.adapter` with the bulk-rewrite recipe from the platform-plugin-migration playbook. Single `mock.patch` string in test_stream_consumer.py also repointed. * `tests/tools/test_send_message_missing_platforms.py`: thin `(token, extra, chat_id, message)` compat shim around the plugin's `_standalone_send(pconfig, …)` so existing test bodies continue to work without rewriting every signature. Validation ---------- * Plugin discovery: mattermost registers from `plugins/platforms/mattermost/` alongside discord / teams / irc / line / google_chat / simplex. All 9 hooks present (setup_fn, standalone_sender_fn, apply_yaml_config_fn, is_connected, check_fn, allowed_users_env, allow_all_env, cron_deliver_env_var, max_message_length=4000). * Mattermost-touching tests: 62/62 pass (`test_mattermost.py` + `test_send_message_missing_platforms.py`). * Targeted selectors (mattermost or platform_registry or stream_consumer or ws_auth_retry or media_download_retry or send_multiple_images or send_message_tool or platform_connected): 433/433 pass. * Full sweep (`scripts/run_tests.sh tests/gateway/ tests/cron/ tests/tools/test_send_message_tool.py tests/tools/test_send_message_missing_platforms.py tests/integration/`): **6220/6220 pass in 47.8s, 0 failures**. * Lint: ruff clean on all touched files. * Git identity verified: kshitijk4poor. * Rename detection: R071 (similarity dropped from a hypothetical R09x by the ~320-line appended register block — ~36% growth over the 873-LoC base, vs Discord's 5101 LoC base which kept R091). Closes part of NousResearch#3823.
…e_manager Follow-up to @benbarclay's Docker s6 PR (NousResearch#30136). The Phase 4 hooks `_maybe_register_gateway_service` and `_maybe_unregister_gateway_service` were already documented as "no-op on host", but they reached that no-op by: 1. importing `hermes_cli.service_manager` 2. calling `get_service_manager()` (which calls `detect_service_manager()`) 3. checking `mgr.supports_runtime_registration()` and returning False If anything in step 1 or 2 raised an unexpected exception (e.g. a host machine with a partial s6 install — `/proc/1/comm == s6-svscan` somehow, but `/run/s6/basedir` absent, or vice versa), the `except Exception` in the hook would print a confusing "⚠ Could not register s6 gateway service: ..." warning on a non-container machine that has never touched the container. Reorder so `detect_service_manager() != "s6"` is checked FIRST, and return silently for any detection failure. Host machines now: - never import the s6 backend - never call get_service_manager() - never print an s6-shaped warning under any failure mode E2E confirmed on host Linux (systemd): `_maybe_register_gateway_service(...)` produces empty stdout, detect_service_manager() returns "systemd". Existing tests updated to patch `detect_service_manager` for the s6 call-through cases (they previously relied on get_service_manager being the only gate, which is no longer true). Added one new test — `test_register_silent_when_detect_throws` — asserting that a broken detector cannot leak a warning to host users. cc @benbarclay — visible behavior change vs. your branch is one fewer code path on host. Test changes are minimal (one helper + `_patch_detect_s6` opt-in per s6 test). Happy to revert if you prefer the original shape.
…ch#26942) (NousResearch#31759) xAI's grok-imagine-image API returns ephemeral imgen.x.ai/xai-tmp-* URLs that 404 within minutes — long before downstream consumers (Telegram send_photo, browser preview, multi-tier delivery fallback) get a chance to fetch them. The xAI image_gen provider was passing those URLs through unchanged on the elif url: branch; b64 responses were already cached locally via save_b64_image. Result: every image_generate call on a Telegram-routed xai-oauth profile delivered no image, falling through to text-only. Adds agent.image_gen_provider.save_url_image() — a sibling helper to save_b64_image that downloads URL bytes to $HERMES_HOME/cache/images/. Content-type-aware extension inference with URL-suffix fallback; oversize cap (25MB default) with partial-write cleanup; empty-body refusal. Mirrors the audio_cache pattern used by text_to_speech. Wires save_url_image into both the xAI and OpenAI providers' URL branches. When the download fails (network blip, 404 in-flight) we log a warning and fall back to the bare URL rather than turning the tool call into a hard error — the gateway's existing URL-send fallback then gets a chance to surface the original error legibly. Test plan: - tests/agent/test_save_url_image.py — 8 direct tests against a real in-process HTTP server: bytes round-trip, content-type → extension, URL-suffix fallback, default-to-png, 404 propagation, empty-body refusal, oversize cap + cleanup, filename uniqueness. - tests/plugins/image_gen/test_xai_provider.py — flip test_successful_url_response (was asserting the bug), add test_url_response_falls_back_to_bare_url_when_download_fails. - tests/plugins/image_gen/test_openai_provider.py — symmetric pair. 160/160 in the broader image_gen test surface.
X Premium+ also grants Grok OAuth access — the 'SuperGrok Subscription' wording suggested SuperGrok was the only entitlement path. Updated to 'SuperGrok / Premium+' across the picker label, setup wizard, auth flows, and docs so Premium+ subscribers know the row applies to them too.
…t encoding Two CI follow-ups to @benbarclay's NousResearch#30136 salvage: 1. scripts/run_tests_parallel.py — add 'docker' to _SKIP_PARTS so the new tests/docker/ harness doesn't run in the regular test (N) matrix. The harness builds the real Dockerfile in a session fixture, which can exceed pytest-timeout's 180s ceiling on ubuntu-latest where Docker IS available — it surfaced as 6 identical setup-timeout failures across slices 1–6 on the first CI run. The docker harness has its own dedicated runner via .github/actions/hermes-smoke-test (added in NousResearch#30136) plus the docker-lint workflow. Same treatment as tests/integration/ and tests/e2e/ — runs separately, not in the main shards. 2. hermes_cli/service_manager.py — pin encoding='utf-8' on the /proc/1/comm read_text call. Ruff PLW1514 enforcement rolled in between Ben's last push and the salvage; pure ruff-fix, no behavior change.
Follow-up to @benbarclay's NousResearch#30136 salvage. The pre-existing PID-1 contract tests in tests/tools/test_dockerfile_pid1_reaping.py (added with NousResearch#15012) hardcoded tini/dumb-init/catatonit as the only accepted inits, so they failed after NousResearch#30136 replaced tini with s6-overlay's /init. s6-overlay's PID 1 is s6-svscan, which reaps zombies non-blockingly on SIGCHLD — same contract the test exists to enforce. Two updates: * test_dockerfile_installs_an_init_for_zombie_reaping — accept 's6-overlay' as a known-installed marker (matches the s6-overlay install layer in Ben's Dockerfile). * test_dockerfile_entrypoint_routes_through_the_init — accept '/init' as a known-routed marker (s6-overlay's PID-1 binary lives at /init by convention). Both assertions still fire if a future Dockerfile rewrite drops the init entirely. Local: 7/7 pass.
Documents five approaches for adding tools beyond what the official image ships with: npx/uvx for npm/Python tools, ad-hoc apt installs that Hermes remembers, derived images for durability, sidecar containers for multi-service stacks, and upstreaming via issue/PR for broadly useful additions.
…ker-docs docs(docker): add 'Installing more tools in the container' section
Resolves the explicit "Known follow-up" left by commit 2f8ceea and the resulting CI failures in tests/docker/test_dashboard.py and tests/docker/test_s6_profile_gateway_integration.py. The product gap --------------- Every hermes runtime operation inside the container runs as the hermes user (UID 10000) via s6-setuidgid. But s6-supervise — spawned by s6-svscan running as PID 1 — creates each service's supervise/ and top-level event/ directories with mode 0700 owned by its effective UID (root). That left every s6-svc / s6-svstat / s6-svwait call from hermes hitting EACCES on the supervise/control FIFO and supervise/status — i.e. the entire S6ServiceManager lifecycle (register, start, stop, unregister) was inert in production. The 2f8ceea commit message called this out and deferred the fix. The audit changes that landed alongside it (defaulting docker_exec to -u hermes) made the integration tests reproduce the bug deterministically; the fix below resolves it. The fix: pre-create the supervise/ skeleton hermes-owned ---------------------------------------------------------- Reading s6's source (src/supervision/s6-supervise.c::trymkdir + control_init), the mkdir and mkfifo calls that build the supervise tree are EEXIST-safe: if the directory or FIFO is already present, s6-supervise reuses it and skips the chown/chmod fix-up that would normally make event/ 03730 root:root. So if we lay the skeleton down with hermes ownership before triggering s6-svscanctl -a, s6-supervise inherits our layout and never touches it. The death_tally / lock / status regular files written later by s6-supervise (still as root) land mode 0644 — world-readable — which is all s6-svstat needs. New module-level helper _seed_supervise_skeleton(svc_dir) in hermes_cli/service_manager.py lays down: svc_dir/event/ hermes:hermes 03730 svc_dir/supervise/ hermes:hermes 0755 svc_dir/supervise/event/ hermes:hermes 03730 svc_dir/supervise/control hermes:hermes 0660 (FIFO) svc_dir/log/event/ hermes:hermes 03730 (if log/ present) svc_dir/log/supervise/ hermes:hermes 0755 svc_dir/log/supervise/event/ hermes:hermes 03730 svc_dir/log/supervise/control hermes:hermes 0660 (FIFO) The log/ branch matters because the logger is a second s6-supervise instance — without it, unregister rmtree races on the logger's root-owned supervise dir even after the parent slot's supervise/ is hermes-owned. The helper is idempotent and swallows PermissionError on chown so it works equally well when called from root (cont-init.d) or hermes (runtime register). Wiring ------ 1. S6ServiceManager.register_profile_gateway calls _seed_supervise_skeleton(tmp_dir) just before publishing the slot via Path.replace. Runtime-registered profile gateways are set up by hermes. 2. container_boot._register_service does the same in the cont-init.d reconciliation path so boot-time-restored profile slots inherit the same layout. 3. New cont-init.d/015-supervise-perms script chowns the supervise/ and event/ trees for STATIC s6-rc services (dashboard, main-hermes). These are spawned by s6-rc before cont-init.d gets to run, so the EEXIST-trick doesn't apply; we chown the already-existing tree instead. s6-supervise keeps using the same files; it never re-asserts ownership on a running service. The script skips s6-overlay internal services (s6rc-*, s6-linux-*) so the supervision tree itself stays root-only. 015- slot is intentional: lex-sorts between 01-hermes-setup and 02-reconcile-profiles in the container's C-locale, so the chown finishes before the reconciler walks the scandir. Unregister teardown reordering ------------------------------ S6ServiceManager.unregister_profile_gateway now fires s6-svscanctl -an BEFORE rmtree (with a 200ms grace), so s6-svscan reaps the supervise child and releases its file handles on supervise/lock + supervise/status before we try to remove the directory. Previously rmtree raced s6-supervise on a set of files inside the supervise dir, and even with the parent supervise/ now hermes-owned, the contained files (death_tally, lock, status, written by root) could still be in use. Dashboard down-state redesign ----------------------------- The original PR NousResearch#30136 review fix wrote a 'down' marker file into /run/service/dashboard/ via cont-init.d/03-dashboard-toggle. That approach was broken in two ways: (a) /run/service/dashboard is a symlink to a TRANSIENT /run/s6-rc:s6-rc-init:<tmpdir>/ directory while s6-rc is mid-transaction; the touch landed in a soon-to-be-discarded tmp. (b) Even when written to the final /run/s6-rc/servicedirs/ location, the 'down' file is only consulted by s6-supervise at slot startup. s6-rc's user-bundle explicitly transitions 'dashboard' to 'up' on every boot, overriding any down marker. The right fix is the canonical s6 pattern: when HERMES_DASHBOARD is unset, the dashboard run script exits 0 and a companion finish script exits 125. Per s6-supervise(8), exit code 125 from the finish script is the 'permanent failure, do not restart' marker — equivalent to s6-svc -O. The slot reports as 'down' to s6-svstat, matching the reality that no dashboard process is running. When HERMES_DASHBOARD IS truthy, finish exits 0 and restart-on-crash semantics apply. 03-dashboard-toggle is removed (its function is now subsumed by the run/finish pair). Tests ----- Adds four unit tests for _seed_supervise_skeleton covering the produced layout, the log/ subservice case, the skip-when-no-log case, and idempotency. The live-container verification continues to live in tests/docker/test_s6_profile_gateway_integration.py and tests/docker/test_dashboard.py — both now pass against the rebuilt image. References ---------- * Skarnet skaware mailing list 2020-02-02 (Laurent Bercot + Guillermo Diaz Hartusch) on unprivileged s6 tool semantics: http://skarnet.org/lists/skaware/1424.html * just-containers/s6-overlay#130 — same EEXIST-preseed pattern, community-validated 2016 onward * https://skarnet.org/software/s6/servicedir.html — exit-code 125 semantics in finish scripts (cherry picked from commit c41f908)
…against historical-comment masquerade PR NousResearch#30136 CI: test_dockerfile_entrypoint_routes_through_the_init failed because the test hardcoded known_inits = ('tini', 'dumb-init', 'catatonit'). The PR replaced tini with s6-overlay's /init (which execs s6-svscan as PID 1) — same SIGCHLD-reaping contract, different name, so the substring scan against ENTRYPOINT missed it. Two-part fix: 1. Extend the accepted token list to include 's6-overlay', 's6-svscan', and '/init'. The contract these tests enforce is behavioural ('some PID-1 init reaps SIGCHLD'), so the names list is purely a recognition table and any reaper-capable family should qualify. 2. Harden test_dockerfile_installs_an_init_for_zombie_reaping (the sibling check) against comment-only matches. It was scanning the full Dockerfile text and only passed because the word 'tini' is still in a historical comment explaining why we used to use it. The next person to clean up that comment would have silently broken the test. New _instruction_text() helper joins only the parsed, non-comment Dockerfile instructions so stale comments can't satisfy the check. (cherry picked from commit ffc1bb6)
…ycle test After the supervise-perms fix lands, the s6 lifecycle actually works for the hermes user — hermes -p <profile> gateway start now genuinely brings the supervised gateway up rather than silently no-op'ing on EACCES. That exposes a latent bug in this test's assertion: it expected 'want up' to appear literally in s6-svstat output, but s6-svstat elides redundancies — when the slot is currently up AND s6 wants it up, the output is just 'up (pid N pgid N) X seconds'; the explicit 'want up' token only appears when current ≠ wanted (e.g. 'down (exitcode 1) … , want up' on a crash-loop). Add a small helper _svstat_wants_up() that reads the want-state correctly across both spellings: * 'up …' → wanted up (unless explicit 'want down') * 'down …, want up' → wanted up explicitly * 'down …' → wanted down Both stop and start assertions now use the helper. Also rewords the module docstring to acknowledge that the supervised process may succeed OR crash-loop depending on environment, but the want- state contract holds either way. (cherry picked from commit 02c933a)
…t image The new tests/docker/ suite (added by this PR) was being picked up by the sharded pytest matrix in tests.yml, where its session-scoped `built_image` fixture issued a 3-7min `docker build` under tests/docker/conftest.py's 180s pytest-timeout cap. Every test in the directory failed in fixture setup across all 6 shards. Fix the suite so it actually runs (not skips): 1. Wire the docker tests into docker-publish.yml's build-amd64 job, right after the existing smoke test. The image is already loaded into the local daemon as `nousresearch/hermes-agent:test`; set HERMES_TEST_IMAGE to that and the fixture's pre-built-image branch short-circuits the rebuild. 21 tests run in ~90s locally against a prebuilt image, no rebuild cost on top of the existing build step. 2. Exclude tests/docker/ from scripts/run_tests_parallel.py's default discovery so the sharded matrix in tests.yml stops trying to build the image. Explicit positional paths (`pytest tests/docker/` or `scripts/run_tests.sh tests/docker/`) still pick the suite up — the skip rule honors directory-level user intent, matching the existing per-file override pattern. The dedicated docker-tests step runs on every PR that touches docker code (the existing path filters on docker-publish.yml already cover `tests/docker/**` via `**/*.py`), so the suite gates real changes. (cherry picked from commit 4c48186)
…on Windows On Windows, the setuptools-generated hermes.exe launcher is a separate native process that spawns python.exe (the interpreter running the update code). os.getpid() returns the Python PID, but the launcher (which holds the file lock) is the parent. Without walking the parent chain, every 'hermes update' reports its own launcher as a concurrent instance - a false positive. This patch builds an exclusion set containing the Python process and its entire ancestor chain, so the running invocation never reports itself.
…ction Follow-up to @Strontvod's fix. Tests: - Five new tests in test_update_concurrent_quarantine.py cover the parent- chain exclusion: the .exe launcher is excluded, an unrelated sibling hermes.exe is still reported, multi-level ancestry is fully excluded, PID cycles in the parent chain don't hang, and a partially-stubbed psutil (no Process attribute) degrades gracefully instead of crashing. - New _fake_psutil_with_parent_chain helper builds a fuller stand-in (Process / NoSuchProcess / AccessDenied + process_iter) than the process_iter-only SimpleNamespace the older tests use. Hardening: - Broaden the except in the parent-walk to bare Exception. The original fix listed (NoSuchProcess, AccessDenied, ValueError), but those names are evaluated lazily during exception matching — if psutil is a partial stub without the attribute, the exception handler itself raises AttributeError that escapes. The function is documented as 'never raises' (the surrounding update flow depends on it), so the broader catch keeps the contract regardless of how the dependency is shaped. AUTHOR_MAP: - Map schepers.zander1@gmail.com -> Strontvod so the salvaged commit resolves to @Strontvod in the release notes. All 18 detect_concurrent + quarantine tests pass.
…-bf5898da feat(docker)!: s6-overlay container supervision (salvage of NousResearch#30136)
…-status-rule fix(tui): keep status rule one-line in skinny terminals
…ousResearch#27385) (NousResearch#31894) NousResearch#27385 reports that on macOS the browser sees the xAI 'authorization received' success page but Hermes still raises xai_callback_timeout. The loopback HTTP handler was silent — no log line on receipt, no log line on wait timeout — so triaging the gap between 'browser saw success' and 'CLI saw timeout' required either a code change or guesswork. Adds two INFO log lines: - Per callback hit (handler): path, has_code, has_state, has_error, truncated User-Agent. Booleans / fingerprints only — no actual code/state strings leak. - On wait timeout: report whether result.code or result.error was populated at deadline. Distinguishes three failure modes: 1. No hit log + timeout log w/ has_code=False has_error=False → xAI's IDP never reached the loopback (firewall, port-binding, IPv6/IPv4 mismatch, browser blocked private-network access). 2. Hit log w/ has_code=False has_error=False + timeout log → xAI hit the loopback without OAuth params (the bare-URL case the handler already 400s on). 3. Hit log w/ has_code=True + timeout log w/ has_code=False → result_lock contention or race; would indicate a real bug. 133/133 in tests/hermes_cli/test_auth_xai_oauth_provider.py, tests/hermes_cli/test_xai_oauth_pkce_token_exchange.py, and tests/run_agent/test_codex_xai_oauth_recovery.py.
…arch#31895) The locale switcher appeared broken because hardcoded markdown links (`](/docs/X)`) got double-prefixed by Docusaurus to `/docs/<locale>/docs/X` (404) in non-English locales, and the MDX hero `<a href>` on the index page escaped locale routing entirely. Changes: - Rewrite 922 `](/docs/X)` -> `](/X)` across 166 docs files (strip trailing .md too). Docusaurus prepends locale + baseUrl itself. - docs/index.md -> index.mdx; hero "Get Started" anchor -> Docusaurus <Link> so it stays inside the active locale. - Drop `ko` locale entirely from docusaurus.config.ts + delete i18n/ko/ (4 stale auto-translated kanban pages, <2% coverage, misleading). Verified `npm run build` succeeds for both en and zh-Hans; `build/zh-Hans/ index.html` has no /docs/zh-Hans/docs/... double-prefixed paths. PR2 will translate the 335 English docs into i18n/zh-Hans/.
The legacy runtime_calls[-1] == "anthropic" check in test_model_switch_uses_requested_provider failed in CI under specific test-shard scheduling with 'custom' == 'anthropic', across multiple unrelated PRs on 2026-05-25. The May 23 pin (commit 3127a41) monkeypatched parse_model_input + detect_provider_for_model to remove the dependency on live _KNOWN_PROVIDER_NAMES module state but the flake reappeared anyway — root cause still not reproducible locally even under stress runs. The other three assertions ("Provider: anthropic" in result, state.agent.provider == "anthropic", state.agent.base_url == "https://anthropic.example/v1") already prove fake_resolve_runtime_provider was called with requested="anthropic" for the model-switch step — the agent's provider and base_url come directly from that fake's return value. The tail-position check was redundant and the only assertion that flaked. Replaces runtime_calls[-1] == "anthropic" with "anthropic" in runtime_calls so the plumbing path is still covered without depending on call ordering.
….5-pro Follow-up on top of @jacevys' PR NousResearch#21437 cherry-pick: - _provider_model_ids() now also matches normalized == 'openai-api' for the live /v1/models fetch path, so users see the full catalog instead of just the curated list. - Add gpt-5.5-pro and gpt-5.3-codex to the curated list for parity with the existing 'openai' table (used as fallback when /v1/models fails). - Add scripts/release.py AUTHOR_MAP entry for jacevys so CI doesn't block the salvage PR.
…arch#25752) The bug: cron/scheduler.py:_resolve_cron_enabled_toolsets returns an LLM-supplied per-job enabled_toolsets verbatim. The disabled_toolsets passed to AIAgent was a hardcoded [cronjob, messaging, clarify] that ignored agent.disabled_toolsets from config.yaml. An LLM could call cronjob(action='add', enabled_toolsets=['terminal','file'], prompt='...') and the cron-spawned agent would receive terminal+file even when the operator had globally disabled them. Fix: new _resolve_cron_disabled_toolsets() helper that ALWAYS layers agent.disabled_toolsets on top of the cron baseline. AIAgent's disabled_toolsets takes precedence over enabled_toolsets, so this stops the bypass regardless of what the per-job override contains. This is the disabled-side fix. Three concurrent PRs (NousResearch#25842, NousResearch#25815, NousResearch#25780) proposed intersection-side variants on _resolve_cron_enabled_toolsets; this fix is more robust because it stops the leak at the precedence boundary AIAgent itself enforces, not at a layer above. Regression test reproduces the issue's PoC exactly: config.yaml has agent.disabled_toolsets=[terminal,file]; cron job has enabled_toolsets=[web,terminal,file]; assertion: AIAgent receives disabled_toolsets containing terminal AND file. Salvaged from PR NousResearch#25786 by @Schrotti77. Simplified the implementation: dropped a 23-line _normalize_toolset_list() helper (handled str/tuple/ set/garbage input shapes) in favor of the existing convention (agent_cfg.get('disabled_toolsets') or []) used elsewhere in the codebase. YAML always parses these as lists; the elaborate normalizer was theatre for shapes we never produce. Closes NousResearch#25752 Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
Two defense-in-depth fixes on cron output path handling:
1. cron/jobs.py:update_job() rejects mutation of the immutable 'id' field
(raises ValueError). Dashboard PUT /api/cron/jobs/{id} converts this to
HTTP 400. Without this, an attacker who can reach the update endpoint
could rename a job's id to '../escape' and move its output directory
outside OUTPUT_DIR.
2. cron/jobs.py:_job_output_dir() validates job IDs before composing
paths: rejects '.', '..', '/', '\\', absolute paths, and Windows drive
prefixes. Used by save_job_output() and remove_job() so legacy unsafe
IDs (from before this guard) fail closed rather than half-applying a
shutil.rmtree or output write outside the sandbox.
Tests:
- update_job rejects {'id': '../escape'} without renaming
- remove_job(legacy '../escape' id) raises ValueError without deleting
files outside OUTPUT_DIR or removing the job from the store
- save_job_output rejects '..', './escape', 'nested/escape',
absolute paths
- dashboard PUT /api/cron/jobs/{id} with {'id': '../escape'} returns
400, job list unchanged
Salvaged from PR NousResearch#29826 by @zapabob. Simplified implementation:
- Dropped a 23-line _validate_job_output_id() helper using Path.parts
semantics. The inline check (path separators + dot-components +
is_absolute) is shorter and behaviorally identical.
- Dropped the secondary OUTPUT_DIR.resolve()/relative_to() check —
redundant once we reject any path separator at the input boundary.
- Dropped the _docs/2026-05-21_cron-output-path-hardening_codex.md
planning artifact (we don't check planning docs into the repo).
Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
…on is empty Closes NousResearch#32992. The chat path resolves Codex credentials via `resolve_codex_runtime_credentials` which only reads `providers.openai-codex.tokens` (the singleton). The auxiliary path uses `_read_codex_access_token` which checks the credential_pool first. For users whose tokens live only in the pool — manual seed, partial re-auth, restore from backup, or any state where the singleton is empty but the pool is healthy — the chat path raised AuthError or (worse, since OpenAI(api_key='') silently attaches no header) the wire saw HTTP 401 "Missing Authentication header" while the auxiliary path worked fine. This adds a pool fallback to `resolve_codex_runtime_credentials`: when the singleton has no usable access_token, scan `credential_pool.openai-codex` for the first entry that has a non-empty access_token and isn't in an exhaustion cooldown window (`last_error_reset_at` in the future). If found, return that token with `source="credential_pool"`. If no usable entry exists, the original AuthError propagates as before. Regression tests cover: - Empty singleton + healthy pool entry → pool token returned - Pool fallback skips entries currently in cooldown - Empty singleton + empty/wedged pool → AuthError propagates (existing contract preserved)
…eartbeat in place (NousResearch#33187) NousResearch#33151 flipped THREE Telegram display defaults to false: - tool_progress: new -> off (kept: per-tool stream is too chatty) - interim_assistant_messages: T -> F (REVERTED here) - long_running_notifications: T -> F (REVERTED here) - busy_ack_detail: T -> F (kept: verbose iteration counter) The two reverts were wrong. interim_assistant_messages = the model's REAL words mid-turn ("I'll inspect the repo first.", "Let me check both files in parallel"). That is signal, not noise. Suppressing it left Telegram users staring at "typing..." for the entire turn duration with no feedback. long_running_notifications = the periodic heartbeat. Silent agent for 30 minutes is worse than one bubble updating every 3 minutes. Changes: - gateway/display_config.py: Telegram tier-1 inbox keeps both defaults on (only tool_progress and busy_ack_detail stay off). - gateway/run.py _notify_long_running(): edit a single heartbeat message in place (where the adapter supports it) instead of posting a new "Still working..." bubble each interval. Telegram, Discord, Slack, Matrix all qualify. Falls back to send-new when edit fails. - gateway/run.py: tighten heartbeat text. "⏳ Still working... (12 min elapsed — iteration 21/60, running: terminal)" -> "⏳ Working — 12 min, terminal". Verbose iteration detail moves behind busy_ack_detail (one knob now controls both busy acks AND heartbeat verbosity). - tests/, cli-config.yaml.example, website/docs/user-guide/messaging: updated to reflect the corrected story.
…r slash enums xAI's /v1/responses endpoint rejects service_tier with HTTP 400 "Argument not supported: service_tier" when users activate /fast mode. Also add a safety-net strip_slash_enum call in _preflight_codex_api_kwargs to catch any tool schemas that might slip through the caller-level sanitization. xAI's Responses API grammar compiler rejects enum values containing forward slashes (e.g. HuggingFace model IDs like "Qwen/Qwen3.5-0.8B") with the opaque "Invalid arguments passed to the model" error. Fixes the root cause of "Invalid arguments passed to the model" errors reported by xAI OAuth (SuperGrok) users.
…tests (NousResearch#28490) Three additions on top of @Nami4D's salvage: 1. Gate the preflight slash-enum strip on the model name pattern (grok-* / x-ai/grok-*). The original PR stripped slash-containing enum values from every codex_responses request, but native Codex (OpenAI) and GitHub Models DO accept slash enums — stripping them there would silently degrade tool-schema constraints. xAI is the only Responses-API surface that rejects the shape. 2. Resolve the merge conflict in agent/transports/codex.py by preserving both the timeout-forwarding block that landed on main between the PR's branch point and now AND the new service_tier strip. Behavioural intent of both is preserved. 3. Six new tests in tests/agent/transports/test_codex_transport.py covering: - TestCodexTransportXaiServiceTierStrip (3 tests): xAI strips service_tier from request_overrides; non-xAI codex_responses and GitHub Models both KEEP service_tier (regression guards so the strip stays xAI-only). - TestPreflightSlashEnumStrip (3 tests): Grok and aggregator- prefixed Grok model names both trigger the safety-net strip; non-Grok models preserve slash enums as a regression guard against the strip becoming too broad. 51/51 in tests/agent/transports/test_codex_transport.py. Co-authored-by: Nami4D <hello@nami4d.tech>
Remove the ancestor-check gate and the separate move-latest job. On main pushes, the merge job now tags both :main and :latest in a single imagetools create call. Releases still get :<tag> only. Removed: - move-latest job (ancestor check + retag dance) - Decide whether to move :main step (ancestor check in merge) - Compute tag step - push_main gate on manifest push - merge job outputs (nothing downstream needs them anymore)
…ousResearch#33228) Closes NousResearch#33175. switch_model() in agent/agent_runtime_helpers.py mutated agent.model and agent.provider before rebuilding the client, with no try/except to restore them on failure. If the rebuild raised (bad API key, network error, build_anthropic_client failure, etc.) the agent was left with the new model+provider name paired with the OLD client — producing HTTP 400s like "claude-sonnet-4-6 is not supported on openai-codex" on the next turn. Callers in cli.py, gateway/run.py, and tui_gateway/server.py already catch the exception and warn the user, but the warning was misleading because the swap had partially succeeded; the agent's state was torn. Snapshot every mutated field before the swap, wrap the swap+rebuild block in try/except, and restore the snapshot on failure before re-raising so the caller's warning surfaces. Reported by @amirariff91. Tests cover both branches (chat_completions and anthropic_messages) and the cross-branch case (anthropic -> openai).
Pre-requisite for PR NousResearch#32020 salvage (auth: global auth.json fallback in _load_provider_state). Contributor_audit strict mode fails if any commit author email on main is unmapped. Co-authored-by: kshitijk4poor <kshitijk4poor@gmail.com>
In profile mode, _load_provider_state previously returned None when a provider was absent from the profile's auth.json — even if the user had authenticated at the global root. This broke runtime credential resolvers that read state directly (resolve_nous_access_token, resolve_nous_runtime_credentials), causing profiles without their own nous login to fail with 'Hermes is not logged into Nous Portal' despite a valid global session. Push the existing read-only global fallback (already used by get_provider_auth_state and read_credential_pool) into _load_provider_state so every caller benefits, and simplify get_provider_auth_state into a thin wrapper. Writes still target the profile only — profile state continues to shadow global state on the next read after a per-profile login. Behavior in classic (non-profile) mode is unchanged because _load_global_auth_store returns an empty dict. Adds 5 tests covering the new contract on _load_provider_state directly. Existing 770 auth/credential/nous tests still pass.
Add google-antigravity-cli as a text-only external-process provider for auxiliary tasks, backed by local agy -p.
Adds a TLS-intercepting egress proxy for remote terminal sandboxes (Docker
v1; Modal/SSH to follow). When enabled, the sandbox holds opaque proxy
tokens; iron-proxy swaps them for real provider API keys at the egress
boundary. Compromising the sandbox leaks tokens that only work from behind
the proxy.
Wraps ironsh/iron-proxy (Apache-2.0, Go binary). Same lazy-install pattern
as the recently merged Bitwarden Secrets Manager integration — pinned
version, SHA-256 verified download into ~/.hermes/bin/iron-proxy, no apt
or sudo required.
Disabled by default. Run `hermes egress setup` to mint tokens and
`hermes egress start` to launch. The Docker backend then automatically
mounts the CA, sets HTTPS_PROXY + CA-bundle env vars, and adds the
host-gateway hostmap.
New surfaces:
hermes egress install — download the pinned iron-proxy binary
hermes egress setup — interactive wizard (supports --from-bitwarden)
hermes egress start — spawn the managed proxy daemon
hermes egress stop — SIGTERM (+SIGKILL after 5s grace)
hermes egress status — binary + config + pid + listening + mappings
hermes egress disable — flip proxy.enabled = false
hermes egress config — print the path to the generated proxy.yaml
Optional Bitwarden integration: `--from-bitwarden` sources the real
upstream credentials from a BSM project at proxy startup, so rotating a
key in the Bitwarden web app propagates to sandboxes on the next proxy
start without touching .env.
Hermes-side scope (v1):
agent/proxy_sources/iron_proxy.py — install + CA + config + lifecycle
hermes_cli/proxy_cli.py — `hermes egress` subcommand tree
hermes_cli/config.py — "proxy:" section in DEFAULT_CONFIG
hermes_cli/main.py — argparse wiring (uses 'egress'
because 'proxy' is the existing
inbound OAuth reverse proxy)
tools/environments/docker.py — CA mount, HTTPS_PROXY, CA-bundle
env vars, --add-host wiring
Hermetic tests cover the full lifecycle: token mint, mapping discovery,
config + mappings I/O, install pipeline (HTTP + tar + checksum all mocked),
subprocess lifecycle (Popen mocked), Docker backend arg builder.
A live E2E test (gated on HERMES_RUN_E2E=1) downloads the real iron-proxy
binary, spawns it, routes a curl request through it against a local fake
upstream, and verifies the Authorization header was swapped from the proxy
token to the real secret value (and the proxy token did NOT leak through
to upstream).
Failures (binary missing, port collision, bad token) never block agent
startup — they emit a warning and continue. The Docker backend refuses to
start a sandbox when proxy.enabled=true but the daemon is dead, unless
proxy.enforce_on_docker is explicitly set to false.
Docs: website/docs/user-guide/egress/{index,iron-proxy}.md
Tests: tests/test_iron_proxy.py (35), tests/test_iron_proxy_e2e.py (1)
P0 — must-fix
- iron_proxy: emit default upstream_deny_cidrs (loopback, IMDS
169.254.0.0/16, RFC1918) when caller passes None. Honours the docs
promise that cloud-metadata IPs are refused regardless of allowlist.
- iron_proxy: bind 127.0.0.1 (+ docker0 bridge IP on Linux) instead of
INADDR_ANY (':9090'). LAN peers with a leaked sandbox token could
otherwise spend the operator's API quota against any allowlisted
upstream.
- ensure_ca_cert: write the CA private key via os.open(..., 0o600)
instead of shutil.copy2+os.chmod — closes the TOCTOU window where
the key existed under the default umask.
- discover_uncovered_providers + proxy.fail_on_uncovered_providers
config: refuse to start (when strict) if env vars for non-bearer
providers (Anthropic native x-api-key, AWS SigV4, Azure OpenAI,
etc.) are present. Surfaces a wizard warning in non-strict mode.
P1 — should-fix
- start_proxy: build a minimal subprocess env (PATH/HOME/locale +
only the env names referenced by mappings) instead of os.environ
.copy(). Strips proxy-recursion vars (HTTPS_PROXY etc.). Stops
the proxy's /proc/<pid>/environ from leaking every host secret
to same-uid local processes.
- start_proxy: optional Bitwarden refresh path
(refresh_secrets_from_bitwarden=True, bitwarden_config=...).
When credential_source=bitwarden, cmd_start wires it in — that's
what delivers the rotation guarantee the docs make.
- build_proxy_config: wire audit_log into the rendered yaml
(log.audit_path). Parameter was accepted but never used.
- ensure_audit_log: pre-create the audit log with 0o600 perms so
iron-proxy inherits tight permissions instead of relying on umask.
- Rename 'hermes proxy ...' → 'hermes egress ...' in user-facing
strings (docstring, RuntimeError messages, post-setup banner).
- start_proxy: open log file with 0o600 perms and close the parent
fd immediately after Popen — fixes the per-restart fd leak.
- DockerEnvironment: detect collisions between docker_env and the
egress-controlling env vars (HTTPS_PROXY, SSL_CERT_FILE, etc.).
When enforce_on_docker=true, fail loud rather than silently
inverting the isolation; when false, warn and let docker_env win.
- proxy_cli: merge_mappings preserves existing tokens on re-setup;
--rotate-tokens flag re-mints all of them. Stops re-running
`hermes egress setup` from invalidating tokens baked into
already-running sandboxes.
- proxy_cli: --from-bitwarden fail-loud on disabled BW config,
missing access token, or empty vault. Previously fell through to
the env path while still writing credential_source: bitwarden.
- docker.py: narrow `except Exception` → `except ImportError`;
iron_proxy._read_tunnel_port_from_config: same. Bare excepts
were masking real config-load bugs.
- start_proxy: write pidfile via os.open with O_NOFOLLOW + 0o600
+ st_uid check. Refuses to follow a pre-existing symlink at the
pidfile path.
- mint_proxy_token docstring: document the 128-bit suffix entropy
explicitly (sha256 truncated to 32 hex chars).
P2 — follow-up
- start_proxy: poll-with-timeout (100ms cadence on _port_listening)
instead of an unconditional 5s sleep. Saves several seconds per
Docker container create when enforce_on_docker=true.
- docker.py: apply enforce_on_docker semantics when CA file vanishes
between status.configured check and CA mount. Previously returned
empty args silently.
- docker.py: refuse to mount when mappings.json is empty/corrupt
(was indistinguishable from upstream outage from inside the
sandbox).
- install_iron_proxy: tarfile.extract(..., filter='data') to silence
the PEP 706 deprecation and opt into the 3.14+ default.
- _proxy_state_dir: chmod 0o700 unconditionally; add
_proxy_state_dir_ro() so read-only callers don't create the dir.
- stop_proxy: re-verify pid before SIGKILL via /proc/<pid>/stat
starttime AND _pid_alive. Prevents SIGKILL'ing a recycled pid.
- _pid_alive: tightened cmdline check — basename match on argv[0]
plus an in-process nonce env var ('iron-proxy' in cmdline matched
'tail iron-proxy.log' and editors with the log open).
- docker.py: NODE_OPTIONS=--use-openssl-ca so Node.js routes through
the OpenSSL CA store SSL_CERT_FILE controls, narrowing the
Python/curl-replace vs Node-add asymmetry waefrebeorn flagged.
P3 — polish
- proxy_cli: dest='egress_command' (was 'proxy_command' which
collided lexically with the inbound OAuth subparser).
- iron_proxy_version: cache by binary path — get_status is called
per Docker container create, version is constant per binary.
- Drop unused `import sys` from iron_proxy.
- proxy_cli: `is not None` check on --tunnel-port (was treating 0
as falsy and silently substituting the default).
- proxy_cli cmd_disable: use get_status().pid instead of reaching
into ip._read_pid() (stale pidfile from a crashed run would have
fired a spurious "still running" warning).
- Tests: replace hardcoded /tmp/ca.* paths with tmp_path-derived
fixtures so tests are hermetic across hosts.
CI
- Windows footguns scanner: os.kill(pid, 0) is now gated behind
platform.system() != 'Windows' with a windows-footgun: ok marker;
signal.SIGKILL falls back to SIGTERM on Windows via
getattr(signal, 'SIGKILL', signal.SIGTERM).
- docs MDX compilation: replace bare `<https://…>` URLs with
`[text](url)` syntax (MDX-jsx parser rejects the angle-bracket
form).
Tests
- 32 new tests covering default deny CIDRs, bind policy, audit log
wiring, subprocess env minimization, CA TOCTOU 0o600, state dir
0o700, empty-mappings refusal, CA-vanished refusal, docker_env
collision detection, token preservation/rotate, uncovered provider
detection, and the proxy_cli command handlers + argparse wiring.
- All 156 tests in test_iron_proxy + test_iron_proxy_cli +
test_docker_environment + test_config pass locally.
Acknowledged but not addressed in this revision
- E2E test for HTTPS CONNECT + TLS-MITM path: existing E2E exercises
plain HTTP; full MITM coverage needs separate CI infra (real iron-
proxy binary + curl with custom CA). Tracked as follow-up.
- Cosign-style supply-chain verification for the binary checksum:
upstream iron-proxy doesn't sign releases yet. Accepted pattern
(same as Bitwarden integration); tracked as follow-up.
- CA rotation CLI (`hermes egress rotate-ca`): scope-cut to a
follow-up.
Reviewers: @annguyenNous @waefrebeorn @GodsBoy @erhnysr
The bws helper's warnings list contains non-secret status messages
('rate limited', 'project not found', etc.), but CodeQL's taint
analyzer can't distinguish those from the secrets dict returned by
the same call. Log the count instead of the strings — the warnings
are still observable via 'hermes secrets bitwarden status'.
Land runtime hardening that was present in Gary's live Hermes checkout, with focused test coverage.
… override The upstream commit dbe5d84 added a universal main-model fallback that pre-fills `model` before provider branches run. This caused the Antigravity provider to inherit the user's main model (e.g. gpt-5.5) instead of using 'antigravity-cli'. Hardcode the model since agy has no model-selection flag.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0dbd84a1fa
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| try: | ||
| db.set_session_title(fork_id, str(title)) | ||
| except ValueError as exc: | ||
| return web.json_response(_openai_error(str(exc), code="invalid_title"), status=400) |
There was a problem hiding this comment.
Validate fork title before mutating sessions
When /api/sessions/{session_id}/fork is called with an invalid title (for example >100 characters or a duplicate title), this ValueError path returns 400 only after the handler has already marked the source session as branched, created the fork session, and copied messages into it. That leaves persistent session state changed even though the API reports failure, unlike _handle_create_session, which rolls back on title validation errors; validate the requested title before end_session/create_session or roll back the partial fork.
Useful? React with 👍 / 👎.
| if use_cache: | ||
| _write_disk_cache(cache_key, entry, home_path) |
There was a problem hiding this comment.
Block Bitwarden disk cache from read_file
When Bitwarden Secrets Manager is enabled, this writes every fetched secret value to the predictable path $HERMES_HOME/cache/bws_cache.json, but the new read guard only blocks auth.json, .env, .anthropic_oauth.json, mcp-tokens/, etc. and does not deny this cache file. A prompt-injected agent can therefore call read_file on the cache and exfiltrate all BWS-backed API keys despite the .env/auth-store protections; either avoid caching raw values or add this cache path to get_read_block_error before enabling disk persistence.
Useful? React with 👍 / 👎.
| docker buildx imagetools create \ | ||
| -t "${IMAGE_NAME}:main" \ | ||
| -t "${IMAGE_NAME}:latest" \ | ||
| "${args[@]}" |
There was a problem hiding this comment.
Preserve monotonic Docker tag updates
On main pushes with multiple workflow runs in flight, this step now always publishes this run's digest as :main/:latest; the previous ancestor check that skipped older commits was removed. If an older image build finishes after a newer commit has already pushed its manifest, it can overwrite both tags with the stale digest and roll Docker users back until another successful publish, so the merge job should retain the existing-tag ancestry guard or equivalent compare-before-push logic.
Useful? React with 👍 / 👎.
🔎 Lint report:
|
| Rule | Count |
|---|---|
unresolved-import |
102 |
invalid-argument-type |
64 |
unresolved-attribute |
34 |
unsupported-operator |
20 |
invalid-assignment |
10 |
invalid-method-override |
4 |
not-subscriptable |
3 |
no-matching-overload |
1 |
not-iterable |
1 |
invalid-return-type |
1 |
First entries
tests/docker/test_tui_passthrough.py:18: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
plugins/platforms/mattermost/adapter.py:352: [invalid-method-override] invalid-method-override: Invalid override of method `send_image_file`: Definition is incompatible with `BasePlatformAdapter.send_image_file`
tests/hermes_cli/test_kanban_promote.py:210: [unresolved-attribute] unresolved-attribute: Attribute `status` is not defined on `None` in union `Task | None`
tests/agent/test_non_stream_stale_timeout.py:38: [invalid-argument-type] invalid-argument-type: Argument to `AIAgent.__init__` is incorrect: Expected `list[str]`, found `str | bool`
tests/agent/test_non_stream_stale_timeout.py:38: [invalid-argument-type] invalid-argument-type: Argument to `AIAgent.__init__` is incorrect: Expected `int`, found `str | bool`
tests/run_agent/test_codex_silent_hang_hint.py:30: [invalid-argument-type] invalid-argument-type: Argument to `AIAgent.__init__` is incorrect: Expected `int | float`, found `str | bool`
tests/run_agent/test_codex_silent_hang_hint.py:30: [invalid-argument-type] invalid-argument-type: Argument to `AIAgent.__init__` is incorrect: Expected `dict[str, Any]`, found `str | bool`
tests/run_agent/test_credential_pool_interrupt.py:24: [invalid-argument-type] invalid-argument-type: Argument is incorrect: Expected `int`, found `str | Unknown`
cli.py:7204: [invalid-assignment] invalid-assignment: Object of type `int | float` is not assignable to attribute `_slash_confirm_deadline` of type `int`
tests/tools/test_tts_plugin_dispatch.py:213: [not-subscriptable] not-subscriptable: Cannot subscript object of type `None` with no `__getitem__` method
hermes_cli/dashboard_auth/cookies.py:55: [unresolved-import] unresolved-import: Cannot resolve imported module `fastapi.responses`
tests/agent/test_file_safety_cross_profile.py:219: [unresolved-attribute] unresolved-attribute: Attribute `lower` is not defined on `None` in union `str | None`
tests/agent/test_save_url_image.py:19: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/hermes_cli/test_dashboard_auth_audit.py:10: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/agent/test_display_tool_failure.py:10: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/agent/test_transcription_registry.py:20: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/hermes_cli/test_dashboard_auth_gate.py:14: [unresolved-import] unresolved-import: Cannot resolve imported module `fastapi.testclient`
tests/gateway/test_ntfy_plugin.py:21: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/cron/test_cronjob_schema.py:18: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["schedule"]` and `Unknown | str | dict[str, str] | ... omitted 3 union elements`
tests/tools/test_memory_tool.py:179: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["hardcoded_secret"]` and `str | None`
tests/hermes_cli/test_dashboard_auth_ws_tickets.py:12: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
cli.py:2381: [unresolved-import] unresolved-import: Cannot resolve imported module `prompt_toolkit.key_binding.key_processor`
plugins/platforms/mattermost/adapter.py:910: [unresolved-import] unresolved-import: Cannot resolve imported module `aiohttp`
tests/cron/test_cronjob_schema.py:26: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(i: SupportsIndex, /) -> str, (s: slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> list[str]]` cannot be called with key of type `Literal["schedule"]` on object of type `list[str]`
tests/hermes_cli/test_dashboard_auth_status_endpoint.py:16: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
... and 215 more
✅ Fixed issues (47):
| Rule | Count |
|---|---|
invalid-argument-type |
18 |
unresolved-attribute |
11 |
unresolved-import |
5 |
unsupported-operator |
4 |
invalid-method-override |
4 |
not-iterable |
1 |
invalid-return-type |
1 |
invalid-assignment |
1 |
call-top-callable |
1 |
invalid-type-form |
1 |
First entries
cli.py:12601: [unsupported-operator] unsupported-operator: Operator `+` is not supported between objects of type `str | list[tuple[str, str, str]] | int | Queue[Unknown]` and `Literal[1]`
tests/hermes_cli/test_mcp_tools_config.py:92: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> LiteralString, (key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> str]` cannot be called with key of type `Literal["exclude"]` on object of type `str`
gateway/platforms/mattermost.py:867: [invalid-argument-type] invalid-argument-type: Argument is incorrect: Expected `list[str]`, found `(list[str] & ~AlwaysFalsy) | None`
cli.py:12324: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> LiteralString, (key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> str]` cannot be called with key of type `list[tuple[str, str, str]]` on object of type `str`
gateway/platforms/api_server.py:2492: [invalid-argument-type] invalid-argument-type: Argument to function `create_job` is incorrect: Expected `list[str] | None`, found `Unknown | LiteralString`
tests/tools/test_vercel_sandbox_environment.py:17: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/run_agent/test_plugin_context_engine_init.py:85: [unresolved-attribute] unresolved-attribute: Object of type `bound method _StubEngine.update_model(model: str, context_length: int, base_url: str = "", api_key: str = "", provider: str = "") -> None` has no attribute `assert_called_once`
cli.py:12324: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(i: SupportsIndex, /) -> Unknown, (s: slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> list[Unknown]]` cannot be called with key of type `list[tuple[str, str, str]]` on object of type `list[Unknown]`
gateway/platforms/mattermost.py:352: [invalid-method-override] invalid-method-override: Invalid override of method `send_image_file`: Definition is incompatible with `BasePlatformAdapter.send_image_file`
gateway/platforms/mattermost.py:809: [unresolved-import] unresolved-import: Cannot resolve imported module `aiohttp`
cli.py:7112: [invalid-argument-type] invalid-argument-type: Argument to constructor `enumerate.__new__` is incorrect: Expected `Iterable[Unknown]`, found `(str & ~AlwaysFalsy) | (list[tuple[str, str, str]] & ~AlwaysFalsy) | (int & ~AlwaysFalsy) | (Queue[Unknown] & ~AlwaysFalsy) | list[Unknown]`
tests/tools/test_vercel_sandbox_environment.py:206: [unresolved-attribute] unresolved-attribute: Unresolved attribute `SandboxStatus` on type `ModuleType`
cli.py:7109: [unresolved-attribute] unresolved-attribute: Attribute `splitlines` is not defined on `list[tuple[str, str, str]] & ~AlwaysFalsy`, `int & ~AlwaysFalsy`, `Queue[Unknown] & ~AlwaysFalsy` in union `(str & ~AlwaysFalsy) | (list[tuple[str, str, str]] & ~AlwaysFalsy) | (int & ~AlwaysFalsy) | (Queue[Unknown] & ~AlwaysFalsy) | Literal[""]`
cli.py:7023: [unresolved-attribute] unresolved-attribute: Attribute `put` is not defined on `str`, `list[tuple[str, str, str]]`, `int` in union `str | list[tuple[str, str, str]] | int | Queue[Unknown]`
gateway/platforms/api_server.py:2492: [invalid-argument-type] invalid-argument-type: Argument to function `create_job` is incorrect: Expected `bool`, found `Unknown | LiteralString`
cli.py:12324: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(i: SupportsIndex, /) -> tuple[str, str, str], (s: slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> list[tuple[str, str, str]]]` cannot be called with key of type `Queue[Unknown]` on object of type `list[tuple[str, str, str]]`
cli.py:12324: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(i: SupportsIndex, /) -> tuple[str, str, str], (s: slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> list[tuple[str, str, str]]]` cannot be called with key of type `list[tuple[str, str, str]]` on object of type `list[tuple[str, str, str]]`
cli.py:12323: [unsupported-operator] unsupported-operator: Operator `<=` is not supported between objects of type `Literal[0]` and `str | list[tuple[str, str, str]] | int | Queue[Unknown]`
tools/environments/vercel_sandbox.py:300: [unresolved-import] unresolved-import: Cannot resolve imported module `vercel.sandbox`
cli.py:12324: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> LiteralString, (key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> str]` cannot be called with key of type `Queue[Unknown]` on object of type `str`
cli.py:7106: [invalid-argument-type] invalid-argument-type: Argument to function `_panel_box_width` is incorrect: Expected `str`, found `(str & ~AlwaysFalsy) | (list[tuple[str, str, str]] & ~AlwaysFalsy) | (int & ~AlwaysFalsy) | (Queue[Unknown] & ~AlwaysFalsy)`
gateway/platforms/matrix.py:243: [unresolved-import] unresolved-import: Cannot resolve imported module `mautrix`
tests/run_agent/test_plugin_context_engine_init.py:86: [unresolved-attribute] unresolved-attribute: Object of type `bound method _StubEngine.update_model(model: str, context_length: int, base_url: str = "", api_key: str = "", provider: str = "") -> None` has no attribute `call_args`
cli.py:12323: [unsupported-operator] unsupported-operator: Operator `<` is not supported between objects of type `str | list[tuple[str, str, str]] | int | Queue[Unknown]` and `int`
cli.py:7112: [not-iterable] not-iterable: Object of type `_T@enumerate` is not iterable
... and 22 more
Unchanged: 4765 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
…e_check_xsrf pitfalls Add two pitfalls discovered when running the skill against a fresh Jupyter server: - Pitfall #9: When the websocket reply channel hangs on every execute even though the kernel actually ran (REST shows execution_state=idle and execution_count increments), force zmq transport with --transport zmq. The zmq transport uses jupyter_client directly and sidesteps the broken websocket layer. - Pitfall #10: A fresh ServerApp rejects POST /api/sessions with "_xsrf argument missing from POST" unless you start it with --ServerApp.disable_check_xsrf=True. Needed for REST-only flows where no browser/cookie is establishing the XSRF token.
What
Syncs fork with NousResearch/hermes-agent upstream (400 new commits) and cleanly rebases all 15 PAI-specific patches on top.
PAI patches preserved
Conflict resolutions (3)
Interaction bug found and fixed
Upstream commit
dbe5d849added a universal main-model fallback that pre-fillsmodelbefore provider branches run. This caused the Antigravity provider to inherit the user's main model (e.g. gpt-5.5) instead ofantigravity-cli. Fixed by hardcoding the model sinceagyhas no model-selection flag.Test results
Remaining failures in full suite are pre-existing upstream issues (systemd/WSL on macOS, web_server config round-trip, ACP test ordering) — zero diff from upstream/main on those files.
Rollback
Backup branch:
backup/pre-upstream-sync-20260527-103733