Repository navigation
chore(fork): merge upstream main b3746e35ddd3 (RIG-8527) - #50
Merged
mattwilkinsonn merged 8909 commits intoNov 10, 2026
Merged
mattwilkinsonn merged 8909 commits into
mattwilkinsonn merged 8909 commits into
Conversation
…d changes A provider skipped because plugins.disabled names it stayed missing after the user enabled it in a running gateway or Desktop backend. The home layer only rescanned when the plugin directories' mtimes moved, and enabling a plugin edits config.yaml alone. The layer now remembers the disabled set it scanned with, so a changed selection triggers the rescan on the next periodic check. Answers Greptile r4511588226 on NousResearch#151816.
…ipped a plugin 1ee5734 loaded config.yaml on every periodic plugin-dir check. That broke two CI tests: a bypass session that must never open the profile's config.yaml, and a remote config backend whose poll raced the extra read. The layer now records the disabled set only when a scan actually skipped a plugin it names, and the refresh compares against config only then. Every other home does no config reads, as before.
…only The /model picker for a Claude subscription (DirectSDK) profile lost its cached live list every time Claude Code refreshed its OAuth token, because ~/.claude/.credentials.json (and the other vendor token files) were hashed into every non-Codex provider's catalog fingerprint. An external-process provider now fingerprints its command env overrides, the resolved binary and its declared catalog_credential_files, and skips the hard-coded vendor files and the Hermes auth stores, which its catalog never reads. Closes NousResearch#129094.
…aunches The catalog fingerprint resolved the profile's default process_command even when a process_command_env_vars override picked another executable. An upgrade that repointed an override symlink changed neither the override text nor the default binary, so the picker kept the old executable's model list. The launch path and the fingerprint now share external_process_command, so both select the same command. Answers Greptile r4511687631 on NousResearch#151817.
…e URL A dashboard or TUI session that runs on a provider other than the profile default (an OpenAI Codex session on a profile whose default is the Claude subscription DirectSDK provider) got its /fast ultrafast refused: the slash worker's HermesCLI seeded base_url from model.base_url (process://...) while --provider said openai-codex, and the route gate rejected that mix before any entitlement check. Both spawn sites (first use in slash.exec and _restart_slash_worker) now pass the live agent's base_url as --base-url, the same way --provider already travels (NousResearch#57283). The worker sets it as the CLI's seed only, not as an explicit launch base_url, so the gateway resume policy for the worker's session is unchanged. Mechanism and test shape from @686f6c61's handoff on NousResearch#141953 (branch fix/141953-slash-worker-base-url, commit d9e5bad); the fork is no longer reachable, so this is a reimplementation with credit. Closes NousResearch#141953.
…ion endpoint The base-URL test drove the worker with a fake HermesCLI, so it showed the assignment but not that the command reads it. The new test runs the worker's main() with a real HermesCLI, a conflicting process:// profile default and a stubbed Codex entitlement lookup, then sends /fast ultrafast. The lookup must receive the session's chatgpt.com URL and _explicit_base_url must stay unset. It fails without the worker's seed. The fake-CLI test goes back to covering --provider only. Answers Greptile r4511615523 on NousResearch#151818.
hermes chat -q / -z / --oneshot fired the background title model call on the first turn like an interactive session. Nothing ever shows that title (one-shot sessions are hidden from every picker), and on a process provider such as the Claude subscription DirectSDK plugin the call is a second paid CLI spawn per run. _maybe_title_session_at_turn_start now returns early when agent_is_oneshot(agent), next to the cron skip; the oneshot_footprint docstring lists it with the other things a one-shot does not do.
…core ci: lint, tests and js-tests run their commands through just
…port The catalog field reader printed shell assignments and the gate parsed them back with shlex, a shell layer between two Python programs. It now prints one JSON object, and the gate rejects the entry unless it gets exactly REPO, SHA, SUBDIR and BAD as strings. verify_uv_lock.py let TimeoutExpired escape after 600 s, so the job lost the attempt's log and published no review status. A timed-out attempt now prints its partial log, reports "uv.lock check timed out" and stops: retrying three more 600 s attempts would outlive the job.
…scripts ci: repo-guards, plugin-catalog and skills-index steps run scripts instead of inline bash
README rule 17 lets a catalog entry ship a native binary listed under attested_binaries, if `gh attestation verify` proves the plugin repo built it at the pinned commit. The old inline step fed its parser as `python3 -I - "$entry" <<PYEOF </dev/null`; the later </dev/null replaced the heredoc, so the parser ran an empty program, the list always came back empty and `gh attestation verify` never ran. NousResearch#151877 moved the step into scripts/ci/ and kept that dead branch out, so behaviour stayed the same. The field reader now returns the attested paths beside the clone source and rejects any that is not a plain relative path. The gate verifies each one against the entry's repo at the pinned sha, then removes it so the payload scan ignores it, and any other binary still fails. No catalog entry declares attested_binaries today, so nothing admitted now changes.
_check_attested removes each verified file from the clone so the payload scan ignores it, and it ran before _check_self_updater. A plugin could list a JS updater under attested_binaries: once its provenance checked out the file was gone, the self-updater grep never saw it, and users still got it from the pin. Provenance shows where a file came from, not what it does, so the self-updater gate now runs before anything is removed.
A provider profile's classify_api_error can mark an auth failure as one no other provider can fix (no CLI login where an external-process provider runs). The 401/403 escalation in route_classified_error ignored that flag and switched to the fallback chain anyway, so a subscription route silently billed a different provider (NousResearch#132337). Built-in auth verdicts keep should_fallback=True, so their behavior is unchanged.
New recipes, each one line over the script CI already ran: - `just docs-generate`: extract-skills.py, then generate-skill-docs.py. - `just docs-check-generated`: the "committed skill docs match the generator" block, moved verbatim into scripts/ci/check_generated_skill_docs.sh (same `git add -N`, same diff paths, same ::error:: line and exit 1). - `just docs-check-links`, `just docs-lint-diagrams`. - `just docs-check`: generate, then the three checks, in CI's order. docs-site-checks.yml calls docs-install, docs-generate, the three checks and docs-build. deploy-site.yml calls docs-generate and docs-install; its generate-skill-docs.py step moves up beside extract-skills.py because it reads neither the plugin stars nor the plugin catalog the steps between them write. Its full bilingual `npm run build`, staging and Pages deploy steps stay inline. Python recipes run through scripts/hermes-python, which uses the interpreter setup-pm exports as HERMES_PYTHON, as python3 did.
Two new recipes, one line each: - `just electron-runtime`: node scripts/build/electron-runtime.mjs --source . - `just js-install-tests`: the tests-js-only, production, no-scripts pnpm install the bundle smoke drives its chat with. e2e-desktop.yml and e2e-desktop-core.yml call `just js-install` and `just electron-runtime`; e2e-desktop-update.yml calls `just js-install`. desktop-bundle-smoke.yml calls `just js-install-tests` on both its macOS and Windows legs (they share one step list), each after setup-pm with toolchain all, which installs just. js-tests.yml, windows-desktop-attach-e2e.yml and the install-e2e-*-run.yml workflows still spell the same commands out.
…hrough just `just termux-payload <payload> <package>...` calls the new scripts/termux/stage_payload.sh, which runs termux_pkg_build.sh for each package in order, then stage_runtime_libs.py: the same four commands the "Stage the pinned bionic runtimes" step ran (build_cpython.sh and build_uv.sh are one-line wrappers over termux_pkg_build.sh with the package name as the subdir). The archive_inputs step before it stays a separate step: it needs R2 credentials and is not a developer command. The native linker contracts step runs `just test -j 2 <files>`, which is scripts/run_tests.sh --require-paths with the same arguments. desktop-bundled-release.yml stages the same payload in separate steps and is unchanged.
…cs job docs-lint-diagrams called the website's lint:diagrams script, which needs ascii-guard on PATH; only CI installed it, so `just docs-check` stopped there locally. The recipe now runs the pinned ascii-guard through scripts.ci.python_packages, the same pm environment CI uses, with the same arguments: 442 files checked locally, and a malformed box fails it as it fails the npm script. check_generated_skill_docs.sh moves to website/scripts/, beside the generator it checks, so a change to it starts the docs job.
…x alone docs-check-generated compared the working tree with the index, so on a laptop any unstaged docs edit failed as generator drift, just stopped before the links and diagram checks, and `git add -N` left untracked drafts intent-to-add in the real index. The check now works in a scratch copy of the index, and given a generator command it snapshots the docs before running it. CI's no-argument call compares against the committed docs as before. Co-authored-by: Bear Huddleston <196404565+BearHuddleston@users.noreply.github.com>
value.get(type) in the image_type set raises TypeError (unhashable type) when a message part's type field holds a dict, list, or set. Guard the membership test with isinstance(str) so only string types are compared and non-string parts fall through to the existing recursion.
…nvariants A non-string type neither raises nor stops the descent: the nested image under a union-typed node is still found, which a skip-the-dict fix would miss (danfye, KCAYAAI on NousResearch#119827). The other cases pinned behaviour main already had.
…he provider's process:// address legacy_launch_params replayed a pre-authority row's saved base_url as an explicit launch override. For an external-process provider that value is the provider profile's own process://<name> address, which the runtime reads from the profile and never from model.base_url; _apply_launch_overrides accepts only http(s), so adoption refused with invalid_params field=base_url and a canonical Bot Chat holding such a route refused every bot-to-bot delivery. Keep the provider, drop the address.
…only process:// The salvaged fix keyed the drop on external-process providers. A row saved on the virtual MoA provider carries `moa://local` the same way and is refused by the same http(s) launch check (NousResearch#147676). The rule is general: when the saved route names a provider (not bare custom) and its base_url is not http(s), the address belongs to the provider, so the provider alone is the route. A bare-custom route keeps its URL, so the custom-endpoint paths are unchanged.
…attestation-gate fix(ci): the plugin catalog gate verifies attested binaries
A plain group reply carries message_thread_id as a reply anchor, and a forum_topic_created service message must stay ahead of the thread-less opener that adopts its topic, so both keep the chat lane.
A message outside allowed_topics was dropped with no log line, so a topic the operator meant to allow (often a thread-less General message read as topic 1) looked like a dead gateway (NousResearch#52635). The first drop per (chat, topic) is now named at INFO with the allowlist. _topic_gates_pass moves from adapter.py into the topic-routing mixin.
The handoff repointed the route but left the persisted topic binding on the topic's old session, so the binding heal switched the synthetic handoff turn and every later message straight back to it: the handed-off conversation never reached the topic. Same class as /branch --here and /resume (NousResearch#58850).
…ollow-ups Greptile finding "Follow-ups overtake the topic opener": a thread-less DM opener the adapter adopts into a just-created topic stayed on the chat queue, while later explicitly-threaded messages used the topic queue, so a slow opener (photo download) could be overtaken. The update processor now mirrors the creation record and queues such an opener on both the chat lane and the new topic's lane.
…/resume Review finding (NousResearch#151772, P1): after a handoff repointed a Telegram DM topic and was suspended, a /resume that finished meanwhile was undone: the handoff resumed and wrote its topic binding unconditionally (route newer, binding cli-session), so the next message's heal moved the topic onto the handed-off session, and the synthetic handoff turn still ran in the newer conversation. The handoff's repoint and binding write now run inside the same per-topic publication /resume and /branch --here use, so the binding heal never acts between them. When another switch overlapped it, the binding is written only while the route still names the handoff, under the shared bind lock; a superseded handoff writes nothing, skips its synthetic turn and fails (the CLI keeps its session and can retry).
… the new topic Review finding (NousResearch#151772, P1, Greptile 4515985243): the processor and the adapter kept separate creation records. A slow root DM update held the chat lane, so the forum_topic_created handler (the adapter's record) ran after the first explicitly threaded message had already ended the follow, and a later thread-less root message within five seconds adopted the topic. Creation and adoption are now one ordered decision: update admission calls the adapter's _admit_topic_follow in arrival order (recording an authorized, non-bot creation; adopting, extending or ending the follow), and the handlers reuse that per-message result instead of deciding again.
…s suspended Review finding (NousResearch#151772, P1, Greptile 4515985269): the handoff re-checked the route only when another topic publication overlapped it, but /new and /reset move the route and write their binding without one. A /new landing between the handoff's switch and its bind was overwritten, and the next turn followed the binding back to the handed-off session. The handoff now binds through _follow_telegram_topic_switch, which checks the route unconditionally under the shared bind lock, so a superseded handoff writes nothing, skips its synthetic turn and fails.
…Research#75416, salvage NousResearch#76685) DoH routinely answers api.telegram.org with a single A record. The adapter discovers fallback IPs once per connect, so that lone IP was the whole fallback path: a DNS blip plus that edge going dark stranded polling until a restart. The seed IPs now always follow the DoH answers. Salvages NousResearch#76685 (@duclucky), re-applied onto the current discovery code.
_replacement_telegram_adapter() looked the reconnect replacement up in runner.adapters, which only ever holds the PRIMARY profile's map. A torn-down secondary adapter therefore delegated its in-flight sends - mid-turn progress, notices - to the primary profile's bot, delivering one profile's traffic into another bot's chat (NousResearch#143364, tracker row MSG-43). Resolve through runner._adapters_for_profile(self._owner_profile) instead: a named profile with no live adapter gets {} and fails closed, the primary keeps its own map. _wait_for_reconnection() shares the helper, so a stale secondary now actually waits for its own reconnect instead of seeing the primary's live bot. Closes NousResearch#143364.
…ofile replacement (NousResearch#98228, NousResearch#143364) After a replacement reconnect an in-flight turn keeps the retired adapter. send() already delegated to the replacement, but edit_message() and delete_message() returned "Not connected"/False, so progress fragmented into new bubbles and cleanup left them behind. Both now delegate the same way. The replacement lookup (moved with the reconnect wait into telegram_replacement.py, shrinking adapter.py) resolves through the runner's _adapters_for_profile only, so a secondary with no live replacement fails closed instead of borrowing the primary bot.
…sResearch#136412, salvage NousResearch#136427) The fallback transport walks sticky -> IPv4 literals -> hostname inside one send, and every path got the client's full 10s connect timeout. With the two seed IPs that is 3 x 10s = 30s, exactly the telegram-send deadline. The deadline fired mid-walk as a bare TimeoutError, which _classify_send_exception must treat as possibly delivered (retryable=False), so the reply was dropped: no retry and no plain fallback. Both transports built by build_ptb_requests now share a 15s connect budget per request walk. The first path keeps its full connect timeout and later paths split the rest. The walk therefore ends with its own ConnectTimeout, which is classified as a safe retry. A tighter configured connect is never widened. Salvages NousResearch#136427 (@liuhao1024). The budget split is ported. The adapter.py hunks (moving _shutdown_abandoned_app) are dropped: the budget now lives in the shared request builder.
…view 5948ab5 made a typing keep-alive take the chat's shared send+edit slot when it fired. The stream consumer SKIPS an interim edit while that slot is held, and `_keep_typing` ticks every 2 s with a 1 s slot, so in a group (which streams by editing one message) typing kept stealing the slot from the preview edits: the preview stayed on its first chunk. When Telegram then refused the finalize edit, the continuation was computed from that stale prefix and the first message read "<<A-fin-stre", so the answer's opening never appeared whole. Typing still checks the slot and the flood window (it stays quiet while a send or edit holds the chat) and still sends at most one call per chat per 2 s, so NousResearch#99643's fix (N DM topics multiplying sendChatAction) is kept. It just no longer claims the slot. Release-only e2e red on v0.23.1 rc.2 (run 42720042886): test_telegram_contract.py::test_contract[stream_finalize_rejected_group], 8/8 locally on 4f6ebb9. Bisected (DM cell + group cell together, as CI runs the file) to 5948ab5. With this change: the pair passes 3/3 and the whole contract file passes (14/14).
…ir requester NousResearch#152125 bound shared-chat exec approvals to the requester (or approvals.approvers / the platform admins), but the other prompts a chat member can answer for someone else stayed open: another member could press "Always" on a relay or native /reload-mcp confirmation and run its handler, or answer the agent's clarify question (a choice button, "Other", or a typed reply) for the person who asked. Both prompts now record the same authority an exec approval records when raised (tools.approval_context.prompt_authority: the turn's actor, else the command's sender, plus approvals.approvers and the chat type), and every chat answer path checks it through gateway.approval_actor.may_answer: - relay prompt_response (gateway/relay/prompt_resolvers.prompt_refusal), with the bot-owning profile's admins as for exec approvals; - Telegram, Discord, Slack, WhatsApp Cloud and Feishu button callbacks (slash_confirm_refusal / clarify_refusal, published on the plugin API beside approval_refusal); - the runner's typed-reply intercepts (/always, "2", free text after "Other"); another member's text is routed as their own message. A refused tap gets the existing "Only <name> can answer this approval." and the prompt stays pending. A turn merged from two senders records no requester, so only approvers and admins can answer, as for approvals. The default approvers ["*"] keeps today's behaviour. Supersedes the slash_confirm/clarify half of NousResearch#72008, whose session-owner mechanism no longer applies on main (main records the requester itself). Co-authored-by: necoweb3 <65560494+necoweb3@users.noreply.github.com>
…checked against Review of NousResearch#152310 (blocking): _hm_clarify_reply checked the sender against the session's oldest clarify, awaited transcription, then answered whichever clarify was oldest by then. If Alice's question expired mid-transcription and Bob's turn asked its own, Alice's transcript answered Bob's question unchecked. The reply now resolves the checked clarify_id (clarify_gateway.attempt_text_response); if that entry is gone, the transcript falls through as an ordinary message. Every other answer path (relay, Telegram/Discord/Slack/WhatsApp Cloud/Feishu taps, typed slash confirm) already resolves by exact id with no await between check and resolve. P3s from the same review: - Feishu HTTP-webhook card replies keep the refusal toast, as long-connection mode does (a refused tap answered {} before). - _prompt_refused_to's string dispatch becomes two direct helpers. - _request_slash_confirm reads event.sender_id(); the decline-fallback test now passes a real MessageEvent instead of a SimpleNamespace. Co-authored-by: necoweb3 <65560494+necoweb3@users.noreply.github.com>
commented
Nov 10, 2026
commented
Nov 10, 2026
Collaborator
Author
Rigel patch inventory
Needs attention
Counts: KEPT 29, SUPERSEDED 5, PARTIAL 1, MISSING 0. |
mattwilkinsonn
marked this pull request as ready for review
November 10, 2026 14:37
rigel-mintaka
force-pushed
the
hermes-sync/rig-8527-merge-upstream-main-b3746e35ddd3
branch
from
November 10, 2026 15:34
2cc28d5 to
0a781c7
Compare
commented
Nov 10, 2026
Collaborator
Author
Review round 1 (one all-lens review, at 2cc28d5), then re-syncRe-synced onto
Findings at 2cc28d5 (0 high, 1 medium, 7 low):
Tests on the re-synced tree:
|
commented
Nov 10, 2026
Collaborator
Author
Review round 2 (interdiff 2cc28d5 → 0a781c7)0 high, 1 medium, 2 low.
Fixed at aedfe38 (squashed into the recorded merge). Tier tests: 24 passed. They fail without the fix: 3 failed. Delegation, review and lifecycle suites: 161 passed. Ruff is clean. |
Co-authored-by: Matt Wilkinson <matt@rigel.build>
rigel-mintaka
force-pushed
the
hermes-sync/rig-8527-merge-upstream-main-b3746e35ddd3
branch
2 times, most recently
from
November 10, 2026 16:24
aedfe38 to
dc1ff60
Compare
commented
Nov 10, 2026
Collaborator
Author
Review round 3 (interdiff 0a781c7 → aedfe38): 0 high, 0 medium, 2 low
Fixed at dc1ff60. Tier, backend and delegate suites: 111 passed. Ruff is clean. The interdiff left is wording plus one predicate, verified by tests; all-clear on dc1ff60. |
mattwilkinsonn
approved these changes
Nov 10, 2026
5 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Merges upstream NousResearch/hermes-agent
main(b3746e3) intorigel-release(b8d4599, after #46–#49) throughrjc upstream-sync. Keep it a merge commit, sorigel-releasekeeps upstream history.Related Issue
RIG-8527. Card-format ruling: RIG-8797 (upstream bounded JSON).
Type of Change
Changes Made
admission_conflict, local-routepermission_denied. feat(approval): send a reviewer DENY to the human instead of blocking (RIG-8544) #43 replaced feat(approval): show a plugin's request and review it before the human (RIG-5666) #5.How to Test
-j 3, 1,437 files: 12,454 passed. The remaining failures also fail on upstream (cron interpreter cases,approval_boundary), or they are e2e tests for connections we don't use.Checklist
Code
Documentation & Housekeeping
For New Skills
N/A.
Screenshots / Logs
N/A.
Refs RIG-8527
Co-authored-by: Matt Wilkinson matt@rigel.build