fix(broker+cli): multi-session DM routing + broadcast self-loopback (v0.3.2)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

Two related bugs surfaced in multi-session production use of 1.8.0:

1. Replies via `claudemesh send <from_id>` rejected with "no connected
   peer for target" when the original sender's session had rotated
   (Claude Code restart, /resume). Root cause: from_id carried the
   ephemeral session pubkey, which disappears the moment the session
   ends. Fix: handleSend pre-flight now also resolves the target
   pubkey against the persistent meshMember table and routes to the
   owning member's live session(s); MCP push channel now sets from_id
   to the stable member pubkey and exposes the ephemeral one under
   from_session_pubkey.

2. Broadcast/* and @group sends loopback'd to the sender's *sibling*
   sessions (same member, different session keypair), surfacing a
   spurious "tampered or wrong keypair" decrypt warning on the
   sender's own inboxes. Fix: broadcast/group fan-out now skips by
   memberPubkey, not just by presence_id, so the entire sender member
   is excluded — direct sends keep per-presence skip so a member can
   still DM their own sibling session intentionally.

Push envelope now also carries senderMemberPubkey alongside
senderPubkey so any other client of the WS channel can choose the
right one.
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 22:05:11 +01:00
parent 038a5b5bf7
commit 716e674473
5 changed files with 90 additions and 11 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "claudemesh-cli",
"version": "1.9.0",
"version": "1.9.1",
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
"keywords": [
"claude-code",

View File

@@ -680,14 +680,23 @@ Your message mode is "${messageMode}".
const prioBadge = msg.priority === "now" ? "[URGENT] " : msg.priority === "low" ? "[low] " : "";
const kindBadge = msg.kind === "broadcast" ? " (broadcast)" : "";
const content = `${prioBadge}${fromName}${kindBadge}: ${body}`;
// `from_id` MUST be a stable replyable id. Older clients of this
// channel have been pasting from_id straight back into
// `claudemesh send <id>`; if from_id is the SESSION pubkey it
// bounces with "no connected peer" the moment the sender's
// session restarts. Send the MEMBER pubkey (stable across
// reconnects) as from_id, and keep the ephemeral session pubkey
// available under from_session_pubkey for crypto-aware callers.
const fromMemberPubkey = msg.senderMemberPubkey ?? fromPubkey;
try {
await server.notification({
method: "notifications/claude/channel",
params: {
content,
meta: {
from_id: fromPubkey,
from_pubkey: fromPubkey,
from_id: fromMemberPubkey,
from_pubkey: fromMemberPubkey,
from_session_pubkey: fromPubkey,
from_name: fromName,
...(msg.senderMemberId ? { from_member_id: msg.senderMemberId } : {}),
mesh_slug: client.meshSlug,

View File

@@ -100,7 +100,12 @@ export interface PeerInfo {
export interface InboundPush {
messageId: string;
meshId: string;
/** Sender's *session* pubkey — ephemeral. Rotates on session restart.
* Used by crypto_box_open to verify the seal. Prefer the member
* pubkey for replies. */
senderPubkey: string;
/** Sender's *member* pubkey — stable. Use as the reply target. */
senderMemberPubkey?: string;
/** Stable mesh.member id of the sender — preferred id for replies. */
senderMemberId?: string;
/** Sender's current display name (a join from the broker). */
@@ -2036,6 +2041,7 @@ export class BrokerClient {
messageId: String(msg.messageId ?? ""),
meshId: String(msg.meshId ?? ""),
senderPubkey,
...(msg.senderMemberPubkey ? { senderMemberPubkey: String(msg.senderMemberPubkey) } : {}),
...(msg.senderMemberId ? { senderMemberId: String(msg.senderMemberId) } : {}),
...(msg.senderName ? { senderName: String(msg.senderName) } : {}),
...(msg.topic ? { topic: String(msg.topic) } : {}),