5 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
92bb276a3e fix: v0.1.11 — fix crypto_box decryption with session pubkeys
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
Release / Publish multi-arch images (push) Has been cancelled
Store sender's sessionPubkey on message_queue at send time.
drainForMember returns COALESCE(sender_session_pubkey, peer_pubkey)
so the recipient gets the correct sender key for decryption.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:23:42 +01:00
Alejandro Gutiérrez
af8f8ed1f9 feat: v0.1.10 — per-session ephemeral keypairs
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
Release / Publish multi-arch images (push) Has been cancelled
Each WS connection generates its own ed25519 keypair (sessionPubkey)
sent in the hello handshake. The broker stores it on the presence
row and uses it for message routing + list_peers. This gives every
`claudemesh launch` a unique crypto identity without burning invite
uses — member auth stays permanent, session identity is ephemeral.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:14:33 +01:00
Alejandro Gutiérrez
c8682dd700 fix(cli): deduplicate --dangerously-load-development-channels flag
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
Release / Publish multi-arch images (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:56:30 +01:00
Alejandro Gutiérrez
004602a83c fix(cli): v0.1.8 — remove Zod dependency (bun bundler crash)
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
Release / Publish multi-arch images (push) Has been cancelled
Replace Zod schemas with plain TypeScript validation in env.ts,
config.ts, and invite/parse.ts. Zod 4 classes break under bun
build --target=node (Class2 is not a constructor).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:51:42 +01:00
Alejandro Gutiérrez
2a2aac3622 feat(cli): v0.1.7 — --name, --mesh, --join flags for launch
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
Release / Publish multi-arch images (push) Has been cancelled
- `claudemesh launch --name Mou` sets per-session display name
- `claudemesh launch --mesh car-dealers` selects mesh (interactive picker if >1)
- `claudemesh launch --join <token-or-url>` joins a mesh inline before launching
- Broker stores per-presence displayName override (prefers over member default)
- Session config isolated via tmpdir (auto-cleanup on exit)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:45:29 +01:00
15 changed files with 356 additions and 136 deletions

View File

@@ -307,6 +307,8 @@ export async function refreshStatusFromJsonl(
export interface ConnectParams {
memberId: string;
sessionId: string;
sessionPubkey?: string;
displayName?: string;
pid: number;
cwd: string;
}
@@ -321,6 +323,8 @@ export async function connectPresence(
.values({
memberId: params.memberId,
sessionId: params.sessionId,
sessionPubkey: params.sessionPubkey ?? null,
displayName: params.displayName ?? null,
pid: params.pid,
cwd: params.cwd,
status: "idle",
@@ -369,8 +373,10 @@ export async function listPeersInMesh(
> {
const rows = await db
.select({
pubkey: memberTable.peerPubkey,
displayName: memberTable.displayName,
memberPubkey: memberTable.peerPubkey,
sessionPubkey: presence.sessionPubkey,
memberDisplayName: memberTable.displayName,
presenceDisplayName: presence.displayName,
status: presence.status,
summary: presence.summary,
sessionId: presence.sessionId,
@@ -385,7 +391,15 @@ export async function listPeersInMesh(
),
)
.orderBy(asc(presence.connectedAt));
return rows;
// Prefer session pubkey for routing, session displayName for display.
return rows.map((r) => ({
pubkey: r.sessionPubkey || r.memberPubkey,
displayName: r.presenceDisplayName || r.memberDisplayName,
status: r.status,
summary: r.summary,
sessionId: r.sessionId,
connectedAt: r.connectedAt,
}));
}
/** Update the summary text on a presence row. */
@@ -404,6 +418,7 @@ export async function setSummary(
export interface QueueParams {
meshId: string;
senderMemberId: string;
senderSessionPubkey?: string;
targetSpec: string;
priority: Priority;
nonce: string;
@@ -418,6 +433,7 @@ export async function queueMessage(params: QueueParams): Promise<string> {
.values({
meshId: params.meshId,
senderMemberId: params.senderMemberId,
senderSessionPubkey: params.senderSessionPubkey ?? null,
targetSpec: params.targetSpec,
priority: params.priority,
nonce: params.nonce,
@@ -458,6 +474,7 @@ export async function drainForMember(
_memberId: string,
memberPubkey: string,
status: PeerStatus,
sessionPubkey?: string,
): Promise<
Array<{
id: string;
@@ -498,14 +515,14 @@ export async function drainForMember(
WHERE mesh_id = ${meshId}
AND delivered_at IS NULL
AND priority::text IN (${priorityList})
AND (target_spec = ${memberPubkey} OR target_spec = '*')
AND (target_spec = ${memberPubkey} OR target_spec = '*'${sessionPubkey ? sql` OR target_spec = ${sessionPubkey}` : sql``})
ORDER BY created_at ASC, id ASC
FOR UPDATE SKIP LOCKED
)
AND m.id = mq.sender_member_id
RETURNING mq.id, mq.priority, mq.nonce, mq.ciphertext,
mq.created_at, mq.sender_member_id,
m.peer_pubkey AS sender_pubkey
COALESCE(mq.sender_session_pubkey, m.peer_pubkey) AS sender_pubkey
)
SELECT * FROM claimed ORDER BY created_at ASC, id ASC
`);

View File

@@ -56,6 +56,7 @@ interface PeerConn {
meshId: string;
memberId: string;
memberPubkey: string;
sessionPubkey: string | null;
cwd: string;
}
@@ -93,6 +94,7 @@ async function maybePushQueuedMessages(presenceId: string): Promise<void> {
conn.memberId,
conn.memberPubkey,
status,
conn.sessionPubkey ?? undefined,
);
for (const m of messages) {
const push: WSPushMessage = {
@@ -400,6 +402,8 @@ async function handleHello(
const presenceId = await connectPresence({
memberId: member.id,
sessionId: hello.sessionId,
sessionPubkey: hello.sessionPubkey,
displayName: hello.displayName,
pid: hello.pid,
cwd: hello.cwd,
});
@@ -408,12 +412,14 @@ async function handleHello(
meshId: hello.meshId,
memberId: member.id,
memberPubkey: hello.pubkey,
sessionPubkey: hello.sessionPubkey ?? null,
cwd: hello.cwd,
});
incMeshCount(hello.meshId);
const effectiveDisplayName = hello.displayName || member.displayName;
log.info("ws hello", {
mesh_id: hello.meshId,
member: member.displayName,
member: effectiveDisplayName,
presence_id: presenceId,
session_id: hello.sessionId,
});
@@ -422,7 +428,7 @@ async function handleHello(
// races the caller's closure assignment, causing subsequent client
// messages to fail the "no_hello" check.
void maybePushQueuedMessages(presenceId);
return { presenceId, memberDisplayName: member.displayName };
return { presenceId, memberDisplayName: effectiveDisplayName };
}
async function handleSend(
@@ -432,6 +438,7 @@ async function handleSend(
const messageId = await queueMessage({
meshId: conn.meshId,
senderMemberId: conn.memberId,
senderSessionPubkey: conn.sessionPubkey ?? undefined,
targetSpec: msg.targetSpec,
priority: msg.priority,
nonce: msg.nonce,
@@ -448,7 +455,9 @@ async function handleSend(
// Fan-out over connected peers in the same mesh.
for (const [pid, peer] of connections) {
if (peer.meshId !== conn.meshId) continue;
if (msg.targetSpec !== "*" && peer.memberPubkey !== msg.targetSpec)
if (msg.targetSpec !== "*"
&& peer.memberPubkey !== msg.targetSpec
&& peer.sessionPubkey !== msg.targetSpec)
continue;
void maybePushQueuedMessages(pid);
}

View File

@@ -52,6 +52,8 @@ export interface WSHelloMessage {
meshId: string;
memberId: string;
pubkey: string; // must match mesh.member.peerPubkey
sessionPubkey?: string; // ephemeral per-launch pubkey for message routing
displayName?: string; // optional override for this session
sessionId: string;
pid: number;
cwd: string;

View File

@@ -1,6 +1,6 @@
{
"name": "claudemesh-cli",
"version": "0.1.6",
"version": "0.1.11",
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
"keywords": [
"claude-code",

View File

@@ -14,7 +14,10 @@ import { parseInviteLink } from "../invite/parse";
import { enrollWithBroker } from "../invite/enroll";
import { generateKeypair } from "../crypto/keypair";
import { loadConfig, saveConfig, getConfigPath } from "../state/config";
import { hostname } from "node:os";
import { writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir, hostname } from "node:os";
import { env } from "../env";
export async function runJoin(args: string[]): Promise<void> {
const link = args[0];
@@ -78,6 +81,16 @@ export async function runJoin(args: string[]): Promise<void> {
});
saveConfig(config);
// 4b. Store invite token for per-session re-enrollment (launch --name).
const configDir = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
const inviteFile = join(configDir, `invite-${payload.mesh_slug}.txt`);
try {
mkdirSync(dirname(inviteFile), { recursive: true });
writeFileSync(inviteFile, link, "utf-8");
} catch {
// Non-fatal — launch will fall back to shared identity.
}
// 5. Report.
console.log("");
console.log(

View File

@@ -1,82 +1,238 @@
/**
* `claudemesh launch` — spawn `claude` with the dev-channel flag so the
* claudemesh MCP server's `notifications/claude/channel` pushes get
* injected as system reminders mid-turn.
* `claudemesh launch` — spawn `claude` with peer mesh identity.
*
* Equivalent to:
* claude --dangerously-load-development-channels server:claudemesh [extra args]
*
* Any additional args (e.g. --model opus, --resume, -c) are passed
* through verbatim. Use --quiet to skip the informational banner.
* Flow:
* 1. Parse --name, --join, --mesh, --quiet flags
* 2. If --join: run join flow first (accepts token or URL)
* 3. Load config → pick mesh (auto if 1, interactive picker if >1)
* 4. Write per-session config to tmpdir (isolates mesh selection)
* 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME
* 6. On exit: cleanup tmpdir
*/
import { spawn } from "node:child_process";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir, hostname } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { loadConfig, getConfigPath } from "../state/config";
import type { Config, JoinedMesh } from "../state/config";
function printBanner(): void {
// --- Arg parsing ---
interface LaunchArgs {
name: string | null;
joinLink: string | null;
meshSlug: string | null;
quiet: boolean;
claudeArgs: string[];
}
function parseArgs(argv: string[]): LaunchArgs {
const result: LaunchArgs = {
name: null,
joinLink: null,
meshSlug: null,
quiet: false,
claudeArgs: [],
};
let i = 0;
while (i < argv.length) {
const arg = argv[i]!;
if (arg === "--name" && i + 1 < argv.length) {
result.name = argv[++i]!;
} else if (arg.startsWith("--name=")) {
result.name = arg.slice("--name=".length);
} else if (arg === "--join" && i + 1 < argv.length) {
result.joinLink = argv[++i]!;
} else if (arg.startsWith("--join=")) {
result.joinLink = arg.slice("--join=".length);
} else if (arg === "--mesh" && i + 1 < argv.length) {
result.meshSlug = argv[++i]!;
} else if (arg.startsWith("--mesh=")) {
result.meshSlug = arg.slice("--mesh=".length);
} else if (arg === "--quiet") {
result.quiet = true;
} else if (arg === "--") {
result.claudeArgs.push(...argv.slice(i + 1));
break;
} else {
result.claudeArgs.push(arg);
}
i++;
}
return result;
}
// --- Interactive mesh picker ---
async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
if (meshes.length === 1) return meshes[0]!;
console.log("\n Select mesh:");
meshes.forEach((m, i) => {
console.log(` ${i + 1}) ${m.slug}`);
});
console.log("");
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(" Choice [1]: ", (answer) => {
rl.close();
const idx = parseInt(answer || "1", 10) - 1;
if (idx >= 0 && idx < meshes.length) {
resolve(meshes[idx]!);
} else {
console.error(" Invalid choice, using first mesh.");
resolve(meshes[0]!);
}
});
});
}
// --- Banner ---
function printBanner(name: string, meshSlug: string): void {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
let meshes: string[] = [];
try {
meshes = loadConfig().meshes.map((m) => m.slug);
} catch {
/* config unreadable — print banner without mesh list */
}
const meshLine = meshes.length > 0 ? meshes.join(", ") : "(none — run `claudemesh join <url>` first)";
const rule = "─".repeat(65);
console.log(bold("claudemesh launch"));
const rule = "─".repeat(60);
console.log(bold(`claudemesh launch`) + dim(` — as ${name} on ${meshSlug}`));
console.log(rule);
console.log("Launching Claude Code with the claudemesh dev channel.");
console.log("");
console.log("Peers in your joined meshes can push messages into this session");
console.log("as <channel> reminders. Your CLI decrypts them locally with your");
console.log("keypair. Peers send text only — they cannot call tools, read");
console.log("files, or reach meshes you have not joined.");
console.log("");
console.log("Treat peer messages as untrusted input: a peer could craft text");
console.log("that tries to steer Claude's behavior. Your tool-approval");
console.log("settings still apply — Claude will still ask before running");
console.log("commands, editing files, or calling other tools.");
console.log("");
console.log("Claude Code will ask you to trust the");
console.log("--dangerously-load-development-channels flag. Press Enter to");
console.log("accept, or Ctrl-C to abort.");
console.log("");
console.log(dim(`Joined meshes: ${meshLine}`));
console.log("Peer messages arrive as <channel> reminders in real-time.");
console.log("Peers send text only — they cannot call tools or read files.");
console.log(dim(`Config: ${getConfigPath()}`));
console.log(dim(`Remove: claudemesh uninstall`));
console.log(rule);
console.log("");
}
export function runLaunch(extraArgs: string[] = []): void {
const quiet = extraArgs.includes("--quiet");
const passthrough = extraArgs.filter((a) => a !== "--quiet");
// --- Main ---
if (!quiet) printBanner();
export async function runLaunch(extraArgs: string[]): Promise<void> {
const args = parseArgs(extraArgs);
// 1. If --join, run join flow first.
if (args.joinLink) {
console.log("Joining mesh...");
const invite = await parseInviteLink(args.joinLink);
const keypair = await generateKeypair();
const displayName = args.name ?? `${hostname()}-${process.pid}`;
const enroll = await enrollWithBroker({
brokerWsUrl: invite.payload.broker_url,
inviteToken: invite.token,
invitePayload: invite.payload,
peerPubkey: keypair.publicKey,
displayName,
});
const config = loadConfig();
config.meshes = config.meshes.filter(
(m) => m.slug !== invite.payload.mesh_slug,
);
config.meshes.push({
meshId: invite.payload.mesh_id,
memberId: enroll.memberId,
slug: invite.payload.mesh_slug,
name: invite.payload.mesh_slug,
pubkey: keypair.publicKey,
secretKey: keypair.secretKey,
brokerUrl: invite.payload.broker_url,
joinedAt: new Date().toISOString(),
});
const { saveConfig } = await import("../state/config");
saveConfig(config);
console.log(
`✓ Joined "${invite.payload.mesh_slug}"${enroll.alreadyMember ? " (already member)" : ""}`,
);
}
// 2. Load config, pick mesh.
const config = loadConfig();
if (config.meshes.length === 0) {
console.error(
"No meshes joined. Run `claudemesh join <url>` or use --join <url>.",
);
process.exit(1);
}
let mesh: JoinedMesh;
if (args.meshSlug) {
const found = config.meshes.find((m) => m.slug === args.meshSlug);
if (!found) {
console.error(
`Mesh "${args.meshSlug}" not found. Joined: ${config.meshes.map((m) => m.slug).join(", ")}`,
);
process.exit(1);
}
mesh = found;
} else {
mesh = await pickMesh(config.meshes);
}
// 3. Session identity. The WS client auto-generates a per-session
// ephemeral keypair on connect (sent in hello as sessionPubkey).
// We just set the display name via env var.
const displayName = args.name ?? `${hostname()}-${process.pid}`;
// 4. Write session config to tmpdir (isolates mesh selection).
const tmpDir = mkdtempSync(join(tmpdir(), "claudemesh-"));
const sessionConfig: Config = {
version: 1,
meshes: [mesh],
};
writeFileSync(
join(tmpDir, "config.json"),
JSON.stringify(sessionConfig, null, 2) + "\n",
"utf-8",
);
// 5. Banner.
if (!args.quiet) printBanner(displayName, mesh.slug);
// 6. Spawn claude with ephemeral config + dev channel + display name.
// Strip any user-supplied --dangerously-load-development-channels
// to avoid duplicates — we always inject our own.
const filtered: string[] = [];
for (let i = 0; i < args.claudeArgs.length; i++) {
if (args.claudeArgs[i] === "--dangerously-load-development-channels") {
i++; // skip the next arg (the channel value) too
continue;
}
filtered.push(args.claudeArgs[i]!);
}
const claudeArgs = [
"--dangerously-load-development-channels",
"server:claudemesh",
...passthrough,
...filtered,
];
// Windows: npm global binaries are .cmd shims. Node's spawn without
// shell:true does not resolve PATHEXT, so we need shell:true on win32
// to find claude.cmd. POSIX stays shell-less to avoid quoting surprises.
const isWindows = process.platform === "win32";
const child = spawn("claude", claudeArgs, {
stdio: "inherit",
shell: isWindows,
env: {
...process.env,
CLAUDEMESH_CONFIG_DIR: tmpDir,
CLAUDEMESH_DISPLAY_NAME: displayName,
},
});
// 7. Cleanup on exit.
const cleanup = (): void => {
try {
rmSync(tmpDir, { recursive: true, force: true });
} catch {
/* best effort */
}
};
child.on("error", (err: NodeJS.ErrnoException) => {
cleanup();
if (err.code === "ENOENT") {
console.error(
"✗ `claude` not found on PATH. Install Claude Code first: https://claude.com/claude-code",
"✗ `claude` not found on PATH. Install Claude Code first.",
);
} else {
console.error(`✗ failed to launch claude: ${err.message}`);
@@ -85,10 +241,15 @@ export function runLaunch(extraArgs: string[] = []): void {
});
child.on("exit", (code, signal) => {
cleanup();
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});
// Cleanup on parent signals too.
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
process.on("SIGINT", () => { cleanup(); process.exit(0); });
}

View File

@@ -1,27 +1,23 @@
import { z } from "zod";
/**
* CLI environment config.
*
* Read once at startup. Overridable via env vars so users can point
* at a self-hosted broker or a staging instance without rebuilding.
*/
const envSchema = z.object({
CLAUDEMESH_BROKER_URL: z.string().default("wss://ic.claudemesh.com/ws"),
CLAUDEMESH_CONFIG_DIR: z.string().optional(),
CLAUDEMESH_DEBUG: z.coerce.boolean().default(false),
});
export type CliEnv = z.infer<typeof envSchema>;
export interface CliEnv {
CLAUDEMESH_BROKER_URL: string;
CLAUDEMESH_CONFIG_DIR: string | undefined;
CLAUDEMESH_DEBUG: boolean;
}
export function loadEnv(): CliEnv {
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error("[claudemesh] invalid environment:");
console.error(z.treeifyError(parsed.error));
process.exit(1);
}
return parsed.data;
return {
CLAUDEMESH_BROKER_URL:
process.env.CLAUDEMESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
CLAUDEMESH_CONFIG_DIR: process.env.CLAUDEMESH_CONFIG_DIR || undefined,
CLAUDEMESH_DEBUG: process.env.CLAUDEMESH_DEBUG === "1" || process.env.CLAUDEMESH_DEBUG === "true",
};
}
export const env = loadEnv();

View File

@@ -30,9 +30,12 @@ Commands:
install Register MCP + Stop/UserPromptSubmit status hooks
(add --no-hooks for bare MCP registration)
uninstall Remove MCP server + hooks
launch [args] Launch Claude Code with real-time push messages enabled
(add --quiet to skip the info banner; passes through
extra flags, e.g. --model, --resume)
launch [opts] Launch Claude Code with real-time push messages
--name <name> Display name for this session
--mesh <slug> Select mesh (picker if >1, omitted)
--join <url> Join a mesh before launching
--quiet Skip the info banner
-- <args> Pass remaining args to claude
join <url> Join a mesh via https://claudemesh.com/join/... URL
list Show all joined meshes
leave <slug> Leave a joined mesh
@@ -67,7 +70,7 @@ async function main(): Promise<void> {
await runHook(args);
return;
case "launch":
runLaunch(args);
await runLaunch(args);
return;
case "join":
await runJoin(args);

View File

@@ -5,22 +5,19 @@
* verification and one-time-use invite-token tracking land in Step 18.
*/
import { z } from "zod";
import { ensureSodium } from "../crypto/keypair";
const invitePayloadSchema = z.object({
v: z.literal(1),
mesh_id: z.string().min(1),
mesh_slug: z.string().min(1),
broker_url: z.string().min(1),
expires_at: z.number().int().positive(),
mesh_root_key: z.string().min(1),
role: z.enum(["admin", "member"]),
owner_pubkey: z.string().regex(/^[0-9a-f]{64}$/i),
signature: z.string().regex(/^[0-9a-f]{128}$/i),
});
export type InvitePayload = z.infer<typeof invitePayloadSchema>;
export interface InvitePayload {
v: 1;
mesh_id: string;
mesh_slug: string;
broker_url: string;
expires_at: number;
mesh_root_key: string;
role: "admin" | "member";
owner_pubkey: string;
signature: string;
}
export interface ParsedInvite {
payload: InvitePayload;
@@ -28,6 +25,21 @@ export interface ParsedInvite {
token: string; // base64url(JSON) — DB lookup key (everything after ic://join/)
}
function validatePayload(obj: unknown): InvitePayload {
if (!obj || typeof obj !== "object") throw new Error("invite payload is not an object");
const o = obj as Record<string, unknown>;
if (o.v !== 1) throw new Error("invite payload: v must be 1");
if (typeof o.mesh_id !== "string" || !o.mesh_id) throw new Error("invite payload: mesh_id required");
if (typeof o.mesh_slug !== "string" || !o.mesh_slug) throw new Error("invite payload: mesh_slug required");
if (typeof o.broker_url !== "string" || !o.broker_url) throw new Error("invite payload: broker_url required");
if (typeof o.expires_at !== "number" || o.expires_at <= 0) throw new Error("invite payload: expires_at must be a positive number");
if (typeof o.mesh_root_key !== "string" || !o.mesh_root_key) throw new Error("invite payload: mesh_root_key required");
if (o.role !== "admin" && o.role !== "member") throw new Error("invite payload: role must be admin or member");
if (typeof o.owner_pubkey !== "string" || !/^[0-9a-f]{64}$/i.test(o.owner_pubkey)) throw new Error("invite payload: owner_pubkey must be 64 hex chars");
if (typeof o.signature !== "string" || !/^[0-9a-f]{128}$/i.test(o.signature)) throw new Error("invite payload: signature must be 128 hex chars");
return o as unknown as InvitePayload;
}
/** Canonical invite bytes — must match broker's canonicalInvite(). */
export function canonicalInvite(p: {
v: number;
@@ -96,41 +108,34 @@ export async function parseInviteLink(link: string): Promise<ParsedInvite> {
);
}
const parsed = invitePayloadSchema.safeParse(obj);
if (!parsed.success) {
throw new Error(
`invite link shape invalid: ${parsed.error.issues.map((i) => i.path.join(".") + ": " + i.message).join("; ")}`,
);
}
const payload = validatePayload(obj);
// Expiry check (unix seconds).
const nowSeconds = Math.floor(Date.now() / 1000);
if (parsed.data.expires_at < nowSeconds) {
if (payload.expires_at < nowSeconds) {
throw new Error(
`invite expired: expires_at=${parsed.data.expires_at}, now=${nowSeconds}`,
`invite expired: expires_at=${payload.expires_at}, now=${nowSeconds}`,
);
}
// Verify the ed25519 signature against the embedded owner_pubkey.
// Client-side verification gives immediate feedback on tampered
// links; broker re-verifies authoritatively on /join.
const s = await ensureSodium();
const canonical = canonicalInvite({
v: parsed.data.v,
mesh_id: parsed.data.mesh_id,
mesh_slug: parsed.data.mesh_slug,
broker_url: parsed.data.broker_url,
expires_at: parsed.data.expires_at,
mesh_root_key: parsed.data.mesh_root_key,
role: parsed.data.role,
owner_pubkey: parsed.data.owner_pubkey,
v: payload.v,
mesh_id: payload.mesh_id,
mesh_slug: payload.mesh_slug,
broker_url: payload.broker_url,
expires_at: payload.expires_at,
mesh_root_key: payload.mesh_root_key,
role: payload.role,
owner_pubkey: payload.owner_pubkey,
});
const sigOk = (() => {
try {
return s.crypto_sign_verify_detached(
s.from_hex(parsed.data.signature),
s.from_hex(payload.signature),
s.from_string(canonical),
s.from_hex(parsed.data.owner_pubkey),
s.from_hex(payload.owner_pubkey),
);
} catch {
return false;
@@ -140,7 +145,7 @@ export async function parseInviteLink(link: string): Promise<ParsedInvite> {
throw new Error("invite signature invalid (link tampered?)");
}
return { payload: parsed.data, raw: link, token: encoded };
return { payload, raw: link, token: encoded };
}
/**
@@ -155,8 +160,6 @@ export function encodeInviteLink(payload: InvitePayload): string {
/**
* Sign and assemble an invite payload → ic://join/... link.
* The canonical bytes (everything except signature) are signed with
* the mesh owner's ed25519 secret key.
*/
export async function buildSignedInvite(args: {
v: 1;

View File

@@ -15,38 +15,38 @@ import {
} from "node:fs";
import { homedir } from "node:os";
import { join, dirname } from "node:path";
import { z } from "zod";
import { env } from "../env";
const joinedMeshSchema = z.object({
meshId: z.string(),
memberId: z.string(),
slug: z.string(),
name: z.string(),
pubkey: z.string(), // ed25519 hex (32 bytes = 64 chars)
secretKey: z.string(), // ed25519 hex (64 bytes = 128 chars)
brokerUrl: z.string(),
joinedAt: z.string(),
});
export interface JoinedMesh {
meshId: string;
memberId: string;
slug: string;
name: string;
pubkey: string; // ed25519 hex (32 bytes = 64 chars)
secretKey: string; // ed25519 hex (64 bytes = 128 chars)
brokerUrl: string;
joinedAt: string;
}
const configSchema = z.object({
version: z.literal(1).default(1),
meshes: z.array(joinedMeshSchema).default([]),
});
export type JoinedMesh = z.infer<typeof joinedMeshSchema>;
export type Config = z.infer<typeof configSchema>;
export interface Config {
version: 1;
meshes: JoinedMesh[];
}
const CONFIG_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
export function loadConfig(): Config {
if (!existsSync(CONFIG_PATH)) {
return configSchema.parse({ version: 1, meshes: [] });
return { version: 1, meshes: [] };
}
try {
const raw = readFileSync(CONFIG_PATH, "utf-8");
return configSchema.parse(JSON.parse(raw));
const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.meshes)) {
return { version: 1, meshes: [] };
}
return { version: 1, meshes: parsed.meshes };
} catch (e) {
throw new Error(
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,

View File

@@ -21,6 +21,7 @@ import {
isDirectTarget,
} from "../crypto/envelope";
import { signHello } from "../crypto/hello-sig";
import { generateKeypair } from "../crypto/keypair";
export type Priority = "now" | "next" | "low";
export type ConnStatus = "connecting" | "open" | "closed" | "reconnecting";
@@ -74,6 +75,8 @@ export class BrokerClient {
private pushHandlers = new Set<PushHandler>();
private pushBuffer: InboundPush[] = [];
private listPeersResolvers: Array<(peers: PeerInfo[]) => void> = [];
private sessionPubkey: string | null = null;
private sessionSecretKey: string | null = null;
private closed = false;
private reconnectAttempt = 0;
private helloTimer: NodeJS.Timeout | null = null;
@@ -109,8 +112,13 @@ export class BrokerClient {
return new Promise<void>((resolve, reject) => {
const onOpen = async (): Promise<void> => {
this.debug("ws open → signing + sending hello");
this.debug("ws open → generating session keypair + signing hello");
try {
// Generate per-session ephemeral keypair for message routing.
const sessionKP = await generateKeypair();
this.sessionPubkey = sessionKP.publicKey;
this.sessionSecretKey = sessionKP.secretKey;
const { timestamp, signature } = await signHello(
this.mesh.meshId,
this.mesh.memberId,
@@ -123,6 +131,8 @@ export class BrokerClient {
meshId: this.mesh.meshId,
memberId: this.mesh.memberId,
pubkey: this.mesh.pubkey,
sessionPubkey: this.sessionPubkey,
displayName: process.env.CLAUDEMESH_DISPLAY_NAME || undefined,
sessionId: `${process.pid}-${Date.now()}`,
pid: process.pid,
cwd: process.cwd(),
@@ -202,7 +212,7 @@ export class BrokerClient {
const env = await encryptDirect(
message,
targetSpec,
this.mesh.secretKey,
this.sessionSecretKey ?? this.mesh.secretKey,
);
nonce = env.nonce;
ciphertext = env.ciphertext;
@@ -348,7 +358,7 @@ export class BrokerClient {
plaintext = await decryptDirect(
{ nonce, ciphertext },
senderPubkey,
this.mesh.secretKey,
this.sessionSecretKey ?? this.mesh.secretKey,
);
}
// Legacy/broadcast path: no senderPubkey means the message

View File

@@ -0,0 +1 @@
ALTER TABLE "mesh"."presence" ADD COLUMN "display_name" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "mesh"."presence" ADD COLUMN "session_pubkey" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "mesh"."message_queue" ADD COLUMN "sender_session_pubkey" text;

View File

@@ -192,6 +192,8 @@ export const presence = meshSchema.table("presence", {
.references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
sessionId: text().notNull(),
sessionPubkey: text(),
displayName: text(),
pid: integer().notNull(),
cwd: text().notNull(),
status: presenceStatusEnum().notNull().default("idle"),
@@ -220,6 +222,7 @@ export const messageQueue = meshSchema.table("message_queue", {
senderMemberId: text()
.references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
senderSessionPubkey: text(),
targetSpec: text().notNull(),
priority: messagePriorityEnum().notNull().default("next"),
nonce: text().notNull(),