| Commit message (Collapse) | Author | Age | Files | Lines |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`chatbox.py` said per-device subkey derivation means "two devices never share
an AES key". That is false in the deployment that exists, and stating it hid
the reason the design is actually safe.
Two clients of one account on one node normally hold the **same** identity key:
a second browser fetches the keypair bundle from the node and recovers the
existing key rather than minting a new one, and so does a fresh Electron
install. Device linking — a distinct key, countersigned — is the exception, not
the rule, which is why nobody is ever asked to pin anything when they open a
second browser. So two clients routinely share a device key and therefore its
chat subkey.
What makes that safe is the nonce, not the derivation: 96 random bits, never a
counter. Two independent senders under one key collide only on the birthday
bound, unreachable at chat volume; two independent senders advancing one
*counter* collide immediately, which is precisely what C1 and §15.0b are about.
The true property is "no mutable sending state at all" — the hazard removed
rather than partitioned — and the design therefore degrades correctly into the
deployment as it is, where a chain-based one would have failed silently on the
day someone opened a second tab.
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
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
`stream_seg` answered with an MPEG-TS segment as base64 with no encryption at
all — the one message on the content plane that never went through a
GEK-derived key. Live on both transports, answering any authenticated member.
It predates `stream_data`, which does the same job properly (`chunk_ciphertext`,
keyed per segment, AES-256-GCM) and has since Phase 12. Its only browser caller,
`fetchStreamSegment`, was defined and never once invoked — a plaintext media
endpoint with no client. Removed rather than repaired.
Gone with it: `_extract_segment` and the ffmpeg semaphore in quic_server, the
`fetch_stream_segment` QUIC client method, and `_b64decode` in transport.js,
which had no other caller.
The H6 regression test lived on this handler — it pinned `_do_stream_segment_async`
to a coroutine so `subprocess.run` could not stall the event loop for thirty
seconds per request. It is replaced by the property that outlives the handler:
no transport carries media outside an AEAD, asserted on `stream_seg` and
`data_b64` across all three transport modules. The half of H6 that survives —
the live streaming path still spawns ffmpeg — keeps its own test.
BREAKING CHANGE: `stream_seg` is no longer answered on either transport. No
shipping client sends it. Recorded as part of MNP 2.0, whose other half — the
sealed upload — carries the version bump.
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
Some Wayland compositors never schedule a first paint for an unmapped
surface, but the window stays unmapped until show() runs, which was
gated entirely on that paint's ready-to-show event — a cycle with no
way out on its own. Observed under GNOME/Mutter on a VM whose
virtio-gpu device fails command-buffer creation. Add a bounded
fallback show(), guarded on isVisible() so it's a no-op once the
event has already fired normally and doesn't steal focus back from
whatever the person switched to.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CHKaCz2gq3ya8t13CvQ7Hp
|
| |\
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
Groups refactor, phases 1-3.
The root model replaces the old `upload` flag and group-wide `member_upload`
with per-root `writable`/`removable`/`ejected`, carried by a `RootSet` that
both front doors — the loopback API and signed MNP — reach through the same
`ops` functions. MNP goes to 1.1, additively: the roots table now rides on
`index_delta`, so a root added, removed, ejected or plugged reaches every
connected client instead of only whoever reloaded.
The group UI becomes a plugin architecture: an application is a registry
entry in `apps.js` plus its own files, with directories stored generically
by `ops.set_app_directories` under whatever the app is called. A reference
application, hidden behind `?dev=1`, is what makes that claim testable —
adding it is what found the two places still naming apps by hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
e1dbdf0 made a replugged root re-enrich, which was correct and not enough:
the operator still watched their albums vanish. Measured on the reported
library with a cold metadata cache, the node broadcast twice — the first
delta stripped every album, the second put them back 14 seconds later.
Fourteen seconds of "no music found" is the bug, whatever happens after.
An entry's id is its content hash, so an entry that comes back under the
same id, name and path is the same bytes in the same place and everything
enrichment derived from it still holds. `_rescan_root` now carries those
fields across the drop-and-rescan that `reconcile` and `plug_root` share.
Re-enrichment stays as the fallback for what genuinely changed: a
different id is different content, and a different name or path can change
the folder and filename fallbacks that artist, album, display_title and
track_no rest on, so those entries are still handed to the daemon through
`rescanned_ids`.
`uploader_id`/`uploader_pk` ride along. They are the same shape of field —
set once on an entry, readable from nowhere on disk — and they decide who
may delete the file, so losing them to a replug quietly took a right away.
Verified on the running node: one broadcast 550ms after the plug, carrying
the albums, and no metadata lookups at all.
The tests now assert the field on the entry rather than a call to an
enricher. Counting calls is what let the previous version of this file pass
while the operator still saw an empty tab.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
Reported live: a removable root ejected from Files and plugged back in
returned with its files and without its albums. Music showed "no music
found" and stayed there through a force reload — the loss was on the
node, not in the client.
`plug_root` drops the root's entries and rescans, which is right; the
drive may have changed while it was away. What comes back is a bare
IndexEntry: `_hash_or_cached` fills id/name/path/size/type and nothing
else. Every enrichment field goes with the old object, and the Music tag
fields are cached nowhere by design (enrich_audio.py re-reads them so a
rename can re-derive the filename fallback), so re-enrichment is the only
way back.
Two gates then made sure it never ran:
* enrichment is scheduled for `delta.additions`, and ejecting broadcasts
nothing, so `_last_broadcast_snapshot` still held those ids — the
rebuilt entries diffed as updates, not additions;
* `_enrich_new_*_entries` skips anything in `_enriched_attempted`, which
is only discarded for `delta.deletions` — and dropping and rescanning
inside one call broadcasts no deletion either.
A restart cleared both, since an empty snapshot makes every entry an
addition. Nothing short of one did.
The indexer now records the ids it rebuilt and the daemon drains them at
broadcast time: their "already attempted" mark is discarded and they
rejoin the entries offered to the three enrichment passes. Not
Music-specific — Videos lost durations and titles and Photos lost
thumbnails the same way; Music is just where an untagged file has no
album to file itself under, so the app goes empty rather than plain.
`reconcile()` does the same drop-and-rescan when a root reappears on its
own, so a USB drive that fell off and re-mounted hit this with nobody
touching the UI. Covered too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
`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
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
Caught in review, and the review was right. The previous commit documented the
flag as deprecated so that `--help` and the man page would agree. That solved
the wrong problem: the flag contradicts the model this whole refactor exists to
establish, and the coherent answer was to delete it.
It wrote `upload_dir` into a *brand-new* `[[groups]]` block, and
`GroupConfig.__post_init__` reads that key by forcing every other root
read-only and appending that path as the one writable one. So
`group add --dir X --writable --upload-dir Y` silently made X read-only — two
mechanisms deciding which directories accept uploads, one of them invisible, in
a group created after the model that replaced it.
Gone from the CLI, from `ops.attach_group`, from the loopback API and from the
MNP `group_attach` payload, which now carries `writable` instead. The *read*
path in `config.py` is deliberately untouched: an existing node.toml using
`upload_dir` must keep working, and that is the only legitimate use left. The
man page says so under the config key, and no longer lists an option.
The test that guarded the deprecation wording now guards its absence — and
earned itself immediately by finding a `group add` usage string still offering
the flag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
`member upload` reached the generic usage line for the other `member` verbs —
"usage: meshbay-node member upload <username>" — which advertises a removed
feature and sends the operator looking for a username it would then reject. It
names `root set --writable` now, and the man page carries the same. Three lines
between an operator finding the replacement and concluding the CLI is broken.
`--upload-dir` still works, so an existing script keeps working, but its help
and the man page say it is the old spelling and name what replaced it.
The Windows pass (§4.4) is what can be checked from here, made checkable:
drive letters and UNC through `as_posix()` into TOML, a drive root having no
basename to derive a name from — sharing a whole drive is ordinary there — and
a case-insensitive collision, which on NTFS and exFAT is one directory indexed
as two roots. `PureWindowsPath` throughout, for the reason the backslash test
earlier this branch got wrong.
What it cannot check is written down rather than glossed: ReadDirectoryChangesW
dropping events, MAX_PATH, and whether an eject actually lets a drive be
removed. §7d says so, along with two things the plan never considered — the
RO/RW asymmetry in `_do_dir_delete`, and `index_delta` carrying roots but not
`dirs`.
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
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
The directory table travelled on `index_sync` alone — a *full* index, which the
node only ever sends on request. Every ongoing change went out as an
`index_delta`, which carried files and nothing else. So the message that says
"something changed" was the one message that could not say a root had.
The acks hid it: `root_add_ack` and friends broadcast the new table to whoever
is connected, so the common cases looked right. What that could not cover was
the operator's own client, where the ack landed and was then overwritten — the
table calls `onRefreshIndex` after an add, that fetch returns the set from
*before* the node's reload (fire-and-forget, because a rescan is minutes on a
real library), and `applyIndex` writes it over what the ack had just delivered.
The new directory appeared for one paint and vanished.
Two halves. `index_delta` now carries the roots table, sealed with the rest and
identical to `index_sync`'s — additive, so a 1.0 client sees a field it does not
read. And the table no longer refreshes the index after a root change: the ack
gives it the new set immediately, and the delta the node pushes when the scan
finishes gives it again, along with the files.
The test that pins it uses an *eject* as its case, because an eject changes no
file at all — the entries freeze — so its delta is empty of additions,
deletions and updates. Without the table it says literally nothing, which is
how a library disappearing from under a group went unannounced to everyone but
whoever pressed the button.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
`RootSet.describe()` feeds two audiences that want opposite things. The index
payload goes to every member and has always deliberately carried no paths — a
member is told what exists and whether it is readable, not that the library
sits under someone's home directory. The loopback API answers the operator
themselves, over a channel that already requires their machine and the run
token, and the path is exactly what they asked for.
The `root list` CLI I added in Phase 1 read `path` from the member form, so it
printed a placeholder for every directory. Nothing caught it: the CLI reads a
dict, the API returns a dict, and neither end states which keys it owes.
`describe(with_paths=True)` is the operator's view and `list_groups` is the
only caller. The shared-directories table has the same hole over MNP — the
roots there come from the index payload — so its Path column now appears only
when a path is actually present, rather than rendering a column of blanks.
The test asserts both halves, because they pull opposite ways: one that only
checked the operator sees paths would be satisfied by leaking them to every
member. It reads the indexer's source for the member side, and compares the
CLI's key reads against what the payload offers for the other — checked to
fail in each direction independently.
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
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
The previous commit was the wrong fix. `add_root` did leave the running node
unchanged, but editing the live `RootSet` in place — which is what I did — is
wrong in the other direction.
`DirectoryIndexer.retarget` decides what to scan by diffing the names it
already holds against the ones it is handed, and `_retarget_indexer` hands it
`groups_ctx[gid]["roots"]`: the very object the op had edited. So the new root
sat on both sides of the comparison, nothing was scanned, and the directory
would have appeared in the table permanently empty. `_reload_config_inner`
diffs the same way and would have concluded nothing changed. `remove_root` had
the same shape and would have kept serving a removed directory's files.
The real defect is that two front doors did different things. `ui/app.py` has
always fired the daemon's `reload_fn` after these ops, which re-reads node.toml
and builds a *fresh* set; the MNP path retargeted a stale object instead. That
asymmetry is exactly what `ops.py` exists to prevent, and it is why the bug
survived until an operator added a directory from a browser — the loopback path
worked all along.
So: the ops leave the live set alone, `_retarget_indexer` asks the daemon to
reload, and `update_root` keeps editing in place because flags change no files
and the synchronous upload handler reads that object on the next request.
The tests now check the files rather than `describe()`, which proves nothing
about whether anything was scanned. One of them demonstrates the failure mode
instead of describing it, so the rule is checkable and will say so if
`retarget` ever changes. Two more cover the seam itself — that the MNP path
reloads, and that a context with no daemon still retargets.
Diagnosed by reading the running node's journal rather than the source: the
first add logged "Reloading config" and a rescan, the two later ones logged
neither. I should have looked there before the first attempt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
`add_root` appended to the config and to node.toml and stopped there.
`_retarget_indexer` — the MNP path — then re-points the indexer at
`groups_ctx[gid]["roots"]`, an object nobody had touched, so it was retargeted
at exactly what it already had. The directory was in the config file and
invisible everywhere else until a restart.
Worse than invisible: the ack does carry the new set, so the client showed the
directory for one paint and the next index_sync took it away again — which
reads as a UI bug and is not one. Adding it a second time was then refused as
colliding with itself, which is the only reason anyone found out.
`remove_root` and `update_root` already updated the live set; this one was
missed. The loopback API hid it, because `ui/app.py` fires `reload_fn()` after
the op and that re-reads node.toml from disk. The MNP path does not, and the
shared-directories table only started offering Add over MNP in this refactor —
a latent bug made reachable.
The ack now describes the set the node will actually serve rather than one
built on the side, so the two cannot disagree.
test_root_ops_reach_the_live_set.py holds all three ops to it, including the
counter-property that the same directory is still refused twice and that
node.toml and the live set stay in step — the two halves drifting is how an
operator's next restart silently undoes their last change. Four of its seven
fail against the code above.
Recovery on a node already in this state is `meshbay-node reload`: node.toml
has everything, nothing was lost.
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
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
§7c said the Phase 2 fallbacks made the migration script unnecessary. That is
true of every setting but one, and the one it is not true of fails in the
unsafe direction.
`member_upload` is no longer consulted anywhere. A group whose operator had
turned uploads off keeps a node.toml root saying `upload = true`, which reads as
writable — so on the first restart after the upgrade that group accepts uploads
from every member again, silently. It cannot be a fallback: "uploads are off for
this group" and "this root is writable" are two sentences that happened to
disagree, and only the operator knows which they meant.
QE/migration/check_upload_policy.py (unversioned, per the QE/ rule) reads
roster.db and node.toml, reports which groups are affected, and prints the
`root set --no-writable` line for each. It writes nothing and exits non-zero
when something needs a decision, so a deploy script can gate on it.
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
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
`video_root` (a string), `audio_root` (a string) and `photo_roots` (a list)
said the same thing three ways: three roster accessors, three ops, three MNP
messages, three admin-op subjects. They become `set_app_directories(app_key,
paths)` and its single-directory wrapper, stored under `<app>_directories` and
keyed by the app's registry name — so an application can be added without
touching this layer, which is the whole claim of the plugin architecture.
The three old names still work. Their MNP messages are handled, and the roster
falls back to the old key when the new one is unset, so a node upgraded into
this keeps working with no migration step — the plan called for a script, and
a script nobody runs on the machine where it matters is worse than a fallback.
Two things are new rather than moved:
The paths are validated. The setters this replaces accepted anything, so a
typo — or a path left behind when a root was removed — was stored happily and
then matched no entry, leaving an app showing an empty tab with nothing to
distinguish "misconfigured" from "no files yet". Deliberately not
`RootSet.resolve()`: that also refuses a currently-unavailable root, and an
operator must be able to point an app at a library on a drive they ejected.
The legacy scalar is derived, never stored. `video_root` still rides on the
handshake ack for MNP 1.0 clients; kept as a second stored value it would
drift from the list within one run, which reads as "it works after a restart".
Also here: chat's own two settings (a directory, which must be on a read-write
root because it is a destination rather than a view, and a link-preview switch
gating the unfurl path — checked before the cache, or turning it off would
still serve every preview already fetched), the `app_directories`,
`chat_directory` and `chat_link_preview` MNP messages, the plural
`<app>_directories` on the handshake ack, and `music` as the app's one
identifier where storage said `audio` and the registry said `music`.
The Music enricher now resolves a boundary per configured directory rather
than one for the group: with several, a single boundary is wrong for all but
one of them, and for Music that is the difference between reading a folder as
an artist and reading it as a release.
Suite: 11 failures, all pre-existing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
It asserted `Path(win_dir).as_posix() == "C:/Users/alice/Media"`, which is only
true where `Path` is a `WindowsPath`. On every other machine a backslash is an
ordinary filename character, `as_posix()` converts nothing, and the test failed
against correct code — so it has never passed on this suite's usual host, and
never guarded anything there. `PureWindowsPath` names the flavour and makes it
the same assertion on all three platforms.
The round trip also only ever proved that `as_posix()` produces a parseable
string, never that the config writer calls it — which is the defect, and one no
Linux machine can reproduce: the file is written, parsed and served correctly
here and fails on the operator's Windows box. A second test reads ops.py for
every f-string landing on the right of a TOML `path =` and requires
`as_posix()` in it. Weak evidence, and the only kind available for a platform
the suite does not run on; checked to fail with the call removed.
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
|
| |/
|
|
|
|
|
|
|
|
|
|
| |
Replace the upload boolean with per-root writable/removable/ejected flags.
Backend: new ops (update_root, eject_root, plug_root), MNP 1.1 protocol
messages, live RootSet updates so API always reflects current state, CLI
root subcommand (add/remove/set/list/eject/plug).
Frontend: SharedDirectoriesTable with optimistic toggle switches, eject/plug
in Files and Settings, upload gated on root.writable, ejected-root filtering
in all media apps, updated Create Group wizard, 10-locale i18n.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
| |
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
| |
Put the @owner handle on the same line as the group name (flex baseline)
to save a row on mobile. Move the video buffering indicator inside the
video-container as an absolute overlay so it no longer pushes the video
down when it appears briefly during seeks.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
content
The real-time watchdog path lacked the duplicate-content guard that
reconciliation already had. When a file with identical content appeared
(e.g. browser download appending " (2)"), add_entry overwrote the
original's index entry — making it vanish from the file list despite
still being on disk. Now check get_entry before adding, matching the
reconciliation logic.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
| |
Remove the Select/Done toggle — checkboxes and action buttons are
always visible. A select-all checkbox in the header row follows the
standard pattern: check selects all visible items, uncheck clears the
entire selection, indeterminate when partially selected. Navigation
and file preview are unaffected (only the checkbox selects).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
| |
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Files above 40 MB are no longer read in full. Instead, blake3 hashes
45 MB of samples (first 20 MB + last 20 MB + 5 MB at 50% offset).
Files at or below 40 MB are unchanged (full read, hash_version 1).
A new `hash_version` field on IndexEntry (default 1) travels on the
wire and through the cache so both versions coexist without breaking
existing nodes or clients.
The IndexCache auto-migrates its schema on open (ALTER TABLE), so no
manual step is required on upgrade. A standalone migration script is
available in QE/migration/ for operators who want to preview or force
a full re-hash.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two independent breaks in the Windows first-run path:
- node:start's win32 branch never linked the node's Ed25519 key to the hub
account, so the daemon sat at waiting_for_account and the Create Group
wizard span on "Detecting local node…" for ever — the only way through
was pasting the key by hand on the Profile page. The Linux branch has
always done this inline; factor it into linkNodeKeyAndAwaitRunning() and
call it from win32 too. PUT /v1/users/me/node_key overwrites, so this
also recovers an account still carrying a previous machine's node key.
- service.ps1's install branch did `$action = New-ScheduledTaskAction`,
shadowing its own [ValidateSet(...)][string]$Action parameter (PowerShell
variable names are case-insensitive). The CimInstance was coerced to the
string "MSFT_TaskExecAction", Register-ScheduledTask -Action rejected it,
and "background service" mode never created the task — reproduced live.
Rename the locals to $taskAction / $bootTrigger / $taskPrincipal.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
| |
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
-LogonType S4U
schtasks.exe has no flag naming the logon type directly -- it only infers
S4U vs Interactive from whether /rp is present, and both readings broke
live on a blank-password account: /rp "" fails schtasks' own credential
validation, and omitting /rp registers "Interactive only", which never
launches the process at boot or on demand despite installing cleanly.
Register-ScheduledTask -LogonType S4U names the logon type explicitly, no
inference. Confirmed live: install, manual start, and unattended boot-time
start all now work.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
| |
Same 768px breakpoint the hamburger appears at, where the nav is tightest and
the button has least to offer.
Belt and braces rather than the only guard: `capabilities.tray` is false
without the Electron bridge, so no browser has ever rendered this. What this
covers is the app's own window dragged narrow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The first icon was an arrow dropping into a receptacle, which is the download
glyph -- a vertical stem above a container reads that way whatever the context.
Replaced with a window folding a chevron into itself: no stem, and the frame
says which object is being minimised. Applied to both the nav button and the
panel indicator, which carried the same wrong shape.
The menu now offers Start or Stop for the node daemon, chosen from its actual
state and shown only when there is a daemon to act on: `supported && installed`,
so a machine with no node installed gets no entry rather than a control that
fails when used. `restart` is the start verb -- systemd's restart starts a
stopped unit, and there is no separate one to call.
The three service handlers become named functions so the tray drives exactly
what the Node page drives, instead of a second copy of the systemctl and Task
Scheduler branches. A read of the state that throws is treated as no control
at all.
The menu is rebuilt on a 5s timer while an indicator exists, and again straight
after an action. libappindicator has no "menu is about to open" event, so a menu
built once would show a stale Start/Stop for the life of the process; `systemctl
--user show` costs a few milliseconds.
Locales: tray.start_node / tray.stop_node in all ten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A dedicated monochrome button in the nav, immediately left of the notification
bell, hides the window to a tray indicator. Linux only for now; Windows is
being done on that OS, and the capability is declared per platform so the
button never appears where the desktop shows no indicator -- there it would
hide the window for good.
Hides, never closes: `window-all-closed` quits the app, so closing here would
make "minimise" mean "exit" and drop the session, the transfers and the node
connection. `second-instance` now calls the same restore path, since focusing
a hidden window does nothing visible.
The context menu is not decoration. Under libappindicator -- how GNOME shows a
tray at all, via the AppIndicator extension -- `tray.on('click')` never fires;
the indicator only opens its menu. A tray whose sole affordance was a click
would be inert on the one desktop this targets. The click handler is kept for
desktops that do send it.
Menu labels come from the renderer with the IPC call: the locale files are the
interface's, the main process has no i18n, and a second string table is how two
of them start disagreeing. English fallbacks if none arrive.
The icon lives in src/, not build/: package.json `files` packages only `src/**`
and `ui/**`, so an icon under build/ is present in a dev run and missing from
every installed one. Monochrome, stroked, matching the nav glyph.
Verified on this host: Ubuntu GNOME with ubuntu-appindicators@ubuntu.com and
libayatana-appindicator3 present, so the indicator has somewhere to appear.
Not yet run end to end.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Checked every instruction in packaging/README.md against the code. Four were
wrong; the rest hold.
- `meshbay-hub --generate-keys` does not exist. The hub CLI takes --config,
--log-level and a prune-groups subcommand, so the documented first step of
the hub install fails with "unrecognized arguments". It is also unnecessary:
app.py's lifespan generates the keypair on first start when the file is
absent. Step removed, behaviour documented instead.
- The systemd table named two source files that do not exist. The system unit
is built from systemd/meshbay-node.service and *installed as*
meshbay-node@.service; the user unit comes from meshbay-node-user.service.
Only the destination column was right.
- The output line pinned 0.9.0; every package is 0.10.0. Replaced with
<version> so it cannot go stale again, and MESHBAY_BUILD_DIR is mentioned.
- The firewall section documented only the client's casting profile. It now
covers the node profile added in 40abf09, with the LAN scoping that profile
requires.
Verified as accurate and left alone: the /tmp/meshbay-build/out/ path, the
install order, hub.toml.example, the desktop file, and the default.env claim
(true as of c2eade6, which implemented the copy it described).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Windows-only changes, all found by actually running the previous session's
work rather than by review alone:
- CTRL_CLOSE_EVENT/LOGOFF/SHUTDOWN handler (platform.py, ctypes
SetConsoleCtrlHandler) so closing a console window, signing off, or a
system shutdown runs the daemon's real _shutdown() instead of Windows
just ending the process — closing WebRTC sessions and any in-flight
ffmpeg transcode instead of orphaning it. `taskkill /F` itself stays
uncatchable (like SIGKILL), so autostart_run() now spawns with
CREATE_NEW_PROCESS_GROUP instead of DETACHED_PROCESS and autostart_end()
tries CTRL_BREAK_EVENT against the recorded pid first, falling back to
the hard kill only if that doesn't stop it in time.
- Replaced the Node page's two independent autostart/service-mode toggles
with one "start automatically" select (off / at sign-in / as a
background service). The old pair let both be active at once — starting
the daemon twice, at boot and at sign-in — and their layout broke
wrapping inside .node-service's flex row. The new control always removes
whichever mechanism is active before installing the target; platform.py's
service_install() does the same on the CLI side. The "background
service" option disables itself (with a hint pointing at the CLI) when
running unpackaged, since service-mode.ps1/service.ps1/firewall.ps1 all
assume an installed build's layout — verified live rather than assumed
by actually running those scripts unelevated.
- findNodeBinary() no longer bakes a stray \r into resolved paths. Found by
rebooting after enabling per-user autostart: where.exe listed two
matches, and stdout.trim().split('\n')[0] only strips the whole string's
ends, leaving line one's own trailing \r attached — which landed inside
the Startup .vbs's quoted path and broke it with "Unterminated string
constant" at boot. Fixed by splitting on \r?\n and trimming every line.
- Dependency audit for the Windows installer (docs/WINDOWS-PORT.md): no
VC++ Redistributable needed, confirmed by inspecting the built
node-runtime's actual import table rather than assuming. New
docs/windows-build.md: a concise clone-to-installer build guide.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The installer's own mode question is effectively one-shot: customInstall
skips it entirely once the firewall rules already exist, for any reason --
and per-user mode sets those up on its own, with no Scheduled Task involved.
So declining once (or the rules existing from something unrelated, as
happened on a dev machine this session) was a dead end: no reinstall, repair,
or uninstall/reinstall cycle could ever bring the question back, since
uninstall defaults to leaving both alone.
Add the other door in (and out): a checkbox on the Node page, next to the
existing per-user autostart toggle, wired main.js -> preload.js -> platform.js
-> node-page.js. It runs packaging/win/service-mode.ps1 -- the exact script
installer.nsh already runs -- via one Start-Process -Verb RunAs elevation, so
the two paths can never disagree about what service mode means. The elevation
helper writes a tiny param()-based .ps1 to %TEMP% so the target script path
and its arguments bind through real PowerShell parameters instead of nested
string-quoting.
Also fixes a real pre-existing gap found while checking this: 8 of the 10
locale catalogues (all but en/fr) were missing the autostart/service-mode
keys added in an earlier commit this session (b782886) -- test_locales.py's
key-set-parity check uses a for-loop with an inline assert, so it stopped at
the first mismatch (fr) and never actually reached the other eight. Backfilled
all five keys (three pre-existing, two new) in de/es/it/ja/nl/pl/pt-BR/zh-CN.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|