Skip to content

Release 1.2.11a2 - #311

Closed
github-actions[bot] wants to merge 316 commits into
masterfrom
release-1.2.11a2
Closed

github-actions[bot] wants to merge 316 commits into
masterfrom
release-1.2.11a2

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

Human review requested! This supersedes any earlier open release proposal for this repository.

JarbasAl and others added 30 commits June 5, 2026 21:57
The stable 1.5.0 still bundles the hivemind agent protocol (ovos_bus_client.hpm:OVOSProtocol), which collides with the standalone hivemind-ovos-agent-plugin under the same entry-point name. 2.0.0a3+ dropped it (ovos-bus-client#207).
…back (#131)

* feat: protocol v3 Noise handshake client (XXpsk2/KKpsk0) with v2 fallback

Adds the HiveMind protocol version 3 session layer per HIVEMIND-CRYPTO-1
§3.4 on the client side: hivemind_bus_client.noise wraps the
poorman_handshake.noise primitive with the negotiation, prologue and
transport-framing glue, and HiveMindSlaveProtocol runs the Noise
handshake when both peers advertise protocol version >= 3.

- Noise_XXpsk2_25519_ChaChaPoly_SHA256 by default; KKpsk0 selected when
  the server's static key is already pinned and the server offers it
- the PSK is derived from the shared password (argon2id, salted with
  SHA-256 of the server node_id); a wrong password aborts the handshake
  cryptographically, fail-fast, with no fallback to the legacy handshake
- the server's cleartext HELLO and parameter HANDSHAKE payloads plus the
  selected Noise protocol name are bound into the Noise prologue
  (downgrade/tampering protection)
- after Split() every session message is a Noise transport message;
  sequential nonces reject replayed, tampered or reordered messages
- the server's static key is TOFU-pinned in the node identity file
  (keyed by endpoint); a pinned-key mismatch aborts the connection
- the static X25519 key persists next to the identity file
  (NodeIdentity.noise_key) and survives restarts
- the access_key stays a clear-text routing identifier in the URL
  exactly as in v2 (it is a non-secret username); the password is the
  only secret and is never transmitted
- v2 and below are untouched: max_protocol_version=2 or a pre-v3 server
  falls back to the legacy handshake transparently
- connect() accepts handshake_max_retries to bound the handshake wait
  (None keeps the legacy retry-forever behaviour)
- e2e migration matrix: v3<->v3, v3 client<->v2 server, v2 client<->v3
  server, wrong password, tampered negotiation, replayed transport
  message; e2e now runs against an isolated XDG home

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: scope coverage to unit tests (exclude e2e requiring a live master)

The e2e suite spins up a hivemind-core master and runs under coverage
instrumentation; those belong in build-tests (green), not the coverage
job. Coverage measures hivemind_bus_client unit coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: pin poorman-handshake>=2.0.0a1 (Noise from PyPI, drop git ref)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: offer 25519_AESGCM_SHA256 Noise suite (enables Web Crypto / JS v3)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: keep tests green under poorman 2.0 backstop + min_protocol_version

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: install test extra in coverage job (pytest-asyncio for async tests)

The coverage job ran without the test extra, so pytest-asyncio was
missing and the async unit tests errored (they pass in build-tests
which installs .[test]).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: pre-install pytest-asyncio + poorman prerelease in coverage job

The shared coverage workflow uses uv, which won't resolve the poorman
2.0.0a1 prerelease floor without --prerelease=allow; pre-seeding it (and
pytest-asyncio) via pip lets the uv install succeed. build-tests (pip)
already passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: split e2e-only deps into an e2e extra so uv coverage resolves

The circular v3 git-ref (hivemind-core@branch) + hivescope are e2e-only,
but living in the test extra made the uv-based coverage job fail to
resolve (No solution). Unit tests import neither; move them to an e2e
extra. coverage installs unit-only test; build-tests installs test,e2e.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: use pre_install_pip for pytest-asyncio in coverage (not install_extras)

The coverage reusable workflow does not accept install_extras like
build-tests does (it tried to install a package named 'test'). Use the
supported pre_install_pip input for the async plugin; the package's
runtime deps come from the plain install (UV_PRERELEASE=allow).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… publish) (#134)

* fix: keep hivemind-core git ref out of package metadata so PyPI accepts the wheel

PyPI rejects any uploaded package whose metadata declares a direct URL
dependency (400 'Can't have direct dependency'), which blocked every
alpha since the v3 e2e matrix was added (frozen at 0.9.2a1). Move the
Noise-capable hivemind-core git ref from the e2e extra into build-tests
pre_install_pip; e2e still installs it via pip, the wheel stays clean.
Revert to a version pin once hivemind-core v3 releases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: use bare git+URL in pre_install_pip (no 'name @' whitespace split)

uv fails to parse 'hivemind-core @ git+...' because pre_install_pip
splits on whitespace; a bare git+URL installs the same package.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: run client build-tests as unit-only to break the v3 circular dep

The client<->core e2e matrix needs a Noise-capable hivemind-core, which
depends on a poorman-2.0 hivemind-bus-client that is not yet published —
an unresolvable cycle during the coordinated v3 rollout. Run the unit
suite here (Noise wire format is covered by fixture tests vs the Python
reference); the live matrix runs in the hivescope integration harness
post-release. This lets the client publish so core can pin it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
AsyncHiveMessageBusClient could not complete a handshake against a
protocol-v3 hivemind-core hub, making the published [async] extra
unusable. Two shipped bugs:

1. The async client never defined max_protocol_version, so the shared
   HiveMindSlaveProtocol._should_use_noise read its `2` default and Noise
   was never negotiated. Add the attribute (default 3), mirroring the sync
   client, and add the noise_transport session state + encrypt/decrypt in
   on_message/emit so a negotiated v3 session actually carries traffic.

2. The handshake state machine sent its frames with a bare self.hm.emit(),
   which is a coroutine on the async client and was never awaited, so the
   frame never reached the wire and connect() hung forever. Route the
   handshake/hello/ping emits through HiveMindSlaveProtocol._emit(), which
   schedules the coroutine on the running receive loop for the async client
   and is a no-op wrapper for the sync client. HANDSHAKE frames are guarded
   to stay cleartext (deferred emit runs after noise_transport is assigned).

Adds a real e2e (tests/e2e) driving AsyncHiveMessageBusClient against a
live hivemind-core master via hivescope: v3 Noise session round-trip and
v2 legacy-fallback round-trip. Both reproduce the hang before the fix and
pass after. Sync v3 matrix + async unit suite stay green.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
`_decode_bitstring_v1` coerced ANY unknown 5-bit message-type code to
11 (THIRDPRTY) via `_INT2TYPE.get(code, 11)`, silently accepting
corrupt or forged frames. HIVEMIND-WIRE-1 §4.2 requires: "A receiver
MUST reject a frame carrying an unassigned or reserved value as
malformed."

The decoder now raises ValueError for any 5-bit code not in the
assigned registry (13-31 are unassigned). The three client receive
loops (sync/async/http) wrap `decode_bitstring` and drop a malformed
frame with a logged error instead of crashing the receive loop.

Wire compatibility is preserved: codes 0-12 are all currently emitted
by this library and the HiveMind-js peer (cross-platform vectors), so
they remain accepted and round-trip unchanged. The library's wire
numbering diverges wholesale from the aspirational §4.2 table
(6=HELLO vs INTERCOM, 8=CASCADE vs reserved, 11=THIRDPRTY vs reserved,
QUERY/CASCADE/RENDEZVOUS binary-emitted vs text-only). Renumbering the
wire is a breaking change left for a coordinated spec revision and is
documented inline; only the unassigned-code (13-31) rejection is
enforced here.

Adds regression tests (unassigned codes rejected, all assigned types
still round-trip) and updates docs/serialization.md.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix: defer legacy password validation until negotiation

* test: enforce production password policy in e2e

* test: isolate E2E identity storage

* test: document E2E identity helper

* fix: log active legacy handshake key size
* Fix websocket reconnect lifecycle

* fix: harden reconnect worker lifecycle

* test: harden reconnect recovery coverage

* fix: isolate reconnecting event callbacks

* fix: reset protocol handshake state on reconnect

* fix: serialize reconnect worker lifecycle

* fix: harden reconnect retry lifecycle
openvoiceos-bot and others added 27 commits September 23, 2026 19:38
* fix: a wire payload must be a JSON object, and nothing repairs it

HIVEMIND-MSG-1 §4: the HANDSHAKE and HELLO payload MUST be a JSON object,
`{}` the only empty form, and a node rejects any other shape and never
repairs it. Two repairs in this library defeated that, and both reached the
node before it could judge the frame.

`__init__` turned `None` into `{}`, so a `"payload": null` frame was
accepted. `as_dict` ran `json.loads` on a string payload, so a HELLO whose
payload was the STRING `'{"site_id": "injected"}'` was parsed into an object
and acted on: the connection ended up carrying that site id.

`HiveMessage.from_wire` is now the one door for a frame that arrived over a
connection. It refuses a payload that is not an object, and it never parses
a string one. The string repair leaves `as_dict`. `deserialize` validates
through the same helper, before its own `try`, so the refusal is not
swallowed by the fall-through to the BUS shape.

Seven call sites built a message straight from a parsed frame and so never
met either door. All seven now go through `from_wire`: `async_client` twice,
`http_client` twice, `client` twice and `util` once. The brief named six;
the seventh is `client.py`'s dict branch, which matches the other two.

`from_wire` keeps the per-hop fields `deserialize` drops. That difference is
deliberate: `deserialize` serves a node about to re-route a message, while a
client reads `source_peer` to decide whether a PROPAGATE is trusted
(`protocol.py:963`), so routing these sites through `deserialize` would have
changed that decision without saying so.

The Python constructor keeps its default: `HiveMessage(TYPE)` with no
payload still builds `{}`. No wire value is read there, so nothing is
repaired.

An absent payload is refused only for the types §4 requires one for, named
in `_PAYLOAD_REQUIRED`. The first cut refused it for every type and broke a
PING frame, which carries no payload and is the whole message; an existing
dispatch test caught it. Both halves are now pinned: a PING frame may omit
the key, and a PING frame with the wrong shape is still refused.

Fail-before, both repairs restored and every test kept: 19 failed, 7 passed.
With the change the file is 11 passed and 22 subtests. Whole suite 999
passed, 4 skipped, run in five chunks because one process per suite is more
memory than this box has.

Consumer sweep in the body, and in
knowledge/wiki/audits/hivemind/t3997-payload-repair-lives-in-the-client.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* the inner payload of a wrapped message goes through the same door

The first commit closed the top-level door. The inner envelope of a routing
message had none: `HiveMessage.payload` builds the inner view with
`HiveMessage(**self._payload)`, and the constructor still parsed a string
payload with `json.loads`, so the exact repair `from_wire` refuses was
reapplied one level down. A PROPAGATE carrying a PING whose payload was the
string '{"flood_id": "forged", "peer": "attacker"}' had it parsed into an
object, and `handle_ping` read the forged id off it. PING travels
PROPAGATE-wrapped, so the wrapped form is the normal form for the one control
type that floods the mesh. HIVEMIND-MSG-1 section 4 also forbids a node to
rewrite the inner payload of a wrapped routing message.

The constructor no longer parses a string. It raises MalformedWirePayload and
names `from_wire` as the door for a wire value. The in-process default stays:
`HiveMessage(TYPE)` with no payload is still `{}`, because no wire value was
read.

`decode_bitstring` was the one caller that depended on the constructor for
parsing: a binary frame carries its payload as JSON text. It is a wire door
in its own right, so it now parses the text itself and checks the result
against section 4, and a frame whose payload is not an object is refused as
MalformedBinaryFrame, the type every other bad binary frame already raises.
Before this, a binary frame was the third way to reach the repair.

`as_dict` raises MalformedWirePayload instead of asserting. `python -O`
compiles an assert out, so the guard would be missing in exactly the
deployment that runs with it, and protocol.py carries the same note about an
assert this project already lost that way. Measured: at the reviewed head a
string payload is EMITTED under `-O`; here it is refused. The test drives
`python -O` in a subprocess rather than reading the source text, which a
comment would satisfy.

The test file gains the cases the review said it lacked: three wrapper types
by three inner control types, every refused string shape wrapped in a
PROPAGATE(PING), and the binary-frame door. Four controls keep the
legitimate traffic honest: a wrapped PING with an object payload, a
PROPAGATE(BUS), an ordinary binary BUS frame, and the constructor's own
empty-payload default.

`test_message.py::test_from_json_string` pinned the behaviour that is now
removed. It asserts the refusal instead, and builds the same message through
`from_wire`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: JarbasAi <jarbasai@mailfence.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…#298)

`HiveMindSlaveInternalProtocol.handle_outgoing_mycroft` built both of
its frames with `payload=message.serialize()`, which is a STRING. Since
1.2.4a1 the constructor refuses a string payload (HIVEMIND-MSG-1 §4),
so every SHARED_BUS forward and every targeted upstream reply raised
`MalformedWirePayload` out of the internal bus handler. Passive
monitoring and the reply leg were both dead.

Measured in hivemind-test-harness at dev: 5 failures, all of this
shape, and two of them say "SHARED_BUS payload must be a JSON object,
got str".

The fix passes the `Message` itself. The constructor already converts
one to `{"type": .., "data": .., "context": ..}`, which is the object
§4 requires and the shape the `payload` property reads back. No new
conversion, and no repair of a wire value: nothing here reads the wire.

tests/test_outgoing_bus_payload_is_an_object.py: 3 failed before this
change, 5 pass after, and the set carries two controls — a frame for
nobody is still not emitted, and a string payload is still refused.

Co-authored-by: JarbasAi <jarbasai@mailfence.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…292)

HIVEMIND-MSG-1 §4 gives the payload a form per msg_type. For BINARY it is
"an opaque byte string with an associated handling instruction". For BUS and
SHARED_BUS it is a Layer-1 bus message, and for the routing types a nested
HiveMessage, both objects.

The text path got its one validated door in #290, HiveMessage.from_wire().
The binary send path had none. BitArray.append() does not supply one, so
four shapes passed through get_bitstring:

- BUS with a str that is not an object was written to the frame as raw
  bytes. No peer can parse it, so the far end dropped the message and the
  sender never learned of it.
- BUS with a list or an int raised a bare AssertionError that named neither
  the payload nor the rule.
- BINARY with a dict became a SINGLE BIT on the wire. BitArray.append()
  reads a mapping as a one-bit token, so the payload was lost and the frame
  still went out.
- BINARY with a str raised "ValueError: Can't parse token" from inside
  bitstring, which reads a string as a format specification.

Each is now refused at the sender with MalformedWirePayload, the same
exception the text path raises, naming the shape and the clause.

The check sits in _get_bitstring_v1 rather than at the client call site,
because that function is the one place every binary frame is built. The same
defect was reachable from client.py, async_client.py and http_client.py.

TEXT IS A LEGITIMATE PAYLOAD FORM. A Message reaches the encoder as its own
JSON text, so the object test accepts a str that decodes to an object as
well as a dict, and refuses only text that is not JSON or that decodes to
something else. A check that refused every str looked correct and broke 18
existing cases, because the pre-serialized form is what the library itself
produces. The two accepted forms encode the same bytes, which a test now
asserts.

No correct frame moves. Twelve valid cases were encoded with the unpatched
function and this one and compared byte for byte: BUS with a dict, BUS with
a Message, BROADCAST with a nested object and BINARY with bytes, each at
compressed False, True and None. All twelve are identical.

ONE EXISTING TEST CHANGES, and the change is a consequence worth naming.
test_wire_payload_is_an_object.py built its malformed frames by calling
get_bitstring with a bad payload. The sender now refuses to build them, so
the decoder test lays the bits out itself per HIVEMIND-WIRE-1 §4.1. It
proves the same property against a frame a non-conforming peer can send,
and the counts are unchanged: 78 passed, 4 skipped, 80 subtests, the same as
before this commit.

Co-authored-by: JarbasAi <jarbasai@mailfence.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…rrives (#293)

* fix: a handshake retry does not downgrade before the server's offer arrives

wait_for_handshake waits for handshake_event and, on timeout, calls
protocol.start_handshake() to resend. start_handshake read:

    if self._server_handshake_payload and self._should_use_noise(...):
        self.start_noise_handshake(...)
        return
    self._legacy_start_handshake(self._server_handshake_payload or {}, retry=True)

With no offer yet, _server_handshake_payload is None, so the guarded branch
was skipped and the legacy call ran with an empty dict. That sends a protocol
v2 pubkey handshake decided on NO evidence about the peer, and hivemind-core
5.x refuses it with 1008: HIVEMIND-CRYPTO-1 §3 makes Noise the only key
exchange and gives no legacy fallback.

start_handshake now returns when the offer has not arrived. Waiting is safe
because every caller is a bounded retry loop that re-waits, in client.py,
async_client.py and http_client.py, so a peer that never offers ends in that
loop's own timeout instead of provoking a refusal. A test asserts that
property of the callers, so the guard cannot be left dangling by a future
caller that sends once and blocks.

REPRODUCED, not inferred. A protocol positioned as a retry finds it, with
_server_handshake_payload None, takes _legacy_start_handshake with {} and
logs

    hivemind_bus_client.protocol:_legacy_start_handshake:786 - INFO -
    hivemind does not support binarization protocol

which is the same file, the same line and the same message as the nightly
log in hivemind-test-harness
test_listener_releases_every_client_after_a_burst.

ONE CORRECTION TO THE CLASSIFICATION in
live-hivemind-test-harness-T-1567.md. The downgrading client did not read a
server payload that was missing the v3 capability fields. It read no payload
at all. hivemind-core sends its offer from one place and it always carries
max_protocol_version, binarize, encodings, ciphers and noise; its only other
HANDSHAKE send is a Noise message. So the server offers every satellite the
same full payload, and this one had simply not received it yet. There is no
route through a defective server payload.

WHAT THIS DOES NOT FIX. The nightly test fails in two shapes. This is the
refusal shape. The PollTimeout shape is a second defect: the log shows two
handle_hello frames per satellite, so the server saw two CONNECTIONS, and a
satellite admitted under one session id and registered under another leaves
the registry short. start_handshake cannot cause that, because it already
returns early while a Noise handshake is in flight. That shape needs its own
task.

ONE EXISTING TEST FIXTURE CHANGES.
test_the_timed_retry_does_not_open_the_window_for_an_envelope asserted that
the retry resends, which the old code only did by downgrading on an empty
payload. Its subject is the security property that a peer holding its
envelope back past the retry is still refused, and that is unchanged. The
fixture now carries the pre-v3 server's own HANDSHAKE request, which is what
a real retry answers, so the retry resends through the legacy path and the
refusal assertions run as before. The fixture also sets
max_protocol_version, because hm is a bare MagicMock and _should_use_noise
compares that attribute with an int, which raises TypeError on a mock. The
old short-circuit never reached the comparison.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: the retry loops are bounded only when max_retries is set

reviewer's SHIP-WITH-FIXES on this PR. The first commit called every
start_handshake caller "a bounded retry loop" in three places: the
start_handshake comment, this test file's module docstring, and a test name.
That is wrong by default.

`wait_for_handshake(timeout=5, max_retries=None)` in client.py and
async_client.py defaults to None, and connect() passes it straight through.
Reproduced rather than taken on trust, and the numbers match the reviewer's:
with the default it ran 14 attempts in 3 s and never returned, while
max_retries=3 raised RuntimeError in 0.80 s after 3 attempts.

THE DEFAULT IS NOT CHANGED, deliberately. "By default this waits for
reconnect forever" is that method's documented contract, and it is the right
one for a satellite whose hub may be restarting: bounding it would make every
caller that relies on it give up instead, which is a regression well outside
a fix for a handshake downgrade. What was wrong is what I wrote about it, so
that is what changed.

The three places now say the loops RE-WAIT, which is the property the guard
actually needs, and that they are bounded only when max_retries is set. The
test is renamed from ..._inside_a_bounded_loop to ..._inside_a_rewaiting_loop,
because re-waiting is what it asserts and always was.

THE HONEST CONSEQUENCE, now stated where it belongs rather than glossed:
under the default, a peer that never sends its offer leaves the client
waiting instead of timing out. That is the trade this PR makes deliberately.
Waiting for a hub is what a satellite is for, and the downgrade it replaces
got the satellite refused with 1008 and, because the client records that as a
refused identity, taken off the mesh until it was restarted.

A new test pins both halves so the prose cannot drift again: it asserts
max_retries still defaults to None in both clients, with a message saying the
wording needs rewriting if that changes, and it asserts that a loop WITH a
bound really does raise after that many attempts.

Suite unchanged otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: JarbasAi <jarbasai@mailfence.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Two clauses, and the first version of this change satisfied only one.

HIVEMIND-MSG-1 §4: a BROADCAST, PROPAGATE, ESCALATE, QUERY or CASCADE
payload "is itself a HiveMessage (a nested envelope)". Both commands
passed the Layer-1 Message straight in, which the constructor turns
into {"type","data","context"} with no msg_type, so the far end raised
TypeError the moment it read .payload. Both now wrap in
HiveMessage(BUS, ...).

HIVEMIND-MSG-1 §5: "When target_site_id is not set, or is empty, on a
BROADCAST, a PROPAGATE, or an ESCALATE, no site identifier is equal to
it. A node MUST NOT deliver the inner BUS message of that envelope to
its Layer-1 bus." The key is read from the OUTER envelope.

So wrapping alone left the frame parseable and UNDELIVERABLE: measured
at the previous head, both commands emitted target_site_id=None even
with --siteid, because that flag reached node.connect, which declares
THIS node's own site, and never the message. It is now set on the outer
envelope.

Measured, per command, with --siteid test-site:

  before: target_site_id = None
  after:  target_site_id = 'test-site'

tests/test_scripts.py: 2 subtests fail before this change and pass
after, one per command. test_the_far_end_can_read_the_payload no longer
asserts merely that the payload is not None; it reads the inner
envelope's type and the Layer-1 message inside it. A control asserts
the key is NOT on the inner envelope, because §5 reads it from the
outer one and setting it inside would look right and deliver nothing.

Suite: 1018 passed, 4 skipped, 85 subtests, no failures, in a venv
built from .[test,async,e2e].

Co-authored-by: JarbasAi <jarbasai@mailfence.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
hivemind-core sends 1008 from seven sites. Three are in decode() — a
non-Noise message on a v3 session, an invalid Noise transport message,
and an unencrypted message when crypto is required — and all three need
an ADMITTED connection to reach. The clients latched _auth_rejected on
the CODE alone and stopped reconnecting, so one malformed frame took a
correctly registered satellite off the mesh permanently and blamed its
access key.

Both clients now read the connection state, not the reason text.

The sync client needs one thing the async client does not.
websocket-client delivers the disconnect to on_error with an exception
FIRST, and that path clears handshake_event; teardown then calls
on_close(1008), where a guard reading handshake_event.is_set() saw
False and latched anyway. The fact is therefore stamped on the
WebSocketApp: run_forever builds a new one per attempt, so the mark has
exactly the lifetime of the connection it describes. No clear can
downgrade it and nothing leaks into the next connection.

Three things the review round added, each measured:

The mark is read with `is True`, not as a truth test. Any object that
invents attributes — a MagicMock, a proxy — answers a getattr for an
attribute nobody set with something TRUTHY, and the guard then believed
a session was established when it never was. That fails OPEN: a
genuinely refused key stops latching and retries for ever. Two rows in
test_client.py caught it, and one row now pins it.

on_close takes args[0] only when it is not None. A caller passing None
as the ws handed the None straight through, the guard fell back to
handshake_event, which on_error had already cleared, and the 1008
latched again — measured: ws=socket gave None, ws=None gave the latch.

The async site had NO test. Every async 1008 row set connected_event
and never handshake_event, so they covered the never-established branch
only; forcing async_client._receive_loop's session_was_established to
False left the whole set green. One row now sets the handshake first,
and it is the row that fails on that mutation. The comment left where
the deleted source-text test stood claimed those three sites were
covered; it was false for this one and now says so.

Suite: 1022 passed, 4 skipped, 83 subtests, no failures.

Co-authored-by: JarbasAi <jarbasai@mailfence.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix: refuse a Layer-1 Message as a routing payload

HIVEMIND-MSG-1 §4: for BROADCAST, PROPAGATE, ESCALATE, QUERY and CASCADE
"the payload is itself a HiveMessage (a nested envelope) whose msg_type is
typically BUS". The constructor converts a Message to {"type", "data",
"context"} with no msg_type, so a Layer-1 message put straight into a
routing type is not an envelope: the far end raises TypeError the moment it
reads .payload, and the sender is told nothing. That is what
hivemind-client escalate and propagate shipped until #300.

The refusal stands beside the conversion whose invariant it states, and it
is a library invariant, not a conformance point: it is invisible on the
wire and can only refuse this library's own caller (architecture ruling on
T-5567).

Three codec fixtures stop asserting that an undeliverable frame
round-trips: test_escalate_roundtrip encoded '{}', test_broadcast_roundtrip
'{"type":"alert"}', and test_all_message_types used one bus payload for
every type, including the five wrappers.

Scope, measured and not assumed. A payload that is already a dict with no
msg_type is NOT refused. Every wire door hands this constructor exactly
that: from_wire, deserialize, decode_bitstring, the inner view the payload
property rebuilds, and hivemind-core's door at protocol.py:584. Refusing it
is the receive-side twin T-5170 ruled out, and §4 forbids an admitting node
to inspect the inner payload of a wrapped routing message. A first version
refused the dict too: it broke three tests here and hivemind-core's
test_malformed_wrapper_payload.py, which builds that dict on purpose. A
Message object never arrives from a wire.

Suite: 1070 passed 4 skipped before, 1099 passed 4 skipped after, 152
subtests, no failures. With the guard reverted and the new tests kept, 11
fail and the 12 control cases still pass.

Consumer sweep across org:JarbasHiveMind found one caller that builds a
routing type from a Message, hivescope test_bare_query_is_malformed, fixed
first in hivescope#86, which passes with and without this guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: cite §4 with §2, because the key is required by composition

reviewer-33 attacked the §4 reading and it came back the other way: the
disclaimer was wrong, and wrong in the cautious direction.

MSG-1 §4 makes a routing payload "itself a HiveMessage (a nested envelope)".
§2 makes msg_type a required field of a HiveMessage -- "msg_type | yes" in
the three-field table, and "msg_type is the only field a receiver may rely
on to decide how to handle a message". So a nested envelope with no
msg_type is not a HiveMessage, and the key is spec-required by composition.
There is no silence here to read as permission.

Verified verbatim at architecture origin/dev 7eabe88, which is the merged
text and not the draft head this review read.

The comment no longer says §4 "names the shape and no field" and no longer
credits this library for the key. The refusal now reads "(HIVEMIND-MSG-1 §4
with §2)" and names msg_type, and the test asserts both clauses instead of
§4 alone.

