Repository navigation
Release 1.2.11a2 - #311
Closed
github-actions[bot] wants to merge 316 commits into
Closed
Release 1.2.11a2#311github-actions[bot] wants to merge 316 commits into
github-actions[bot] wants to merge 316 commits into
Conversation
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
* 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>
run 35903895293
…#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>
run 36180102989
…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>
run 36234014731
…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>
run 36235038201
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>
run 36240527619
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>
run 36240656530
* 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>
run 36251605191
…#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>
run 36861402998
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>
run 39366917176
Contributor
|
Important Review skippedBot user detected. To trigger a single review, invoke the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Human review requested! This supersedes any earlier open release proposal for this repository.