| Commit message (Collapse) | Author | Age | Files | Lines |
| ... | |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A PDF preview showed the "this browser will not display the PDF inline"
fallback everywhere — in the desktop client since its first launch, and in
the browser since the hub started sending a CSP on 2026-09-01. It read as a
missing native feature because before that commit the hub sent no policy at
all, so Chrome had once worked and the application never had.
Two directives govern one feature. `files-app.js` decrypts the file in the
page and hands it to `<object type="application/pdf">` from a Blob; Chromium
loads that as plugin data (`object-src`, absent and therefore falling back to
`default-src 'none'`) and then renders it in an internal frame (`frame-src`).
Opening either alone changes nothing visible — the second refusal produces the
same fallback. `'self'` covers neither: a same-origin `blob:` URL is not
matched by it in either directive, measured in Chrome 152 against the deployed
page and in Electron 44 against the client's own policy.
`plugins` stays at its default `false`: the built-in viewer is not behind that
flag on Electron 44, verified by rendering one.
Widening `object-src` from `'none'` to `blob:` admits only what page script
minted itself, at a type this code sets — PDFium parsing bytes that came from
a node, which is what any browser does with the same file once downloaded.
Tests: each policy is pinned to carry `blob:` in both directives (each fails
if either token is removed), and the two policies are now held identical
directive by directive apart from the two deliberate differences — the comment
claiming they were the same had already drifted and nothing checked it. The
CSP source parser in test_desktop_shell.py read `//` comment lines as
directives, which is the "parse directives, not text" mistake this file
already records; it skips them now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XauykfBvRrpy6RYbF6F7Wu
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Reported from Chrome, with a screenshot: four downloads with no pause button
and an upload beside them with one, and nothing anywhere saying why.
The reason is real. Without a granted download folder the browser writes
through the service worker — a download it already owns, which cannot be paused
without stalling it somewhere we can neither see nor resume. An upload writes to
the node, which keeps the position, so it is always pausable. But that was
stated only in a Settings line nobody reads on the way to a download, and a gap
where the row above has a button is not an explanation.
So a download that cannot be paused now shows a dimmed pause icon where the
button would be, carrying the reason and the remedy in its tooltip. Not a
button: there is nothing to click, and a disabled one invites the click anyway.
And only where the advice can be taken. Firefox and Safari have no folder to
choose — the streamed path is the only target they have, which is what §6.5 of
~/next/improve-downloads.md costs out — so telling someone there to choose one
would be advice they cannot follow. Nothing is drawn.
Hub suite 866 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Reported while testing the flag day: pausing an upload put it under "Finished".
"Finished" was defined by exclusion — everything that is not running, queued or
preparing — so it swallowed `paused` the day pausing shipped. A transfer
somebody stopped on purpose then sat beside the ones that are actually over,
offering a resume button in the section of things that cannot be resumed, and
dropped out of the badge, which announced less activity than there was.
Paused is now its own group, in all ten catalogues, and counts as active: it is
not over, the person means to come back to it.
The three filters are lifted out of `app.js` and executed rather than described
in the test, and one case asserts that every status lands in exactly one group
— a state added later that falls into none is a transfer the panel simply does
not show, which is how this one got in.
The same report also said the three running downloads lost their pause buttons
when the upload was paused. That part is **not** explained and **not** fixed:
the store returns `pausable` true and status `running` for all three (new test),
closing an upload lease pumps only the upload queue, and the button's condition
is a pure function of those two. All three say the buttons should have stayed,
so an observation is missing rather than a cause.
Hub suite 864 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Stage 4 of ~/next/improve-downloads.md, the flag day. Leases become compulsory
and a 2.x peer is refused at the handshake.
**The bound on leaseless reads (§3.4.1) did not exist, and it is what makes the
rest mean anything.** Browsing a group is never subject to a transfer slot —
that is an operator decision and a requirement: a member must be able to browse
a group at capacity exactly as they browse an idle one. But "not leased" cannot
mean "unbounded", or a client that simply omits `tr` transfers outside every cap
and the caps are decoration. A session may now read two distinct files at once
without a lease: one because a viewer looks at one file, two so that prefetching
the next photo stays possible. A count of files and not a byte budget, because a
RAW photo is 60-80 MB and is browsing while a 40 MB archive is a download, and
no size threshold separates them. Thumbnails, posters and cover art never reach
this check at all — they resolve out of the node's own cache.
It is a fairness control among cooperating clients, in the company of
`max_concurrent_streams`, and is not a defence against a member determined to
saturate a node's disk. That member is a member, and the answer to them is
`member revoke`.
**MNP_VERSION and MNP_MIN_SUPPORTED both move to 3.0**, on both sides. The
messages are additive; the requirement is not. An opt-in switch would leave a
leaseless branch reachable on every node, which is finding C6's lesson — a
transport that accepted a bare JWT — one feature later.
**The desktop client now checks before it connects.** The SPA is served by the
hub and picks up a new client on reload; the application ships its own
interface, so an un-updated one would sign in, list groups, and fail every
connection with `version_too_old` — a refusal in a protocol vocabulary with
nothing anyone can act on. It asks `/v1/hub/version` for `client.minimum` and
says so plainly instead. An unreachable hub is deliberately *not* "too old": a
captive portal or a closed laptop must not make starting the application
impossible.
**Every package is aligned on 0.13.0.** `meshbay-client/package.json` had
drifted to 1.0.0 while the Python packages were on 0.12.0 — invisible until
something compared those numbers, and then load-bearing: an installed client
announcing 1.0.0 sorts above a 0.13.0 minimum and walks through the gate meant
to stop it. That is stated in the code rather than left to be rediscovered; it
is acceptable exactly once, because the operator is updating every client, node
and hub by hand for this flag day. A new test fails if two packages ever
disagree again, and another fails if the hub would refuse the client the tree
builds.
Node suite 1209 passed, hub suite 861 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Reported from Chrome: a download started while an upload was running took
thirty seconds to begin, every time. The console named it exactly —
/_mbdl/mtty5btz-sbmgdegx 404 ()
[MeshBay] the worker did not answer the download within 15s (attempt 1)
A 404 from the hub means the request reached the *network*: the worker looked,
found no entry for that id and let it through. So the worker was alive and
controlling the page, and the message handing it the stream had simply never
been processed.
`pending` lives in the worker's memory, and a worker with nothing to do is
terminated within tens of seconds. A WebRTC upload gives it no events at all,
so minutes of uploading leave it dead; the stream posted to it is lost,
silently, and the iframe then wakes it with nothing to find.
`mbdl-ping` already existed for this exact reason -- sent every ten seconds
*while* writing, because a streaming response does not count as activity.
Nothing sent one before *starting*. So a download now wakes the worker and waits
for the pong, and `sw.js` answers `mbdl-ready` once it has actually stored the
entry, which the page waits for before navigating: confirmed rather than
assumed. A worker that predates the ack sends nothing and the page navigates
anyway, which is what it did before.
This cause was measured and wrongly dismissed hours earlier, with an idle probe
that made the worker work between its own attempts -- it never actually slept.
A measurement that does not reproduce the conditions refutes nothing. The
harness now models a worker that is asleep: a ping wakes it, and anything else
posted while it sleeps is lost, which is what made the failure silent.
`test_backpressure_is_real` read the first `worker.postMessage` in the function
to check that the readable half is transferred rather than copied. The wake-up
put a ping in front of it, so it began inspecting a call that carries only a
port -- and kept passing. It now checks every post, each bounded by its own
call, since the keep-alive ping transfers nothing at all. Same shape as the
upload-seal contract this morning: a guard that reads "the first" stops
guarding the moment something is inserted before it.
Hub suite 851 passed. Both new cases checked against the unfixed source.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Stage 8 of ~/next/improve-downloads.md, second half, plus the gap it exposed in
stage 7.
**Asking where to resume.** The node identifies an upload by (member,
directory, filename), so a client resuming one has to name the file — and
`transfer_open`, the obvious place to ask, travels in clear. Naming it there
would undo exactly what sealing this path bought in MNP 2.0: before it, the same
file was ciphertext leaving a node and plaintext arriving at one. So the
question is asked inside the seal that already exists, as an ordinary
`file_upload` with no bytes and `chunk_index: -1`. The node writes nothing,
creates no state, reserves no name, and answers with `resume_from` in the sealed
ack. A node that predates it refuses the index, which the client reads as "start
from the beginning" — the behaviour it had anyway — and the wait is bounded so
one that answers neither does not strand an upload.
The probe is answered after every check the write path makes, so it cannot ask
questions about a directory the caller may not write to, and it answers only
about the member who asks: otherwise one member could measure another's
progress on a file they never sent, and worse, resume it.
**Pausing an upload.** Reported: no pause button on an upload, even in the
desktop app. Stage 7 built pause around the download path — a target declares
whether it can be stopped — and an upload has no local target to ask. It was
also refused by design, since a transfer handed a lease it cannot re-create must
not be offered a button that would drop its slot for good. Uploads now ask for
their slot rather than being handed one, and say they are pausable outright: a
File is seekable and the node keeps the position. Resuming re-probes rather than
trusting the client's own memory, so it works across a reconnect too.
**And the slot they hold.** `_do_file_upload` never called `slots.touch(tr)`.
Chunks are not gated by the lease, so the file arrived — but the node reclaimed
a grant nobody appeared to be using after thirty seconds, twice, then abandoned
it, and the widget follows the lease. Measured from the journal: a 3.5 GB upload
read "waiting, 0 ahead" for a minute and a half while it was transferring. The
download twin of this was fixed on 2026-09-08; the same omission was still here,
invisible until uploads took a real lease.
`test_the_upload_itself_is_sealed` now checks every message `uploadFile` sends
rather than the first. Adding the probe put a second one in front of the one it
was written for, and it would have kept passing while guarding nothing.
Node suite 1202 passed, hub suite 850 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Reported from Chrome: connecting to a group triggered a page refresh within
seconds, taking the WebRTC session down with it.
The boot check added with the bypass repair asked its question by *performing a
download* -- a four-byte stream through a hidden iframe. Chrome rations the
downloads a page may start without a user gesture to about three, measured: on
a first visit three consecutive attempts went served, served, refused. So the
check competed with the person's own downloads for that budget, and its answer
depended on how much of the budget was left. On a healthy page it concluded the
worker could not serve, and reloaded.
The same mistake the repair was written to fix, from the other side: paying a
capability to obtain a diagnostic.
The replacement costs nothing and asks nothing. Measured on Chrome, at document
start, before anything registers:
first visit controller false, registration false
ordinary reload controller true, registration true
hard reload controller false, registration true
Being uncontrolled while an active registration already exists names a
hard-reloaded document exactly, so that is now the whole of the evidence. A
first visit is uncontrolled too and is not a bypass -- the worker is installing
and will claim the page in a moment -- which is precisely the case that was
reloading.
Four cases, each checked against the unfixed source, including that priming
performs no download at all. Hub suite 847 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Reported from testing 7a: pause worked in the desktop app and no button
appeared in Chrome. That is the design working — without a granted download
folder, "save automatically" means the service worker, and that target is a
download the browser already owns — but nothing anywhere said so, and choosing
a folder looked like a question of where files land.
So the Settings line now says what it costs not to choose one, in all ten
catalogues. It renders only where a folder can be chosen at all, which is
exactly the browsers the advice applies to.
Also pins the tier table the pause button is drawn from: a granted folder, a
save dialog and the desktop sink can be paused, a service-worker stream cannot.
Four cases through the real `_openDownloadTarget`, and one more that reads the
value off the real `downloads.js` rather than a stub of it -- the first version
of these stubs did not carry the field at all, so the cases would have passed
while checking nothing.
Hub suite 847 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Stage 7a of ~/next/improve-downloads.md: pausing within a session, on the
targets that can actually do it. Resuming across a reload is 7b.
A paused transfer holds **nothing**. Its slot goes back to the node the moment
it stops and resuming rejoins the queue at the tail, because anything else lets
one member close a node by pausing four downloads and going to lunch. So the
lease is taken inside the run loop rather than before it, and pause is refused
outright for a transfer that could not ask for another one.
Resuming is exact rather than approximate: the pipeline stops between two
chunks and never inside one, so what is on disk is always a whole number of
chunks and `fromChunk` is a verified position. The failure mode being avoided
is a file that looks complete and is quietly corrupt.
The target has to survive it, so a pause no longer reaches the `abort()` that a
failure does -- that would delete Electron's `.part` or the file just created in
the granted folder, leaving nothing to continue. And the in-memory fallback
keeps its accumulated chunks rather than starting a second array.
The button is drawn only where the target says it can. A service-worker stream
says no, in its own code and for its own reasons: the browser is already writing
an HTTP response into its own download folder, not feeding it stalls that
download where we cannot see or resume it, and an idle worker is terminated
within seconds. Firefox and Safari therefore keep cancel and get no pause, which
is the decision recorded in §6.5.
Cancelling a paused transfer ends it. A paused run is parked on a promise;
without waking it the row said "cancelled" over work that had not stopped and a
target that was still open.
Six cases, each checked against the unfixed source. Hub suite 842 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The repair worked but arrived too late to help: about thirty seconds after a
hard reload, by which time four downloads had been started and hung, and the
page reloading under them read as an unexplained refresh.
Two delays, both removed.
`_claimController` waited its whole control budget before asking for the claim.
A page that is uncontrolled while an active worker exists will never be claimed
on its own -- a document fetched by a hard reload is exactly that shape -- so
the fifteen seconds were spent waiting for something that was not coming. The
claim is now asked for first; waiting is the fallback, not the opening move.
Measured in the harness: 6042ms of a 6000ms budget before, milliseconds after.
And a download that starts while the self-test is still running now waits for
it rather than racing it. Otherwise the click spends both its attempts failing
on a path that is about to be repaired, which is what put four frozen rows on
screen.
Hub suite 836 passed. Both new cases were checked against the unfixed source.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Downloads on Firefox failed with "the worker did not answer the download within
15s", every time, for one operator, while the same profile driven from here
succeeded every time. Their own test sequence found it: a freshly started
browser downloaded four files out of four, twice; one Ctrl+F5 and every
attempt afterwards failed; restart, fine again; Ctrl+F5 before any attempt and
the very first one failed.
A document fetched by a hard reload is loaded with the service worker bypassed.
It can still be claimed afterwards, so `navigator.serviceWorker.controller`
comes back and every check in `_claimController` passes — but the navigations
that document starts keep missing the worker, and the hidden iframe a streamed
download needs is a navigation. On Firefox and Safari that is the only way to
write a file too large to hold in memory, so the download cannot happen at all,
for the life of the page.
Being controlled is not being servable, so priming now asks the question
directly instead of inferring it: a four-byte stream and a hidden iframe,
exactly as a real download would, torn down completely so nothing lands in the
download folder. When it goes unanswered the page reloads once, ordinarily,
which puts it back under the worker. The flag lives in sessionStorage rather
than a variable because it has to survive the reload it triggers, and because a
page that is still unservable afterwards must stop rather than loop.
Also stops telling people to change browser. The message said "use the desktop
app, or Chrome or Edge" for a state an ordinary reload undoes, on the one path
Firefox has no alternative to; all ten catalogues now say to reload first.
The hard reloads were on my instruction: the SPA's HTML is served `no-store`,
so a plain reload has always picked up a new build and Ctrl+F5 was never needed.
Hub suite 834 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Four downloads on Firefox sat at "preparing" indefinitely, with the target queue
already bypassed there, so each opening was hanging on its own. The node journal
showed `d=0/8(q0) u=0/8(q0)` — no transfer had been asked for yet.
`_claimController` had two waits with no deadline at all,
`navigator.serviceWorker.register()` and `navigator.serviceWorker.ready`, while
SW_CONTROL_BUDGET_MS bounded only the wait that comes after them. `_swPromise`
is shared, so one unsettled wait left every download on the page suspended on
the same promise for the life of the tab.
Measured on Firefox 154, against a local 127.0.0.1 site so no hub was involved:
a worker that installs gives register() in 8ms and ready in 0ms; a worker whose
install handler rejects gives register() in 7ms and a `ready` that never settles
— still pending past ten seconds. register() resolves as soon as the
registration object exists, carrying nothing but an *installing* worker; ready
is what waits for an active one.
Every wait is now inside one budget, with two carve-outs so that a deadline
never costs a capability. A `ready` that times out while registration.active is
set is not fatal: ready may be waiting on a newer worker that cannot install
while an older one serves perfectly well. And the mbdl-claim recovery keeps its
own budget outside the deadline, because giving up there would cost Firefox the
only unbounded way it has to write a download to disk.
A deadline alone would have been a better-explained failure rather than a fix: a
registration stuck with nothing but an installing worker does not heal, and
every later visit finds the same one. So when ready times out with no active
worker, the registration is discarded and asked for once more with a fresh
budget, and the page repairs itself instead of needing developer tools.
Four cases pinned, each checked against the unfixed source. Hub suite 830
passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Four downloads on Firefox all sat at "preparing", with the node journal showing
`d=0/8(q0) u=0/8(q0)` — not one transfer opened, so nothing had got past the
client's target opening.
Serialising those openings was new in d6c4808, and on Firefox it regressed what
had always worked: four openings that ran at the same time began waiting on the
slowest. `_targetQueue` is module-level and never reset, so an opening that
never settles leaves the page unable to start any download again until it is
reloaded.
Two bounds, both narrowings of the queue rather than of any capability:
Only an opening that could actually show a dialog joins it. Firefox and Safari
have no `showSaveFilePicker` at all, so nothing there can race anything and the
queue bought nothing while costing everything; they now bypass it entirely,
which restores the previous behaviour by construction rather than by tuning.
And no opening waits behind another for longer than TARGET_QUEUE_BUDGET_MS
(90s) — generous enough never to cut in front of a real dialog, finite because
the alternative is a download panel that only a reload can fix. Releasing early
is safe: whatever is ahead is still the only unbatched opening, so the released
one takes the streamed path and opens no second dialog.
Measured on Firefox 154 against the deployed hub before writing any of this:
`register` and `ready` return instantly, the page is controlled, and four
serialised openings are served in 5-18 ms. The streamed path was never the
delay; the queue was.
Both new cases were checked against the unfixed source: without the bypass the
peak concurrency is 1 instead of 4, and without the budget the stuck-opening
case hangs. Hub suite 826 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Selecting four files on Chrome produced a Save As dialog for the first, then
one for the second only after that file had finished, while the last two timed
out; on a later attempt the three remaining transfers appeared frozen.
Two things were going on. Opening the target inside `prepare` had removed the
accidental serialisation that `for (…) await downloadFile(e)` used to provide,
so `_openTargetInTurn` now queues the openings — but a queue whose head is an
unanswered dialog is a head-of-line block, which is what the "freeze" was.
The code already recovered from a picker with no gesture behind it by streaming
instead, on the `SecurityError` Chrome throws. That branch was never reached:
Chrome does not throw, it shows the dialog anyway and waits for a human. So
anything that has to wait its turn is now marked `batched`, and a batched
opening prefers the streamed path whatever the download mode says. The first
file of a batch — the one actually holding the gesture — still gets its dialog,
so the preference is honoured where it can be. For the rest there is no gesture
left to spend and nothing is lost by streaming: the file still lands on disk,
in the browser's own download folder. Only the choice of folder goes, and it
was not on offer. If the worker does not answer, a batched download falls back
to the dialog rather than failing.
Also logs which path led to a dialog. A dialog is the one outcome nobody can
diagnose after the fact — it looks the same whether it was asked for or fallen
back to — and the report this fixes needed three test cycles to narrow. The two
harnesses that lift `_openDownloadTarget` as text now route console.info to
stderr, since they parse stdout as JSON.
Measured against the deployed hub in Chrome 152: the streamed path serves the
hidden iframe in 2-3 ms on a normal load, after a hard reload (via the
`mbdl-claim` recovery already in `_claimController`), and twice in the same
document. Hub suite 824 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Clicking Download produced nothing — no row, no icon, no panel — for as long as
it took to open somewhere to write, and then several rows at once. The streamed
path waits for the worker twice; a Save As dialog waits for a person. The row
was created after that, so the slowest part of a download happened with nothing
on screen to say it had begun.
The store gains a `prepare` step, distinct from `run`, and the order is now:
row, then target, then slot.
That last part is why the obvious fix was wrong. Taking the slot first would let
the row appear immediately, and it was tried this morning: a granted slot has to
be taken up within the node's deadline, opening a target can outlast it, and
three downloads became one. (The diagnosis at the time blamed that ordering for
revocations which were in fact a missing `touch()` call — the revert was right
for the wrong reason.) `makeLease` is called after `prepare` succeeds, never
before.
Three behaviours fall out, each with a test:
- a dismissed dialog leaves nothing behind. `prepare` returning false drops
the row: nothing started, so nothing should remain on screen to explain it;
- the row takes the name the file was actually saved under, once known;
- a refusal above the memory ceiling fails the row that is already there,
rather than creating one to kill it.
`preparing` counts as live everywhere — badge, cancel, clearFinished, and
`_busy`, since closing a transport under a preparing transfer strands it exactly
as under a queued one. Six places asked "is this finished?" and were drifting
apart; there is one definition now.
Two mistakes in the tests, worth the note: one counted positions in an output
array by hand and was one out, which reads exactly like a failing assertion
about the code — the values are tagged now, not indexed. And
test_zip_size_limit.py's stub did not run `prepare`, so it no longer reached the
size check the file is about; it now behaves like the real store.
819 hub, 1169 node, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Steps 5 and 6 of ~/next/improve-downloads.md. The node has handed out slots
since step 2 and nothing asked for one; now the client does, and the panel shows
what is happening.
`transport.openTransfer()` returns a Lease: `acquire()` resolves when the node
grants, `release()` gives it back exactly once, and nothing else in the client
speaks to the node about slots. Whether a node hands out slots is read from the
handshake ack rather than guessed from a timeout — "no answer yet" and "this
node will never answer" are indistinguishable in time, and guessing wrong either
stalls every download or defeats the cap.
Two things exist only because a queue can lie: a watchdog re-asks when a pushed
grant does not arrive (the node is idempotent on `tr`, so asking again is free),
and a grant for a transfer the page has forgotten is handed straight back rather
than held until the node's deadline.
The slot is asked for **after** there is somewhere to write, and that ordering
is load-bearing: opening a target takes thirty seconds of streamed-download
timeouts, or as long as somebody leaves a Save As dialog open, and a grant not
taken up in time is revoked. Moving it earlier looked better and broke three
downloads into one. Pinned by a test.
The panel groups by state — running, waiting, finished — rather than re-sorting a
flat list, so a row moves only when its own state does. The ETA is withheld until
the speed window holds real measurement: a figure from the first two chunks
swings between four seconds and an hour, and people plan around the first number
they see. One live region announces state changes and not progress.
Three silent paths closed on the way: a download refused for want of a user
gesture (a browser grants one file picker per gesture, and downloading three
files is one gesture) now falls back to the streamed path, which needs none; a
click with no connection says so instead of doing nothing at all; and a queued
transfer counts as busy, so a transport is never closed under one that is
waiting for a grant that could then never arrive.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A download froze part-way through, on Firefox, with an empty console and a node
that stayed perfectly healthy. Three separate measurements cleared the node
(615 MB pulled whole over MNP), the transport (three files interleaved on one
connection, 1.5 GB, all whole) and the service worker (three concurrent 150 MB
streams in real Firefox 154) — because none of them was wrong.
The empty console was the evidence. `_sendAndWait` logs every timeout, so no
chunk request had expired: the client was not waiting on the node. Of the three
awaits left on that path only one was unbounded.
**A service worker with no event for about thirty seconds is terminated**, and
`respondWith(new Response(stream))` does not extend its life while the response
is still being written. The reader vanished mid-file and `writable.write()` then
never resolved and never rejected — no error, no log, no failed transfer, just a
progress bar that stops. The first stress probe wrote 450 MB in two seconds and
passed: fast enough to hide it entirely.
Measured in Firefox 154, writing 1 MB every 2 s: without the ping it stalled at
17 MB after 59 s; with it, 40 MB in 80 s, complete.
- the page pings the worker every 10 s while it writes, and the worker
answers. Receiving a message is an event, and an event resets the timer;
- that interval stops itself after two minutes with no write. A target can be
opened and never written to — a transfer cancelled while it waits for a slot
never runs, so nothing calls close() or abort() — and a timer nobody clears
pings for the life of the page. It also kept the Node test process alive for
ever, which is the same defect wearing a louder symptom;
- `writable.write()` is bounded at 60 s and fails with a message naming the
chunk. That does not fix whatever stopped a sink; it turns an unexplainable
freeze into a failed transfer that says so, which is the difference between
a mystery and a bug report.
Also: `Content-Disposition` lost a filename to a single apostrophe.
`encodeURIComponent` leaves `'` alone and `'` is the delimiter in RFC 5987's
`filename*=<charset>'<lang>'<value>`, so the header became unparseable and
Firefox named the file after the URL — 449 MB of film arrived complete as
"mtsshk9w-ohqty535". `(`, `)` and `*` get the same treatment, and a plain ASCII
`filename=` rides alongside.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Three headers govern whether a page may be framed, and all three had to be
wrong for the streamed download to work — so fixing them one at a time cost an
afternoon of redeploys and retests. They were visible together in a single
`curl -I` against the deployed hub, which is where this should have started.
The streamed-download path navigates a hidden iframe to `/_mbdl/<id>` so the
service worker is asked for the response it is holding. On Firefox and Safari
that is the only way to write a large file to disk: neither has the File System
Access API, and OPFS is capped at 10% of the volume's size (measured on Firefox
154: 389,233,459 bytes of a 3,892,334,592-byte volume, refused to the byte),
which a film exceeds.
- `frame-src` was reCAPTCHA's two origins with no `'self'`, so the frame
could not be loaded at all. Added when the captcha needed a frame; nobody
connected the two.
- `frame-ancestors 'none'` forbids all framing, this origin included.
- `X-Frame-Options: DENY` says the same in an older dialect. The spec says a
browser must ignore it when frame-ancestors is present — relying on that
while shipping a header that contradicts our own policy is asking to be
surprised, and we were: the CSP was fixed and the download stayed broken.
`'self'` and `SAMEORIGIN` refuse every foreign origin exactly as `'none'` and
`DENY` do. The clickjacking property is untouched; what they additionally allow
is this origin framing itself, which is the only thing the download needed.
Pinned three ways: `frame-src` must carry `'self'`, `frame-ancestors` must be
`'none'` or `'self'` and never name an origin, and the two framing headers must
agree — the defect was the disagreement, and either one read as correct alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`pipelinedDownload` with no writable allocates `new Array(totalChunks)` and
keeps every decrypted chunk, so whatever `_openDownloadTarget` returns null for
is held whole in RAM. That floor had no upper bound: the
`!window.showSaveFilePicker` branch returned null at any size, so on a browser
without the File System Access API a 20 GB film went to memory whenever the
streamed path did not answer. Nothing logged, nothing refused; the symptom was
the tab dying, with no error attributable to this code.
MEMORY_CEILING is 100 MB and every `return null` in that chain now goes through
a guard that throws above it. The refusal names the size, the limit and why the
streamed path declined, and lands in the transfers panel as a failed transfer
rather than in a console nobody opens.
This is a guard, not a limit on what can be downloaded: with the streamed path
primed and retried (previous commit), a file of any size still goes to disk
progressively on every browser. Two things had to change for that to be true:
- the streamed path is now tried in "ask" mode too, for a file over the ceiling
on a browser with no Save As of its own. The mode decides whether to show a
dialog; it was silently deciding whether a film could be downloaded at all;
- FilePreview had no size check whatsoever — a multi-gigabyte PDF or .csv was
fetched whole, and the text branch decoded all of it to keep 500 000
characters. It refuses above the same ceiling and offers the download.
ZIP_MAX_BYTES (512 MB) and the ceiling do not contradict: the archive limit
bounds the archive, the ceiling bounds what may be built in the page, so a
400 MB zip is allowed when there is somewhere to stream it and refused when the
only route left is memory. The build-in-memory confirmation only appears below
the ceiling now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
On Firefox and Safari the service worker is the only unbounded way to write a
download to disk: neither has the File System Access API, and OPFS is not a
substitute — measured on Firefox 154, its quota is exactly 10% of the volume's
size (389,233,459 bytes of a 3,892,334,592-byte volume, refused to the byte),
which a film exceeds. So when this path declines, a large download has nowhere
left to go, which makes its reliability a correctness property.
Four ways it declined, all of them avoidable:
- it was registered inside the first click on Download, so that click paid
install, activate and claim while somebody watched a button do nothing;
- `_swReady` cached a null for the life of the page. One slow first click left
the tab unable to stream anything again, curable only by a reload nobody knew
to do. Only a successful controller is remembered now;
- control was waited for with a 3 s cap. It is 15 s, and a page that is active
but not controlled asks the worker to claim again (`mbdl-claim`) instead of
declaring the path unavailable;
- a missed navigation gave up at once. It gets a second attempt with a fresh id
and iframe, the failed one torn down completely first.
Also closes a MessagePort leaked per download, and gives the reason a name
(`lastStreamFailure`) so a refusal can say what happened. The timeouts became
parameters: the defaults are the production values, no caller passes any, and
the tests do not spend a minute waiting.
`openTarget` gets an unrelated but adjacent fix, in the same file: it creates
the destination with `getFileHandle({create: true})`, so an empty file exists
before the first byte, and `abort()` leaves the target untouched — every
cancelled download left a 0-byte file behind, and since `freeName` avoids
collisions, three cancels left film.mkv, film (2).mkv and film (3).mkv, all
empty. Its `abort()` now removes the entry. Safe here and only here, because
`freeName` guarantees the name was not taken: the `showSaveFilePicker` path
must not do the same, where the person may have picked an existing file whose
contents `abort()` correctly preserves. Verified by hand in Chrome.
test_streamed_download_reliability.py runs the real module under Node against a
stubbed browser — it fails if the null is cached again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
ensureTray() was reachable only from the window:minimize-to-tray handler, so
the indicator did not exist until you had already hidden the window into it.
That is backwards on both desktops — most of what a tray is for is finding an
application that is not in front of you — and on Windows it read as the app
having no tray presence at all.
Created during app.whenReady(), after registerBridge() and before
createWindow(). The order matters: buildTrayMenu reads the nodeService that
registerBridge assigns, so the other way round puts the Start/Stop entry on
the menu one five-second poll late.
The menu's labels were the one thing that came *from* the minimise call, since
the main process has no i18n. A new tray:labels IPC (platform.setTrayLabels)
carries them instead, sent from the renderer's boot once initLocale() has a
catalogue; a language change reloads the page, so the same call covers it. The
window between launch and that first message shows TRAY_FALLBACK, in English.
§5.10's two platform gates become one — trayOS() in main.js, which every tray
path calls. test_desktop_shell.py's existing test is rewritten against it and
two are added: the launch ordering, and that no tray path tests
process.platform inline instead of calling the gate.
Windows still files a new tray icon under hidden icons until the person drags
it onto the taskbar. No API promotes it; documented in WINDOWS-PORT.md §5.11
rather than worked around.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
An arbitrary ceiling, not a technical one: the zip writer streams and holds
one chunk plus a record per file, so it would happily produce a hundred
gigabytes. Past half a gigabyte the honest answer is a subfolder at a time,
or the files individually.
Enforced in file-utils.js's downloadDirectory, which is the one
implementation behind every zip button — Files' single folder, Files'
multi-folder selection, and the Photos album button (docs/photos.md §3).
- Per directory, not per selection: Files zips a whole multi-directory
selection in one click, so an oversized folder is refused and its siblings
still download.
- Before _openDownloadTarget, so no save dialog opens for an archive that is
never going to be written.
- The bound is strict, so a folder of exactly 512 MB still goes through.
- Counted in the 1024-based units formatSize already prints, so the number in
the refusal is the number in the constant.
group.zip_too_large in all ten catalogues. test_zip_size_limit.py runs the
module under Node and pins the refusal, the inclusive bound, and that nothing
is asked or started when a folder is over.
The user guide's "a 40 GB folder costs 40 GB of disk" is no longer true and
now documents the cap instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Chat messages have been signed by the sending device since MNP 2.0, but a
reader had no way to know that the device belonged to the account the node
named: the signature proved *a device*, and `sender_id` was still the node's
word. This closes that for any account a client has already seen.
**What was blocking it was not effort — the evidence was not being kept.**
`_do_device_add` verified the countersignature that admits a second device and
stored only `added_by_pk`: *which* key approved, never the proof. And
`device_add_transcript` binds `nonce_node`, the approving connection's handshake
nonce, so even a stored signature was unverifiable by anyone who had not been on
that connection. `identities` gains `add_sig`, `add_nonce` and `add_ts`, added
before the migration's early return — which fires on every roster widened since
2026-08-18, i.e. all of them, so putting them inside it would have meant they
never arrived.
`group_roster_req`/`resp` relays, sealed under a new groupbox purpose and
answered to **any member of the group**, every live device of every active
member with the evidence that admitted it. The node decides nothing: it hands
over evidence and the client walks the chain from each account's root outwards
(`_verifyRoster`). That is deliberate — the node is the party the property holds
against, so it is not asked to assert trust.
Two holes the tests caught while this was being built:
- "no signature" was being treated as a trust root, so a node that writes the
roster could put any key in an account's row and have it laundered straight
into the verified set. A root is a device that names **no** countersigner.
- pinning only the verified subset at first sight raised "key changed" on
legitimate second devices whose countersignature predates this change. First
sight pins everything the node says, because that is what trust-on-first-use
means and an alarm that fires on normal events stops being read.
The property, and it must not be rounded up: **once a client has seen an
account, a node that later substitutes a key for it is detected. Nothing is
gained at first sight**, where there is nothing to compare against — the same
boundary `per-node-identity-v1.md` draws, unmoved.
The cost, stated because it is real: the roster is member-visible, so every
member learns how many devices the others hold and their public keys. It stays
inside the group, the hub is not involved, and it is scoped per group. A member
who cannot see the keys cannot check them.
User-visible surface: one notice, "this account is using a key you have not seen
before", in ten languages. Nothing else.
16 tests — 7 on the node (the evidence is stored, it verifies from the roster
alone, a fabricated device carries none, another group's members are not
disclosed), 9 running the shipped `_verifyRoster` under node against rosters
built by the shipped Python: a chain of three in any order, a signature by the
wrong key, one for another node, one for another account, and two fabricated
devices signing each other admitting nothing.
Tier 3 (operator-signed roster attestation) stays deferred, with nothing
depending on it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
|
| |\
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
Downloads have been encrypted under a GEK-derived key since the beginning:
`file_chunk` and `stream_data` both go through `chunk_ciphertext`. Uploads
never were. `file_upload` carried the filename and the raw bytes in plain
msgpack, and `file_upload_ack` carried the name the node stored them under —
so the same file was ciphertext leaving a node and plaintext arriving at one.
There was no threat model behind that asymmetry.
Both halves now travel sealed under a third groupbox purpose,
HKDF(GEK, info="meshbay:upload:v1"). The filename, the destination folder and
the bytes are all inside the seal; only `upload_id` and `chunk_index` stay in
clear, because the node routes and orders on them before it can decrypt. This
direction seals *towards* the node — it holds the GEK for its own group — and
it opens the payload before it picks a destination or touches the disk.
What that forced, and why none of it is optional:
- `filename` was the correlation key on both sides. It cannot be: matching an
ack to its request by name would hand back exactly what the seal hides.
`upload_id` replaces it — client-drawn, opaque to the node, unique within a
connection, never an authorization input. The property it guarded (one
refusal fails one upload, not every upload in flight) is unchanged.
- Refusals can no longer quote what they refused. `No directory named 'X'`
becomes `No such directory in this group` plus the `code` that was already
there; the client knows what it sent.
- No plaintext fallback. A path that still accepts plaintext is not a sealed
path, so an unsealed `file_upload` is refused with `upload_not_sealed`.
Hardened while here, because what comes out of a seal is authenticated but not
validated — a member can seal anything: `filename` and `data` have their types
checked before any upload state is created, and `chunk_index`/`total_chunks`,
which are outside the seal by necessity, can no longer raise where a refusal
was meant.
Tests. `test_upload_sealed.py` pins the node half: nothing identifying on the
wire, tamper/wrong-key/wrong-group all refused with nothing written, and
multi-chunk reassembly unchanged. `test_upload_seal_client.py` drives the
shipped `uploadFile` over the shipped `crypto.js` under node and feeds its real
frames to the real `_do_file_upload` — the file lands intact, and the ack the
node actually produced comes back with the name it chose for a collision, which
is the half a source-reading test cannot see. Both upload purposes join the
JS/Python groupbox parity vectors.
BREAKING CHANGE: MNP 2.0. `file_upload`/`file_upload_ack` change shape on the
wire every deployed client speaks, which is MAJOR by the same rule 1.0 was —
but the break is confined to uploads. `MNP_MIN_SUPPORTED` stays at "1.0", so a
1.x peer still connects, browses, downloads, streams and chats; only its
uploads are refused, with a message saying which side is old. The client checks
the node's version before sending a chunk, so neither side meets this as a
timeout. This is the version negotiation shipped in 1.0 earning its keep: 1.0
cost a flag day, 2.0 costs a refusal code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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
|
| |/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`test_a_folder_name_carries_no_trailing_slash` sliced from the first
`dir-row` in files-app.js. Since the parent-directory row was added, that
is the ".." row, whose only cell is an ellipsis — so the slice contained
no `${d}` and the test failed on a name it had never looked at. The
directory row itself has always rendered `${d}` with no trailing slash.
Anchored on `key=${full}` instead, with an assertion that the anchor
still lands on a `dir-row` so the next move fails loudly rather than
silently reading the wrong markup again.
Pre-existing: it fails the same way on main.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The hint was added alongside the .app-save control, when an inert Save
was indistinguishable from an enabled one and the reader had no way to
tell "nothing changed" from "this is broken". The control now reads as
disabled on its own, so the sentence beside it is noise.
Removing it also removes what only existed to carry it: the .app-save-row
wrapper, its two style rules, and settings_app.no_changes in all ten
locales.
test_the_disabled_state_is_visually_distinct sliced the stylesheet on
.app-save-row; it now stops at the closing brace of .app-save:disabled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two defects behind one report — "you cannot always click Save, you do not
notice, and it does not work".
**It did not work, for Chat, systematically.** `_dispatch` resolves an admin ack
against the pending request and returns, which is right for an op whose caller
already knows the value it chose. Chat's pane calls `transport.setChatDirectory`
itself, so nothing told `group-page` anything: the node saved it, every *other*
connected client learned it from the broadcast, and the one that asked went on
showing an unsaved-looking draft. Clicking Save again just re-sent it. Same
shape as the root-ack bug, in a different message — so the set is now
`BROADCAST_ACK_TYPES`, named for the property that makes it true, and covers
`chat_directory`, `chat_link_preview` and `app_directories`.
**You could not notice, because Save was not visibly a button.** It carried
`btn btn-small btn-secondary`, and there is no `.btn` rule in the stylesheet at
all — so it took `.btn-secondary`: no background, a transparent border, dim grey
text. Enabled it already looked like a disabled control; disabled it was the
same thing at 40% opacity. Measured on a real page: enabled is now accent on
white, disabled is grey text on a plain border, and an inert one says why
("No changes to save") rather than leaving the reader to guess what the pane
counts as a change. Videos' second Save — the TMDB one — is the same control,
because two Save buttons in one pane that do not look alike is worse than
either looking wrong.
Verified by driving the real pane in Electron through the whole cycle: inert,
pick a folder, live, save, and the node's answer coming back to disable it
again. Four earlier readings said the enabled button was transparent; all four
were taken inside python's directory-listing page, which the app never runs in
— my scaffolding, not the code.
Hub suite only: the node package is untouched by this.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Every other test of the plugin architecture reads source for the *absence* of
app names. That proves nobody wrote a special case for Videos; it cannot prove
a genuinely new application works, because there was no new application.
This is one. It stores directories, appears as a tab, has a settings pane and
lists files, and the node has never heard its name outside a single allow-list
entry. Two files and one registry line, which is the claim
`docs/refactor-groups.md` §4.1 makes.
It ships hidden behind `?dev=1` (`dev: true` in the registry, the same opt-in
shape as transport.js's `?trace=1`). Registering it normally would put a toy
app in every operator's group; not registering it would prove nothing, since
registration is exactly what is claimed to be sufficient.
**Adding it found two places where the claim was nearly true rather than true,
and both are fixed by making the code less app-specific:**
`group-settings.js` fell back to the whole registry when a group had no
`enabled_apps` yet — which would have turned a hidden app on for everyone. It
asks `availableApps()` now.
`group-page.js` wrote out `videoDirectories` / `musicDirectories` /
`photoDirectories` by hand, so a fifth app would have needed that file edited.
It derives `<key>Directories` from the registry.
Neither was found by reading; both were found by adding the app, which is the
whole reason it exists.
Verified in a real Electron window as well as by the tests: hidden by default,
present with the flag, offered its own settings section, and listing exactly
the files under its configured folder and its subfolders — not the ones beside
it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The message was the first thing in the section, wedged between the intro and
the table header — above everything the eye has already moved past by the time
it appears. It goes last now, under Add directory.
And it was a `settings-hint`: dim grey body text. So "two roots would both be
called uploads" read as a footnote about the section rather than as the reason
nothing happened. A refusal is styled as one and carries `role="alert"`, so it
is announced rather than only drawn; a success stays quiet.
Measured in a real Electron window rather than assumed — the failing add
driven through the typed-path form, then the message's box compared against the
table's and the button's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The same `static/` tree is the web page and the application, and Electron does
not implement `window.prompt`: Chromium leaves it to the embedder and Electron
declines. It does not return null — it **throws**. The call sat above its own
try, so clicking produced no folder, no error and nothing on screen to react
to. A dead button, which is exactly how it was reported.
Measured against this repo's own Electron 44 rather than assumed, because the
first two diagnoses this session were reasoned and wrong:
prompt('name?') -> Error: prompt() is not supported.
confirm('sure?') -> opens a real modal
alert('hi') -> opens a real modal
So `confirm` and `alert` stay — a dozen call sites depend on them — and only
`prompt` is banned. `test_no_prompt_in_the_spa.py` holds the whole tree to it,
with the near-misses it must not flag (`mkdir_prompt`, `promptForName`).
The name now comes from a field in the toolbar, which works in both clients and
can show the node's refusal beside the input instead of after a dialog has
closed. Navigating away drops a half-typed name: it would otherwise create the
folder somewhere the person is no longer looking.
The rest of the chain was verified end to end and was sound: `dir_create`
{dir,name} → the node's handler → `dir_create_ack`, and `list_dirs` walks the
filesystem rather than the index, so a folder with nothing in it appears on the
very fetch that follows. The field itself was then driven inside a real
Electron window — typing, Enter, the click, and the icon rendering.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The control was hidden and its `canCreateDir` left computed and unused. It is
back in the Files toolbar as an icon: the toolbar already carries one labelled
primary action, and a second beside it competes for the width the breadcrumb
trail needs. The name is in `title` *and* `aria-label` — a title is invisible
to a screen reader on a button with no text, so an icon-only control without
both is simply unnamed for anyone not reading with their eyes.
Its gate changes. It required `isNodeAdmin`, which contradicted the node's own
rule — "making a directory is not a privileged act; a member who can add a file
can organise where it goes" — and hid the control from everyone who could have
used it. It now follows the Upload button: a writable root, and not at the top
of a group, where the level is the set of roots rather than a directory on
anyone's disk.
Restoring it surfaced a real gap. `_do_dir_create` never learned about RO/RW:
`_do_file_upload` gained the `writable` check with the model and this one did
not, so a member refused a file in a published library could still leave empty
directories all through it, and could write to a drive mid-eject. Read-only has
to mean read-only for every way of writing, not just for files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
There is no `uploads/` subdirectory any more, and the client names the folder
rather than the root.
It was the last of v5's quarantine — the per-user layer went on 2026-08-14 for
the same reason — and it goes on the same grounds: a folder appearing beside
the operator's library because somebody sent a file is the node deciding how
their disk is arranged. Somebody dropping a file into the folder they are
looking at expects it to be in that folder.
**What made the quarantine worth having was never the subdirectory.** It is the
filename allowlist, the size cap, the chunk ordering and the no-overwrite rule,
and all four are untouched: an existing file is never replaced, the second
sender of IMG_1234.jpg gets a free name, and the check still sits at the write.
Letting the client choose the destination is safe for one reason and only one:
it is resolved through `RootSet.resolve()`, which refuses `..`, absolute
segments and anything whose resolved form escapes its root, symlinks included.
A member answers "which of this group's folders", never "which path on the
operator's disk" — and the test that used to assert the node chose now asserts
that, with six shapes of escape.
`direct` goes with it. Its only job was to say "no subdirectory for this root",
which is now every root, and a config flag that does nothing is worse than none.
Chat's attachment folder finally does something: the directory the operator
picks in the Chat settings pane is where attachments are written, falling back
to the first writable root while they have not chosen one, or if the one they
chose has since been made read-only or ejected — a stale choice should not
become a refusal at send time.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two defects from one screenshot, and a test for the class behind the first.
**The modal was transparent.** `.ftp-panel` asked for `var(--bg-panel,
var(--bg))` and this stylesheet defines neither — the palette is `--bg-base`,
`--bg-surface`, `--bg-raised`. An unknown custom property makes the whole
declaration invalid while the rule around it still applies, so the panel simply
had no background and the page showed straight through it. Three more of mine
were the same: `--bg-hover`, `--bg-input`, `--danger`.
**The field had no layout.** It was built on `.settings-row`, which is
`display:flex; justify-content:space-between` — so a label, a hint and a value
inside one end up spread across a single line in source order, which is how it
read as three unrelated fragments per app. It is its own block now, and the
chosen folders are a table borrowing `.shared-dirs-tbl`: these are lists, and a
wrapped run of chips gives nothing to scan and nowhere to put a per-row remove.
The operator is looking at two lists of directories on one page and they should
read alike.
`test_css_variables.py` is the general form. CSS fails silently and generously
here, and nothing checked. It found four more that predate this branch: the
unread-count badge (`--danger`) had white text on nothing, a transfer link had
no colour, a notification card had no rounding and no unread marker. Fixed, and
`--warn` and `--accent-bg` are promoted from literal fallbacks to real palette
entries at exactly their current light values.
Two of its own regexes were wrong before they were right — a scoped definition
written inline, and one preceded by a comment, were both reported as undefined.
A third check comparing the two palettes fired on `--border-focus`, a focus
ring the themes share deliberately; a heuristic that has to be explained away
on its first run is worse than no test, so it is gone rather than exempted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The SPA is served by the hub, so deploying the hub puts this client in front of
every node — including the ones not updated yet. That window is the normal
state for as long as an operator takes, and for a node someone else runs it may
be indefinite. Three controls were broken across it, and the failure mode is
quiet: an unknown message type is logged by the node and never answered, so the
click produces a thirty-second wait ending in a timeout with nothing on screen
to say the node simply cannot do this.
Files' Upload button read `root.writable`, which a 1.0 node does not send — it
says `upload`, the same answer under the older name. The button disappeared on
every un-upgraded node. It reads both now, and still respects an explicit
`writable: false` rather than falling through to the legacy flag.
The per-app folder pickers spoke `app_directories`. Videos, Music and Photos
each had their own message before that and those still work, so the page
chooses by version: an operator on an older node keeps the ability they had.
`video_root` and `audio_root` hold one folder, so several are refused with a
reason rather than stored as the first and silently truncated.
Root management — writable, removable, eject, plug — has no older equivalent to
route to, so the table goes read-only with a line saying why and pointing at
the `meshbay-node root` commands. Chat's two settings are new with nothing
before them and are hidden the same way.
None of this was inferred from a payload's shape: `_checkNodeVersion` already
parsed the node's version and threw it away, and it is kept now. Coupling a
capability to whether some field happens to be present is how a flag flips
because an unrelated payload changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`ops.update_root` and `remove_root` returned `[]` when the group context had
no live RootSet, and `group-page.js` accepted it: `if (msg.roots)` is true for
an empty array. An op that succeeded would have emptied the shared-directories
table, and "the node says this group has no directories" is not something the
client can tell from "the node could not say". Both ends now refuse it — the
node builds from config rather than answering empty, and the client requires a
non-empty array.
Found while adding `test_spa_imports.py`, which is the other half of this: it
resolves every named import across the SPA against what the target actually
exports. That failure has a shape nothing else here catches — no build step to
fail, so the browser resolves the graph at load, finds a missing binding, and
the page renders blank or the component just does not appear. `node --check`
parses one file at a time and the source-reading guards look inside a file
rather than between two. The settings split moved two shared components into a
new module and rewired eight files to import them, which is exactly the change
where a rename lands in one file and not the other.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Each app's settings were inlined in `group-settings.js` — TMDB, MusicBrainz,
and one folder picker per app, each with its own draft state and save handler
saying the same thing about a different key. They are one file per app now,
reached through the `apps.js` registry, and the page that renders them names no
application at all: adding one is a registry entry and a settings file.
The line between the two is what makes that true. What every app has — folders
— the page does generically, through one `saveDirectories` bound to the app.
What one app alone has, its pane does itself with the transport it is handed.
An app that only needs directories touches neither `group-settings.js` nor
`group-page.js`, which is `test_app_settings_plugin.py`'s subject.
`settings-ui.js` exists because a pane importing the page that renders it is a
cycle, and ES modules answer that with a temporal-dead-zone ReferenceError at
first render — a component that silently does not appear, the fault already
recorded in CLAUDE.md about hook ordering.
The flat depth-indented `<select>` of every folder in the library becomes a
modal tree. It asks the node for nothing: the tree is derived from paths the
client already holds, so it shows exactly what the group's index contains and
adds no folder-browsing protocol. For Chat's attachment folder — the one
directory that is written to rather than read — read-only roots are greyed
out, so the node's refusal arrives before the operator picks rather than when
somebody sends a file.
Videos and Music take a list of folders. A library on two drives could not be
described before; the only recourse was pointing the app at a parent containing
both, which pulls in everything else under it. The scalar shapes survive on the
wire alone, for a node speaking MNP 1.0, and the client reads them as a
one-element list.
Two things the tests caught that I would not have:
`test_asset_versioning` — six new modules were missing from `_ASSETS`. Reached
through the registry rather than imported by name, they are exactly the files
nothing else would notice changing, and a stale one is served from cache with
no version bump.
And `node --check foo.js` does **not** reliably report a module syntax error:
it accepted `${/* ... */''}` — htm template syntax pasted into a plain object
literal — and reported success. A `.mjs` copy forces the module parser and
reports it. The suite had no syntax check at all, which is how that reached a
file; `test_spa_syntax.py` does it for every module now, and pins that the
loose path is not what it uses.
Suite: 12 failures, all pre-existing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Review of the Phase 1 commit found the RO/RW model sound but three paths
unfinished, each of which broke the flow the phase exists to deliver. Plus
29 test failures it introduced and no coverage for anything it added.
Uploads went to the wrong directory. The node read a `root` field on
file_upload that no client ever sent, so every upload landed in the first
writable root while the Files toolbar offered its button based on the root
being browsed — with two writable roots, uploading from one wrote into the
other. Files now names the root it is showing; Chat names one chosen in the
shell (an operator-configured directory arrives in Phase 2); the node refuses
an unknown name rather than falling back, and refuses read-only and ejected
roots by code.
Shared directories were unreachable on the web. The table read its roots
only from the loopback API, which resolves to "not available" in a browser,
so the section rendered for nobody there — while the Uploads controls it
replaced had worked — and the transport.updateRoot/ejectRoot/plugRoot methods
beside it were dead. MNP is now the path, loopback the fallback for a local
node with no live connection, and adding a root over MNP takes a typed path
since no web page can browse a remote disk.
Ejecting updated nobody's screen. transport.js resolves an admin ack against
the pending request and returns, which is right for every op whose caller
knows the value it chose; the root acks carry state only the node can compute,
so the operator who clicked Eject was the one client that never saw it happen.
And the ejected flag reached roster.db but was never read back, so a restart
undid it and the next scan read an empty mount point as an erased library.
Also: the member-upload endpoint answered 200 and did nothing (removed); the
wizard ignored the first root's RW switch; reload compared roots on name and
path, so editing writable in node.toml did nothing; the table had no path
column, which is the only thing separating two libraries sharing a basename;
apps_enabled normalisation differed between the two sides of a signed subject.
Tests: eject/plug, per-root upload refusal and the node.toml rewrite had no
coverage at all. test_member_upload_policy.py is replaced by
test_root_writable_policy.py — it tested a removed feature — and every
property worth keeping from it moved rather than being dropped.
Docs: draft-v6 structural decision 9 is annotated as superseded (the operator
can no longer have a directory only they may write to — a real capability
removed, flagged rather than hidden), the man page documents the root verb and
the RO/RW fields, and refactor-groups.md §7b records what the plan got wrong.
Suite: 41 failures before, 13 after — all 13 pre-existing on main.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
| |
Show a ".." row at the top of subdirectory listings to navigate back to
the parent. No path traversal risk — currentPath is internal state built
from the node's own index. Hide the mkdir button (not useful yet); update
the upload-controls test marker accordingly.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The tray's Start/Stop already drove nodeService.status/stop/restart, which
had full win32 branches for both startup modes from the Node page work --
so enabling it on Windows is widening two platform gates (the `tray`
capability in preload.js, the window:minimize-to-tray handler in main.js),
not new logic.
Replaced the tray icon: the previous white chevron-in-a-box read as an
envelope at tray size. New icon is a small "M" drawn as mesh nodes and
edges, echoing the app icon's own motif, in the brand blue instead of
plain white so it stays legible on both light and dark taskbars/panels.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`index_sync`, `index_delta` and the `handshake_ack` config payload now travel
sealed under a GEK-derived subkey (`meshbay_common/groupbox.py`, mirrored by
`sealGroup`/`openGroup` in `crypto.js`). Only `type`, `v`, `group_id` and the
ack's `node_pk`/`proof`/`sig` stay in clear — a receiver must route and
authenticate before it would trust a decryption. Verify, then decrypt.
The ack line is integrity, not confidentiality: the signed handshake transcript
names no ack field, so `is_node_admin`, `enabled_apps`, `video_root` and the
rest were authenticated by the DTLS channel alone. The index line is defence in
depth against a repeat of C1/C6 — a peer served before the handshake completes
now gets ciphertext, not filenames. Nothing against an observer, the hub, or a
member; that is the whole claim. `index_progress` stays clear (D3, counters
only). Chat is out of scope.
Failure is fatal: a payload that does not open ends the session naming the
message type — never an empty index or an empty `enabled_apps`, both of which
are legitimate states.
Version negotiation ships here too (phase 15.6, brought forward): `v` + `v_min`
on `handshake` and `handshake_challenge`, refused with `version_too_old` /
`version_too_new` / `version_unreadable`. The flag day was already being paid
for; the next breaking change now costs a refusal message.
BREAKING CHANGE: breaks the WebRTC wire every deployed client speaks. Hub and
every node must deploy together; the SPA is served by the hub, so a browser
picks up the new client on reload. See MESHBAY_NODE_PROTOCOL.md §11.1a, §13.1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkzbhmMmK8PqQBtGz5zCvY
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
package twice
Chromium CVEs are fixed in Electron releases, and a client built against
an old one ships those holes to every user. That is a certain harm; a
build that breaks on a new Electron is a repairable one. build-client.sh
now bumps to the latest on every build, prints the comparison, and lets
the build fail if it cannot cope — the failure is the signal to fix, not
a reason to stay behind. It writes package.json and the lockfile on
purpose: the new pin is meant to be committed. A registry it cannot reach
is a warning, not a failure.
Exercised by pinning back to 42.9.2 and building: "==> Electron 42.9.2 ->
44.1.1", exit 0, electron=44.1.1 in the packaged output. Note npm audit
would have said nothing about any of this — Chromium CVEs fixed in
Electron do not reliably reach the npm advisory database.
Separately, package.json declared linux.target [deb, rpm] with its own
deb/rpm depends, so `npm run dist` built a second package under the same
name. The two had drifted: /opt/MeshBay/meshbay-client against
/opt/meshbay-client/meshbay, and Depends: python3-meshbay-common naming
none of the Electron runtime libraries the real DEBIAN/control lists — it
would have installed cleanly and then refused to start. Nothing in the
tree referenced `npm run dist`, which is why the drift was free to
happen. That config is gone, "dist" delegates to build-client.sh, and
test_desktop_shell.py refuses its return.
`--dir` was re-run with no linux block at all: exit 0, same binary
build-client.sh consumes. It needs appId, productName and files, nothing
else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbwJDbNTkiRUh7HTWEoyss
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two defects found while closing out the Search merge, neither of them in
that feature.
The maintenance loop. create_app's lifespan starts cleanup_loop as an
asyncio task, so every test — each entering that lifespan — ran a purge
pass concurrently with its own requests. On SQLite :memory: that is not
merely noisy: the engine uses a StaticPool, one connection for the whole
process, so the request's session and the cleanup task's session
interleave transactions on the same connection. A registration could
commit and then be invisible to the login three lines later, surfacing
as 401 Invalid credentials for an account created moments before, in
roughly one run of test_node_ws_auth.py in four.
The purge itself is not at fault and this is not a production condition.
A passive SQL listener caught the DELETE removing 0 rows, and the INSERT
carrying status='active' — so neither the pending-account mechanism nor
the purge filter is involved, and PostgreSQL gives every session its own
connection. What the fixture removes is the second user of the shared
one. 60 runs of the previously flaky file, 0 failures; reproductions
before the fix landed on attempts 4, 6, 13 and 29 of separate loops, so
a clean run of 60 has about a 1% chance of being luck.
_ASSETS. source-merge.js shipped missing from webapp._ASSETS, the
cache-busting hash's input list — exactly the silent failure docs/apps.md
§4 step 5 warns about: the file changes, the asset URL does not, and a
browser holding the old page keeps the old copy. Harmless this time only
because search-page.js changed in the same commit and is listed, which is
the worst way for it to go unnoticed. Found by re-reading that checklist
for the doc pass, not by any test — so there is a test now, holding
_ASSETS to every .js in static/ (sw.js excepted, unversioned on purpose).
It was the only one missing.
Phase 9 of docs/refactoring-search.md also lands here: mediacenter.md
§10.6, musicbay.md §9b, photos.md §10b, apps.md §2b and step 5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbwJDbNTkiRUh7HTWEoyss
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Phases 5-8 of docs/refactoring-search.md, extending the Videos merge
outward. A library shared by two groups now lists each track once inside
an album and each photo once inside a photo album, and a card served by
several groups says "N sources" instead of naming one of them.
Units come from each application's own grouping, never a copy of its
keys. For Music that meant exporting foldKey: groupMusicEntries folds
case to group but keeps the first-seen spelling to display, and which
group is seen first is whichever index arrived first — so keying a unit
on the display strings would let the chosen source change between page
loads.
A group whose connection fails is marked down and stops being chosen, so
a unit fails over to another group that has the file. Eviction is not a
failure. Every source being down still yields an entry: a tile that
fails to load beats a film that vanished from the grid.
sourceLabel now takes the whole unit rather than one entry. A show's
poster entry is picked for its thumbnail, so a show in two groups whose
cover episode sits in only one of them would have claimed a single
source. SourceTag lives in group-name.js — source-merge.js must keep
importing nothing (its test executes it standalone), and a copy in each
of the three apps is three chances to disagree.
test_search_files_unmerged.py holds the one thing that must not change:
the Files explorer is not merged, because there each group is a folder
and merging would remove a file from one of them. It also asserts the
other three lists are merged, or deleting the merge outright would leave
it passing and saying nothing.
One plan item was dropped as wrong rather than built: the Music queue in
onPreview needed no change. It filters by groupId and is reachable only
from FilesPanel, which is not merged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbwJDbNTkiRUh7HTWEoyss
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A library shared by two groups arrived in the cross-group Search view as
two entries per file: every film was two poster cards, every episode was
listed twice in the season list under the synopsis. Inside one group
this cannot happen — GroupIndex is keyed by blake3 — so the duplication
was the Search page's own, from concatenating N independently keyed
indexes.
source-merge.js folds entries on the content hash and resolves one
source per *unit* (a film, a whole show), so a season does not scatter
across two nodes. A group hosted by the reader's own node wins; failing
that the pick is a hash of the unit key and the reader's id, stable
across renders and reloads — a source that changed mid-stream would tear
down the connection under a film that is playing — and spread across
readers and units.
The units come from video-app.js's own groupVideoEntries rather than a
second copy of its keys here. Only the Videos view is wired up so far;
Music, Photos, failover and the "N sources" badge are phases 5-8 of
docs/refactoring-search.md.
Every test was checked against the fix removed. That is how the first
version of "a unit's files share its source" turned out to prove
nothing: with every episode in every group, per-file and per-unit
picking give the same answer, so it passed against a per-file
implementation. It now uses a unit whose files have unequal sources.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbwJDbNTkiRUh7HTWEoyss
|