Behaviour is unchanged: the guard refuses exactly what it refused before.
Suite 1099 passed, 4 skipped, 152 subtests, no failures, the same counts as
the reviewed head.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: JarbasAi <jarbasai@mailfence.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…#308)

Three things put the REPOSITORY ROOT on `sys.path`, and this repository had
two of them. The root carries a second copy of `hivemind_bus_client`, so
`build_tests` installs a wheel it does not test and cannot detect a packaging
defect.

`tests/__init__.py` made `tests` a package, and the root `conftest.py` re-adds
the root by itself, which is why deleting the marker alone was measured NOT to
fix a repository of this shape. Both go. `pytest_plugins` cannot move into
`tests/conftest.py`, because pytest refuses that key in a non-top-level
conftest, so a `pytest_configure` hook calls
`config.pluginmanager.import_plugin` behind the same `try/except ImportError`.
Ten sibling imports lose their `tests.` prefix; with `tests/` no longer a
package, pytest puts `tests/` itself on `sys.path` and the unprefixed form
resolves.

`tests/e2e/__init__.py` is ADDED, and it is not cosmetic. `tests/e2e/` and
`tests/` both hold a `test_encryption.py`. While `tests` was a package the two
module names differed; with the marker gone both become a top-level
`test_encryption` and collection stops with "import file mismatch". The e2e
marker makes that file `e2e.test_encryption` again. It does not bring the root
back: with the parent no longer a package, the basedir for an e2e file is
`tests/`.

