acfda3759871e50a6899151069a564458604a721
4343 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9f9ec647f5 |
fix(gateway): re-check the live store before broadcasting the state.db warning
_send_session_db_warning_notifications() broadcast the error recorded at startup without asking whether it was still true. A startup `database is locked` routinely clears while the adapters are still connecting (another profile's open, a `hermes sessions` one-shot, a slow SMB lock release), so the home channels were told the store was unavailable when it had already healed — and a warning that is wrong once is ignored the next time it is right. The RecoverableHandleCache opener already clears `_session_db_init_error` on recovery; the broadcast now drives one open attempt through it first and only warns when the error is still standing. A store that is genuinely still down warns exactly as before. Fixes #108031. |
||
|
|
d41e906598 |
test(hosted-rooms): pin the coordination store off the master state.db; label sidecars
Invariant test for the #103489 salvage: a profile gateway and the root gateway resolve the same hosted-room coordination file at the install root, and that file is never the root SessionDB (`state.db`). Every profile gateway starts the hosted-room worker unconditionally, so the old mapping made each profile process a long-lived writer on the master session store — the simultaneous-restart corruption in #102120 and the main offender in the #103339 / #103490 fleet reports. Also: WAL-fallback labels say `shared-state.db`, and test fakes take the store path from `default_db_path()` instead of hardcoding `state.db`. Reported-by: #102120 #103339 (@RChina) #103490 Refs #102120 #103339 #103490 |
||
|
|
0e422e0ece |
fix(gateway): isolate hosted-room state in shared-state.db — profile gateways must not write master state.db
gateway.hosted_rooms.default_db_path() resolved profile gateways (~/.hermes/profiles/<name>/) to the ROOT state.db, making every profile gateway process a long-lived writer on the master session store. Across a 6-gateway fleet this was the recurring multi-writer corruption vector (WAL/FTS collisions during bot-gateway restart storms, observed 2026-09-03 with four state.db corruptions in five weeks fleet-wide; upstream refs #100896, #100313, #102827). Move hosted_room* tables to a dedicated root shared-state.db so profile gateways never open state.db writable. Single-gateway installs are unaffected (same file semantics, different filename); multi-profile fleets stop sharing the master store. Verified live in production: deployed 2026-09-04, zero I/O-error / reopen-race events across 9h+ of active multi-gateway load (previously ~1 failure per 2-3 min). Co-authored-by: Rik <rik.sedd@gmail.com> |
||
|
|
cbd03e6e4c |
fix(gateway): secondary-profile adapters no longer inherit the default's allow-all / allowlists
Under gateway.multiplex_profiles, os.environ holds the DEFAULT profile's .env. Several
adapter-owned authorization gates still read GATEWAY_ALLOW_ALL_USERS, GATEWAY_ALLOWED_USERS
or their platform allowlist/allow-all raw from os.environ, so the default profile opting
into open access opened every secondary email/QQ/WhatsApp/Matrix/Teams/Slack/LINE/DingTalk
bot to any sender (email additionally skipped From: authentication), the default's Matrix
allowlist decided who may approve tool calls on a secondary bot, and a secondary that
opted in only in its own .env was silently deny-all.
Every such read now goes through the adapter's existing module-local scoped reader
(gateway.platforms._shared.get_scoped_secret / matrix _startup_env_secret): profile
scope first, scoped miss = default, never os.environ; the unscoped default-profile and
single-profile paths keep the environ read, where it IS the profile's own value.
Sites: email _allow_all_senders/_allowlist_in_effect; qqbot _open_dm_opted_in;
whatsapp_common _open_dm_opted_in/_live_dm_allow_from; teams _card_action_denied;
matrix _is_authorized_user, MATRIX_ALLOWED_USERS, MATRIX_IGNORE_USER_PATTERNS,
_extra_csv_set (allowed/free-response rooms); slack _slack_allow_bots/_slack_api_human_users;
line _truthy_env/allowlist (allow-all, user/group/room allowlists); dingtalk _extra_get
(allowed_users/chats, free-response chats, require_mention).
Live repro (temp HERMES_HOME, multiplex on, default env GATEWAY_ALLOW_ALL_USERS=true,
secondary scope without opt-in): EmailAdapter._allow_all_senders() True -> False,
QQAdapter._open_dm_opted_in() True -> False, Matrix _is_authorized_user('@stranger')
True -> False, Teams card action allowed -> denied.
Co-authored-by: Drexuxux <drexux0@gmail.com>
Co-authored-by: MoonsvnLyn <FirmamentalSpring@users.noreply.github.com>
Co-authored-by: svector-anu <anuoluwakolapo94@gmail.com>
Co-authored-by: babatorik <durgun.ismail@gmail.com>
Co-authored-by: salch-cred <salch-cred@users.noreply.github.com>
|
||
|
|
a6ee31f55a |
feat(wisdom): add Hermes Collective Wisdom Agent V1 (#94266)
* feat(wisdom): add trusted publish and install foundation
* feat(wisdom): add private contribution loop
* feat(wisdom): add managed consumption workflows
* fix(wisdom): close cross-repository safety gaps
* fix(wisdom): align local package and lifecycle policy
* fix(wisdom): require explicit profile setup
* docs(wisdom): repin reconciled gateway head
* fix(wisdom): fence content downloads and approval receipts
* docs(wisdom): record generation-fenced downloads
* docs(wisdom): record unified delivery PR
* fix(ci): stop passing invalid classifier inputs
* docs(wisdom): remove internal requirements ledger
* feat(wisdom): localize dashboard and desktop copy
* feat(wisdom): complete local contribution and consumption UX
* style(wisdom): satisfy desktop lint
* chore(wisdom): refresh requirements pin
* test(dashboard): allow formatted profile copy
* test(wisdom): stabilize desktop interaction coverage
* fix(wisdom): surface dashboard action failures
* fix(wisdom): add repeatable Portal demo login
* feat(wisdom): add actionable skill notifications
* feat(wisdom): add notification install and update actions
* fix(wisdom): make Telegram skill alerts actionable
* fix(wisdom): always refresh demo Agent login
* feat(wisdom): embed Telegram notification actions
* fix(wisdom): preserve Telegram notifications after actions
* fix(wisdom): keep Telegram notification cards readable
* feat(wisdom): add Telegram candidate approval flow
* feat(wisdom): explain Telegram qualification reasons
* fix(wisdom): reconcile cross-surface candidate actions
* feat(telegram): add Collective Wisdom management command
* chore(wisdom): refresh Gateway contract pin
* chore(wisdom): advance Gateway contract pin
* feat(wisdom): align command UX across clients
* feat(slack): add Collective Wisdom management parity
* feat(wisdom): add security and professionalism reviews
* feat(wisdom): add first-time qualification guidance
* feat(wisdom): simplify qualification sharing choices
* feat(skills): add optional editorial metadata
* feat(wisdom): enrich legacy skill presentation
* fix(wisdom): harden review and update boundaries
* fix(wisdom): emit canonical review timestamps
* fix(wisdom): align with merged gateway and main
* wisdom: add agent-led sharing core (policy, evidence, schemas, templates, delivery, weekly job, share/install flows)
- hermes_wisdom/agent_led/: policy resolution (server > local > defaults),
7-day evidence builder that excludes bundled/hub/managed skills and
dismissed/handled/recently-suggested content hashes, strict pydantic
schemas for agent output with repair-or-reject, fixed copy templates
(Share / Teammate / Published / Update / Mute), idempotent retried
delivery ledger with stale-action resolution, weekly review job,
resumable Share and Install flows.
- prompts/: candidate review, recipient recommendation, share packaging.
- tests/wisdom/test_agent_led.py: 30 tests.
* wisdom: agent-led renderers and button action dispatcher
- render.py: Telegram HTML, Slack blocks, Desktop payload; editorial name
is the emphasized line, product label stays separate.
- actions.py: resolve opaque wa:<action>:<dedup> targets via the delivery
ledger; Not now -> dismissal, Mute -> fixed options, Share -> resumable
packaging flow, Install/Update -> plan command. Never publishes/installs.
* wisdom: CLI verbs, agent_led config default, conversational catalog skill
- hermes wisdom browse/review-week/act/share/dismiss/mute (all --json).
- wisdom.agent_led config block, default enabled.
- SKILL.md rewritten so natural-language catalog questions map to the CLI
verbs, share/install flows and fixed notification templates.
* wisdom: wire agent-led weekly review into gateway tick and Telegram buttons
- gateway housekeeping tick calls maybe_run_weekly_review with a home
channel sender when a Telegram adapter is available.
- Telegram: wa: callbacks resolved through the ledger (stale-safe), mute
duration keyboard, send_wisdom_agent_recommendation rich card + fallback.
* fix(wisdom): integrate local mediation and harden model and setup boundaries
* fix(wisdom): honor authoritative recommendation policy and defer on failure
* fix(wisdom): synchronize opaque suppression and recheck delivery preferences
* feat(wisdom): route weekly selection through the session-owned assessment queue
* fix(wisdom): prepare and submit the reviewed generated share package
* feat(wisdom): separate native Share preparation from publication consent
* feat(wisdom): sync native mute choices through a leased preference outbox
* feat(wisdom): bind native mute controls to durable preference choices
* feat(wisdom): add scoped desktop and dashboard notification settings
* fix(wisdom): revalidate feed recommendations before assessment and delivery
* fix(wisdom): persist validated delivery receipts before completing notices
* feat(wisdom): add private notification claim and receipt client
* Persist Wisdom send reservations and recover delivery acknowledgements
* Route legacy Wisdom controls through current native review
* Add typed private Wisdom operation outcome client
* fix(wisdom): make agent-led advice usable in the local demo
* fix(wisdom): keep requested consent outside proactive limits
* fix(wisdom): distinguish unavailable assessments and preserve digest text
* fix(wisdom): assess ongoing usefulness beyond the current task
* fix(wisdom): restore immediate qualification sharing controls
* fix(wisdom): separate qualification review from installation advice
* fix(wisdom): collapse review checklists and simplify sharing copy
* fix(wisdom): show compact sharing progress and publication receipts
* fix(wisdom): require credential prefixes rather than matching skill names
* fix(wisdom): finish package checks before presenting sharing consent
* fix(wisdom): scan local skills before qualification cards
* fix(wisdom): update moderation results on existing sharing cards
* fix(wisdom): keep sharing review accessible from receipt cards
* fix(wisdom): align mediated review cards and collapsible checks
* fix(wisdom): clarify clean security summary wording
* fix(wisdom): normalize consent plans and add explicit recheck
* fix(wisdom): keep install and update receipts concise
* fix(wisdom): collapse assessments and deduplicate operation cards
* fix(wisdom): restore private Portal review from native cards
* fix(wisdom): sync Portal publication to original consent card
* fix(wisdom): show local skill version on sharing cards
* fix(wisdom): skip agent recommendations for self-published versions
* fix(wisdom): simplify candidate notices and local-edit recovery copy
* feat(wisdom): submit locally reviewed packages with one confirmation
* feat(wisdom): expose safe receipt and outcome sync recovery
* wisdom: onboarding notice says detect and share, names the user's own skill
Copy review from the product owner on the first and returning
qualification notices (fixed delivery mode):
- the feature blurb now says the org enabled detection *and sharing*
- both notices say the detected skill is one the user created
- both close with an exclamation mark
Applied identically to hermes_wisdom.notice, the desktop and web i18n
strings, and the tests that assert the sentences.
* wisdom: one opener, no approval line, ask to share after the skill is shown
Product owner review of the candidate card.
- The Hermes written card now opens with the same sentence as the fixed card
("Your organisation has enabled Collective Wisdom, a feature designed to
automatically detect and share useful skills across all team members.")
instead of its own blurb, so there is one first time message.
- "Nothing is shared without your approval." removed from Telegram, Slack
and Desktop. The buttons already make the permission explicit.
- "Would you like to share?" no longer appears before the skill is named.
It is now the last line, after the skill name, description, why suggested
and the checks, and reads "Would you like to share it?" (matching the
agent led template wording).
Tests updated for the new order; proposalNotice removed from all desktop locales.
* wisdom: American spelling, organization
Product owner decision: user facing copy uses American spelling.
Changes "Your organisation" to "Your organization" in the chat notice,
the Hermes written card opener, the desktop and web strings, and the
tests that assert them. Identifiers such as nas_organisation:* and the
German and French locales are untouched.
* wisdom: candidate card copy round 4 (owner review)
Apply the product owner's round 4 copy decisions to the Hermes Collective
Wisdom candidate card on Telegram, Slack, Desktop and the shared views:
1. Hermes-written cards are titled "Hermes Collective Wisdom" instead of
the bare "Collective Wisdom".
2. The "Reusable skill ready to review" line is gone from the candidate
card (Telegram rich card and plain fallback, legacy agent-led share
template).
3. The skill name and description are labelled: "Skill name: <name>" and
"What it does: <description>" (Telegram, Slack, Desktop).
4. "Why suggested:" is now "Why others might benefit:".
5. A passing professionalism review reads "Safe to share at work ✓ (no
inappropriate content found)" with no per-check bullets and no "Pass";
a failed review reads "Needs a look before sharing at work (possible
inappropriate content)" and lists only the checks that flagged
something. Pending/unavailable wording is unchanged.
6. Telegram button toasts: "Will ask later...", "Preparing more
details...", "Sharing...".
7. Qualification reasons: "You used this skill consistently across many
days." and "You've really refined this skill."
8. prompts/wisdom_candidate_review.md asks for a compelling
editorial_name, a simple one_line_description and a compelling
why_coworkers_benefit under 300 characters; "Be concise and
convincing." becomes "Be concise and compelling: the goal is that the
user wants to share it."
Tests updated for the new strings; review_text() gains direct coverage.
* wisdom: re-apply owner copy after rebase
- Native share cards (advice_view/interaction_view): drop the approval line, ask "Would you like to share it?" as the last line after the checks
- Hermes-written completion card titled "Hermes Collective Wisdom"
- Qualification reasons use the owner wording (consistently across many days / really refined)
- American spelling (organization) in remaining English copy
- Desktop test asserts the current Share button; web test matches the returning notice
* fix(wisdom): pin reconciled Gateway and verify Unicode hash vectors
Pin Gateway 60cd2d6b613ae3cd4a6e65155d1142006d907e78 and byte-identical producer artifacts. Verify every content-order case and package-manifest binding. Validation: 186 focused Python tests, Ruff and contract verifier.
* fix(wisdom): reconcile optional SDK tests and frontend lint
* fix(wisdom): default to agent-written notification summaries
* fix(wisdom): restore deferred install review and browse controls
* feat(wisdom): inspect installed setup with exact package provenance
* feat(wisdom): run native-approved installed setup steps with durable evidence
* fix(wisdom): recover interrupted setup with explicit native consent
* feat(wisdom): hand native installs into guided setup review
* fix(wisdom): continue requested setup with fixed notification copy
* fix(wisdom): preserve setup while waiting for a session model
* fix(wisdom): expose canonical setup review controls on desktop
* fix(wisdom): resume setup after recorded automatic updates
* fix(wisdom): make missing setup prerequisites recheckable
* chore(wisdom): align Agent with verified Gateway contract
* fix(wisdom): stop guessing team slugs in portal links
* fix(wisdom): retire pending advice on account sign-out
* fix(wisdom): cancel advice after terminal account revocation
* fix(wisdom): fence feed responses across account sign-out
* fix(wisdom): checkpoint signed-out feed before reactivation
* fix(wisdom): link proactive advice to scoped notification settings
* fix(wisdom): coalesce queued publication recommendations by version
* fix(wisdom): keep package review navigation local and deferable
* fix(wisdom): reflect installed state in discovery controls
* fix(wisdom): show exact checks before command confirmation
* chore(wisdom): pin bounded analytics privacy contract
* chore(wisdom): pin retired legacy notification contract
* feat(wisdom): review publisher usage with exact sharing copy
* fix(wisdom): align discovery and review check summaries
* fix(wisdom): show expired consent before confirmation
* fix(wisdom): require fresh review for legacy install controls
* fix(wisdom): preserve review expiry across check toggles
* fix(wisdom): retain update policy in native install reviews
* fix(wisdom): surface failed native card edits
* fix(wisdom): persist local command approval reviews
* fix(wisdom): use saved approvals for messaging commands
* test(wisdom): provide scan result in setup handoff fixture
* test(wisdom): exercise Telegram approvals with saved review state
* fix(wisdom): retain suppression policy for offline deferral
* fix(wisdom): reconsider candidates after deferred suppression expires
* fix(wisdom): bind review checks and report verified readiness separately
* fix(wisdom): persist accepted publication intent and recover exact outcomes
* fix(sync): pin UTF-8 tree ordering across writers
* chore(wisdom): pin organisation-scoped Gateway authorization
* fix(wisdom): restrict consent delivery to user-facing sessions
* chore(wisdom): refresh reviewed Gateway contract pin
* fix(wisdom): preserve kept tools in Blank Slate exclusions
* test(auth): reset anonymous fixture with a profile-scoped cache
* fix(wisdom): gate local surfaces and work on current profile entitlement
* fix(wisdom): invalidate quiet tool cache on entitlement changes
* test(wisdom): authorize local consent gateway fixtures
* fix(wisdom): keep entitlement decoding free of native crypto imports
* test(wisdom): provide local entitlement to demo CLI subprocess
* ci: leave upstream workflow unchanged in Wisdom PR
* fix(wisdom): ship package and contracts in Nix wheels
---------
Co-authored-by: hbizi <36184542+hbizi@users.noreply.github.com>
|
||
|
|
05d705dd69 |
Merge pull request #105839 from victor-kyriazakos/fix/relay-explicit-disable
fix(relay): honor explicit profile disable before relay startup |
||
|
|
45a6101f36 |
fix(gateway): secondary-profile send_message, notices and /loop wakeups go out via their own bot
Under gateway.multiplex_profiles a turn running for a secondary profile P resolved its live adapter by bare platform from runner.adapters — the DEFAULT profile's map — so P's send_message tool calls (send/react/media on slack, matrix, wecom, buzz, ntfy, every plugin platform), its "Gateway shutting down/restarted" and /update notices, its /loop wakeups, and its Discord bot's unauthorized-slash operator alert all left through the default bot (Telegram DMs landed in the user's chat with the other bot). Every such door now resolves through the profile-aware, fail-closed resolver already used by the inbound reply path (authz_mixin: _adapters_for_profile / _authorization_adapter / _adapter_for_source): P's own adapter, or None → a clear error, never the default bot. - tools/send_message_senders.py::_live_adapter — resolve via runner._authorization_adapter(platform, get_active_profile_name()); shared by _send_via_adapter, _handle_react/unreact, media sends, matrix E2EE fast path and the WeCom standalone sender. - gateway/authz_mixin.py — extract _adapters_for_profile (the whole map, for relay- aware resolve_delivery_transport callers); _authorization_adapter reuses it. - gateway/run_shutdown.py — shutdown/restart notice for a running session uses the session's source transport / agent:<profile>: key lane, never self.adapters. - gateway/slash_commands.py + run_notifications.py — /restart and /update markers persist `profile`; the restart notice, update result and update prompt resolve the requester's own adapter (legacy markers fall back to the session_key lane). /goal, /heartbeat, /approve, /deny confirmations use the source's own transport. - gateway/slash_commands_goals.py + run_goals.py — /loop persists `profile` in its route; the wakeup watcher scans every served profile's store under its own scope (same shape as _handoff_watcher) and fires through that profile's adapter map. - plugins/platforms/discord/adapter.py::_notify_unauthorized_slash — alert stays in the owning profile (its adapters and its home channels). - plugins/platforms/wecom/adapter.py::_standalone_send — via _live_adapter. - docs: website/docs/user-guide/multi-profile-gateways.md (outbound identity). Tests (red on base): tests/tools/test_send_message_multiplex_profile_adapter.py, tests/gateway/test_multiplex_notice_egress_profile_adapter.py. Co-authored-by: pierrenode <298902573+pierrenode@users.noreply.github.com> |
||
|
|
c3e01c753d |
fix(gateway): /p/<profile>/ webhook delivery and api_server callbacks stay on the routed profile
Under gateway.multiplex_profiles a /p/<profile>/ webhook route (or one with
`profile: <name>`) executed under that profile but delivered its reply as the
FIRST profile owning the target platform: `_find_adapter` took
`runner.adapters` then iterated `_profile_adapters` in dict order, and the
home-channel fallback read `runner.config` (the default profile's). The gh leg
for `github_comment` inherited the process environ, i.e. the default profile's
GH_TOKEN. Both directions leaked: a secondary route posted through the default
bot, and a default route borrowed a platform parked only on a secondary.
The api_server `/p/<profile>/api/platforms/<platform>/events` callback had the
same shape — `_get_platform_callback_adapter` read `runner.adapters` regardless
of `_api_request_profile`, so a secondary's Google Chat / Teams events were
verified and dispatched by the default adapter.
Now:
- webhook: `_delivery_info` / the deliver_only dict carry the resolved
profile; `_find_adapter(platform, profile)` resolves through the runner's
shared fail-closed `_authorization_adapter`; the delivery leg runs inside
`_profile_scope(profile)` and takes the home channel from that profile's
`load_gateway_config()`; `gh` gets GH_TOKEN/GITHUB_TOKEN from the profile
secret scope (default's values dropped from the child env when the profile
has none).
- api_server: the callback adapter resolves via
`_authorization_adapter(platform, _api_request_profile.get())`; a named
profile without the adapter is a 503, never the primary's adapter.
A profile without the target platform fails closed ("not connected" → 502 /
503) instead of a silent cross-profile send.
Fixes #65939
Fixes #84266
Co-authored-by: 604maestro <604maestro@protonmail.com>
Co-authored-by: mjshorty <mjshorty@users.noreply.github.com>
Co-authored-by: StellarisW <stellarisw@users.noreply.github.com>
|
||
|
|
3b044261b6 |
fix(gateway): media denylist covers every profile's credentials, not just the launch home
Under `gateway.multiplex_profiles` one process serves every `<root>/profiles/*`,
but `_media_delivery_denied_paths` expanded `_ROOT_CREDENTIAL_PATHS` only under
the import-time `_HERMES_HOME` / `_HERMES_ROOT`. A `MEDIA:<root>/profiles/<B>/.env`
(or auth.json, state.db, config.yaml, sessions/, mcp-tokens/) emitted in ANY
profile's turn — including B's own, whose HERMES_HOME override was never
consulted — passed validation and was natively uploaded to the chat. The ALLOW
side (`_profile_cache_roots`) already enumerated profiles at check time; the
DENY side did not.
`_credential_home_roots()` now yields the active `get_hermes_home()`, the shared
root and every `<root>/profiles/*` at check time (shared `_profile_dirs()` with
the allow side), and the denylist is built from that. Profile cache artifacts
and plain agent-written files under a profile stay deliverable.
Live repro (/tmp/mux_audit/fix-media-denylist/repro.py): before, all five of
profiles/B/{.env,auth.json,state.db,config.yaml,sessions/s1.json} validated as
deliverable while <root>/.env was blocked; after, all None, cache/images/gen.png
and report.pdf still deliverable.
No prior report. Write-side analogue: #107327 / #107335 (memo keying, different
mechanism — left as is).
|
||
|
|
6f5dda1aee |
fix(relay): read the opt-out through the loader's own YAML reader
relay_explicitly_disabled() went through hermes_cli.config.read_raw_config,
which turns a malformed config.yaml into {} and then applies the managed
overlay. load_gateway_config() raises on the same file and falls back to
env + gateway.json WITHOUT the managed layer. With a managed
relay.enabled: false and an injected URL the two readers disagreed: the
loader kept relay enabled and suppressed native Slack, while startup
refused to register relay — a profile with no route at all.
Extract the loader's file selection into config_loader.read_yaml_layers()
and have both consumers use it; the predicate mirrors the loader's
fallback (no YAML layer) on a parse error. Regression test fails on the
previous predicate.
|
||
|
|
1d5debfa65 |
fix(config): apply managed overlay when the user config.yaml is absent
Narrow the PR's load_yaml_layer rewrite to the one behavior the relay fix needs: an absent user file is an empty layer, so administrator- pinned values (a managed relay opt-out among them) still reach the gateway on a host with no config.yaml. Everything else returns to the loader's existing contract. A MALFORMED user file still raises so load_gateway_config() keeps emitting its 'Failed to process config.yaml — falling back to .env / gateway.json' warning instead of silently applying the managed layer over the broken one; the loader keeps its own yaml read rather than importing the CLI's raw reader. Changing the malformed-file contract touches every platform and belongs in its own PR. |
||
|
|
337d69856f |
fix(relay): legacy gateway.json opt-out is advisory, like every platform
The loader stamps _enabled_explicit only from YAML blocks; a gateway.json `enabled: false` is re-enabled by env presence for slack, telegram and everyone else. Tagging the marker for platforms.relay alone in the legacy reader made relay the one platform whose gateway.json verdict beat the env — a per-platform pin nobody would find later. If legacy opt-outs should become authoritative, that is one rule for all platforms in its own PR. relay_explicitly_disabled() now reads the marker the loader itself sets instead of re-deriving the verdict from 'enabled' in block, so the standalone predicate and load_gateway_config() cannot disagree. |
||
|
|
5746b5bae6 |
fix(relay): veto the disable at the activation boundary only
Every relay object (adapter, media client, WS transport) is constructed inside the registry factory that register_relay_adapter() installs, and that call plus self_provision_relay()/send_relay_policy() all resolve through relay_url(). So the opt-out has three reachable boundaries: relay_url(), relay_fronted_platforms() (env-sourced, standalone cron) and the force=/url= bypass in register_relay_adapter(). The guards on RelayAdapter.connect, _get_media_client, RelayMediaClient.enabled/download and every WS dial/redial were unreachable unless config flipped on a RUNNING gateway — which the docs in this PR say is unsupported. Half-honoring that flip is worse than not honoring it: inbound kept flowing over the open socket while media downloads silently returned None and the next redial terminated. Drop those five guards, the _RelayDisabled terminal exception they needed, and the legacy-JSON lru_cache that existed only to make a full config merge affordable on the per-message path. The activation-time predicate runs a handful of times per boot; the shared raw-config and managed-scope readers already cache on mtime. |
||
|
|
4bdd64b334 |
The free tier is created in one place, at boot, only behind HERMES_GUEST_ONBOARDING=1 (NS-847) (#107697)
* fix(auth): close the free tier's gaps against the gateway's welcome-tier contract The inference gateway's welcome tier (NousResearch/api DOCS/anon-tier/plan.md) serves an anonymous account exactly one model on its own host, refuses everything else with a structured 429, cross-refuses a request on the wrong host with a 400 (403 while the tier is dark), and tells a signed-in account that still asks for `nous/welcome` what to switch to in an `x-nous-model-switch` header. Four client-side gaps against that contract: - Auxiliary calls were refused on every session. The auxiliary client asked the welcome host for the Portal's recommended compaction/vision model, a guaranteed 429 `model_not_free` before each fallback. On the welcome host it now uses `nous/welcome` (its backing model covers auxiliary work) and skips Nous for vision, which the welcome model does not take. - The structured 429 body was never read. The classifier now parses `reason` / `retry_after` / `alternates` / `upgrade_url`: `model_not_free` and `feature_not_free` are non-retryable gates that fall back; `at_capacity`, `admission_closed` and `rate_limited` are rate limits that honour `retry_after` and never rotate the free tier's only credential. The wrong-host 400 and the dark-tier 403 are deterministic, so they abort this route and fall back instead of retrying or re-exchanging. The terminal paths say what happened and name the sign-in (`/login` in a chat, `hermes auth upgrade` in a terminal). - The `x-nous-model-switch` header was ignored. The chat-completions transport records it beside the rate-limit and credits headers; the next call moves the session, and the config default when it still names `nous/welcome`, to the backing model the gateway named. - A guest fell back to the paid host. With `inference_base_url` absent from the exchange or outside the host allowlist, routing defaulted to inference-api, where every request is a 400. A guest now defaults to the welcome literal at the exchange, in the shared store's shape, and in effective routing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit fc758aad7efceff6223fc144a9b5c69f13e41bd8) * feat(auth): the free tier is set up on request; nous.guest_setup decides whether also on first use A caller that names nous/welcome on a Nous route with no Nous identity in reach — the guided setup's session (provider=nous, which skips the resolver's nothing-configured rung), the free-tier picker row, a bare --provider nous pointed at it — is asking for the free tier. The OAuth runtime rung now sets it up there instead of failing "not logged in", so the guided chat no longer races the root profile's first-run mint. nous.guest_setup is the policy seam: "auto" (default) keeps today's first-use setup wherever nothing else is configured; "on-request" mints only when the free tier is asked for by name (nous/welcome, /login, hermes auth upgrade, replacing a retired identity). Implicit callers — the resolver's last rung, the first-run check, free_tier.status, the CLI's background setup, the connector token path — still adopt what the shared store holds, so every profile follows the one identity the guided setup created, but never create one on their own. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit ae915ddc65ecdb81b81e29b604671d15cd49233c) (cherry picked from commit 62ad1ff3ab200ea064975a32c502041b25910165) * feat(auth): the guided setup provisions the free tier explicitly; nous.guest_setup is auto | explicit Two questions govern the free tier: may it exist (nous.guest) and who may CREATE the identity (nous.guest_setup). "auto" (default) keeps today's first-use setup wherever nothing else is configured. "explicit" means Hermes never creates one on its own: the only creator is the new provision_free_tier() primitive, exposed as the free_tier.provision RPC, which the guided setup on Hermes Desktop calls as its first step — on the root gateway, before the setup profile and before the guided chat exists — so the identity lands in the root store every profile reads through and is there before any session asks for nous/welcome. That closes the race against the backend's own setup, and makes "only when the setup-bot flow is used" literally true. The earlier "on-request" tier is replaced: it minted whenever any caller named nous/welcome (the hermes model row, --provider nous), which treated a model name as intent and was broader than the guided setup. Under "explicit" a nous/welcome request with no identity fails "not logged in" as before the free tier existed, and /login or hermes auth upgrade report nothing to sign in from. Implicit callers still adopt an identity the shared store holds, and a retired credential is replaced (a continuation, not a creation). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit c63d2c935c1e59016164fdfb90cf70b4094466a0) * fix(auth): remove the nous.guest_setup knob; the free tier is created on first use `nous.guest_setup: auto | explicit` decided who may CREATE the free-tier identity. Under its default every line it added was inert (`may_mint` always true), nothing in tree set `explicit`, unknown values read as `auto`, and under `explicit` a CLI-only install could never get an identity, which contradicts the first-run contract (first command mints, then chats). The mint race the knob accompanied is already benign: every caller takes the profile lock then the shared-store lock, and the loser adopts what the winner wrote. What makes the guided setup win deterministically is `provision_free_tier()` behind the `free_tier.provision` RPC, which stays. `nous.guest` remains the only free-tier policy. Removed: `guest_setup_policy()` and its constants, the `explicit=` / `may_mint=` threading through `ensure_portal_identity` and `_reconcile_and_provision`, the flag at the three replacement call sites (now no-ops), the config default, the docs section, and the four `guest_setup` test-config entries. The three policy tests that hold regardless of the knob are kept under `TestExplicitProvision`; the two that only tested the knob are deleted. (cherry picked from commit d8a50526d93c374c0067dd935b5a65055e0af261) * fix(gateway): a server-driven model switch off nous/welcome does not evict the cached agent When a signed-in account still asks the paid host for `nous/welcome`, the inference gateway serves the current backing model and names it in `x-nous-model-switch`. `apply_model_switch` moves the live session to that model and moves `config.yaml`'s default off the alias in the same step. The messaging gateway's fallback-eviction check compares the agent's model with the config default and evicts on any mismatch that is not a /model override, so when the config write did not land (unreadable config, lock) the cached agent was evicted once per turn, and prompt caching with it. `apply_model_switch` now stamps the alias it moved the session off on the agent, and `_is_intentional_model_switch` treats "agent moved off the alias the config still carries" as deliberate, beside the existing /model override case. The check takes the agent and the config model instead of a bare model string; its one caller in `_run_agent_evict_on_fallback` passes them. (cherry picked from commit 696d1ec86b69db28bf002c841e9389b85178a954) * fix(auth): the free tier outranks implicit host credentials in provider resolution On a fresh install with a leftover ~/.aws profile, resolve_provider("auto") reached the Bedrock rung before the free-tier rung, so the first turn ran on Bedrock and failed 403 while the free tier was still being minted in the background at agent setup (NS-829). Live on a Mac with ~/.aws present: 28 s, three retries, no answer; the next process then switched to nous/welcome. The free-tier rung now sits directly above the Bedrock chain: when nous.guest is on, an existing free-tier identity answers, else a blocking mint runs, and only then does the boto chain get a say. Everything above is unchanged and still wins: CLI creds, config.yaml model.provider, env keys, the OpenRouter pool, a logged-in active_provider. nous.guest: false skips the rung, and a failed mint still falls through to Bedrock and the no-provider guidance. Tests: six precedence cases (identity present, fresh mint, free tier off, env key still wins, sign-in still wins, failed mint falls through). The opt-out test now neutralizes the AWS chain like the precedence tests do; on a machine with ~/.aws it was failing for the same reason as the bug. Live after the fix, same Mac, AWS credentials visible, isolated shared store: identity minted 2 s in, turn on model=nous/welcome provider=nous, answer in 11 s. (cherry picked from commit a04b05260cd334dd7199ad9b6cd5b2538364c75a) * fix(auth): review follow-ups for the free-tier rung (NS-829) - tests/agent/test_bedrock_integration.py: the Bedrock auto-detect test switches the free tier off; its contract is the boto chain, and the free tier now sits above it. - gateway/run_notifications.py: the free-tier startup line reads auth.json before consulting the resolver, so a gateway boot on a machine with AWS credentials never mints or refreshes over the network. - hermes_cli/anon_auth.py: module docstring says where the free tier sits in the ladder instead of "the ladder is untouched". - tests/hermes_cli/test_provider_precedence.py: two invariant tests instead of six (parametrized ladder cases; a failed mint that returns None or raises falls through to Bedrock). scripts/run_tests.sh on the five affected files: 147 passed, 0 failed. (cherry picked from commit 10790d148c60ada11b9ecdde2cd2c836c6a82a11) * feat(auth): HERMES_GUEST_ONBOARDING=1 is the one launch gate for the free tier; HERMES_FORCE_GUEST is gone The free tier is pre-GA. Until GA it must not exist for anyone who did not ask for it: no identity minted, no portal traffic, no free-tier copy on any surface. One environment variable now decides that, and one function reads it. `guest_enabled()` returns False unless `HERMES_GUEST_ONBOARDING` is exactly "1"; only then does `nous.guest` (the user's off switch) get consulted. Every free-tier site already funnels through `guest_enabled()`, so the gate closes minting, routing, connector entitlement, status lines and the picker row in one place. With the variable unset, `resolve_provider("auto")` on a fresh install raises `no_provider_configured` exactly as upstream does. `HERMES_FORCE_GUEST` and `force_guest_mode()` are removed. They inverted the gate (forced the tier ON over `nous.guest: false`), their "new" value re-minted identities as a side effect of provider resolution, and `_has_any_provider_ configured` read them ahead of every other check, making the CLI a second reader of a flag that must have exactly one. `_forced_new_done` and the `force` parameter of `_reconcile_and_provision` go with them. Supersedes the dev lever introduced in fcf9d11679 (rung 1) and hardened in b5c162c3ec. Ruling: NS-845 Q1.1 (recorded on NS-847). Not a user preference: the variable is never written to config.yaml or .env and never shown in setup. It is deleted at GA together with its comment in anon_auth.py. This is a deliberate, temporary exception to the "no new HERMES_* env vars for non-secret config" rule. Tests: fixtures set the gate instead of deleting the old lever; one new invariant (`test_launch_gate_off_means_no_free_tier_at_all`) proves that "", "0", "true" and "new" all leave the tier off with zero portal calls, red on the previous commit. The `HERMES_FORCE_GUEST=new` re-mint test is deleted with the feature. * feat(auth): the free-tier identity is created in one place, at boot; every other site is a read Before this commit eight sites could create a Nous free-tier identity as a side effect of something else: resolving a provider, the CLI's first-run check, the CLI's session setup (in the background beside an own key), a connector bearer read, the desktop polling `free_tier.status`, the sign-in precondition, the desktop's `free_tier.provision`, and the dead-credential re-mint. A poll could mint. Provider resolution could hit the network. Two of them raced each other on a fresh install. Now `hermes_cli/free_tier_bootstrap.py::run_bootstrap` is the only creator. `hermes serve` runs it on a daemon thread from `_lifespan` beside the other background boots; `cmd_chat` runs it synchronously before the first-run guard. It inventories credentials first (`resolve_provider("auto", skip_free_tier=True)`: what would carry inference if the free tier did not exist), creates the identity only when `guest_enabled()`, resolves inference, records a `SetupRecord` in process memory and broadcasts ONE `setup.ready` event. It runs on every boot; only the mint is gated. `ensure_portal_identity` now requires `explicit=True` and raises otherwise. Its callers are the bootstrap, the desktop's `free_tier.provision` (the explicit retry when the boot could not create the identity) and the two dead-credential replacements (`auth_nous.resolve_nous_runtime_credentials`, `managed_tool_gateway._replace_dead_guest_token`). The background thread path and `provision_free_tier` are deleted with their last callers. Reads that used to mint and now only read: `auth.py::resolve_provider` rung 7 (an existing identity still outranks the Bedrock chain, NS-829 ordering kept), `main.py::_has_any_provider_configured`, `cli_agent_setup_mixin._ensure_runtime_credentials`, `managed_tool_gateway.read_nous_access_token` (no identity -> None), `anon_sign_in.run_sign_in` (no identity -> Unavailable), `methods_free_tier` `free_tier.status`. `setup.status` answers from the record for the launch profile, blocking up to 8 s while the bootstrap is in flight so a client's first poll lands after the identity exists rather than racing it; a named profile, or a process that never ran the bootstrap, keeps today's live probe. The record's fields ride along additively (`ready`, `free_tier`, `other_providers`, `inference_provider`). Identity and inference are decoupled (NS-845 Q1.3): the mint sets `active_provider="nous"` only when the inventory found nothing else usable (`_mint_locked(carries_inference=)`); an adopted account always does. A token refresh no longer re-elects the provider it refreshed (`_save_provider_state_to_source` writes credentials, not the user's choice) — that write was how an own-key install ended up on the free tier after the first connector call. Supersedes the mint sites in fcf9d11679, a42d0748fc (first-run check), bbbaa8935a (CLI background setup), 0179efc989 (`free_tier.status` mint), 62ad1ff3ab / c63d2c935c / d8a50526d9 (the `nous.guest_setup` knob and `provision_free_tier`), and a04b05260c (blocking mint in the resolver). Ruling: NS-845 Q1.2 + Q1.3, recorded on NS-847. Tests: `TestBootstrapIsTheOneCreator` (one mint per process; own key keeps inference; reads never reach the portal; a refused mint is memoised), `free_tier.status` fails loudly if it ever calls the creator, the resolver stub fails loudly if resolution ever mints, `setup.status` reads the record, `skip_free_tier` proves the inventory question. The three sign-in tests for the deleted pre-mint collapse into one (`no identity -> Unavailable, zero portal calls`). Live: real `_lifespan` boot with a fake portal, gate on and off (/tmp/ns847-recon/evidence/e2e-rung5-c2-serve-boot.txt), and the CLI matrix incl. an own-key cell (e2e-rung5-c2-bootstrap.txt), 20/20. * fix(credits): the welcome host is free-tier evidence, so a free-tier identity never sees "run /topup" A free-tier identity carries $0 by design, so the portal seed reports `paid_access=False` for it. `is_free_tier_model` did not know the welcome host, read that as a depleted account, and every free-tier turn ended with the credits-depleted notice telling the user to top up an account they do not have. Rule (4) in `is_free_tier_model`: a `base_url` on the Nous welcome host (`anon_auth.route_is_welcome_host`) is the free tier. The host is the evidence, not the model name: the paid inference host can serve `nous/welcome` to a named account and that account's depletion is real, so `("nous/welcome", <inference host>)` stays False. Local data only, like the three rules above it. Restores the two contracts dropped by hermes-magic 674e11d1eaa (the prototype line ran without unit tests): the welcome host is free without any pricing evidence; the model name alone is not. The first is red without this fix. * fix(copy): free-tier text stops promising a connector transfer and never names the config key Sign-in copy on every surface said "Sign in to keep your connectors" and ended with "Your connectors are kept." The transfer registry that would make that true is empty (NS-821): nothing carries over today. The copy now says what signing in does give ("unlock more models and tools") and the completion line names the account, not a transfer. The docs page loses the "connectors carry over" paragraph for the same reason. The picker's off-state line exposed `nous.guest: false` and the word "guest"; user copy names the free tier only (R-USR-1). The docs page gains the pre-rollout note: until GA nothing on it happens without `HERMES_GUEST_ONBOARDING=1`. Its "first command mints" and "replaced on next use" sentences now describe the boot bootstrap. zh is a strict locale: the `freeTier` block was English placeholder text copied from `en`; it is now Chinese. `connectorsKept` is renamed `completedBody` since it no longer talks about connectors. * feat(desktop): the free-tier launch flag is decided once in Electron and stamped onto every backend spawn The Python backend reads `HERMES_GUEST_ONBOARDING` and treats exactly "1" as on. Until now nothing in the desktop set it, so a packaged app could never turn the free tier on, and a backend spawned by the app could disagree with the app about whether the tier was live. `electron/guest-onboarding.ts` owns the decision: `guestOnboardingEnabled` is true when the launch env has `HERMES_GUEST_ONBOARDING=1` or argv has `--guest-onboarding` (the packaged-app spelling). It is read ONCE at launch into a module constant. `desktopBackendSpawnEnv` wraps every backend env as the outermost call and writes the flag LAST, as "1" or an explicit "0", so no earlier spread (`process.env`, `backend.env`) can resurrect a stray value from the parent shell. Stamped onto all three spawn sites: the primary `serve` spawn, the pooled per-profile spawn, and the remote SSH `exec env ...` command (which gains ` HERMES_GUEST_ONBOARDING=1` only when on). The embedded terminal PTY and the backend probes are not backend spawns and do not get it: a `hermes --tui` typed in the pane must not mint. The renderer learns the same fact read-only through the existing `hermes:launch-flags` sync IPC (`guestOnboarding`) and preload (`window.hermesDesktop.guestOnboardingEnabled`). Ruling: NS-845 Q1.1 / Q2 (env var is the contract, `--guest-onboarding` maps to it in main). Two invariant tests on the pure helpers: only "1" or the argv flag enables; the spawn env carries "1"/"0" as the last word and preserves every other key. * feat(desktop): the renderer learns free-tier readiness from one `setup.ready` push, not a 60 s poll The backend's boot bootstrap now announces `setup.ready` once, after it has created (or refused) the free-tier identity and resolved the inference route. The renderer used to discover both by polling `setup.status`, `setup.runtime_check` and `free_tier.status` every 60 s from `useStatusSnapshot`; a fresh install's chip, notice strip and onboarding overlay could sit stale for up to a minute after boot, and three RPCs a minute per window kept asking a question whose answer changes only at boundaries the backend already announces. `handleLifecycleEvent` routes `setup.ready` (active source only, like `skin.changed`) to `notifySetupReady()`, a one-shot tick atom in `live-sync.ts` beside the other change ticks. `useStatusSnapshot` listens to it and runs one readiness round at once (`setup.status` + `setup.runtime_check` + `free_tier.status`). The readiness legs also run once on open and on return from another app, as today. The 60 s tick keeps only `getStatus()`. `SetupStatusSnapshot` types the record's additive fields (`ready`, `free_tier`, `other_providers`, `inference_provider`); readiness semantics are unchanged and still key on `provider_configured` + `runtime_check`. Ruling: NS-845 Q1.2 (renderer half). Tests: the lifecycle branch fires one refresh from the active source and none from another; the snapshot hook's contract is three legs on open, one leg on the tick. * fix(cli): the banner names the free tier's model instead of "no model configured" The welcome banner prints before credentials resolve, so on a fresh install `model` is empty and the banner said, in red, "no model configured — run /model or hermes setup". Under the free tier that is false: the route is already known from local state (identity on disk, tier on), and the first message will run on `nous/welcome`. `_banner_left_lines` now asks the route the same question when `model` is empty (`guest_carries_inference()`, a local read) and shows `welcome · Nous Research`. When nothing resolves the red line stays. Ruling: NS-845 ("the banner's 'no model configured' line reads the resolved route"). Live: fresh HERMES_HOME + fake portal, gate on -> `welcome · Nous Research`; gate off -> the red line, zero portal calls. * fix(aux): vision on the free tier uses nous/welcome too The text-only modality on the gateway's `nous/welcome` row is DeepSeek V4 Flash's, the backing model until the repoint; `z-ai/glm-5.3-flash` is natively multimodal and the repoint declares the welcome row `text+image->text`. Skipping Nous for vision on the welcome host would have sent every image step past the free tier for no reason, so the auxiliary client pins the route's one model for every lane. A backing model that takes no images answers with the upstream's own error, which the ladder handles as it always has. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 7456e028faba55480db43015dc2c8df3e393a415) * fix(gateway): hermes gateway run is a boot owner of the free tier too Rung 5 made every demand-time free-tier site a read: resolve_provider, the connector token, the /login precondition. That is only correct if every process that can reach those sites ran the bootstrap first. The CLI (cmd_chat) and hermes serve (_lifespan) did; the standalone messaging gateway did not. A fresh HERMES_HOME with the gate on and `hermes gateway run` reached provider resolution with no identity to consume, and /login returned Unavailable. Reported by @andrexibiza on #107697 (P1). GatewayRunner.start now runs `free_tier_bootstrap.run_bootstrap` on an executor thread right after startup recovery and BEFORE any adapter connects, so a fast first DM cannot arrive with nothing to resolve. It is its own step, not part of the turn-machinery warm-up: the warm-up is an optimisation with an off switch (HERMES_STARTUP_WARMUP_TIMEOUT<=0); the bootstrap is correctness and must always run. With the gate unset it is a local inventory and no network. Live, real GatewayRunner.start against a fake portal in a fresh home: gate on -> 1 create, identity persisted, resolve_runtime_provider=nous, /login precondition sees the identity gate off -> 0 portal calls, no identity, no_provider_configured Before the fix the gate-on row was identical to the gate-off row. Test: the bootstrap seam runs before _start_prefilter_platforms and delegates to the one creator. Red on 5554eb6993 (no seam), green here. --------- Co-authored-by: Robin Fernandes <robin@soal.org> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
cbcf7b72f7 |
feat(gateway): sign in with a Nous account from a chat (/login), one shared sign-in flow (#105261)
* refactor(auth): one sign-in flow behind SignInState, rendered by the CLI and the desktop * feat(gateway): /signin signs the free tier into a Nous account from a DM * feat(cli): chat surfaces name /signin as the sign-in verb * fix(auth): review follow-ups for the shared sign-in flow and /signin * fix(i18n): carry the /status free-tier line in every locale catalog * refactor(cli): the chat sign-in command is /login * fix(auth): durable override cleanup in the /login sweep, and the sign-in flow in its own modules |
||
|
|
a2db110ccc |
feat(auth): Nous free tier: free inference and connectors out of the box, one command to sign in (#105258)
* feat(auth): Nous free tier core: anonymous identity minted on first use, welcome inference, shared-store scoping A fresh install with no provider sets up a free Nous identity (anonymous auth method of the nous provider) instead of forcing the setup wizard. The identity is persisted through the same path a real login uses, so the resolver ladder is unchanged. Two seams differ: token acquisition re-exchanges the anon credential (no refresh token), and routing pins the welcome host's single model nous/welcome. One identity per shared store; nous.guest: false turns the free tier off. * test(auth): free tier core contracts: lifecycle, resolver precedence, exchange seam, model pin * docs(user-guide): free tier and signing in New page explaining what a fresh install gets before any key or sign-in (free inference on nous/welcome plus connectors), how the free tier coexists with a user's own API key, how to sign in with hermes auth upgrade and keep connectors, how to turn the free tier off with nous.guest, what hermes logout does in each state, a troubleshooting table, and a plain privacy note. Wired into the Using Hermes sidebar. * fix(auth): logout leaves the free tier alone and clears the shared store for a real Nous account Logging out of the free tier is a no-op: it is not a login, so nothing is cleared and the user is told they were never signed in. Logging out of a real Nous account now also clears the cross-profile store, so a profile logout is not silently re-adopted on the next boot. * fix(model): switching off the free tier points at signing in, never hops providers * Name the free tier in the gateway startup notice and tell explicit-provider installs about it once * Render the Nous free tier as free tier on auth status, auth list, hermes status and portal info, short-circuit billing copy for it, and skip the keepalive when there is no refresh token * fix(auth): free tier is set up where nothing is configured: resolver last rung and first-run check Both the provider resolver's terminal rung and the CLI first-run check now try to set up the free tier before declaring nothing configured. On a fresh install the first command lands in chat on nous/welcome; a failed setup still falls through to the existing guidance. * Add hermes auth upgrade: sign the free tier into a Nous account while keeping its connectors The device-code flow runs as usual, with a promotion intent registered on the portal between the code request and the token poll so the account that approves the code inherits the free tier's connectors. The promotion status decides the outcome: only a completed one is followed by the token grant, which is persisted over the free-tier singleton and the shared store. Declined, superseded, retired and busy outcomes each print their own plain copy, and a retired identity is cleared so the next use sets up a fresh one. User-facing text never names the free tier's internals. * Show the Nous free tier as one picker row with nous/welcome and hide it when nous.guest is off * fix(auth): upgrade opens the consent page for this sign-in; one mint attempt per process; forced free tier wins the first-run check The browser leg of hermes auth upgrade now prints and opens the promotion claim URL with the claim code, not the generic device page. A failed mint is attempted once per process so several bootstrap sites cannot hit a closed gate or a 429 twice; a retired credential resets that so re-minting still happens. HERMES_FORCE_GUEST is honoured ahead of the first-run provider check. * fix(auth): pin the welcome model on the selected route, not on profile state; background setup retries after a failure A credential-pool entry can select a paid Nous key while the profile singleton is still the free tier. The model pin now keys on the resolved endpoint (welcome host) in agent init and /model, and the pin in model normalization is removed since it had no route to look at. A failed background identity setup releases its latch so a later attempt in the same process can try again. * fix(auth): decide the Nous model together with the route on every credential-pool swap The credential pool can move a Nous agent between the welcome host and the portal host after init. One helper, pin_model_for_route, now runs at init and inside every pool swap, so the welcome host always carries nous/welcome and a paid endpoint always keeps the caller's model. * fix(auth): apply the route model policy on every wire mode during a pool swap; release the setup latch if the thread cannot start * fix(auth): free-tier lifecycle takes profile then shared lock, reconciles with the shared store, persists the mint before exchanging, and clears only the identity that died The shared store is the identity of record for a Hermes root: a profile holding a stale free-tier identity adopts a sibling's newer sign-in instead of keeping the guest, and never overwrites the shared account. Locks are taken in the documented order (profile, then shared). A minted credential is stored as soon as create succeeds, so a rate-limited or timed-out exchange does not lose it and trigger a second mint. Retiring a dead credential removes only that credential from both stores. Guest exchange uses the resolver's canonical portal URL. * fix(auth): a credential rotation never rewrites the conversation model; connectors honour the off switch and replace a retired free-tier credential The welcome host serves one model, so a rotation onto it is refused for any conversation on another model instead of silently switching that conversation to nous/welcome (the model pin applies only when a route is first chosen). The connector token path now treats the free tier as absent when nous.guest is false, including cached tokens, and shares the one dead-credential rule with inference: a retired identity is replaced once rather than returning its stale token. * fix(auth): plain login never imports the free tier as OAuth credentials; the gateway startup line reads persisted state only A free-tier identity in the shared store is not an OAuth credential to offer for import; a real sign-in replaces it. The gateway's startup notice now answers provider precedence from persisted state (no token refresh at boot), so an expired free-tier token cannot stall the online message. |
||
|
|
949724b321 |
fix(gateway): one context-overflow verdict for the reply and the transcript skip
Moving the `if response` guard below the failed branch exposed the normalizer's loose overflow predicate (bare "token"/"exceed"/"context"/"payload", or any 400 on a long session) to failed turns that carry real text: billing, rate-limit, auth and content-policy replies were rewritten to "Session too large / /compact". Hoist run_turn's stricter classifier (compression_exhausted, multi-word phrases, 400 on history > 50) into a module-level `is_context_overflow_failure_result` and use it for both the #1630 transcript skip and the user-facing rewrite, so the two can never disagree. Populated text is only rewritten when it is the bare provider envelope (`_looks_like_gateway_provider_error`) on an overflow turn; curated agent text (compression-timeout guidance, /compress hint) survives. Replaces the sanitizer-wording test with the passthrough invariants that catch the regression. |
||
|
|
613d1f7c03 | fix(gateway): keep session-too-large reply when a failed 400 still has text | ||
|
|
88fc9402d8 |
feat(honcho): declare identity_signature and drop the gateway's honcho keys
The gateway agent cache read honcho.json itself through a honcho-named block in gateway/run.py and gateway/run_agent_cache.py. Every other memory provider had no way to bust the cache when its identity mapping changed. HonchoMemoryProvider.identity_signature() now returns the same values under provider-neutral keys: user_identity, agent_identity, pin_user_identity, runtime_identity_prefix, user_identity_aliases, session_prefixing. The gateway files them under memory.<key> through the MemoryProvider hook. The hook reads config only, memoizes on the file's mtime and size, and returns an empty dict when the file cannot be read. The honcho-specific extractor, its memo and its key tuple are gone from the gateway. The pinPeerName cache-busting test now asserts on memory.pin_user_identity. |
||
|
|
445113dffa |
fix(gateway): count bot messages under the transport profile
The loop guard peeked under the transport profile stamped on the source but counted under the routed profile's scope, so the two read different gateway.bot_loop_guard blocks. _admit_bot_message_for_source runs the count under the same _authorization_profile_home as _is_user_authorized_for_source, and both the ingress gate and the busy path call it. |
||
|
|
a7bc3c7b4b |
fix(gateway): charge the bot loop budget on the busy path
_handle_active_session_busy_message only peeked at the guard, so a bot steering a running turn was never counted and the budget never tripped. The busy handler now admits the message once after the authorization verdict and marks the event. _hm_admit_event skips a marked event, so a follow-up that was queued on the busy path and drains later is not charged twice. |
||
|
|
366f9446c9 |
fix(agent): pass turn_author only to a run_conversation that accepts it
The gateway turn runner and the quiet one-shot passed `turn_author=` unconditionally. Every test double and wrapper with an older `run_conversation` signature raised TypeError, which CI caught across twenty gateway tests. Both call sites now check the callee with the existing `_accepts_keyword` helper. A human `-Q` turn keeps today's call shape with no author keyword. |
||
|
|
55b3ea0b11 |
fix(gateway): count each bot message once in the loop guard and consume the author variable
The Telegram adapter asks the authorization check before dispatch, the ingress gate asks it again, and the busy path asks a third time. Each call counted one loop-guard event, so a Telegram bot tripped the budget after a third of the configured messages. The verdict now only refuses a chat that is cooling down. The ingress gate counts an admitted bot message once. `parse_turn_author` treats only booleans, integers and the strings true/1/yes as a bot flag, and returns None for an author with neither id nor name. Names keep format characters and non-breaking spaces so emoji sequences survive. The quiet one-shot pops HERMES_TURN_AUTHOR before the turn so tool subprocesses do not inherit it. `max_events` must be a whole positive number. Issue numbers move out of code comments. |
||
|
|
5bf69e963c |
fix(gateway): loop guard judges the final authz verdict and reads config.yaml
#91483 added the guard inside the ALLOW_BOTS block. on today's authz layout an admitted bot returns from an earlier rule before that block runs, so the guard never tripped (its own ping-pong test passes turn 21 on this tree). _is_user_authorized now computes the allowlist verdict first and runs every admitted bot-authored message through the guard, so bots admitted by a chat allowlist, allow-all, pairing or delegation are metered too. the budget stays per conversation. settings move from HERMES_BOT_LOOP_* environment variables to config.yaml gateway.bot_loop_guard (defaults added to config_defaults), re-read on every call. an unauthorized bot dm no longer gets a pairing code, so a cooldown produces no outbound traffic. tests rebuilt against real SessionSource objects. |
||
|
|
3a37785f7f |
fix(gateway): add bot-to-bot loop guard for Telegram
Telegram Bot API 10.0 (2026-05-08) lets bots receive messages from other
bots, and the platform ships no loop guard of its own. core.telegram.org
/api/bots/bot-to-bot ("Loop prevention") requires the BOT to make
bot-message handling terminate predictably via dedupe, per-chat rate
limits and maximum interaction depth, and warns that "failure to handle
loops properly may lead to degraded performance or platform
restrictions".
Hermes had no brake at all: TELEGRAM_ALLOW_BOTS in gateway/authz_mixin.py
was a bare `return True` with no counter, window or cooldown.
TELEGRAM_ALLOW_BOTS=mentions does not help, because when bot A replies to
bot B the reply itself satisfies the mention test, so every turn re-arms
the peer. Observed 2026-08-21: two Hermes bots exchanged 132 messages in
one group before a human intervened.
This adds gateway/bot_loop_guard.py, a thread-safe sliding-window budget
with cooldown and sweeping of stale buckets, wired into
_is_user_authorized. Defaults (20 events / 60 s window / 60 s cooldown)
match the OpenClaw reference implementation.
Two placement details that are easy to get wrong:
- The guard runs BEFORE the TELEGRAM_GROUP_ALLOWED_CHATS shortcut. That
env var returns True for any sender in an allowlisted chat, bots
included, ~32 lines before the is_bot block, so a guard placed in the
is_bot block never executes for the one configuration that needs it.
- The budget is keyed per CONVERSATION, not per (sender, receiver). This
process only sees inbound messages, so the receiver is constant; keying
on the pair would give each sender its own budget and N bots would need
N x budget messages to trip a guard meant to cap the whole exchange.
Installations without ALLOW_BOTS are byte-identical in behaviour: the
guard only runs on the source.is_bot branch with ALLOW_BOTS set to
mentions or all. HERMES_BOT_LOOP_PROTECTION=off is a full kill switch.
Adds tests/gateway/test_bot_loop_guard.py (7 cases), each verified
failing against the tree without this patch.
|
||
|
|
0ac631b6c0 |
feat(api): accept a turn author on session chat and runs; peer dm forwards it
POST /api/sessions/{id}/chat, /chat/stream and /v1/runs take an optional
"author" object. absent or null keeps today's behavior; a non-object is a 400
invalid_author. the value reaches run_conversation as turn_author and nothing
else: it is a claim by an API-key holder and grants no capability.
hermes peer dm and peer run add the author to the request body when the
message_agent runner launched them with HERMES_TURN_AUTHOR set, so a
cross-machine dm is attributed the same way a local one is.
|
||
|
|
a40deb0301 |
feat(gateway): bust the cached agent on provider-declared identity values
_extract_cache_busting_config knew one memory provider by name and imported its config class to read identity keys. it now also asks the configured provider for identity_signature() through plugins.memory.load_memory_provider and merges the result under "memory.<key>". the hardcoded block stays until that provider implements the hook. |
||
|
|
70b1ff6930 |
feat(memory): carry the turn's author into the memory-provider contract
`on_turn_start` documents a per-turn kwargs channel — "kwargs may include: remaining_tokens, model, platform, tool_count" — and `MemoryManager` forwards whatever it receives. Its only caller passed nothing, so a memory provider had no way to learn who wrote the turn it was being told about. Providers that key durable state on identity resolve one identity when the session is created. A shared session does not work that way: threads are shared by default (`thread_sessions_per_user` is False), so alice, bob, and another agent all write turns into a session whose peer is whoever spoke first. The gateway's answer today is the `[name]` prefix it prepends to the message text, which the model reads and a provider cannot. `turn_author` now travels from the gateway through `run_conversation` into `build_turn_context`, which forwards `author_id`, `author_name`, and `author_is_bot` to every provider. It stops there — the trio never reaches the model, and providers that ignore the kwargs are unaffected. The bot flag is sent on every transport, not only shared sessions: a provider deciding whether a turn may write to durable memory needs it in a DM too. `SessionSource.is_bot` is only as good as its producers. `build_source` defaults it to False and 3 of 32 adapter call sites pass it, so most platforms still report every author as human. Populating the rest is follow-up work; nothing here depends on the flag being right yet. |
||
|
|
22356075f1 |
fix(cron): make user-written bare-platform home targets continuable
A managed cron with `deliver: "slack"` (no captured origin) delivers to the Slack home channel, but the brief was never mirrored into that session and the in_channel seed never fired: bare-platform targets resolved with no `_resolved_from` provenance, so `_target_mirror_eligible` treated them like `all` broadcast expansions. - `_resolve_delivery_targets` derives `from_broadcast` from the raw token and passes it to `_resolve_single_delivery_target`; a user-written bare platform token is tagged `_resolved_from: "home"`, `all` expansions stay untagged (fan-out is never continuable). - `_target_mirror_eligible`: `home` eligible under the same flags as `origin_fallback` (per-job attach_to_session wins, else cron.mirror_delivery). - `_MIRROR_PROVENANCE_RANK`: `home` == `origin_fallback` so token order through dedup cannot strip eligibility. - Docs, tool schema text, cron/gateway AGENTS.md updated; existing exact-dict pins carry the new key. Squashed from PR #101819 (commits 5fe612c9, 65e36926, 10e0af1d, f2cc4639): they predate the cron/scheduler.py -> scheduler_delivery.py split and do not cherry-pick individually onto current main. |
||
|
|
f97a4102dd |
fix(relay): accept a provision response that issues no secret (lockstep with gateway-gateway#223) (#104767)
* fix(relay): accept a provision response that issues no secret Lockstep with gateway-gateway #223 (finding F-004). The connector used to return the stored per-gateway secret from POST /relay/provision on EVERY replay, to anyone reaching the endpoint with the right gatewayId. It now returns credential material only to a caller that proves recorded ownership or possession, and answers secretIssued:false otherwise with secret/deliveryKey ABSENT. This gateway treats that as a hard failure, so #223 cannot merge until this ships. 1. A secret-less response is a VALUE, not an error. _post_provision() raised on any response without a secret. Two legitimate responses hit that raise once #223 lands: - every platform after the first in a multi-platform boot. All platforms share ONE gatewayId, so the first POST mints the secret and the rest are replays of an unchanged binding. self_provision_relay() catches RuntimeError per platform and continues, so a Telegram+Discord gateway would front Telegram and leave Discord unfronted with a warning. - a re-provision by a caller that legitimately no longer holds the secret. Refusing to hand it back IS F-004; /relay/rotate is the way back. Only a malformed body raises now. No secret AND no secretIssued key is still the old unexpected-response case (a pre-F-004 connector), so that keeps failing loudly -- which is what makes this deployable in either order. 2. Only a response that CARRIES credentials may write them. A separate bug that change 1 exposes rather than causes. The guard tested the same env var it wrote: once a withheld response reaches it, it writes an empty secret -- which then reads as "still unset" on the next pass, masking itself, while GATEWAY_RELAY_ID and _DELIVERY_KEY have ALREADY been stamped from a response carrying no credentials. Guard on the secret being present. Tests: 5 invariants in tests/gateway/relay/test_provision_secret_optional.py. Proven red on base -- reverting gateway/relay/__init__.py to origin/main turns 2 of the 5 red, one per fix, then green with the fix restored. My first draft of the multi-platform test passed on base because it had the FIRST platform issue the secret, which satisfies the old guard so it never re-enters and the bug never fires; it proved nothing until the ordering was fixed and a direct write-rule test added. tests/gateway/relay: 303 passed, 0 failed. The 6 failures in the wider tests/gateway run (systemd_notify, wecom_callback, shutdown_forensics, scale_to_zero) reproduce identically on clean origin/main -- pre-existing. * fix(relay): fail closed on the F-004 discriminator; a fully-withheld boot is not a provision Two review findings on the parent commit, each with a red-on-parent test. 1. `_post_provision()` accepted ANY present `secretIssued` value when `secret` was absent (`true`, `"false"`, `0`, …) and settled them as a successful provision. The only credential-less shape the connector emits is literal JSON `false`, so match the discriminator by value: `is not False` raises. 2. `self_provision_relay()` returned True and logged the INFO "self-provisioned" line when every platform answered `secretIssued:false` — no credential in hand, WS upgrade about to be refused, nothing in the boot log to say why. Now: WARNING naming `/relay/rotate` / pin `GATEWAY_RELAY_SECRET`, return False. tests/gateway/relay/: 308 passed, 0 failed. |
||
|
|
167448edca |
perf(gateway): warm the Python toolchain probe in the boot warm-up
The gateway's startup warm-up (_warm_turn_machinery_sync) primed the run_agent import graph, tool schemas and context files, but left the process-local Python toolchain probe to the first AIAgent. That agent starts the probe worker in its constructor and its first system-prompt build then blocks in get_environment_probe_line() on the python/pip subprocesses (~250 ms measured) before the first model request — with local models too, so provider metadata warm-up (#105999) does not cover it. Call the same resolver from the warm-up: it owns the agent.environment_probe opt-out check here, and the resolver itself owns remote-backend omission, the single worker, its cache and the bounded fail-open wait, so the first turn simply finds the line cached. No agent is constructed and no session prompt is frozen at startup; a disabled probe or a remote terminal backend leaves the host untouched. Slim redo of #106075 by @francip: same mechanism, folded into the existing sync warm-up instead of a new module + asyncio.gather + second suppress block; the two behavior tests are kept, the concurrency tests dropped. Co-authored-by: Franci Penov <49422+francip@users.noreply.github.com> |
||
|
|
d6f954f510 |
refactor(signal): fence realigned tables so the code-block pass owns every MONOSPACE range
The salvaged detector recorded a second MONOSPACE range from a hand-rolled position walker and then had to rebase earlier ranges by the padding delta (the contributor's follow-up commit). Two range bookkeepers over one text is where the bugs were: a pipe table inside a fenced code block was realigned AND double-styled, because the walker ran after the fences were stripped and could no longer tell code from table. Run table realignment on the raw markdown instead, outside fences only, and wrap each aligned table in a fence. The existing code-block pass then emits exactly one MONOSPACE range per table and the existing removal/adjust machinery shifts every later range — no second offset model, no rebase loop. The contributor's fixup commit becomes unnecessary and is not carried. Tests trimmed to two invariants: a CJK/emoji table aligns by display width with correct UTF-16 offsets for the range and the style after it, and a fenced pipe table stays code while a real table before it keeps a later code range pointed at its own text (also covers the lone-pipe negative). Refs #106181 |
||
|
|
0edc47debf |
feat(signal): render markdown tables as monospace-aligned blocks
markdown_to_signal() passed GFM tables through untouched, so Signal's proportional font left columns unaligned. Detect table blocks after code-block extraction, re-align them with realign_markdown_tables(), and cover each rendered block with a single MONOSPACE style range. Fixes #106181 |
||
|
|
c50bf92f44 | fix(gateway): flush spoken acknowledgments at tool boundaries | ||
|
|
d5926b2494 | fix: persist API delegation units once without waking the model | ||
|
|
705bcdcf37 |
fix(gateway): adopt compression tip for explicit-header chat continuations (#98619)
An explicit X-Hermes-Session-Id client re-sending a pre-rotation id read the closed parent's stale history slice, bound the turn and the wake target to the closed parent, and — once the detached delivery writer adopted the live tip — could no longer consume a late delegation delivery row persisted on the tip, while still being granted wake_capable="1". Resolve the client-addressed id through the same canonical resolve_resume_session_id resolution the delivery writer (gateway/wake.py) and /v1/runs use, before loading history: the read, the turn, and the wake binding all select the live continuation tip. Response headers (SSE and JSON) keep echoing the client-provided id, so the external identity stays stable across rotations regardless of when a turn rotates relative to prepared response headers. Compression-tip read/write routing and the stable-echo identity contract originate from #13437 (manuelschipper), ported onto the post-refactor route owner with real-SessionDB regressions (JSON and streaming). Reported-and-investigated-by: shojikumaru (Sho + Alpha) via #98619 Co-authored-by: manuelschipper <11737902+manuelschipper@users.noreply.github.com> |
||
|
|
71818aaf52 |
fix(gateway): deny wake authority for caller-supplied conversation_history
#98619 review follow-up: an explicit session_id plus non-empty caller-supplied conversation_history skipped the SessionDB load yet was still marked wake-capable ('not previous_response_id' alone). Such a turn is authoritative on the caller snapshot and never consumes the SessionDB delivery row a detached completion persists, so wake is now fixed before the history load can overwrite the variable and denied on the same default-deny contract as previous_response_id continuations. Adds an HTTP regression proving the binder receives wake_capable="" and _conversation_history_for_session stays uncalled on that branch. |
||
|
|
0efb525420 |
fix(gateway): adopt live compression continuation for /v1/runs delivery
Address the compression-rotation blocker from the follow-up review of #98619: The parent run can compress/rotate before a detached delegate completes. The captured origin session id is then a closed parent: the delivery append is rejected with CompressionSessionClosedError forever (the watcher retries the same stale id), and no read surface exposes the row. - persist_delegation_delivery now resolves the live continuation tip via the canonical resolve_resume_session_id (the same resolution /api/sessions/{id}/messages reads use) before appending, failing open to the original id. - /v1/runs adopts the resolved tip for a client-addressed session id (body id or declared key): history loads from it, the turn and wake binding target it, so the run no longer writes into the closed parent. End-to-end regression: parent compresses, detached delivery persists to the stale id, and the next same-id run loads the delivery row from the live tip and binds it. |
||
|
|
3db45bab6e |
fix(gateway): load declared-key history and deny wake for response-chain runs
Address the two /v1/runs blockers from re-review of #98619: - P1: resolve the session identity (body id > response chain > declared X-Hermes-Session-Key > run_id fallback) BEFORE loading history, so a header-only declared-key run now loads its selected session's SessionDB history. A persisted async-delegation delivery row therefore reaches the next same-key run's context, which is what the wake opt-in promises. - P1: a previous_response_id continuation consumes its ResponseStore snapshot and can never see a SessionDB delivery row (async completion persists to SessionDB only), so it is no longer granted wake_capable; delegate_task keeps its synchronous fallback on that branch until a merge contract exists for the chain. Regressions: declared-key history load (delivery row consumed), no-row declared key stays fresh, response-chain continuation denied wake with snapshot history preserved, plain run still granted wake. |
||
|
|
dffc0fa2d5 |
fix(gateway): require wake-capable session provenance for background delegate_task (#98619)
Rebuilt against the post-refactor owners: the chat-completions route now
lives in gateway/platforms/api_server_openai_routes.py, the wake gate in
tools/delegate_tool_dispatch.py, and session_context.py was reshaped —
the original patch aimed at code main no longer has.
Header-less OpenAI-compatible clients get a fingerprint-derived session
id bound as the api_server chat_id. delegate_task's background gate
treated ANY bound session id as wake-capable and dispatched detached
subagents, but for derived ids the wake self-post lands in a session
whose history the client never reloads — the result is undeliverable by
construction.
Bind a wake_capable provenance flag at session-bind time, DEFAULT-DENY
at the central boundary (set_session_vars and _bind_api_server_session
both treat an omitted declaration as denied; a binder that says nothing
grants no wake authority): "1" only from audited producers whose client
can address the id again (explicit X-Hermes-Session-Id — 403-gated on
API_SERVER_KEY — native /api/sessions/{id} routes, /v1/runs); "" for
fingerprint-derived ids. The delegate gate requires the flag (fail-closed,
captured pre-child-construction alongside origin_wake_sid) and keeps the
forced-sync fallback with its honest note.
Reported-and-investigated-by: shojikumaru (Sho + Alpha) via #98619
|
||
|
|
2b9b3b829f |
fix(gateway): drop the 6-hour auto-delete line when /debug fell back to dpaste.com
The gateway /debug handler is the twin of `hermes debug share`: same upload_to_pastebin() fallback chain, different renderer. With the CLI output fixed, the gateway reply still ended with the localized "Pastes will auto-delete in 6 hours." even when the URL it just printed was a dpaste.com paste that lives 1 day and cannot be deleted. The retention of the fallback is already disclosed by _GATEWAY_PRIVACY_NOTICE at the top of the same reply, so the honest rendering is to omit the paste.rs-only line rather than add a second (17-locale) string. The _schedule_auto_delete comment now says what the sweep can actually act on. Refs #106164 |
||
|
|
e3f50da513 |
fix(sessions): gateway PATCH applies pinned last so pin wins over hidden (#106171)
set_session_pinned now clears hidden, which makes the order the PATCH handler
applies its flags in load-bearing: with pinned first, a {pinned: true,
hidden: true} body would re-hide the row it just pinned. Apply
archived -> hidden -> pinned, the same order the dashboard's
_RENAME_FLAG_SETTERS already uses, so both PATCH surfaces agree that a pin
wins.
Salvaged from #106188 (ordering hunk only; the PATCH-level test is dropped to
keep the fix at two invariant tests).
|
||
|
|
5de30f36f1 |
refactor(gateway): reuse recorded_gateway_home_conflicts for the scoped PID home check
The salvaged fix added `_pid_record_matches_home`, a near-copy of `recorded_gateway_home_conflicts(record, expected_home=...)` (already the scoped-home predicate used by the #89315 stop guard). Reuse it instead of carrying a second helper with the same semantics (legacy records without `hermes_home` are accepted by both). Also trims the salvaged tests to the two invariants that prove the symptom (scoped probe reports a live foreign profile's PID and leaves its identity files intact; a dead-PID scoped record is still cleaned). The third-home-claim variant exercised the same `saw_live_pid` branch. |
||
|
|
c2a3aff3f8 |
fix(gateway): keep unscoped poison-file cleanup after scoped PID scoping (#106406)
The live-record unlink protection must only apply to scoped reads (polling another profile must not delete its identity files). The unscoped path keeps main's behavior: a live record owned by another home inside this home's gateway.pid is unlinked on refusal, per the #89315 cross-profile stop contract (test_cross_profile_kill_refusal.py). (cherry picked from commit 1c7343f9c0175867b9649ab8c54cf924d04e2266) |
||
|
|
a23319d0f7 |
fix(gateway): scoped PID queries validate against the probed home (#106406)
get_running_pid(pid_path) validated records against the serve process's HERMES_HOME, so a dashboard scoped status poll (?profile=) rejected a live foreign profile's record, force-unlinked its gateway.pid/gateway.lock, and reported running=false. Validate scoped queries against the probed home (pid_path.parent, like the expected_home sibling) and never cleanup-unlink a live record's identity files; dead-PID records still clean up. (cherry picked from commit 472ca1b8d87df5246d83864e9b1492d06e99e8e5) |
||
|
|
91adf584a4 |
fix(gateway): every send_multiple_images override returns the aggregate SendResult
#106167 widened the base contract to SendResult but left six native-batch overrides (Discord, Email, Matrix, Mattermost, Slack, Telegram) returning None, which (a) meant a media-only reply on those platforms still reported FAILURE because _record_delivery(None) records nothing, and (b) produced new `ty` invalid-method-override diagnostics against the widened base (#106192). Make the contract honest instead of annotating it Optional: each override now rolls its batches (and any per-image fallback) into one SendResult, so the turn-outcome accounting works on every platform, not just Signal and the base loop. The "legacy overrides return None" comment in _send_image_batch goes away with the legacy. ty on the 8 touched files: origin/main 241 diagnostics / 20 override, this branch 241 / 20 — byte-identical diagnostic set; the intermediate `-> SendResult` head without this commit had 246 / 25. Refs #106192 |
||
|
|
78de23053f |
fix(gateway): report media-only turns as SUCCESS when attachments deliver
Thread record_delivery through _deliver_attachments, _deliver_media_attachments and _send_image_batch so attachment sends feed the turn outcome tracker. send_multiple_images (base default and Signal override) now returns SendResult (success when at least one image/batch was accepted); _send_attachment_batch returns bool. Legacy native-batch overrides that still return None record nothing, keeping their previous behavior until migrated. Fixes #106153 |
||
|
|
c15e1e94c1 | fix(relay): log terminal config opt-out on websocket dial | ||
|
|
4f3c4be7ca | fix(config): reuse unchanged legacy gateway reads | ||
|
|
b530a482d8 |
fix(gateway): the ephemeral delete goes to the adapter that sent the final
Follow-up to #106316. send_final_ledgered resolved the live adapter internally and _send_final_text resolved it a second time for _schedule_ephemeral_delete; a reconnect between the two sent the delete to a transport that never owned result.message_id (the ownership rule _final_delivery_adapter documents). The bracket now returns (result, adapter). The queued lane carried the ledger identity through MessageEvent.message_id while the PR added ledger_message_id for exactly that; it now uses the typed field, and the ledger read is getattr-tolerant of duck-typed events (a missing attribute was swallowed as "ledger skipped"). |