aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/chat_send_probe.py
Commit message (Collapse)AuthorAgeFilesLines
* test(hub): read the transport wherever it is splitChristophe Besson6 hours1-2/+8
| | | | | | | | | spa_source.transport_files() takes the classic transport*.js scripts from the hub's shell, in load order. Every test that read transport.js reads them all, the Node harnesses run them joined as one scope, the chat probe loads each, and the desktop shell must load them in order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* style: ruff's own fixes, mechanically appliedChristophe Besson6 days1-1/+0
| | | | | | | | | | | | | | | | | | | | `ruff check .` had gone unrun long enough to report 568 errors, which is the same as having no linter: the next real finding would have been invisible in the noise. This is the 521 it fixes by itself, in 173 files, and nothing else — the 98 it cannot fix are the next commit. What actually changed: import sorting (225), imports nobody used (87, none of them a re-export — no `__init__.py` is touched, which was the one way this could have broken an import elsewhere), `datetime.timezone.utc` to `datetime.UTC` (69) and `asyncio.TimeoutError` to `TimeoutError` (18), both plain aliases on the 3.12 this project requires, `Optional[X]` to `X | None` (24), and f-strings with nothing to interpolate (19). Checked rather than assumed: every module in the three packages still imports, and the suite is 2893 passed — the same count, test for test, as the merge before it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(spa): drive the composer's recovery through the real reconnectChristophe Besson2026-09-091-2/+43
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The reconnect scenario added with the fix proved the composer follows `_setDevicePk`, and it poked that method itself at both ends. That is a narrower claim than it reads as: it says nothing about whether a reconnect *reaches* it, and the harness's own `Host` stands in for group-page.js, so a green probe did not mean the page joins the two. Both halves are real now. The scenario calls `connect()` with the arguments `_reconnectLoop` calls it with; it stops at signaling, because there is no hub in the harness, and the identity has to be gone by then — connect() drops it before it touches the network. The restore is the shipped `_announceDevice`, answered by the stand-in node with a `device_hello_ack` as `_do_device_hello` answers it, and the key it settles on is the one the following send seals and signs with. Three assertions check the scenario went that way rather than through a variable set by the test. The seam the harness cannot drive gets its own check: the wiring exists, the prop is in `commonProps`, and the callback is set *before* `connect()` — after it, device_hello's answer is missed and the composer starts closed. That check first passed with the wiring deleted, on the strength of a comment naming the callback; it matches the assignment now. Two things the harness turned up. `do_POST` answered every path, so the offer connect() posts to the hub was swallowed as the measurement and put the machine's own SDP, public address included, into the probe's output — it answers `/log` and nothing else now. And a connect() that gives up before `await channelReady` left that promise rejected with nobody attached, so closing the peer connection printed "Uncaught (in promise) DataChannel closed" on every failed reconnect attempt — noise in exactly the log a freeze is read from. Checked against the unfixed source both ways: with the clear removed from connect() and the wiring removed from group-page.js, three cases fail; with them back, 15 pass. Hub and node suites 2266 passed, 4 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019GXmScYB1uR29YCt74si9J
* fix(spa): a reconnect must give the Chat composer backChristophe Besson2026-09-091-3/+60
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The Chat tab froze about every other day — the textbox stopped taking clicks — and it never recovered on its own: no timeout ends this one, only leaving the group or restarting the client. A console dump of a session it happened in ruled out everything it could and named nothing. What that dump established was almost entirely negative, and that was the useful part. No `Response timeout`, no `unsolicited`/`unrouted`/`with nothing waiting` — so the 2026-08-30 routing defect, which produces this exact symptom for thirty seconds, had not recurred. No `PC state: disconnected|failed`, no second ICE cycle, no `Reconnected after N attempt(s)` — so the connection was alive and untouched. The freeze was in the page, and no path that logs anything had run. The composer is `disabled=${sending || cannotSend}`, and `cannotSend` was `transport.connected && !transport.devicePk`, read off a **ref** during render. `devicePk` is settled inside connect(), so every reconnect clears it and settles it again; a ref changing re-renders nothing, and nothing else announced it. So the panel went disabled on whatever unrelated re-render came next — a message arriving — long after the identity was actually lost, and had no event that would open it again. group-page.js never touches `status` after 'connected', and `onReconnected` is claimed by video-player.js, so there was no second chance. It was silent as well as sticky. `_announceDevice` had three exits that wrote `devicePk` without a word: two early returns that left the *previous* connection's value standing, and a reply that is not `device_hello_ack` — an `error` reply does not throw, so the `.catch()` at the call site never saw it. Reproduced in chat_send_probe.py, which mounts the real ChatPanel over the real transport: with the old code, identity cleared leaves the composer open, an arriving message latches it shut, and restoring the identity does not reopen it. Every write to `devicePk` now goes through `_setDevicePk(pk, why)`, which logs, traces and calls `onDeviceIdentity`; group-page holds the answer as state and ChatPanel takes it as `deviceReady`. Defaulting that prop to `true` fails open — a wiring mistake here must not be able to leave anyone with a dead textbox. Two things found on the same path and fixed with it. `_send` throwing inside _sendAndWait's executor left the pending entry and its 30s timer behind, so a request that never reached the wire still logged a "Response timeout" half a minute later. And the instrumentation this was meant to be diagnosed with (3be8bd2) writes to localStorage behind ?trace=1, not to the console, so the dump could not have carried it: the two lines that decide the composer's state are now logged unconditionally, and MeshBayTrace gains `record` so the composer writes into the same timeline as the channel events. Hub suite 2264 passed, 4 skipped. chat_send_probe.py gains a `reconnect` scenario and test_chat_send.py four cases, each checked against the unfixed source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019GXmScYB1uR29YCt74si9J
* test(hub): stop the Chrome profile cleanup racing its own childrenChristophe Besson2026-09-081-1/+10
| | | | | | | | | | | | | | | | | | | | | | `terminate()` signals the parent only. Chrome's zygote, renderer and gpu children outlive it by a moment and go on writing into the profile, so rmtree walked a directory that gained a file between its readdir and its rmdir and raised "Directory not empty". The probe exited non-zero, and every test in the file errored at setup — intermittently, roughly one run in three, for a reason nowhere near the chat code they were testing. TemporaryDirectory(ignore_cleanup_errors=True) in all four probes that own a profile: a few bytes left in a throwaway directory are harmless, failing the run is not. `proc.wait()` after `kill()` was also missing — a killed process still has to be reaped. layout_probe.py never cleaned up at all (mkdtemp, no removal) and never waited for Chrome; it leaked a profile into /tmp on every run. Ten consecutive runs of test_chat_send.py are clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* Merge origin/main into the chat encryption workChristophe Besson2026-09-071-104/+138
|\ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Both sides landed a breaking MNP change and both called it 2.0, which is right: the sealed upload, the removal of `stream_seg` and mandatory chat encryption share one flag day. They are recorded as one version in `__init__.py` rather than as a race between two. The resolutions that were decisions rather than mechanics: * **`MNP_MIN_SUPPORTED` moves to "2.0".** The sealed upload alone was a *confined* break — a 1.x peer could still connect, browse, download, stream and chat, with only its uploads refused by `upload_not_sealed` — so the floor deliberately stayed at "1.0". Mandatory chat encryption ends that confinement: a 1.x peer can neither produce a sealed chat message nor read one, so it would connect, look fine, and be unable to say anything. Refusing it at the handshake is the honest form. The per-message `upload_not_sealed` path is untouched and still right if the floor is ever lowered. * **`sendChat` throws on an `error` reply**, from origin, applied to the sealed send. It matters more after this change, not less: the node now refuses a stale epoch, a malformed envelope and a device claim that is not the connection's own, so there are three new ways for a message to be rejected and none of them may look like a message that was sent. * **`req_id` supersedes the per-type routing** this branch added for `chat_keys_resp` and `device_hello_ack`. Both blocks are kept beside the existing `chat_hist_resp` one, for the same stated reason — a node too old to stamp — and their comments no longer claim to be the mechanism that closes the class. `req_id` is. * **`chat_send_probe.py` is rebuilt on origin's structure**, not beside it: two scenarios, a stub that stamps `req_id`, `music_meta_req` as the older pending request. The encrypted path is layered on — a real Ed25519 device key generated in the page, and a `chat_keys_resp` sealed by the shipped Python, because a payload the page built itself would prove only that the page agrees with the page. * **`test_reply_correlation.py` now sends a sealed message.** Its subject is which of the two messages leaving that handler carries the id; plaintext chat was only the fixture, and the node refuses one now. * `groupbox` keeps both new purposes (`upload`, `chat_keys`); `protocol.py` keeps origin's removal of `STREAM_SEGMENT` and this branch's correction of the "Double Ratchet message" comment on `CHAT_MESSAGE`, which was wrong when it was written and is wrong differently now. Full suite on the merged tree: 1993 passed, 11 failed — the same 11 that fail on a pristine checkout (2 Windows service tests, 1 apps-enabled policy, 7 transcode tests that pass in isolation, and the WebRTC invite test that hangs on its own). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
| * fix(mnp): give a reply an id, so it stops being routed by luckChristophe Besson2026-09-071-56/+89
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | MNP carried no correlation id. A reply named its own type and nothing else, so a client with more than one request in flight worked out which one a message answered from the message itself — and for the replies that name nothing it could not. `_dispatch` fell through to matching by arrival order, which is a guess. `_sendAndWait` had the right value all along: it keys `_pending` by `this._seqId++` and never put it on the wire. The guess fails asymmetrically, which is why it hid. The victim is not the request that was answered wrongly — it is the unrelated one that now waits out its own 30s timeout for a reply already delivered elsewhere. Live on 2026-09-06: five `music_meta_req` sat pending for over 100 seconds behind a failing MusicBrainz, and a `device_list_result` was handed to one of them. The composer is disabled while a send is in flight, so a chat message whose reply went astray the same way left the Chat tab looking frozen for thirty seconds, then unfroze on its own. The `ack` half of this was fixed on 2026-08-30 by matching on request type. That closed the instance and left the class open: a refusal has no type to match on either, and `_dispatch_message`'s catch-all answers every unforeseen failure with `{"type": "error", "detail": "Request failed"}` — 238 of this module's 240 error sends name nothing at all. `req_id` now rides on the request and comes back on the reply. On the node it is published for the whole handler in a ContextVar and stamped by `_send`: a parameter would have meant threading an argument through all 240 send sites, and asyncio copies the context into a task, so a handler that `_spawn`s its real work still answers under the right id. It is never stamped on a broadcast — those answer nothing, and the owner check in `_send` is what keeps a chat broadcast or an index push from reaching another peer looking like a reply. On the client, `_dispatch` resolves on `req_id` first and the arrival-order fallback is gone the moment a node proves it stamps (`_correlates`, armed by the handshake's own reply). The fallback stays for an MNP 1.0 node, unchanged and no wider: there it is the only thing there is, and removing it would leave device_list_result, join_result and the handshake replies reaching nobody. Two things fall out. `sendChat` refuses an `error` reply like every other request in the file — it returned it as success, which did not matter while a refusal reached the wrong caller anyway and would now show a rejected message as sent. And `_group_ctx` uses `.get`: a reload pops a removed group while sessions connected to it are open, and every request they had left raised KeyError into that same catch-all. Sealed index messages are the one exception to the fast path. They cannot be handed over until they are opened, which is asynchronous while `_dispatch` is not — resolving on the id alone gave `fetchIndex` the envelope and skipped `onIndexSync` entirely. Caught by extending `index_seal_probe.mjs` to stamp a reply the way a current node does, after the hub suite passed over it: the probe built its own frames and had never seen one. Tests, all failing before and passing after: `test_chat_send.py` drives the real ChatPanel over the real transport for both shapes of reply with an older request pending (3 of its 6 are new, and the 3 for `ack` pass either way, so it discriminates); `test_reply_correlation.py` pins the node's half — the refusals that name nothing else, the broadcast that must not be stamped, and a late reply from a spawned task answering under its own id rather than the most recent request's. Full suite: 1897 passed, same 11 pre-existing failures as before. QUIC keeps its own dispatch and is not stamped. It is disabled by default and no browser request reaches it, but the asymmetry is real. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dn1xYx9uT69mCB6UDvyKAN
* | feat(chat): encrypt group chat under per-device epoch keys (MNP 2.0)Christophe Besson2026-09-071-6/+89
|/ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Chat messages are sealed with AES-256-GCM under a key derived per group, per epoch, per *device*, and signed over the ciphertext with the device key the node pinned. The node relays and archives; it cannot read a message. There is no switch. MNP goes to 2.0 and MNP_MIN_SUPPORTED moves with it, so a 1.x peer is refused at the handshake with `version_too_old` rather than admitted and then unable to speak. An opt-in flag was designed and rejected: every node is a test node, so it would have bought nothing and left a plaintext branch reachable — C6's lesson one feature later. A test reads the source and refuses any code that consults a `chat_encrypted` setting. Not Sender Keys, and `senderkeys.py` is now documented as unused. With distribution under the group key and a node that serves history to devices which were not present, the node must retain each chain's earliest key, and a chain key at iteration i yields every message key from i on by pure HKDF — forward secrecy is zero either way. What the ratchet was left buying was stateful client code with silent failure modes, three of them reproduced: any member could sign as any other, a second device dropped the first's chain, and the skipped-key cache grew without bound. The reasoning is in docs/chat-sender-keys.md, which is the specification and the decision record. Epochs, not rotation: the epoch key is wrapped under the group key at delivery and never stored under it, so `gek_rotate` is a re-wrap. A group-key-derived archive key would have made every message ever sent unreadable on the first `member unpin`, which is the documented step after removing a member. A new epoch opens on member revoke/unpin, device revoke and `gek_rotate`; old epochs are kept and still delivered, so history stays readable to everyone who could already read it, and nothing anywhere deletes one. Three prerequisites this needed, each a live defect on its own: * The peer registry was keyed by user_id, so one account's second device evicted the first and the broadcast skipped recipients by account — a person's phone never saw what they typed on their laptop. * The handshake authenticated an account, never a device. `device_hello` (additive, signed, refused unless the key is a live device of this account in the node's own roster) is what lets the node refuse a member claiming somebody else's key. * `_admin_exec_file_delete` authorized against the exact uploading key, so device linking had already broken deleting your own file from your other device. It now authorizes against any non-revoked device of `uploader_id`. Found by driving the real panel over the real transport, not by reading source: `chat_keys_resp` was routed by arrival order and handed to an unanswered `media_meta_req` — the original frozen-tab defect in a message type that did not exist when that probe was written. And `_asText` had been deleted with an unrelated helper beside it; its only caller sits inside a promise the panel catches, so every conversation rendered empty with nothing in the console. Existing node data is migrated by QE/migration/migrate_chat_encryption.py (not versioned, per the QE rule), run with the node stopped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
* fix(hub): route the chat ack to the request that asked for itChristophe Besson2026-09-031-0/+206
Typing a message froze the Chat tab: the composer stopped taking clicks and keystrokes, the message never appeared, and it was there all along on the next visit to the tab. The node answers a chat message with a bare {"type": "ack"} -- no request id, no type of its own -- so _dispatch had nothing to match it on and left it to the arrival-order guess at the end of the function. That guess is wrong the moment anything else this browser asked for is still waiting: the ack went to *that* request, and the chat send waited out _sendAndWait's own 30s timeout. Since the composer is disabled while a send is in flight, that reads as a frozen tab; the node had stored the message and answered, into somebody else's promise. An outstanding request is the ordinary case, not a rare one. The node refuses an unknown file_id with a bare `error`, which names no request either and so reaches none, leaving the Videos tab's media_meta_req in _pending for the full 30s. That is the one that was live when this was found. - `ack` is now matched by request type: chat_msg, or the keypair-bundle store and delete, which name themselves in `detail`. A node naming neither still has its reply placed rather than dropped. Every line of chat-app.js is correct and every routed message in transport.js is routed correctly -- the defect is in the seam, so tests/harness/ chat_send_probe.py drives the two together: the real ChatPanel over the real MeshBayTransport, with only the DataChannel replaced by a stand-in answering what the node answers. test_chat_send.py asserts against it, and with the fix reverted all three of its tests fail on the three visible halves of the defect -- the composer still disabled, the message absent, and the ack resolving the unrelated request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GFF4BL8VSKrghkSLzCrTVs