The third trigger is the one a conftest change cannot reach.
`test_the_as_dict_guard_survives_python_dash_o` must use a subprocess, because
it checks a guard survives `python -O`. `python -c` puts the CURRENT WORKING
DIRECTORY on the child's `sys.path`, and pytest runs from the repository root,
so that child imported the working tree whatever the parent resolved. It now
runs from an empty temporary directory. `-P` does the same in one flag but is
Python 3.11, and this repository still builds 3.10.

Measured on dev 7c1c5f6, the wheel built with `uv build` and installed
non-editable with the `test,e2e` extras, which is what `build_tests` passes.
The instrument prepends `raise ImportError(...)` to the WORKING TREE copy of
`hivemind_bus_client/__init__.py` and changes nothing else, so a green run
proves the wheel was imported:

    before, clean              1099 passed, 4 skipped, 152 subtests
    before, tree poisoned      1 FAILED, 1098 passed
    after, clean               1099 passed, 4 skipped, 152 subtests
    after, tree poisoned       1099 passed, 4 skipped, 152 subtests

The single failure before the fix is the subprocess cell, and it is the whole
reason this change is not hygiene: 1098 sibling cells testing the wheel said
nothing about it.

Co-authored-by: JarbasAi <jarbasai@mailfence.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
bitstring 5 removed BitStream and ConstBitStream and reversed the argument
order of BitArray.insert(). serialization.py imported BitStream at module
load, and the dependency is bitstring>=4.1.1, so a fresh install on Python
3.11 or later took bitstring 5 and nothing that imports
hivemind_bus_client.client could start (ImportError: cannot import name
'BitStream').

The decoder now reads through a small cursor over Bits slices, which behave
the same in both major versions, and the encoder pads with prepend() instead
of insert(). The wire format is unchanged: the cross-platform vectors pass
on both versions.

The new tests run the codec in a child interpreter with BitStream removed
and the old insert() order refused, so they fail on the previous code under
bitstring 4 as well as 5. The existing tests that built frames with
BitStream or the old insert() order use the shared API.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 7ffd2e76-e31b-4d10-bf6b-403bf80bac99

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot closed this Oct 20, 2026
@github-actions
github-actions Bot deleted the release-1.2.11a2 branch October 20, 2026 22:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants