From 9075ae05dfea80f03bac35d2722b365328849f97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:58:50 +0100 Subject: [PATCH] fix(cli): messages stop landing on the wrong sessions (wrong-peer noise, bug9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reply routinely fanned out to every session of the sender's member, which is the "peers spread messages to the wrong peers" noise. Three links fed each other: - channel meta put the MEMBER key in from_pubkey/from_id, and the skill told the model to reply by from_name (repeatable across sessions). from_pubkey/from_id are now the sender's session key; the member key moves to from_member_pubkey; the skill says reply to from_pubkey. - a DM to a member key was claimed by that member's daemon socket and shown to all its sessions. New daemon/resolve-target.ts is the single resolver for /v1/send: a member key maps to its only live session, is refused (409 member_target_ambiguous + candidates) when it has several, and needs --fanout to reach all of them on purpose. A duplicated display name or prefix is ambiguous instead of first-match; control- plane rows are never addressable. - a session MCP whose token didn't resolve fell into the unfiltered SSE stream and rendered every sibling's DMs. The MCP now subscribes with scope=session and gets no message events without a resolved session. Also: --self lists only the current mesh and requires a registered session (it could message its own new row); a DM to a control-plane row says so instead of the wrong "session pubkeys are ephemeral" hint, and `peer list --all` tags those rows (bug9). Spec: .artifacts/specs/2026-09-26-session-identity-and-routing.md §2-3, §6 Co-Authored-By: Claude Opus 5.5 --- apps/cli/skills/claudemesh/SKILL.md | 14 ++-- apps/cli/src/cli/argv.ts | 1 + apps/cli/src/commands/peers.ts | 3 + apps/cli/src/commands/send.ts | 53 +++++++++++-- apps/cli/src/daemon/events.ts | 6 +- apps/cli/src/daemon/ipc/handlers/send.ts | 3 + apps/cli/src/daemon/ipc/server.ts | 61 ++++++++------- apps/cli/src/daemon/resolve-target.ts | 77 +++++++++++++++++++ apps/cli/src/entrypoints/cli.ts | 4 +- apps/cli/src/mcp/server.ts | 14 +++- apps/cli/src/services/bridge/daemon-route.ts | 13 +++- apps/cli/tests/unit/resolve-target.test.ts | 67 ++++++++++++++++ .../tests/unit/sse-require-session.test.ts | 23 ++++++ apps/cli/tests/unit/sse-resubscribe.test.ts | 2 +- 14 files changed, 285 insertions(+), 56 deletions(-) create mode 100644 apps/cli/src/daemon/resolve-target.ts create mode 100644 apps/cli/tests/unit/resolve-target.test.ts create mode 100644 apps/cli/tests/unit/sse-require-session.test.ts diff --git a/apps/cli/skills/claudemesh/SKILL.md b/apps/cli/skills/claudemesh/SKILL.md index 3b17fd5..b958a1d 100644 --- a/apps/cli/skills/claudemesh/SKILL.md +++ b/apps/cli/skills/claudemesh/SKILL.md @@ -15,7 +15,7 @@ description: Use when the user asks to send a message to a peer Claude session, ## When you receive a `` message -Respond IMMEDIATELY (unless `meta.kind` is `"welcome"` or `"system"` — those are informational, no reply needed). Pause your current task, reply via `claudemesh send`, then resume. Read `from_name`, `mesh_slug`, and `priority` from the channel attributes. Reply by setting `` to the sender's `from_name`. Do not ignore low-priority messages — acknowledge them briefly even if you defer action. If the channel meta contains `subtype: reminder`, this is a scheduled reminder you set yourself — act on it. +Respond IMMEDIATELY (unless `meta.kind` is `"welcome"` or `"system"` — those are informational, no reply needed). Pause your current task, reply via `claudemesh send`, then resume. Read `from_pubkey`, `from_name`, `mesh_slug`, and `priority` from the channel attributes. Reply by setting `` to the sender's **`from_pubkey`** (its session pubkey) — it reaches exactly that session. Don't reply by `from_name` (several sessions can share a name) or by `from_member_pubkey` (it reaches every session of that member). Do not ignore low-priority messages — acknowledge them briefly even if you defer action. If the channel meta contains `subtype: reminder`, this is a scheduled reminder you set yourself — act on it. ### Channel attributes (everything you need to reply is in the push) @@ -23,10 +23,10 @@ The `` interrupt carries these attributes — no lookup needed: | Attribute | What it is | |---|---| -| `from_name` | Sender's display name. **Use as `to` in your reply** for DMs. Empty/absent on `kind: "welcome"` and `kind: "system"`. | -| `from_pubkey` | Sender's **session pubkey** (hex, ephemeral per-launch). Since 1.34.0 this is the session pubkey of the launched session that originated the send, NOT the daemon's stable member pubkey — sibling sessions of the same human are correctly disambiguated. | +| `from_name` | Sender's display name, for reading. Empty/absent on `kind: "welcome"` and `kind: "system"`. Not a reply address: names can repeat across sessions. | +| `from_pubkey` | Sender's **session pubkey** (hex). **Use as `to` in your reply.** Since 1.38.0 this is always the originating session's key (before, it carried the member key, so replies fanned out to every sibling session). `from_id` is the same value. | | `from_session_pubkey` | Same as `from_pubkey` for session-originated DMs. Kept as a separate key so the model never confuses session vs member identity when a control-plane source is involved. | -| `from_member_id` / `from_member_pubkey` | Sender's stable mesh.member id / pubkey. Survives display-name and session rotation. Use to recognize "the same human across multiple Claude Code windows". | +| `from_member_pubkey` | Sender's stable mesh.member pubkey. Survives display-name and session rotation. Use to recognize "the same human across multiple Claude Code windows" — **not** as a reply target (a DM to it needs `--fanout` and reaches all their sessions). | | `mesh_slug` | Mesh the message arrived on. Pass via `--mesh ` if the parent isn't on the same mesh. | | `priority` | `now` / `next` / `low`. | | `message_id` | Server-side id of THIS message. **Pass to `--reply-to ` to thread your reply** in topic posts. | @@ -38,14 +38,14 @@ The `` interrupt carries these attributes — no lookup needed: **Reply patterns:** ```bash -# DM → use from_name as the target -claudemesh send "" "ack — looking now" +# DM → use from_pubkey (the session) as the target +claudemesh send "" "ack — looking now" # Topic reply → thread it onto the message you got claudemesh topic post "" "yep, looks good" --reply-to # When the sender is on a different mesh you've joined -claudemesh send "" "..." --mesh "" +claudemesh send "" "..." --mesh "" ``` ## Performance model (warm vs cold path) diff --git a/apps/cli/src/cli/argv.ts b/apps/cli/src/cli/argv.ts index 3a94c45..c2d7992 100644 --- a/apps/cli/src/cli/argv.ts +++ b/apps/cli/src/cli/argv.ts @@ -13,6 +13,7 @@ export interface ParsedArgs { command: string; positionals: string[]; flags: Rec */ const BOOLEAN_FLAGS = new Set([ "self", + "fanout", "json", // also accepts --json=a,b,c form below "all", "yes", "y", diff --git a/apps/cli/src/commands/peers.ts b/apps/cli/src/commands/peers.ts index 85e20e0..63ef083 100644 --- a/apps/cli/src/commands/peers.ts +++ b/apps/cli/src/commands/peers.ts @@ -315,6 +315,9 @@ export async function runPeers(flags: PeersFlags): Promise { const statusDot = p.status === "working" ? yellow("●") : green("●"); const name = bold(p.displayName); const meta: string[] = []; + // bug9 (2026-09-26): under --all, mark infrastructure rows — they + // look like peers but don't receive DMs. + if ((p as { peerRole?: string }).peerRole === "control-plane") meta.push("control-plane · no DMs"); if (p.peerType) meta.push(p.peerType); if (p.channel) meta.push(p.channel); if (p.model) meta.push(p.model); diff --git a/apps/cli/src/commands/send.ts b/apps/cli/src/commands/send.ts index 49a9785..e6f5e18 100644 --- a/apps/cli/src/commands/send.ts +++ b/apps/cli/src/commands/send.ts @@ -27,6 +27,11 @@ export interface SendFlags { * sibling session is almost always an accident (copying a hex * pubkey from `peer list` without realizing it was your own row). */ self?: boolean; + /** 1.38.0: a member-pubkey target reaches every live session of that + * member. Without it such a target must resolve to exactly one session + * (spec 2026-09-26 §3) — the default fan-out was the "messages land on + * the wrong peers" noise. */ + fanout?: boolean; } export async function runSend(flags: SendFlags, to: string, message: string): Promise { @@ -109,6 +114,17 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr render.hint("Check `claudemesh peer list` (add --mesh to scope)."); process.exit(1); } + } else if (uniq.length > 1 && isFullPubkey && meshesHit.length === 1 + && !uniq.some((m) => m.pubkey.toLowerCase() === lower)) { + // A full MEMBER pubkey with several live sessions. Fan-out only on + // request; otherwise make the caller pick the session. + meshSlug = meshesHit[0]!; + if (!flags.fanout) { + render.err(`${to.slice(0, 16)}… is a member key with ${uniq.length} live sessions — a DM to it reaches all of them.`); + render.hint(`sessions: ${uniq.map((m) => `${m.displayName} ${m.pubkey.slice(0, 16)}…`).join(", ")}`); + render.hint("Send to one session pubkey, or pass --fanout to reach every session on purpose."); + process.exit(1); + } } else if (uniq.length > 1) { if (meshesHit.length > 1 && !meshSlug) { // Target lives on several meshes — disambiguate by mesh, not prefix. @@ -166,9 +182,16 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr try { const { tryListPeersViaDaemon } = await import("~/services/bridge/daemon-route.js"); const { getSessionInfo } = await import("~/services/session/resolve.js"); - const peers = (await tryListPeersViaDaemon()) ?? []; + const peers = (await tryListPeersViaDaemon(meshSlug)) ?? []; const session = await getSessionInfo(); const ownSessionPk = session?.presence?.sessionPubkey?.toLowerCase(); + // 1.38.0: without our own session pubkey we can't exclude ourselves + // — an unregistered (resumed) session would message its own new row. + if (!ownSessionPk) { + render.err("--self needs this session to be registered with the daemon, and it isn't."); + render.hint("Run `claudemesh session reattach` from this session, then retry."); + process.exit(1); + } const siblings = peers.filter((p) => { const r = p as { memberPubkey?: string; pubkey?: string; channel?: string; peerRole?: string }; if (!r.pubkey) return false; @@ -250,24 +273,33 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr const peers = await tryListPeersViaDaemon(meshSlug); if (peers !== null) { const lower = to.toLowerCase(); - const match = peers.find((p) => { - const r = p as { pubkey?: string; memberPubkey?: string; peerRole?: string }; - if (r.peerRole === "control-plane") return false; + const isKey = (p: unknown) => { + const r = p as { pubkey?: string; memberPubkey?: string }; return r.pubkey?.toLowerCase() === lower || r.memberPubkey?.toLowerCase() === lower; - }); + }; + const isControlPlane = (p: unknown) => (p as { peerRole?: string }).peerRole === "control-plane"; + const match = peers.find((p) => !isControlPlane(p) && isKey(p)); + // bug9 (2026-09-26): the target is a daemon/control-plane row — it's + // listed under `peer list --all` but is infrastructure, not a + // session. Say so instead of blaming "ephemeral keys". + if (!match && peers.some((p) => isControlPlane(p) && (p as { pubkey?: string }).pubkey?.toLowerCase() === lower)) { + render.err(`${to.slice(0, 16)}… is a control-plane (daemon) row, not a session — it doesn't receive DMs.`); + render.hint("Pick a session pubkey from `claudemesh peer list` (without --all)."); + process.exit(1); + } recipientOnline = !!match; recipientName = match ? (match as { displayName?: string }).displayName : undefined; } } const offlineHint = - "Session pubkeys are ephemeral — a key from an ended session never reconnects, so the message can't be delivered. Re-fetch a live target with `claudemesh peer list --json`."; + "It's queued and delivers when that session reconnects. If the session was relaunched it has a new key — re-fetch a live target with `claudemesh peer list --json`."; // Daemon path — preferred when a long-lived daemon is local. UDS at // ~/.claudemesh/daemon/daemon.sock; ~1ms round-trip; persists outbox // across CLI invocations so a `claudemesh send` survives a daemon // crash via the on-disk outbox. { - const dr = await trySendViaDaemon({ to, message, priority, expectedMesh: meshSlug ?? undefined }); + const dr = await trySendViaDaemon({ to, message, priority, expectedMesh: meshSlug ?? undefined, fanout: !!flags.fanout }); if (dr !== null) { if (dr.ok) { if (flags.json) { @@ -286,7 +318,12 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr } // Daemon answered but rejected (409 idempotency, 400 schema). Surface; do not fall through. if (flags.json) console.log(JSON.stringify({ ok: false, error: dr.error, via: "daemon" })); - else render.err(`send failed (daemon): ${dr.error}`); + else { + render.err(`send failed (daemon): ${dr.error}`); + if (dr.candidates?.length) { + render.hint(`candidates: ${dr.candidates.map((c) => `${c.displayName} ${c.pubkey.slice(0, 16)}…`).join(", ")}`); + } + } process.exit(1); } // dr === null → daemon not running and lifecycle couldn't auto- diff --git a/apps/cli/src/daemon/events.ts b/apps/cli/src/daemon/events.ts index 2e138b4..9f95525 100644 --- a/apps/cli/src/daemon/events.ts +++ b/apps/cli/src/daemon/events.ts @@ -62,9 +62,13 @@ export interface SseFilterOptions { * When set, system events (peer_join etc.) are filtered to this * mesh; without it every system event surfaces. */ meshSlug?: string; + /** 1.38.0: the subscriber is a session's MCP. Without a resolved + * session it gets no message events at all (spec 2026-09-26 §2). */ + requireSession?: boolean; } -function shouldDeliver(e: DaemonEvent, f: SseFilterOptions): boolean { +export function shouldDeliver(e: DaemonEvent, f: SseFilterOptions): boolean { + if (f.requireSession && !f.sessionPubkey && e.kind === "message") return false; // No filter set → legacy behavior: deliver everything (used by // diagnostic tooling like `claudemesh daemon events`). if (!f.sessionPubkey && !f.memberPubkey && !f.meshSlug) return true; diff --git a/apps/cli/src/daemon/ipc/handlers/send.ts b/apps/cli/src/daemon/ipc/handlers/send.ts index 9d53082..be2c799 100644 --- a/apps/cli/src/daemon/ipc/handlers/send.ts +++ b/apps/cli/src/daemon/ipc/handlers/send.ts @@ -45,6 +45,9 @@ export interface SendRequest { * broker fan-out attributes the push to the session pubkey instead * of the daemon's member pubkey. */ sender_session_pubkey?: string; + /** 1.38.0: the caller explicitly wants a member-key DM delivered to every + * live session of that member (spec 2026-09-26 §3). Off by default. */ + fanout?: boolean; } export type AcceptOutcome = diff --git a/apps/cli/src/daemon/ipc/server.ts b/apps/cli/src/daemon/ipc/server.ts index c636e9d..2ba9a00 100644 --- a/apps/cli/src/daemon/ipc/server.ts +++ b/apps/cli/src/daemon/ipc/server.ts @@ -4,6 +4,7 @@ import { timingSafeEqual } from "node:crypto"; import { DAEMON_PATHS, DAEMON_TCP_HOST, DAEMON_TCP_DEFAULT_PORT } from "../paths.js"; import type { SqliteDb } from "../db/sqlite.js"; +import { resolveDirectTarget } from "../resolve-target.js"; import { acceptSend, type SendRequest } from "./handlers/send.js"; import { listInbox, deleteInboxRow, flushInbox, markInboxSeen } from "../db/inbox.js"; import { listOutbox, requeueDeadOrPending, type OutboxStatus } from "../db/outbox.js"; @@ -420,8 +421,12 @@ function makeHandler(opts: { // session token (`claudemesh daemon events`) get the unfiltered // legacy stream. The bus itself stays single-shot; demux lives // entirely at the SSE bind layer (events.ts shouldDeliver). - const filter: Record = {}; + const filter: Record = {}; if (session?.presence?.sessionPubkey) filter.sessionPubkey = session.presence.sessionPubkey; + // 1.38.0: an MCP (scope=session) whose token doesn't resolve — a + // resumed session that lost its registration — used to fall into + // the unfiltered legacy stream and render every sibling's DMs. + if (url.searchParams.get("scope") === "session") filter.requireSession = true; if (session?.mesh) { filter.meshSlug = session.mesh; const meshCfg = opts.meshConfigs?.get(session.mesh); @@ -932,6 +937,10 @@ function makeHandler(opts: { parsed.req.sender_session_pubkey = senderSessionPubkey; } } catch (e) { + if (e instanceof RouteError) { + respond(res, e.code === "not_found" ? 404 : 409, { error: e.code, detail: e.detail, candidates: e.candidates }); + return; + } respond(res, 502, { error: "route_failed", detail: String(e) }); return; } @@ -1041,6 +1050,7 @@ function parseSendRequest(body: unknown, idempotencyHeader: string | string[] | const reply_to_id = typeof b.reply_to_id === "string" ? b.reply_to_id : undefined; const mesh = typeof b.mesh === "string" ? b.mesh.trim() : undefined; + const fanout = b.fanout === true; return { req: { @@ -1053,6 +1063,7 @@ function parseSendRequest(body: unknown, idempotencyHeader: string | string[] | destination_kind, destination_ref, mesh, + ...(fanout ? { fanout } : {}), }, }; } @@ -1094,41 +1105,29 @@ async function resolveAndEncrypt( return { target_spec: to, ciphertext, nonce, mesh: meshSlug ?? "" }; } - // 64-char hex pubkey → DM directly. Encrypt with the daemon's member - // secret: recipient decrypts using THEIR session pubkey's matching - // secret on their session-WS, so the sender side just needs any - // private key whose public counterpart is known to the recipient as - // "the sender". Member key is the stable choice and is what the - // recipient already trusts via mesh membership. - if (/^[0-9a-f]{64}$/i.test(to)) { - const env = await encryptDirect(req.message, to, meshSecretKey); - return { target_spec: to, ciphertext: env.ciphertext, nonce: env.nonce, mesh: meshSlug ?? "" }; - } - - // Hex prefix (16+ chars but <64) → resolve via peer list prefix match. - // Matches the ergonomics of `claudemesh peer list` which shows 16-char - // prefixes, so users naturally paste prefixes back. + // Direct target (full pubkey, prefix, or display name). 1.38.0 (spec + // 2026-09-26 §3): one resolver for every caller — a member key is mapped + // to its single live session (or refused when it has several), and a + // name/prefix must match exactly one addressable peer. Before, a member + // key went straight out and the recipient's member-WS showed it to every + // sibling session; a duplicated name silently picked the first match. const peers = await broker.listPeers().catch(() => []); - if (/^[0-9a-f]{16,63}$/i.test(to)) { - const matches = peers.filter((p) => - p.pubkey.toLowerCase().startsWith(to.toLowerCase()) || - (p.memberPubkey ?? "").toLowerCase().startsWith(to.toLowerCase()), - ); - if (matches.length === 0) throw new Error(`no peer matching prefix "${to}"`); - if (matches.length > 1) throw new Error(`prefix "${to}" is ambiguous (${matches.length} matches)`); - const recipient = matches[0]!.pubkey; - const env = await encryptDirect(req.message, recipient, meshSecretKey); - return { target_spec: recipient, ciphertext: env.ciphertext, nonce: env.nonce, mesh: meshSlug ?? "" }; - } - - // Otherwise — display name. - const match = peers.find((p) => p.displayName.toLowerCase() === to.toLowerCase()); - if (!match) throw new Error(`peer "${to}" not found`); - const recipient = match.pubkey; + const resolved = resolveDirectTarget(to, peers, { fanout: req.fanout === true }); + if (!resolved.ok) throw new RouteError(resolved.code, resolved.detail, resolved.candidates); + const recipient = resolved.pubkey; const env = await encryptDirect(req.message, recipient, meshSecretKey); return { target_spec: recipient, ciphertext: env.ciphertext, nonce: env.nonce, mesh: meshSlug ?? "" }; } +/** A send target that can't be resolved unambiguously — a 4xx, not a 502. */ +export class RouteError extends Error { + constructor( + readonly code: string, + readonly detail: string, + readonly candidates: Array<{ pubkey: string; displayName: string }>, + ) { super(detail); } +} + function respond(res: ServerResponse, status: number, body: unknown) { const json = JSON.stringify(body); res.statusCode = status; diff --git a/apps/cli/src/daemon/resolve-target.ts b/apps/cli/src/daemon/resolve-target.ts new file mode 100644 index 0000000..5c086f0 --- /dev/null +++ b/apps/cli/src/daemon/resolve-target.ts @@ -0,0 +1,77 @@ +/** + * Direct-message target resolution — one source of truth for every + * caller of the daemon's /v1/send (CLI, MCP tools, scripts). + * + * Spec 2026-09-26 §3. The noisy failure it prevents: a DM addressed to a + * MEMBER pubkey is claimed by any connection of that member — the daemon's + * member-WS then shows it to EVERY session of that member, and each of + * them reacts. So a member key is resolved to its single live session, or + * refused when there are several (unless the caller asked for --fanout). + * Names and prefixes must match exactly one addressable peer. + */ + +export interface RosterPeer { + pubkey: string; + memberPubkey?: string; + displayName: string; + /** Broker classification; `peerRole` on some IPC shapes. */ + role?: string; + peerRole?: string; +} + +export type ResolveResult = + | { ok: true; pubkey: string; note?: "member_to_session" | "member_fanout" | "offline_member_or_session" } + | { ok: false; code: "not_found" | "ambiguous" | "member_target_ambiguous"; detail: string; candidates: Array<{ pubkey: string; displayName: string }> }; + +function isControlPlane(p: RosterPeer): boolean { + return (p.role ?? p.peerRole) === "control-plane"; +} + +function uniqByPubkey(peers: RosterPeer[]): RosterPeer[] { + const seen = new Map(); + for (const p of peers) if (!seen.has(p.pubkey.toLowerCase())) seen.set(p.pubkey.toLowerCase(), p); + return [...seen.values()]; +} + +const cand = (ps: RosterPeer[]) => ps.map((p) => ({ pubkey: p.pubkey, displayName: p.displayName })); + +export function resolveDirectTarget( + toRaw: string, + roster: RosterPeer[], + opts: { fanout?: boolean } = {}, +): ResolveResult { + const to = toRaw.trim().toLowerCase(); + const peers = roster.filter((p) => p.pubkey && !isControlPlane(p)); + + if (/^[0-9a-f]{64}$/.test(to)) { + if (peers.some((p) => p.pubkey.toLowerCase() === to)) return { ok: true, pubkey: to }; + const sessions = uniqByPubkey(peers.filter((p) => (p.memberPubkey ?? "").toLowerCase() === to)); + if (sessions.length === 1) return { ok: true, pubkey: sessions[0]!.pubkey.toLowerCase(), note: "member_to_session" }; + if (sessions.length > 1) { + if (opts.fanout) return { ok: true, pubkey: to, note: "member_fanout" }; + return { + ok: false, + code: "member_target_ambiguous", + detail: `${to.slice(0, 16)}… is a member key with ${sessions.length} live sessions — pick one session pubkey, or pass --fanout to reach all of them`, + candidates: cand(sessions), + }; + } + // Unknown to the live roster: an offline peer. Keep store-and-forward. + return { ok: true, pubkey: to, note: "offline_member_or_session" }; + } + + if (/^[0-9a-f]{4,63}$/.test(to)) { + const bySession = uniqByPubkey(peers.filter((p) => p.pubkey.toLowerCase().startsWith(to))); + const matches = bySession.length > 0 + ? bySession + : uniqByPubkey(peers.filter((p) => (p.memberPubkey ?? "").toLowerCase().startsWith(to))); + if (matches.length === 1) return { ok: true, pubkey: matches[0]!.pubkey.toLowerCase() }; + if (matches.length === 0) return { ok: false, code: "not_found", detail: `no peer matching prefix "${toRaw}"`, candidates: [] }; + return { ok: false, code: "ambiguous", detail: `prefix "${toRaw}" matches ${matches.length} peers`, candidates: cand(matches) }; + } + + const byName = uniqByPubkey(peers.filter((p) => p.displayName.toLowerCase() === to)); + if (byName.length === 1) return { ok: true, pubkey: byName[0]!.pubkey.toLowerCase() }; + if (byName.length === 0) return { ok: false, code: "not_found", detail: `peer "${toRaw}" not found`, candidates: [] }; + return { ok: false, code: "ambiguous", detail: `name "${toRaw}" matches ${byName.length} sessions — use a session pubkey`, candidates: cand(byName) }; +} diff --git a/apps/cli/src/entrypoints/cli.ts b/apps/cli/src/entrypoints/cli.ts index cc04afd..fdb9d11 100644 --- a/apps/cli/src/entrypoints/cli.ts +++ b/apps/cli/src/entrypoints/cli.ts @@ -403,7 +403,7 @@ async function main(): Promise { // Messaging case "peers": { const { runPeers } = await import("~/commands/peers.js"); await runPeers({ mesh: flags.mesh as string, json: flags.json as boolean | string | undefined, all: !!flags.all }); break; } - case "send": { const { runSend } = await import("~/commands/send.js"); await runSend({ mesh: flags.mesh as string, priority: flags.priority as string, json: !!flags.json, self: !!flags.self }, positionals[0] ?? "", positionals.slice(1).join(" ")); break; } + case "send": { const { runSend } = await import("~/commands/send.js"); await runSend({ mesh: flags.mesh as string, priority: flags.priority as string, json: !!flags.json, self: !!flags.self, fanout: !!flags.fanout }, positionals[0] ?? "", positionals.slice(1).join(" ")); break; } case "inbox": { const sub = positionals[0]; if (sub === "flush") { @@ -571,7 +571,7 @@ async function main(): Promise { case "message": { const sub = positionals[0]; - if (sub === "send") { const { runSend } = await import("~/commands/send.js"); await runSend({ mesh: flags.mesh as string, priority: flags.priority as string, json: !!flags.json, self: !!flags.self }, positionals[1] ?? "", positionals.slice(2).join(" ")); } + if (sub === "send") { const { runSend } = await import("~/commands/send.js"); await runSend({ mesh: flags.mesh as string, priority: flags.priority as string, json: !!flags.json, self: !!flags.self, fanout: !!flags.fanout }, positionals[1] ?? "", positionals.slice(2).join(" ")); } else if (sub === "inbox") { const sub2 = positionals[1]; if (sub2 === "flush") { diff --git a/apps/cli/src/mcp/server.ts b/apps/cli/src/mcp/server.ts index c21ddbe..fbf6382 100644 --- a/apps/cli/src/mcp/server.ts +++ b/apps/cli/src/mcp/server.ts @@ -218,7 +218,7 @@ export function subscribeEvents( // receives every event. const headers: Record = { Accept: "text/event-stream" }; if (opts.sessionToken) headers.Authorization = `ClaudeMesh-Session ${opts.sessionToken}`; - const thisReq = httpRequest({ socketPath, path: "/v1/events", method: "GET", headers }); + const thisReq = httpRequest({ socketPath, path: "/v1/events?scope=session", method: "GET", headers }); req = thisReq; let buffer = ""; @@ -439,6 +439,11 @@ export async function startMcpServer(): Promise { if (ev.kind === "message") { const d = ev.data; const fromName = String(d.sender_name ?? "unknown"); + // 1.38.0 (spec 2026-09-26 §3): from_pubkey / from_id are the sender's + // SESSION pubkey — the only key a reply reaches exactly one session + // with. They used to carry the member key, so replying fanned out to + // every session of that member. The member key moves to its own field. + const fromSession = String(d.sender_pubkey ?? ""); const fromMember = String(d.sender_member_pubkey ?? d.sender_pubkey ?? ""); const rawBody = typeof d.body === "string" ? d.body : null; // 1.38.0 (spec 2026-09-26 §1): second guard — never hand Claude @@ -458,9 +463,10 @@ export async function startMcpServer(): Promise { params: { content, meta: { - from_id: fromMember, - from_pubkey: fromMember, - from_session_pubkey: String(d.sender_pubkey ?? ""), + from_id: fromSession || fromMember, + from_pubkey: fromSession || fromMember, + from_session_pubkey: fromSession, + from_member_pubkey: fromMember, from_name: fromName, mesh_slug: String(d.mesh ?? ""), priority, diff --git a/apps/cli/src/services/bridge/daemon-route.ts b/apps/cli/src/services/bridge/daemon-route.ts index 626e9c3..3585f4e 100644 --- a/apps/cli/src/services/bridge/daemon-route.ts +++ b/apps/cli/src/services/bridge/daemon-route.ts @@ -308,7 +308,7 @@ export type DaemonSendOk = { duplicate?: boolean; status?: "queued" | "inflight"; }; -export type DaemonSendErr = { ok: false; error: string }; +export type DaemonSendErr = { ok: false; error: string; candidates?: Array<{ pubkey: string; displayName: string }> }; export type DaemonSendResult = DaemonSendOk | DaemonSendErr; export async function trySendViaDaemon(args: { @@ -322,6 +322,8 @@ export async function trySendViaDaemon(args: { * so for v0.9.0 this is informational; the caller already picked the * right mesh by either flag or single-mesh-default. */ expectedMesh?: string; + /** 1.38.0: deliver a member-key DM to every live session of that member. */ + fanout?: boolean; }): Promise { if (!(await daemonReachable())) return null; @@ -332,6 +334,8 @@ export async function trySendViaDaemon(args: { broker_message_id?: string; duplicate?: boolean; error?: string; + detail?: string; + candidates?: Array<{ pubkey: string; displayName: string }>; }>({ method: "POST", path: "/v1/send", @@ -346,6 +350,7 @@ export async function trySendViaDaemon(args: { // daemon still works (auto-pick); omitting it on a multi-mesh // daemon returns 400 with the attached list. ...(args.expectedMesh ? { mesh: args.expectedMesh } : {}), + ...(args.fanout ? { fanout: true } : {}), }, }); @@ -357,7 +362,11 @@ export async function trySendViaDaemon(args: { status: res.body.status, }; } - return { ok: false, error: res.body.error ?? `daemon http ${res.status}` }; + return { + ok: false, + error: res.body.detail ?? res.body.error ?? `daemon http ${res.status}`, + ...(res.body.candidates?.length ? { candidates: res.body.candidates } : {}), + }; } catch (err) { // Connection errors → daemon went away mid-call. Treat as "not present" // so the caller falls back rather than failing. diff --git a/apps/cli/tests/unit/resolve-target.test.ts b/apps/cli/tests/unit/resolve-target.test.ts new file mode 100644 index 0000000..5d058d5 --- /dev/null +++ b/apps/cli/tests/unit/resolve-target.test.ts @@ -0,0 +1,67 @@ +/** + * Spec 2026-09-26 §3: the "messages land on the wrong peers" noise. + * A member-key DM reached every session of that member; a duplicated + * display name silently picked the first row; control-plane rows were + * addressable. One resolver now decides for every /v1/send caller. + */ + +import { describe, expect, it } from "vitest"; + +import { resolveDirectTarget, type RosterPeer } from "~/daemon/resolve-target.js"; + +const M1 = "a".repeat(64); +const M2 = "b".repeat(64); +const S1 = "1".repeat(64); +const S2 = "2".repeat(64); +const S3 = "3".repeat(64); + +const roster: RosterPeer[] = [ + { pubkey: M1, memberPubkey: M1, displayName: "daemon", role: "control-plane" }, + { pubkey: S1, memberPubkey: M1, displayName: "Intra-Back" }, + { pubkey: S2, memberPubkey: M1, displayName: "Intra-Back" }, + { pubkey: S3, memberPubkey: M2, displayName: "portal-kit" }, +]; + +describe("resolveDirectTarget", () => { + it("a session pubkey resolves to itself", () => { + expect(resolveDirectTarget(S1, roster)).toMatchObject({ ok: true, pubkey: S1 }); + }); + + it("a member key with one live session resolves to that session", () => { + expect(resolveDirectTarget(M2, roster)).toMatchObject({ ok: true, pubkey: S3, note: "member_to_session" }); + }); + + it("a member key with several sessions is refused, listing them", () => { + const r = resolveDirectTarget(M1, roster); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.code).toBe("member_target_ambiguous"); + expect(r.candidates.map((c) => c.pubkey).sort()).toEqual([S1, S2]); + } + }); + + it("--fanout keeps the member key on purpose", () => { + expect(resolveDirectTarget(M1, roster, { fanout: true })).toMatchObject({ ok: true, pubkey: M1, note: "member_fanout" }); + }); + + it("an unknown full key stays (offline store-and-forward)", () => { + const k = "f".repeat(64); + expect(resolveDirectTarget(k, roster)).toMatchObject({ ok: true, pubkey: k }); + }); + + it("a duplicated display name is ambiguous, not first-match", () => { + expect(resolveDirectTarget("intra-back", roster)).toMatchObject({ ok: false, code: "ambiguous" }); + expect(resolveDirectTarget("portal-kit", roster)).toMatchObject({ ok: true, pubkey: S3 }); + }); + + it("control-plane rows are never addressable by name", () => { + expect(resolveDirectTarget("daemon", roster)).toMatchObject({ ok: false, code: "not_found" }); + }); + + it("a prefix prefers session keys and must be unique", () => { + expect(resolveDirectTarget("3333", roster)).toMatchObject({ ok: true, pubkey: S3 }); + // member-key prefix of a member with two sessions → ambiguous + expect(resolveDirectTarget("aaaa", roster)).toMatchObject({ ok: false, code: "ambiguous" }); + expect(resolveDirectTarget("bbbb", roster)).toMatchObject({ ok: true, pubkey: S3 }); + }); +}); diff --git a/apps/cli/tests/unit/sse-require-session.test.ts b/apps/cli/tests/unit/sse-require-session.test.ts new file mode 100644 index 0000000..3a128a6 --- /dev/null +++ b/apps/cli/tests/unit/sse-require-session.test.ts @@ -0,0 +1,23 @@ +/** Spec 2026-09-26 §2: a session MCP whose token doesn't resolve gets no + * message events — it used to receive every sibling session's DMs. */ + +import { describe, expect, it } from "vitest"; + +import { shouldDeliver, type DaemonEvent } from "~/daemon/events.js"; + +const msg = (data: Record): DaemonEvent => ({ kind: "message", ts: "", data }); + +describe("shouldDeliver: requireSession", () => { + it("an unresolved session MCP gets no messages", () => { + expect(shouldDeliver(msg({ recipient_kind: "session", recipient_pubkey: "ab" }), { requireSession: true })).toBe(false); + expect(shouldDeliver(msg({}), { requireSession: true })).toBe(false); + }); + it("diagnostic subscribers (no filter) keep the full stream", () => { + expect(shouldDeliver(msg({ recipient_kind: "session", recipient_pubkey: "ab" }), {})).toBe(true); + }); + it("a resolved session still gets its own messages only", () => { + const f = { requireSession: true, sessionPubkey: "ab" }; + expect(shouldDeliver(msg({ recipient_kind: "session", recipient_pubkey: "ab" }), f)).toBe(true); + expect(shouldDeliver(msg({ recipient_kind: "session", recipient_pubkey: "cd" }), f)).toBe(false); + }); +}); diff --git a/apps/cli/tests/unit/sse-resubscribe.test.ts b/apps/cli/tests/unit/sse-resubscribe.test.ts index 196e17c..6c6d5dc 100644 --- a/apps/cli/tests/unit/sse-resubscribe.test.ts +++ b/apps/cli/tests/unit/sse-resubscribe.test.ts @@ -37,7 +37,7 @@ interface Fake { function startFakeDaemon(opts: { keepalive?: boolean } = {}): Promise { const fake: Fake = { connections: 0, responses: [] }; server = createServer((req, res) => { - if (req.url !== "/v1/events") { res.statusCode = 404; res.end(); return; } + if ((req.url ?? "").split("?")[0] !== "/v1/events") { res.statusCode = 404; res.end(); return; } fake.connections++; fake.responses.push(res); res.statusCode = 200;