Consolidate native Hermes receive and human activation - #6
Conversation
Package operator tools, leased receive, and human conversation activation in one install. Share the restricted identity fence and exclude competing receivers; preserve state during the documented gateway restart.
Use an explicit test credential and clear inherited live tokens. Keep the plugin and Hermes source checkouts as siblings so pytest imports the plugin package consistently.
Fail closed on multiplexed Hermes runtimes and make installed profile secret scopes authoritative. Resolve Telegram and Mupot credentials per request so rotation cannot fall back to stale process-global values.
Require an installed Hermes secret scope for every credential read. Resolve operator credentials immediately before each transport and suppress reflected JSON-RPC error details.
Bound streamed MCP responses before decoding and reject uncorrelated or conflicting wrappers. Treat invalid send, consumer, and ACK receipts as permanent generic failures.
Retry consumer RPC once only when the connection failed before any request bytes were sent. Require reconciliation after every ambiguous, protocol, domain, or exhausted-safe lease lifecycle failure.
Persist the consumer identity and conservative lease deadline before fatal shutdown. Require explicit exact readback after expiry before clearing, and fail closed when an existing state file is corrupt.
Persist reconciliation intent before every lease attempt so a crash or post-response save failure cannot reopen polling. Clear the fence only after a validated response and durable state transition.
Persist and read-validate each notice fingerprint before publishing dedupe state. Fsync replacement directories and preserve legacy records during additive upgrades.
Notification dedupe must not accept a reused source ID when the authenticated member or target seat changes. Version fingerprints so older unbound records fail closed.
Bind each Hermes background turn to an immutable context so late or misrouted callbacks cannot borrow another lease authority. Invalidate timed-out generations before bounded session cancellation while retaining unresolved durable custody.
Store final ACK envelopes before transport so retries and restarts reuse one idempotency key without another model turn. Require durable human custody before source consumption and preserve ambiguous legacy pending work behind a reconciliation fence.
Capture the plugin manager's immutable owning home and install a fresh Hermes secret scope at registration, native connect, operator transport, and Telegram relay boundaries. Reject moved, missing, or multiplexed profiles before transport.
Disable HTTP response compression at the actual wire boundary. Extend every pre-lease reconciliation fence through the full RPC issuance window and rewrite that deadline before the one classified safe retry.
Replace local-clock recovery with durable random attempt IDs and exact server tombstones. Process only the matching leased tuple, ACK it before clearing, and retain legacy markers as permanent reconciliation fences.
Persist the authenticated tenant and effective seat with each lease attempt so a scope-swapped receipt cannot authorize local commit. Require server-confirmed attempt consumption before clearing recovery evidence.
Hash the immutable profile home identity into each v3 lease marker so a different profile cannot reuse an otherwise identical server scope. Drive acceptance through the real local MCP HTTP and bearer-auth boundary instead of injected result fixtures.
Persist a non-replayable activation state before invoking Hermes. Use durable Routine receipts for eligibility so processed-window eviction cannot suppress or repeat activation.
Carry the originating attempt, strict consumer scope, and profile owner into peer and Routine custody records. Restart replay can then refuse stale or mismatched attempt receipts without falling through to a generic ACK that could consume newer work.
Reject a prepared replay before outbound send unless its persisted attempt owner still matches the immutable profile and fresh strict consumer scope. Preserve the durable reply bytes and send-notice-ACK ordering across restart.
Kasra gate — head
|
| # | guard | file | result |
|---|---|---|---|
| M1 | peer allowlist on delivery | adapter.py:1918 | SURVIVED |
| M2 | routine source identity | routine_events.py:240 | red |
| M3–M5 | role≠member / overprivileged caps / tenant mismatch | mupot_operator.py | red |
| M6 | native+legacy exclusion (config) | init.py:203 | red |
| M7 | native vs _ACTIVE_WATCHERS | init.py:205 | SURVIVED |
| M8 | connect-time identity/tenant | adapter.py:1556 | red |
| M9–M11 | private-chat / forwarded / user==chat | telegram_control.py:143-150 | red |
| M12 | command allowlist | telegram_control.py:164 | SURVIVED |
| M13–M14 | multiplex refusal / installed-scope authoritative | profile_scope.py | red |
| M15–M16 | ACK owner fingerprint / human-custody precondition | adapter.py:2113,1443 | red |
| M17 | attempt_id format | lease_ownership.py:71 | SURVIVED |
| M18 | no-redirect | telegram_control.py:39 | SURVIVED |
| M19–M22 | recipient pinning / attempt correlation / routine pre-check / send context | — | red |
Minimum to clear
- Fence the injected body as quoted non-instruction data with the caveat after it; pass an explicit non-user role to
inject_messageor justify user-role in writing. - Drop
internal=Trueor call_is_sender_authorizedyourself and respect the e-stop; delete or make true theallow_fromcomment. allowed_agentsfails closed on[]/absent; decideagent:prefix + case handling explicitly.- Refusal tests that kill M1, M7, M12, M17, M18.
Athena's bus verdict on this head was PASS but read-only (native suite not runnable there). Adversarial and correctness lenses are orthogonal; both combine before GREEN. This one does not combine.
…p bypass, fail-closed allowlist Kasra gate BLOCK on PR #6 (head 3cc6f29) found five defect classes: - P0-1: notifications.flush() handed an attacker-reachable body (routine decision.question, or a peer terminal-ACK body) to ctx.inject_message unfenced, with the "not an instruction" caveat placed BEFORE the body where a long injection could bury or push it out of context. Now the body is wrapped in a delimited ```mupot-notice fence (immune to the body forging its own close via a zero-width-space escape) and every caveat is appended strictly AFTER the fence. inject_message is also called with an explicit non-"user" role (verified against the pinned Hermes hermes_cli/plugins.py:596: any non-"user" role still reaches the same gateway-injection call, just adds a "[role] " prefix -- it's not a transport-level distinction Hermes enforces, so the fence is the real control, the role is a bonus signal). - P0-2: build_mupot_event always sets event.internal=True. At the pinned Hermes rev (233757037d), gateway/run_inbound.py:174 returns for any internal event before it ever reaches _is_user_authorized_for_source (:185) or the global e-stop gate (:233) -- `hermes pause` silently never stopped mupot traffic, and the adapter's own comment falsely claimed Hermes was enforcing a second copy of the sender allowlist via extra["allow_from"] (verified: that key is read nowhere in gateway/*.py). Kept internal=True (dropping it would route mupot's synthetic, unpaired sources through end-user auth they were never designed to satisfy) and instead added an explicit _estop_engaged() check in _deliver before Hermes is ever reached, fail-safe on a stat error the same way agent.estop.is_engaged() itself does. Removed the dead allow_from line/comment instead of fixing it, to avoid keeping two copies of one predicate. - P1-1: `extra.get("allowed_agents") or DEFAULT` treated an explicitly configured empty allowlist ("" or []) the same as an absent key, silently falling back to the trust-everyone default. An absent key now falls back to the documented default; an explicit empty value denies everyone. - Documented (normalize_agent): verified against Mumega-com/mupot src/agents/messages.ts + src/agents/inbox-routes.ts that from_agent on the wire is the sender's raw agent UUID (auth.boundAgentId) or a fixed system constant -- mupot never emits an "agent:"-prefixed value. The prefix strip is defensive-only for hand-typed config and must not be read as evidence mupot performs this normalization for us. - Deduped _LEASE_ATTEMPT_ID_RE (adapter.py) against lease_ownership.py's identical pattern into one shared ATTEMPT_ID_RE. - P2: added a stranded-notification inspector -- a startup warning log with the count, plus a new mupot_gateway_status tool -- for the activation_unknown/transport_unknown terminal states that previously had no inspector at all. Tests added/updated to prove each of the above and kill mutation guards M1, M7, M12, M17, M18 from the gate's 22-guard list (see docs/telegram-onboarding-evidence.md for the full mutation table and real test counts from this session). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SGwCzY7SU9p9A7mRWWpgqT
…te-BLOCK repair Appends a dated section to telegram-onboarding-evidence.md with this session's actual numbers (239+12 subtests / 27 unittest plain-suite, 276/276 native-suite) and the 7-row mutation table (M1/M7/M12/M17/M18 plus the two new P0-1/P0-2 guards) proving each RED-before/GREEN-after, without altering or re-asserting any of the prior session's numbers above it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SGwCzY7SU9p9A7mRWWpgqT
Kasra re-gate — head
|
… e-stop, honest tests
Second gate-BLOCK repair on this PR (kasra-review re-gate, 2026-09-14). Round 1's
fixes were each individually plausible but each missed the actual defect class;
this round fixes the class, not the reported repro.
P0-1 (fence escape regenerates its own delimiter): the prior
`text.replace("```", "```")` is a left-to-right, non-overlapping replace, so
any backtick run whose length is not a multiple of 3 (4, 5, 6, 9, ANSI-wrapped or
not) left a leftover backtick that recombined with the replacement into a fresh
literal run of 3 -- regenerating the exact delimiter it was meant to escape.
Fixed by escaping every backtick character individually (a zero-width space after
each one), which provably leaves no run of even 2 backticks anywhere in the body,
independent of run length or surrounding CR/LF/ZWSP/ANSI content. Also fixed the
docstring, which asserted the old, false property, and the legacy
`_maybe_start_inbox_stream` injector in __init__.py now routes through the same
escaping primitive instead of injecting unfenced text (one predicate, not two).
P0-2 (e-stop enforced on the named path, not the sink): round 1 added
_estop_engaged() to MupotAdapter._deliver only. _flush_notifications (run every
poll BEFORE the inbox lease) and _handle_routine_event both reach the message
injector and an irreversible source ACK without ever going through _deliver, so
`hermes pause` did not stop either path. Fixed at the actual choke points: the one
call site in notifications.flush() that invokes the injector, plus the top of
_handle_routine_event and _handle_ack_envelope (before any state mutation, mirror
of _deliver's existing guard) so every present and future producer inherits the
gate structurally. Also corrected the adjacent comment's false global claim
("allow_from is read nowhere in gateway/*.py") to the true, scoped claim (it is
read on Hermes's non-internal auth path, which internal=True skips today), and
renamed the test that cemented the false claim.
P1 (vacuous e-stop-unimportable test): the old test deleted agent.estop from
sys.modules in the NATIVE suite, where it is genuinely importable, so the delete
just forced a successful re-import. Rewritten to force a real ImportError via
builtins.__import__, and the fail-open branch now logs a WARNING once (asserted
in the test) instead of failing open silently.
Hygiene: allowed_agents now rejects a non-list/non-string value with a clear
config error instead of a raw TypeError; _live_adapter is cleared on
disconnect so mupot_gateway_status can't report a dead adapter's stale state;
mupot_gateway_status is declared in plugin.yaml's provides_tools; README, the
onboarding runbook, and the example config document allowed_agents
normalization and what to do with a stranded notification.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SGwCzY7SU9p9A7mRWWpgqT
Round-2 gate-BLOCK repair evidence: what was actually wrong on head ab2b9d8, the class-fix shape chosen, the 10-row mutation table executed this session (all RED-when-mutated / GREEN-when-restored, verified not asserted), and the real test.sh/test-native.sh counts (240+12+27 / 302). States plainly what was not independently re-verified this round, including an open question about _poll_loop's interaction with an estop-deferred message that pre-dates this round and was not introduced by it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SGwCzY7SU9p9A7mRWWpgqT
Kasra re-gate #2 — head
|
Round-3 re-gate #2 fix, two classes: P0 -- a pause is a temporal condition, never a state transition. Round-2's _estop_engaged() gates in _deliver/_handle_routine_event/_handle_ack_envelope returned bare on pause, which _poll_loop then read as "message not marked processed" and answered with _protocol_error() -> _quarantine_inbox_polling(), a durable, connect()-refusing state that survives the pause. Fix: (a) check the sentinel at the top of every poll iteration, before inbox_lease -- nothing leased/acked/injected/written while paused, which also keeps the routine_events_disabled-quarantine and sender-policy-DLQ branches inside _process_leased_message from firing (neither can run without a lease first); (b) the three gates now raise a new _EstopDeferred instead of returning bare, so _poll_loop (and reconcile_inbox_polling's own copy of the same pattern) can release the lease and continue without ever calling _protocol_error() or _quarantine_inbox_polling() for a mid-message race. Corrected the two comments that had gone false describing the old, quarantining behavior. P1 -- untrusted notice text was fenced only in flush()'s activation branch; deliver_text (Telegram) and mirror_text (conversation mirror) shipped notice["text"] raw, and mirror_to_session's default role="assistant" made a relayed remote notice replay as a genuine agent turn (Hermes's own gateway/mirror.py:34-38: non-agent text must be role="user"). Fix: build one fenced+caveated string once per notice, before any branch, consumed identically by the activation event, deliver_text, and mirror_text; pass role="user" to mirror_to_session. P3: tightened routine_events._SOURCE_ID_RE to exclude backtick/[/] (defense in depth, not attacker-reachable today); allowed_agents list entries that are not strings now raise ValueError instead of silently stringifying. Tests: real-poll-loop tests with the actual agent/estop.py sentinel (not a faked is_engaged) proving pre-lease pause and mid-message defer for both the Routine and peer _deliver paths, plus a real-Telegram-stub + real-SQLite-mirror test proving the shared fenced string and role="user". scripts/test.sh 240 + 12 subtests + 27/27 unittest (unchanged). scripts/test-native.sh 319 passed (was 302, +17 new). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SGwCzY7SU9p9A7mRWWpgqT
21 mutations executed for real (temporary edit -> pytest run -> git checkout restore -> clean status) against commit 40f8a10: both fencing guards (F1/F2) plus the mirror-role guard, the full pause/liveness class (pre-lease check, the exact "_protocol_error restored on the pause path" mutation the brief named, and all 4 individual e-stop call-site gates), the P1 vacuous-test guard, the peer allowlist no-regression guard, all allowed_agents validation guards including the new P3 fix, the new _SOURCE_ID_RE P3 guard, and the four round-1 guards named explicitly in the brief (M7/M12/M17/M18) each individually re-mutated. scripts/test.sh 240 + 12 subtests + 27/27 unittest (unchanged). scripts/test-native.sh 319 passed, 0 failed (was 302, +17). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SGwCzY7SU9p9A7mRWWpgqT
Kasra re-gate #3 — head
|
…rimitive on e-stop kasra-review re-gate #3 (2026-09-14, PR #6): rounds 1-3 each fixed a NAMED site (_deliver, _handle_routine_event, _handle_ack_envelope, the fence escape) and the next re-gate found the next ungated site -- most recently, _poll_loop checked _estop_engaged() only right before inbox_lease, leaving _replay_routine_events/_replay_reply_outbox/_flush_notifications running unconditionally every tick while paused (real ACKs, real peer sends, real Telegram/mirror egress), plus three _process_leased_message branches (routine-events-disabled quarantine, already-processed re-ack, sender-policy DLQ) that consumed a message without ever reaching a gated function. Round 4 closes the CLASS instead of the next site: - _poll_loop checks _estop_engaged() as its very first statement: a paused tick does nothing but sleep/backoff, full stop. - _process_leased_message itself gates at its top, before dispatching to any branch -- closes the three previously-ungated branches structurally. - A new shared choke point, _refuse_ack_if_estop_engaged(), gates both _ack_expected and _ack_persisted_ownership -- the only two functions that ever call inbox_lease_ack/inbox_ack; every _commit() call site is reached only immediately after one of these two succeeds, so this transitively gates the commit too. - _transmit_final_reply (the peer `send` MCP call, used by both _replay_reply_outbox and the live send() interim path) gates before the network call. - notifications.flush() gates each of its three sinks (activation injector, deliver_text, mirror_text) independently, not just the activation branch. - _EstopDeferred is now caught explicitly in _poll_loop's wrapping of _replay_routine_events/_replay_reply_outbox, before their broad except-Exception clauses, so a mid-iteration pause is never misclassified as a routine-event reconciliation failure or a reply-replay warning. Ported kasra-review's own independent execution drivers (not shipped with the PR) into permanent regression tests: tests/native/test_estop_lease_gate.py (whole-iteration gate across 5 message classes, pre-lease and mid-lease), test_estop_egress_gate.py (outbox egress during pause + the reconcile_inbox_polling pause contract kasra-review found unpinned), test_estop_replay_gate.py (adapted for _EstopDeferred now propagating from direct calls to the replay functions), plus two new deliver_text/mirror_text choke-point tests in test_notifications.py mirroring the existing activation choke-point test's pattern. Documents the exhaustive gated-primitive list in _EstopDeferred's and _estop_engaged's docstrings (replacing the stale per-round named-function lists), and adds a runbook section on the e-stop contract: lease release is expiry-only (redelivery latency bounded by lease_seconds, up to 3600s, not by how fast the pause lifts), and reconcile_inbox_polling() returns False without acking or clearing the quarantine marker while paused. scripts/test.sh: 240 passed + 12 subtests, 27/27 unittest (unchanged). scripts/test-native.sh: 336 passed, 0 failed (was 319, +17 new). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SGwCzY7SU9p9A7mRWWpgqT
…ngthen ported tests - Fixed a vacuous measurement carried over from kasra-review's own driver: test_estop_lease_gate.py read state["routine_quarantine"], which never matched the real routine_event_quarantine key, so it silently reported 0 regardless of what the code did. Fixed the key name and added assertions in both test_pre_lease_pause and test_mid_message_pause so the _process_leased_message top-of-function gate is independently pinned rather than only appearing redundant with the ack choke point. - Added test_live_interim_send_defers_without_transmitting_while_paused (send()'s `if interim:` branch had no test coverage at all). - Added two direct tests pinning _poll_loop's own except-clause wiring for _replay_routine_events and _replay_reply_outbox (isolated from the realistic, timing-dependent mid-iteration race already covered end-to-end elsewhere in this file and in test_estop_lease_gate.py). - Full round-4 mutation table (10 new-guard rows, all executed for real: temp edit -> red -> git checkout -- restore -> git status clean) plus 4 spot-checks confirming round 1-3 guards (M1, F1, M17, E3) are unchanged. scripts/test.sh: 240 passed + 12 subtests, 27/27 unittest (unchanged). scripts/test-native.sh: 339 passed, 0 failed (was 336, +3 new). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SGwCzY7SU9p9A7mRWWpgqT
Kasra re-gate #4 — head
|
… dedupe DLQ Round 5 (kasra-review re-gate #4 AMBER, 2026-09-14) small tidy on the e-stop class: - __init__.py's legacy _maybe_start_inbox_stream deliver() had the untrusted-body fence but no _estop_engaged() check, even though _EstopDeferred's docstring claimed plugin-wide coverage. Gated it (this legacy stream has no deferral/redelivery surface, so a refusal drops the batch -- log once per pause window, not once per dropped batch). Fails open with a one-time warning when mupot_gateway.adapter (and its gateway.* transitive imports) is not importable, matching _estop_engaged()'s own agent.estop fallback -- keeps the plain scripts/test.sh suite working standalone. - adapter.py's _estop_engaged() fail-safe except now warns once per distinct failure window (not once per call) instead of swallowing silently. Four of the plugin's 12 gate sites that refused silently (_refuse_ack_if_estop_engaged, _transmit_final_reply, _process_leased_message's own top gate, send()'s interim path) now log once per pause window via a shared _note_estop_pause_once helper. A pause-refused SendResult now names "estop_paused" in its error string. - DLQ append (sender_policy branch in _process_leased_message, invalid_ack_envelope branch in _handle_ack_envelope) is now idempotent by message id: a pause landing between the DLQ write and the ack no longer produces a duplicate row on redelivery. - Comment/docstring accuracy: _EstopDeferred's docstring no longer overclaims "regardless of which function calls it" (now precise: every inject/consume/egress primitive in this plugin, as of this round) and narrows "or making any network call" to consuming/egress calls (read-only inbox_consumer_status/inbox_lease_reconcile are exempt). notifications.py's deliver_text choke point comment no longer claims the notice "stays exactly as it was" during a pause -- RetryLater's own except-handler does write attempts/retry_at bookkeeping, which is harmless (only feeds an already-capped exponential backoff, no other cap it could violate). - tests/native/test_estop_lease_gate.py read wrong/synthetic outbox keys (always a silent 0); switched to the real notification_outbox/ reply_outbox keys and added the previously-measured-but-unasserted sent_paused/injections_paused/pending_paused assertions, plus ack_calls_paused to test_mid_message_pause. New tests/native/test_estop_observability.py isolates each of the 4 newly-logged choke points, the fail-safe warning window, and the DLQ idempotency fix with direct mutation-provable assertions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SGwCzY7SU9p9A7mRWWpgqT
Round 5 (kasra-review re-gate #4 AMBER) evidence: real mutation-testing results for the 7 new guards (legacy inbox-stream gate, 4 silent choke-point logs + SendResult.error naming, fail-safe warning window, DLQ idempotency), an honest mutation-load-bearing analysis of item 5's new test assertions (pending_paused proven uniquely load-bearing; sent_paused/injections_paused documented as subsumed/unreachable within this harness rather than fabricated as unique), spot-checks of prior rounds' M1/E6/F1 guards (still red), a substitution note for the unrecoverable N1/N3/N6 mutation IDs, real test counts (241+12/27, native 346/346), and the discrepancy reconciliation against the actual re-gate #4 comment text. Runbook gains a line on the legacy inbox stream's drop-not-defer pause behavior. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SGwCzY7SU9p9A7mRWWpgqT
Kasra re-gate #5 — head
|
…e-stop gate F1: legacy inbox-stream deliver() now reaches agent.estop.is_engaged directly instead of through mupot_gateway.adapter's httpx/gateway.config chain. Fails OPEN only when the whole `agent` package is absent (the plain, non-native scripts/test.sh suite); fails CLOSED (drops the batch) when `agent` is present but `agent.estop` specifically is not, and fails SAFE on a genuine is_engaged() exception -- a missing e-stop module must not read as "not paused". F3: the same deliver() closure now compares pause IDENTITY (agent.estop's own engaged_at) instead of a "logged until a disengage is observed" boolean, so a new pause with no intervening batch still logs (deliver() only runs on an actual batch -- event-driven SSE, no idle tick -- so the old boolean could go a full pause window without ever seeing the prior disengage). F2: _EstopDeferred's docstring no longer overclaims "every inject/consume/ egress primitive in this plugin"; it now names the exact two native-receive surfaces it covers and explicitly lists what is NOT gated and why (mupot_operator.py's send/inbox tool calls, by parity with Hermes not stopping a running session's tool calls; the provisioner reminder inject; /approve control traffic, which is Hermes's own concern). F4: adds the invalid_ack_envelope DLQ idempotency test the sender-policy twin already had but this branch's identical dedup guard did not -- D9 survived the full suite; mutation-verified red/green. F5/F6: send()'s except _EstopDeferred now also emits one WARNING per pause window naming the request id for a refused FINAL reply specifically (the one case where a human/peer gets no answer at all), in addition to the existing choke-point INFO; notifications.py's RetryLater comment now lists all three bookkeeping writes (attempts, retry_at, last_error). Real counts: scripts/test.sh 241 passed + 12 subtests, unittest 27/27; scripts/test-native.sh 350/350 (up from 346, +4 new: F1, F3, F4, F5/F6). Spot-checked mutations red->green: D1, D8, D9, N1, F1(fence), M1. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SGwCzY7SU9p9A7mRWWpgqT
Kasra micro-round — head
|
Live canary — PASS (Kasra, 2026-09-14 22:30Z)
Live defect found by the canary: Not yet wired: |
Telegram control wired (Kasra, 2026-09-14 22:33Z)
Remaining for the live pilot: mint one project invite for the owner, redeem via |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f30921115e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| or not _LEASE_ATTEMPT_ID_RE.fullmatch(attempt_id) | ||
| or result.get("attempt_id") != attempt_id | ||
| or result.get("state") not in _LEASE_ATTEMPT_STATES | ||
| or result.get("consumed") is not False |
There was a problem hiding this comment.
Accept consumed terminal receipts during reconciliation
When inbox_lease_ack reaches Mupot but its response is lost, the adapter quarantines the attempt and later passes the authoritative inbox_lease_reconcile result through this validator. A completed attempt is reported as state: "acked", consumed: true, but this unconditional check rejects it, so reconcile_inbox_polling() can never clear the quarantine after precisely the ambiguous-ACK failure it is intended to recover from; it also incorrectly accepts the contradictory acked/consumed:false combination. Validate consumed according to the attempt state so a terminal consumed receipt can complete recovery.
Useful? React with 👍 / 👎.
| def _invoke(self, action: str, args: JsonObject) -> JsonObject: | ||
| try: | ||
| token = self._read_token() |
There was a problem hiding this comment.
Bind protected actions to the credential that passed validation
For every non-status action, call() first validates an identity snapshot and then enters _invoke() again, where this line reads the secret afresh. If the profile credential rotates between those operations, the action can run with a token whose tenant, bound agent, role, and capabilities were never checked; the same gap exists between manager capability checks and the eventual manager action. Carry one token snapshot through the identity/capability checks and protected request, while allowing the next top-level call to observe rotation.
Useful? React with 👍 / 👎.
| if [ ! -f "$HERMES_SOURCE/scripts/run_tests.sh" ]; then | ||
| printf '%s\n' 'HERMES_SOURCE does not contain the canonical Hermes test runner' >&2 | ||
| exit 1 |
There was a problem hiding this comment.
Verify the pinned Hermes checkout before integration
In scripts/test-integration.sh, the Mupot checkout is checked for the exact expected commit and a clean tracked tree, but the supplied Hermes checkout is accepted after only testing for one script. Consequently this acceptance test can pass against an arbitrary or locally modified Hermes runtime even though the documented evidence attributes the result to pinned revision 233757037df1f03f9fe1cfddc097acd5ad7f7510; add equivalent HEAD and cleanliness checks before running Vitest.
Useful? React with 👍 / 👎.
| request = Request( | ||
| urljoin(settings.base_url.rstrip("/") + "/", "/im/webhook"), | ||
| data=body, |
There was a problem hiding this comment.
Preserve an accepted base URL path for Telegram webhooks
TelegramControlSettings.validate() accepts HTTPS base URLs containing a path, but the leading slash in the second urljoin argument discards that path. With a valid configuration such as https://example.invalid/mupot, every command is therefore posted to https://example.invalid/im/webhook instead of https://example.invalid/mupot/im/webhook, making project control fail and potentially sending the webhook-secret header to an unrelated endpoint on the same host. Join a relative im/webhook segment or reject non-root base paths during validation.
Useful? React with 👍 / 👎.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Behavior
The operator plugin can now own the native Mupot receiver and activate its linked human conversation through Hermes. This consolidates the previously separate platform adapter, notification logic, and tests under
mupot_gateway/in this installable repository.Native receive is opt-in and mutually exclusive with the legacy inbox stream. It shares the operator's identity/privilege validation, preserves correlated ACK and notification state, and uses Hermes's existing private-session activation API without granting human approval authority. Existing non-native profiles retain their current behavior.
The migration example and instructions preserve the exact state path, disable obsolete platform plugin copies, and require a full gateway restart. The broader human onboarding/control document distinguishes this integration from the still-needed invitation, role-linked decision, and internal routine execution work.
Validation
233757037df1f03f9fe1cfddc097acd5ad7f7510.