feat(cli): peer list self-marking + send self-DM guard
closes the "DM looped back to my own inbox" footgun.
what was happening: peer list returns one row per presence,
including the caller's own session AND its sibling sessions.
the cli filtered out the exact-session row but left siblings
unlabeled — copying their pubkey from peer list silently
targeted your own sibling, and the message arrived in "your
own inbox" because the sender was you.
fix is two-part.
(1) peer list — tag rows whose memberPubkey matches the
caller's stable JoinedMesh.pubkey:
● displayName (this session) — the exact session running
the cli call
● displayName (your other session) — sibling session of
your own member
visually identical otherwise; just the marker.
(2) claudemesh send — refuse a target that exactly matches the
caller's own member pubkey on the mesh, with a hint pointing
at --self for the rare intentional sibling-DM case.
both changes additive — existing scripts that pass display
names or other peers' pubkeys behave identically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudemesh-cli",
|
"name": "claudemesh-cli",
|
||||||
"version": "1.16.0",
|
"version": "1.17.0",
|
||||||
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude-code",
|
"claude-code",
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export interface PeersFlags {
|
|||||||
|
|
||||||
interface PeerRecord {
|
interface PeerRecord {
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
|
/** Stable member pubkey (independent of session). When sender shares
|
||||||
|
* this with a peer, they're talking to the same person across all
|
||||||
|
* their open sessions. */
|
||||||
|
memberPubkey?: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
@@ -34,6 +38,13 @@ interface PeerRecord {
|
|||||||
channel?: string;
|
channel?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
/** True when this peer is one of the caller's own member's sessions.
|
||||||
|
* Set in the cli (not the broker) by comparing memberPubkey against
|
||||||
|
* the caller's stable JoinedMesh.pubkey. */
|
||||||
|
isSelf?: boolean;
|
||||||
|
/** When isSelf is true, true if this is the exact session running
|
||||||
|
* the command (vs a sibling session of the same member). */
|
||||||
|
isThisSession?: boolean;
|
||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,21 +63,53 @@ function projectFields(record: PeerRecord, fields: string[]): Record<string, unk
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function listPeersForMesh(slug: string): Promise<PeerRecord[]> {
|
async function listPeersForMesh(slug: string): Promise<PeerRecord[]> {
|
||||||
|
const config = readConfig();
|
||||||
|
const joined = config.meshes.find((m) => m.slug === slug);
|
||||||
|
const selfMemberPubkey = joined?.pubkey ?? null;
|
||||||
|
|
||||||
// Try warm path first.
|
// Try warm path first.
|
||||||
const bridged = await tryBridge(slug, "peers");
|
const bridged = await tryBridge(slug, "peers");
|
||||||
if (bridged && bridged.ok) {
|
if (bridged && bridged.ok) {
|
||||||
return bridged.result as PeerRecord[];
|
const peers = bridged.result as PeerRecord[];
|
||||||
|
return peers.map((p) => annotateSelf(p, selfMemberPubkey, null));
|
||||||
}
|
}
|
||||||
// Cold path — open our own WS.
|
// Cold path — open our own WS.
|
||||||
let result: PeerRecord[] = [];
|
let result: PeerRecord[] = [];
|
||||||
await withMesh({ meshSlug: slug }, async (client) => {
|
await withMesh({ meshSlug: slug }, async (client) => {
|
||||||
const all = await client.listPeers();
|
const all = (await client.listPeers()) as unknown as PeerRecord[];
|
||||||
const selfPubkey = client.getSessionPubkey();
|
const selfSessionPubkey = client.getSessionPubkey();
|
||||||
result = (selfPubkey ? all.filter((p) => p.pubkey !== selfPubkey) : all) as unknown as PeerRecord[];
|
result = all.map((p) =>
|
||||||
|
annotateSelf(p, selfMemberPubkey, selfSessionPubkey),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag each peer record with `isSelf` / `isThisSession` so the renderer
|
||||||
|
* (and downstream code that picks targets, e.g. `claudemesh send`) can
|
||||||
|
* tell sender's own sessions from real peers. The broker has always
|
||||||
|
* surfaced a sender's siblings as separate rows because they're separate
|
||||||
|
* presence rows; the cli just hadn't been making that visible.
|
||||||
|
*/
|
||||||
|
function annotateSelf(
|
||||||
|
peer: PeerRecord,
|
||||||
|
selfMemberPubkey: string | null,
|
||||||
|
selfSessionPubkey: string | null,
|
||||||
|
): PeerRecord {
|
||||||
|
const isSelf = !!(
|
||||||
|
selfMemberPubkey &&
|
||||||
|
peer.memberPubkey &&
|
||||||
|
peer.memberPubkey === selfMemberPubkey
|
||||||
|
);
|
||||||
|
const isThisSession = !!(
|
||||||
|
isSelf &&
|
||||||
|
selfSessionPubkey &&
|
||||||
|
peer.pubkey === selfSessionPubkey
|
||||||
|
);
|
||||||
|
return { ...peer, isSelf, isThisSession };
|
||||||
|
}
|
||||||
|
|
||||||
export async function runPeers(flags: PeersFlags): Promise<void> {
|
export async function runPeers(flags: PeersFlags): Promise<void> {
|
||||||
const config = readConfig();
|
const config = readConfig();
|
||||||
const slugs = flags.mesh ? [flags.mesh] : config.meshes.map((m) => m.slug);
|
const slugs = flags.mesh ? [flags.mesh] : config.meshes.map((m) => m.slug);
|
||||||
@@ -122,7 +165,14 @@ export async function runPeers(flags: PeersFlags): Promise<void> {
|
|||||||
const metaStr = meta.length ? dim(` (${meta.join(", ")})`) : "";
|
const metaStr = meta.length ? dim(` (${meta.join(", ")})`) : "";
|
||||||
const summary = p.summary ? dim(` — ${p.summary}`) : "";
|
const summary = p.summary ? dim(` — ${p.summary}`) : "";
|
||||||
const pubkeyTag = dim(` · ${p.pubkey.slice(0, 16)}…`);
|
const pubkeyTag = dim(` · ${p.pubkey.slice(0, 16)}…`);
|
||||||
render.info(`${statusDot} ${name}${groups}${metaStr}${pubkeyTag}${summary}`);
|
const selfTag = p.isThisSession
|
||||||
|
? dim(" ") + yellow("(this session)")
|
||||||
|
: p.isSelf
|
||||||
|
? dim(" ") + yellow("(your other session)")
|
||||||
|
: "";
|
||||||
|
render.info(
|
||||||
|
`${statusDot} ${name}${selfTag}${groups}${metaStr}${pubkeyTag}${summary}`,
|
||||||
|
);
|
||||||
if (p.cwd) render.info(dim(` cwd: ${p.cwd}`));
|
if (p.cwd) render.info(dim(` cwd: ${p.cwd}`));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ export interface SendFlags {
|
|||||||
mesh?: string;
|
mesh?: string;
|
||||||
priority?: string;
|
priority?: string;
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
|
/** Allow sending to a target that resolves to one of the caller's
|
||||||
|
* own sessions. Off by default — trying to message your own
|
||||||
|
* sibling session is almost always an accident (copying a hex
|
||||||
|
* pubkey from `peer list` without realizing it was your own row). */
|
||||||
|
self?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runSend(flags: SendFlags, to: string, message: string): Promise<void> {
|
export async function runSend(flags: SendFlags, to: string, message: string): Promise<void> {
|
||||||
@@ -42,6 +47,23 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr
|
|||||||
flags.mesh ??
|
flags.mesh ??
|
||||||
(config.meshes.length === 1 ? config.meshes[0]!.slug : null);
|
(config.meshes.length === 1 ? config.meshes[0]!.slug : null);
|
||||||
|
|
||||||
|
// Self-DM safety check: if target is a 64-char hex that matches the
|
||||||
|
// caller's own member pubkey (or any of the caller's session/member
|
||||||
|
// entries), refuse without --self. Catches the common pasted-from-
|
||||||
|
// peer-list-not-realizing-it-was-mine footgun.
|
||||||
|
if (!flags.self && meshSlug) {
|
||||||
|
const joined = config.meshes.find((m) => m.slug === meshSlug);
|
||||||
|
if (joined && /^[0-9a-f]{64}$/i.test(to) && to.toLowerCase() === joined.pubkey.toLowerCase()) {
|
||||||
|
render.err(
|
||||||
|
`Target "${to.slice(0, 16)}…" is your own member pubkey on mesh "${meshSlug}".`,
|
||||||
|
);
|
||||||
|
render.hint(
|
||||||
|
"Pass --self to message a sibling session of your own member, or pick a different peer's pubkey.",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Warm path — only when mesh is unambiguous.
|
// Warm path — only when mesh is unambiguous.
|
||||||
if (meshSlug) {
|
if (meshSlug) {
|
||||||
const bridged = await tryBridge(meshSlug, "send", { to, message, priority });
|
const bridged = await tryBridge(meshSlug, "send", { to, message, priority });
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
// Messaging
|
// Messaging
|
||||||
case "peers": { const { runPeers } = await import("~/commands/peers.js"); await runPeers({ mesh: flags.mesh as string, json: flags.json as boolean | string | undefined }); break; }
|
case "peers": { const { runPeers } = await import("~/commands/peers.js"); await runPeers({ mesh: flags.mesh as string, json: flags.json as boolean | string | undefined }); 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 }, 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 }, positionals[0] ?? "", positionals.slice(1).join(" ")); break; }
|
||||||
case "inbox": { const { runInbox } = await import("~/commands/inbox.js"); await runInbox({ json: !!flags.json }); break; }
|
case "inbox": { const { runInbox } = await import("~/commands/inbox.js"); await runInbox({ json: !!flags.json }); break; }
|
||||||
case "state": {
|
case "state": {
|
||||||
const sub = positionals[0];
|
const sub = positionals[0];
|
||||||
|
|||||||
@@ -298,6 +298,14 @@ level, or wire claudemesh to messaging surfaces beyond Claude Code.
|
|||||||
default returns last 30d). CLI: omitting `--mesh` on each
|
default returns last 30d). CLI: omitting `--mesh` on each
|
||||||
verb routes through the matching aggregator. *Shipped
|
verb routes through the matching aggregator. *Shipped
|
||||||
2026-05-03 in CLI v1.16.0.*
|
2026-05-03 in CLI v1.16.0.*
|
||||||
|
- **v0.5.1 — peer list self-marking + send self-DM guard** —
|
||||||
|
`peer list` now tags rows from the caller's own member with
|
||||||
|
`(this session)` or `(your other session)`, so a paste from
|
||||||
|
`peer list --json` doesn't silently target your own sibling.
|
||||||
|
`claudemesh send` rejects targets that resolve to the
|
||||||
|
caller's own member pubkey unless `--self` is passed. Closes
|
||||||
|
the "DM looped back to my own inbox" footgun reported on
|
||||||
|
v1.11.0. *Shipped 2026-05-03 in CLI v1.17.0.*
|
||||||
- **v0.3.2 — multi-session DM routing + broadcast self-loopback** —
|
- **v0.3.2 — multi-session DM routing + broadcast self-loopback** —
|
||||||
fixes two production bugs: (1) replies via `claudemesh send
|
fixes two production bugs: (1) replies via `claudemesh send
|
||||||
<from_id>` rejected with "no connected peer" when the sender's
|
<from_id>` rejected with "no connected peer" when the sender's
|
||||||
|
|||||||
Reference in New Issue
Block a user