23 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
5bcc1fe323 chore(cli): bump to v0.6.4 — fix get_file sealedKey bug
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
2026-04-07 12:57:20 +01:00
Alejandro Gutiérrez
e70f0ed1ff fix(broker/cli): e2e get_file owner sealedKey bug
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
broker: owner also fetches sealedKey from mesh.file_key (not skipped),
  only non-owners are blocked when key is missing
cli: explicit error when encrypted file has no sealedKey (no silent raw download)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:56:36 +01:00
Alejandro Gutiérrez
5f696f47ea feat(cli): v0.6.3 — e2e file crypto module + encrypted share_file
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
- add crypto/file-crypto.ts: encryptFile, decryptFile, sealKeyForPeer, openSealedKey
- update share_file: when to= set, encrypts file + seals key per recipient
- update get_file: decrypts if encrypted + sealedKey present
- add grant_file_access tool: re-seals Kf for a new peer without re-upload
- add getSessionPubkey/getSessionSecretKey getters on BrokerClient
- add grantFileAccess WS method on BrokerClient
- bump version to 0.6.3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:53:13 +01:00
Alejandro Gutiérrez
ccb9fb2a68 feat(broker/db): e2e file encryption schema + db functions
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
- add mesh.file_key table (fileId, peerPubkey, sealedKey, grantedByPubkey)
- add encrypted + ownerPubkey columns to mesh.file
- export insertFileKeys, getFileKey, grantFileKey from broker.ts
- update uploadFile/getFile/listFiles to include encrypted/ownerPubkey
- migration 0012_add-file-encryption applied to prod

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:43:57 +01:00
Alejandro Gutiérrez
898c061089 feat(cli): e2e file encryption — file-crypto.ts + client + MCP tools
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
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:33:39 +01:00
Alejandro Gutiérrez
f7a6559429 feat(broker): add E2E file encryption to HTTP upload and WS handlers
- parse x-encrypted/x-owner-pubkey/x-file-keys headers in handleUploadPost
- pass encrypted and ownerPubkey to uploadFile, call insertFileKeys after
- get_file: fetch sealedKey for non-owners, block if missing, include in response
- list_files: include encrypted field per file
- add grant_file_access WS handler so owners can seal keys for peers
- update types.ts with new message interfaces and union members

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:32:46 +01:00
Alejandro Gutiérrez
579d0c3d3e chore: bump version to 0.6.0
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
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:21:03 +01:00
Alejandro Gutiérrez
190f5a958e refactor(cli): migrate to citty — --help generated from flag definitions
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
Replace manual switch + HELP string with citty defineCommand/runMain.
Flag definitions in index.ts are now the single source of truth for
--help output. Remove parseArgs() from launch.ts; accept citty-parsed
flags + rawArgs (-- passthrough to claude preserved).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:19:16 +01:00
Alejandro Gutiérrez
03661e1b68 docs(cli): expand --help with all launch flags, groups hierarchy, env vars
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
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:16:04 +01:00
Alejandro Gutiérrez
d451fc296e feat: hierarchical group routing + role wiring
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
broker: expand member groups to ancestor paths at drain time (pull model)
- @flexicar message reaches peers in @flexicar/core, @flexicar/output, etc.
- Resolved at drainForMember — no DB changes, fully backward-compatible
- Any depth: flexicar/team/backend also matches @flexicar and @flexicar/team

cli: wire --role all the way through to session config + env
- Config.role field added
- launch.ts stores role in sessionConfig, passes CLAUDEMESH_ROLE env var
- mcp/server.ts includes role in identity string
- manager.ts auto-joins groups from config on WS connect (--groups flag now works)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:09:37 +01:00
Alejandro Gutiérrez
3da5d71275 fix(broker): fix share_file DB insert failures
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
- Normalise tags to Array before Drizzle insert (PgArray mapper calls
  .map() and throws if value is not a standard JS Array)
- Use uploadedByName instead of uploadedByMember FK — the X-Member-Id
  header carries the mesh slug, not a mesh.member primary key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 08:56:43 +01:00
Alejandro Gutiérrez
cdf335f609 fix(broker): fix MINIO_USE_SSL env coercion
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
z.coerce.boolean() treats any non-empty string as true, so MINIO_USE_SSL="false" → true.
Switch to explicit enum+transform.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 08:38:06 +01:00
Alejandro Gutiérrez
0cd16ff358 fix: exclude sender only for broadcasts, not direct messages
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
The sender exclusion filter (excludeSenderSessionPubkey) was blocking
delivery of ALL messages from the sender, including direct messages
to other peers. Now only excludes on broadcast (target_spec = '*').

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:34:09 +01:00
Alejandro Gutiérrez
3e9707276d fix: add diagnostic logging to maybePushQueuedMessages
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:21:29 +01:00
Alejandro Gutiérrez
82cfee315c fix: v0.5.9 — mesh_info returns correct display name
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 18:10:30 +01:00
Alejandro Gutiérrez
ab08be04a5 feat(cli): v0.5.8 — welcome notification on connect
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 18:07:08 +01:00
Alejandro Gutiérrez
ee585a8370 fix(cli): v0.5.7 — event loop keepalive for stdout flush
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
Node.js stdout to a pipe is buffered. Without periodic event loop
activity, WS callback → server.notification() → stdout.write() may
not flush until the next I/O event. A 1s setInterval (NOT unref'd)
keeps the event loop ticking so notifications flush immediately.

This is why claude-intercom worked: its 1s HTTP poll kept the event
loop active as a side effect. Claudemesh's passive WS listener let
the event loop settle, causing stdout to buffer indefinitely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:48:41 +01:00
Alejandro Gutiérrez
1f078bf0c8 fix(web): --no-turbopack for prod build (payload CSS)
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:32:24 +01:00
Alejandro Gutiérrez
2372032a68 fix(cli): v0.5.6 — fix ping_mesh self-send + add diagnostics
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 17:32:03 +01:00
Alejandro Gutiérrez
a70c5fd124 feat(cli): v0.5.5 — ping_mesh diagnostic tool
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
Sends test messages to self through the full pipeline per priority
and measures round-trip timing. Reports send→ack and send→receive
latency. Detects broker priority gating (status=working holds next/low).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:27:00 +01:00
Alejandro Gutiérrez
5c62d287cf fix(cli): v0.5.4 — revert to event-driven push, add Claude Code integration spec
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
Revert poll-based drain (v0.5.2 overcorrection). Claude Code source
confirms notifications are processed event-driven via React
useEffect, not polled. The WS onPush → server.notification() path
is correct.

Added section 13 to SPEC.md documenting the full Claude Code
notification pipeline, feature gates, priority gating, and common
push delivery issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:04:05 +01:00
Alejandro Gutiérrez
9ae378c2e3 fix(cli): v0.5.3 — add push delivery debug logging
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 16:49:49 +01:00
Alejandro Gutiérrez
7381738f0b fix(web): disable turbopack for prod build (payload CSS compat)
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:46:28 +01:00
19 changed files with 9965 additions and 241 deletions

58
SPEC.md
View File

@@ -855,7 +855,63 @@ The broker:
--- ---
## 13. Encryption ## 13. Claude Code Integration — How Push Delivery Works
Understanding how Claude Code processes channel notifications is critical for claudemesh reliability.
### The notification pipeline
```
MCP server (claudemesh-cli)
└─ server.notification("notifications/claude/channel", { content, meta })
└─ writes JSON-RPC to stdout
└─ Claude Code reads from MCP process stdout
└─ setNotificationHandler fires
└─ enqueue({ mode: "prompt", value: wrappedContent, origin: { kind: "channel" } })
└─ React useSyncExternalStore triggers re-render
└─ useQueueProcessor effect fires
└─ processQueueIfReady() → executeInput()
└─ Claude sees ← claudemesh: ...
```
### Key requirements (from Claude Code source)
1. **Feature gate**: `feature('KAIROS') || feature('KAIROS_CHANNELS')` must be true. `KAIROS_CHANNELS` is external (GrowthBook). `--dangerously-load-development-channels` sets `entry.dev = true` which bypasses the allowlist check but still requires the feature gate.
2. **OAuth auth required**: Channel notifications require `claude.ai` authentication (OAuth tokens). API key users are blocked. This means `claude login --for-claude-ai` must have been run.
3. **Server name must match**: The MCP server's declared name (`new Server({ name: "claudemesh" })`) must match the channel entry from `--dangerously-load-development-channels server:claudemesh`.
4. **Meta keys**: Must match `/^[a-zA-Z_][a-zA-Z0-9_]*$/`. No hyphens. All values must be strings.
5. **Capability declaration**: Server must declare `experimental: { "claude/channel": {} }` in capabilities.
6. **Queue processing is event-driven**: `enqueue()` triggers a React store update → `useEffect` fires → processes immediately. No polling needed on the Claude Code side. The 1s poll timer in claudemesh is for draining the WS push buffer into notifications — Claude Code handles the rest instantly.
### Priority gating on the broker
The broker holds `"next"` and `"low"` priority messages when the peer's status is `"working"`. Only `"now"` messages deliver immediately regardless of status. This is by design — but can cause perceived "push not working" when the hook reports `working` status.
```
Status: idle → delivers: now, next, low
Status: working → delivers: now only
Status: dnd → delivers: now only
```
If a peer appears to not receive messages, check their status in `list_peers`. A peer stuck in `"working"` (e.g., stale hook) will only receive `"now"` priority messages.
### Common issues
| Symptom | Likely cause |
|---------|-------------|
| Messages never arrive | Session started before CLI update — restart with `claudemesh launch` |
| Messages arrive with 5+ minute delay | Peer status stuck on `"working"``next` messages held until idle |
| `← claudemesh:` never appears in idle session | Feature gate `KAIROS_CHANNELS` not enabled, or not OAuth-authenticated |
| Messages arrive only on `check_messages` | Channel handler not registered — check `--dangerously-load-development-channels` flag |
---
## 14. Encryption
### Direct messages ### Direct messages

View File

@@ -34,6 +34,7 @@ import {
mesh, mesh,
meshFile, meshFile,
meshFileAccess, meshFileAccess,
meshFileKey,
meshContext, meshContext,
meshMember as memberTable, meshMember as memberTable,
meshMemory, meshMemory,
@@ -717,6 +718,8 @@ export async function uploadFile(args: {
uploadedByMember?: string; uploadedByMember?: string;
targetSpec?: string; targetSpec?: string;
expiresAt?: Date; expiresAt?: Date;
encrypted?: boolean;
ownerPubkey?: string;
}): Promise<string> { }): Promise<string> {
const [row] = await db const [row] = await db
.insert(meshFile) .insert(meshFile)
@@ -732,6 +735,8 @@ export async function uploadFile(args: {
uploadedByMember: args.uploadedByMember ?? null, uploadedByMember: args.uploadedByMember ?? null,
targetSpec: args.targetSpec ?? null, targetSpec: args.targetSpec ?? null,
expiresAt: args.expiresAt ?? null, expiresAt: args.expiresAt ?? null,
encrypted: args.encrypted ?? false,
ownerPubkey: args.ownerPubkey ?? null,
}) })
.returning({ id: meshFile.id }); .returning({ id: meshFile.id });
if (!row) throw new Error("failed to insert file row"); if (!row) throw new Error("failed to insert file row");
@@ -755,6 +760,8 @@ export async function getFile(
uploadedByName: string | null; uploadedByName: string | null;
targetSpec: string | null; targetSpec: string | null;
uploadedAt: Date; uploadedAt: Date;
encrypted: boolean;
ownerPubkey: string | null;
} | null> { } | null> {
const [row] = await db const [row] = await db
.select({ .select({
@@ -768,6 +775,8 @@ export async function getFile(
uploadedByName: meshFile.uploadedByName, uploadedByName: meshFile.uploadedByName,
targetSpec: meshFile.targetSpec, targetSpec: meshFile.targetSpec,
uploadedAt: meshFile.uploadedAt, uploadedAt: meshFile.uploadedAt,
encrypted: meshFile.encrypted,
ownerPubkey: meshFile.ownerPubkey,
}) })
.from(meshFile) .from(meshFile)
.where( .where(
@@ -782,6 +791,8 @@ export async function getFile(
return { return {
...row, ...row,
tags: (row.tags ?? []) as string[], tags: (row.tags ?? []) as string[],
encrypted: row.encrypted,
ownerPubkey: row.ownerPubkey,
}; };
} }
@@ -801,6 +812,7 @@ export async function listFiles(
uploadedBy: string; uploadedBy: string;
uploadedAt: Date; uploadedAt: Date;
persistent: boolean; persistent: boolean;
encrypted: boolean;
}> }>
> { > {
const conditions = [ const conditions = [
@@ -822,6 +834,7 @@ export async function listFiles(
uploadedByName: meshFile.uploadedByName, uploadedByName: meshFile.uploadedByName,
uploadedAt: meshFile.uploadedAt, uploadedAt: meshFile.uploadedAt,
persistent: meshFile.persistent, persistent: meshFile.persistent,
encrypted: meshFile.encrypted,
}) })
.from(meshFile) .from(meshFile)
.where(and(...conditions)) .where(and(...conditions))
@@ -835,6 +848,7 @@ export async function listFiles(
uploadedBy: r.uploadedByName ?? "unknown", uploadedBy: r.uploadedByName ?? "unknown",
uploadedAt: r.uploadedAt, uploadedAt: r.uploadedAt,
persistent: r.persistent, persistent: r.persistent,
encrypted: r.encrypted,
})); }));
} }
@@ -892,6 +906,52 @@ export async function deleteFile(
); );
} }
/** Insert encrypted key blobs for a newly uploaded E2E file. */
export async function insertFileKeys(
fileId: string,
keys: Array<{ peerPubkey: string; sealedKey: string; grantedByPubkey?: string }>,
): Promise<void> {
if (keys.length === 0) return;
await db.insert(meshFileKey).values(
keys.map((k) => ({
fileId,
peerPubkey: k.peerPubkey,
sealedKey: k.sealedKey,
grantedByPubkey: k.grantedByPubkey ?? null,
})),
);
}
/** Get the sealed key for a specific peer, or null if not authorized. */
export async function getFileKey(
fileId: string,
peerPubkey: string,
): Promise<string | null> {
const [row] = await db
.select({ sealedKey: meshFileKey.sealedKey })
.from(meshFileKey)
.where(
and(eq(meshFileKey.fileId, fileId), eq(meshFileKey.peerPubkey, peerPubkey)),
);
return row?.sealedKey ?? null;
}
/** Grant a peer access to an encrypted file (upsert their key blob). */
export async function grantFileKey(
fileId: string,
peerPubkey: string,
sealedKey: string,
grantedByPubkey: string,
): Promise<void> {
await db
.insert(meshFileKey)
.values({ fileId, peerPubkey, sealedKey, grantedByPubkey })
.onConflictDoUpdate({
target: [meshFileKey.fileId, meshFileKey.peerPubkey],
set: { sealedKey, grantedByPubkey, grantedAt: new Date() },
});
}
// --- Context sharing --- // --- Context sharing ---
/** /**
@@ -1302,11 +1362,28 @@ export async function drainForMember(
); );
// Build group target matching: @all (broadcast alias) + @<groupname> // Build group target matching: @all (broadcast alias) + @<groupname>
// for each group the peer belongs to. // for each group the peer belongs to, expanded to all ancestor paths.
//
// Hierarchical routing (downward propagation):
// A peer in "flexicar/core" also matches messages sent to "@flexicar".
// A peer in "flexicar/core/backend" matches "@flexicar/core" and "@flexicar".
// This lets leads send to a parent group and reach all sub-teams.
//
// Resolution happens at drain time (pull model) — no duplicates stored,
// no schema changes, fully backward-compatible.
const groupTargets = ["@all"]; const groupTargets = ["@all"];
if (memberGroups) { if (memberGroups) {
const seen = new Set<string>();
for (const g of memberGroups) { for (const g of memberGroups) {
groupTargets.push(`@${g}`); const parts = g.split("/");
// Add the group itself + every ancestor prefix.
for (let depth = parts.length; depth > 0; depth--) {
const ancestor = parts.slice(0, depth).join("/");
if (!seen.has(ancestor)) {
seen.add(ancestor);
groupTargets.push(`@${ancestor}`);
}
}
} }
} }
const groupTargetList = sql.raw( const groupTargetList = sql.raw(
@@ -1337,7 +1414,7 @@ export async function drainForMember(
AND delivered_at IS NULL AND delivered_at IS NULL
AND priority::text IN (${priorityList}) AND priority::text IN (${priorityList})
AND (target_spec = ${memberPubkey} OR target_spec = '*'${sessionPubkey ? sql` OR target_spec = ${sessionPubkey}` : sql``} OR target_spec IN (${groupTargetList})) AND (target_spec = ${memberPubkey} OR target_spec = '*'${sessionPubkey ? sql` OR target_spec = ${sessionPubkey}` : sql``} OR target_spec IN (${groupTargetList}))
${excludeSenderSessionPubkey ? sql`AND (sender_session_pubkey IS NULL OR sender_session_pubkey != ${excludeSenderSessionPubkey})` : sql``} ${excludeSenderSessionPubkey ? sql`AND NOT (target_spec IN ('*') AND sender_session_pubkey = ${excludeSenderSessionPubkey})` : sql``}
ORDER BY created_at ASC, id ASC ORDER BY created_at ASC, id ASC
FOR UPDATE SKIP LOCKED FOR UPDATE SKIP LOCKED
) )

View File

@@ -23,7 +23,7 @@ const envSchema = z.object({
MINIO_ENDPOINT: z.string().default("minio:9000"), MINIO_ENDPOINT: z.string().default("minio:9000"),
MINIO_ACCESS_KEY: z.string().default("claudemesh"), MINIO_ACCESS_KEY: z.string().default("claudemesh"),
MINIO_SECRET_KEY: z.string().default("changeme"), MINIO_SECRET_KEY: z.string().default("changeme"),
MINIO_USE_SSL: z.coerce.boolean().default(false), MINIO_USE_SSL: z.enum(["true", "false", ""]).transform(v => v === "true").default("false"),
QDRANT_URL: z.string().default("http://qdrant:6333"), QDRANT_URL: z.string().default("http://qdrant:6333"),
NEO4J_URL: z.string().default("bolt://neo4j:7687"), NEO4J_URL: z.string().default("bolt://neo4j:7687"),
NEO4J_USER: z.string().default("neo4j"), NEO4J_USER: z.string().default("neo4j"),

View File

@@ -31,10 +31,13 @@ import {
forgetMemory, forgetMemory,
getContext, getContext,
getFile, getFile,
getFileKey,
getFileStatus, getFileStatus,
getState, getState,
grantFileKey,
handleHookSetStatus, handleHookSetStatus,
heartbeat, heartbeat,
insertFileKeys,
joinGroup, joinGroup,
joinMesh, joinMesh,
leaveGroup, leaveGroup,
@@ -123,7 +126,10 @@ async function maybePushQueuedMessages(
excludeSenderSessionPubkey?: string, excludeSenderSessionPubkey?: string,
): Promise<void> { ): Promise<void> {
const conn = connections.get(presenceId); const conn = connections.get(presenceId);
if (!conn) return; if (!conn) {
log.debug("maybePush: no connection for presence", { presence_id: presenceId });
return;
}
const status = await refreshStatusFromJsonl( const status = await refreshStatusFromJsonl(
presenceId, presenceId,
conn.cwd, conn.cwd,
@@ -138,6 +144,13 @@ async function maybePushQueuedMessages(
excludeSenderSessionPubkey, excludeSenderSessionPubkey,
conn.groups.map((g) => g.name), conn.groups.map((g) => g.name),
); );
log.info("maybePush", {
presence_id: presenceId,
status,
session_pubkey: conn.sessionPubkey?.slice(0, 12),
exclude: excludeSenderSessionPubkey?.slice(0, 12),
drained: messages.length,
});
for (const m of messages) { for (const m of messages) {
const push: WSPushMessage = { const push: WSPushMessage = {
type: "push", type: "push",
@@ -368,6 +381,9 @@ function handleUploadPost(
const tagsRaw = req.headers["x-tags"] as string | undefined; const tagsRaw = req.headers["x-tags"] as string | undefined;
const persistentRaw = req.headers["x-persistent"] as string | undefined; const persistentRaw = req.headers["x-persistent"] as string | undefined;
const targetSpec = req.headers["x-target-spec"] as string | undefined; const targetSpec = req.headers["x-target-spec"] as string | undefined;
const encryptedRaw = req.headers["x-encrypted"] as string | undefined;
const ownerPubkey = req.headers["x-owner-pubkey"] as string | undefined;
const fileKeysRaw = req.headers["x-file-keys"] as string | undefined;
if (!meshId || !memberId || !fileName) { if (!meshId || !memberId || !fileName) {
writeJson(res, 400, { writeJson(res, 400, {
@@ -435,19 +451,44 @@ function handleUploadPost(
: undefined, : undefined,
); );
// Insert DB row // Insert DB row — normalise tags to a real JS Array (Drizzle PgArray
// mapper calls .map() on the value; non-Array iterables break it).
// Skip uploadedByMember FK — memberId from the client header is the
// mesh slug, not a mesh.member primary key.
const encrypted = encryptedRaw === "true";
let fileKeys: Array<{ peerPubkey: string; sealedKey: string }> = [];
if (encrypted && fileKeysRaw) {
try {
fileKeys = JSON.parse(fileKeysRaw);
} catch { /* ignore */ }
}
const dbFileId = await uploadFile({ const dbFileId = await uploadFile({
meshId, meshId,
name: fileName, name: fileName,
sizeBytes: body.length, sizeBytes: body.length,
mimeType: (req.headers["content-type"] as string) || undefined, mimeType: (req.headers["content-type"] as string) || undefined,
minioKey, minioKey,
tags, tags: Array.isArray(tags) ? tags : [],
persistent, persistent,
uploadedByMember: memberId, uploadedByName: memberId || undefined,
uploadedByMember: undefined,
targetSpec: targetSpec || undefined, targetSpec: targetSpec || undefined,
encrypted: encrypted || false,
ownerPubkey: ownerPubkey || undefined,
}); });
if (encrypted && fileKeys.length > 0) {
await insertFileKeys(
dbFileId,
fileKeys.map((k) => ({
peerPubkey: k.peerPubkey,
sealedKey: k.sealedKey,
grantedByPubkey: ownerPubkey,
})),
);
}
writeJson(res, 200, { ok: true, fileId: dbFileId }); writeJson(res, 200, { ok: true, fileId: dbFileId });
log.info("upload", { log.info("upload", {
route: "POST /upload", route: "POST /upload",
@@ -936,6 +977,19 @@ function handleConnection(ws: WebSocket): void {
break; break;
} }
} }
// E2E: for encrypted files, fetch the sealed key for this peer.
// Owners are not blocked if their key is missing (edge case), but
// they still get it returned so the CLI can decrypt normally.
let sealedKey: string | null = null;
if (file.encrypted) {
const peerPubkey = conn.sessionPubkey ?? conn.memberPubkey;
const isOwner = !!(file.ownerPubkey && peerPubkey === file.ownerPubkey);
sealedKey = peerPubkey ? await getFileKey(gf.fileId, peerPubkey) : null;
if (!sealedKey && !isOwner) {
sendError(conn.ws, "forbidden", "no decryption key for this file");
break;
}
}
// Generate presigned URL (60s expiry) // Generate presigned URL (60s expiry)
const bucket = meshBucketName(conn.meshId); const bucket = meshBucketName(conn.meshId);
const presignedUrl = await minioClient.presignedGetObject( const presignedUrl = await minioClient.presignedGetObject(
@@ -957,6 +1011,8 @@ function handleConnection(ws: WebSocket): void {
fileId: gf.fileId, fileId: gf.fileId,
url: presignedUrl, url: presignedUrl,
name: file.name, name: file.name,
encrypted: file.encrypted,
sealedKey: sealedKey ?? undefined,
}); });
log.info("ws get_file", { log.info("ws get_file", {
presence_id: presenceId, presence_id: presenceId,
@@ -977,6 +1033,7 @@ function handleConnection(ws: WebSocket): void {
uploadedBy: f.uploadedBy, uploadedBy: f.uploadedBy,
uploadedAt: f.uploadedAt.toISOString(), uploadedAt: f.uploadedAt.toISOString(),
persistent: f.persistent, persistent: f.persistent,
encrypted: f.encrypted,
})), })),
}); });
log.info("ws list_files", { log.info("ws list_files", {
@@ -1003,6 +1060,23 @@ function handleConnection(ws: WebSocket): void {
}); });
break; break;
} }
case "grant_file_access": {
const gfa = msg as { type: "grant_file_access"; fileId: string; peerPubkey: string; sealedKey: string };
const file = await getFile(conn.meshId, gfa.fileId);
if (!file) {
sendError(conn.ws, "not_found", "file not found");
break;
}
const requestorPubkey = conn.sessionPubkey ?? conn.memberPubkey;
if (file.ownerPubkey && file.ownerPubkey !== requestorPubkey) {
sendError(conn.ws, "forbidden", "only the file owner can grant access");
break;
}
await grantFileKey(gfa.fileId, gfa.peerPubkey, gfa.sealedKey, requestorPubkey ?? undefined);
sendToPeer(presenceId, { type: "grant_file_access_ok", fileId: gfa.fileId, peerPubkey: gfa.peerPubkey });
log.info("ws grant_file_access", { presence_id: presenceId, file_id: gfa.fileId, peer: gfa.peerPubkey });
break;
}
case "delete_file": { case "delete_file": {
const df = msg as Extract<WSClientMessage, { type: "delete_file" }>; const df = msg as Extract<WSClientMessage, { type: "delete_file" }>;
await deleteFile(conn.meshId, df.fileId); await deleteFile(conn.meshId, df.fileId);
@@ -1658,6 +1732,9 @@ function handleConnection(ws: WebSocket): void {
for (const p of peers) for (const g of p.groups) allGroups.add(`@${g.name}`); for (const p of peers) for (const g of p.groups) allGroups.add(`@${g.name}`);
const myPresence = peers.find(p => p.sessionId === [...connections.entries()].find(([pid]) => pid === presenceId)?.[1]?.sessionPubkey); const myPresence = peers.find(p => p.sessionId === [...connections.entries()].find(([pid]) => pid === presenceId)?.[1]?.sessionPubkey);
const peerConn = connections.get(presenceId); const peerConn = connections.get(presenceId);
// Find own display name: match sessionPubkey from the peer list
const selfPubkey = peerConn?.sessionPubkey ?? peerConn?.memberPubkey;
const selfPeer = peers.find(p => p.pubkey === selfPubkey);
sendToPeer(presenceId, { sendToPeer(presenceId, {
type: "mesh_info_result", type: "mesh_info_result",
mesh: conn.meshId, mesh: conn.meshId,
@@ -1670,7 +1747,7 @@ function handleConnection(ws: WebSocket): void {
streams: streams.map(s => s.name), streams: streams.map(s => s.name),
tables: tables.map((t: any) => t.name), tables: tables.map((t: any) => t.name),
collections: [], collections: [],
yourName: peerConn?.groups?.[0]?.name ?? "unknown", yourName: selfPeer?.displayName ?? "unknown",
yourGroups: peerConn?.groups ?? [], yourGroups: peerConn?.groups ?? [],
}); });
log.info("ws mesh_info", { presence_id: presenceId }); log.info("ws mesh_info", { presence_id: presenceId });

View File

@@ -404,12 +404,22 @@ export interface WSDeleteFileMessage {
fileId: string; fileId: string;
} }
/** Client → broker: grant a peer access to an encrypted file. */
export interface WSGrantFileAccessMessage {
type: "grant_file_access";
fileId: string;
peerPubkey: string;
sealedKey: string;
}
/** Broker → client: presigned URL for downloading a file. */ /** Broker → client: presigned URL for downloading a file. */
export interface WSFileUrlMessage { export interface WSFileUrlMessage {
type: "file_url"; type: "file_url";
fileId: string; fileId: string;
url: string; url: string;
name: string; name: string;
encrypted?: boolean;
sealedKey?: string;
} }
/** Broker → client: list of files in the mesh. */ /** Broker → client: list of files in the mesh. */
@@ -423,9 +433,17 @@ export interface WSFileListMessage {
uploadedBy: string; uploadedBy: string;
uploadedAt: string; uploadedAt: string;
persistent: boolean; persistent: boolean;
encrypted: boolean;
}>; }>;
} }
/** Broker → client: acknowledgement for grant_file_access. */
export interface WSGrantFileAccessOkMessage {
type: "grant_file_access_ok";
fileId: string;
peerPubkey: string;
}
/** Broker → client: access log for a file. */ /** Broker → client: access log for a file. */
export interface WSFileStatusResultMessage { export interface WSFileStatusResultMessage {
type: "file_status_result"; type: "file_status_result";
@@ -627,6 +645,7 @@ export type WSClientMessage =
| WSListFilesMessage | WSListFilesMessage
| WSFileStatusMessage | WSFileStatusMessage
| WSDeleteFileMessage | WSDeleteFileMessage
| WSGrantFileAccessMessage
| WSShareContextMessage | WSShareContextMessage
| WSGetContextMessage | WSGetContextMessage
| WSListContextsMessage | WSListContextsMessage
@@ -664,6 +683,7 @@ export type WSServerMessage =
| WSFileUrlMessage | WSFileUrlMessage
| WSFileListMessage | WSFileListMessage
| WSFileStatusResultMessage | WSFileStatusResultMessage
| WSGrantFileAccessOkMessage
| WSContextSharedMessage | WSContextSharedMessage
| WSContextResultsMessage | WSContextResultsMessage
| WSContextListMessage | WSContextListMessage

View File

@@ -1,6 +1,6 @@
{ {
"name": "claudemesh-cli", "name": "claudemesh-cli",
"version": "0.5.2", "version": "0.6.4",
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.", "description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
"keywords": [ "keywords": [
"claude-code", "claude-code",
@@ -47,6 +47,7 @@
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "1.27.1", "@modelcontextprotocol/sdk": "1.27.1",
"citty": "0.2.2",
"libsodium-wrappers": "0.7.15", "libsodium-wrappers": "0.7.15",
"ws": "8.20.0", "ws": "8.20.0",
"zod": "4.1.13" "zod": "4.1.13"

View File

@@ -1,9 +1,12 @@
/** /**
* `claudemesh launch` — spawn `claude` with peer mesh identity. * `claudemesh launch` — spawn `claude` with peer mesh identity.
* *
* Flags are defined in index.ts (citty command) — that is the source of
* truth. This file receives already-parsed flags and rawArgs.
*
* Flow: * Flow:
* 1. Parse --name, --join, --mesh, --quiet flags * 1. Receive parsed flags from citty + rawArgs for -- passthrough
* 2. If --join: run join flow first (accepts token or URL) * 2. If --join: run join flow first
* 3. Load config → pick mesh (auto if 1, interactive picker if >1) * 3. Load config → pick mesh (auto if 1, interactive picker if >1)
* 4. Write per-session config to tmpdir (isolates mesh selection) * 4. Write per-session config to tmpdir (isolates mesh selection)
* 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME * 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME
@@ -18,73 +21,17 @@ import { createInterface } from "node:readline";
import { loadConfig, getConfigPath } from "../state/config"; import { loadConfig, getConfigPath } from "../state/config";
import type { Config, JoinedMesh, GroupEntry } from "../state/config"; import type { Config, JoinedMesh, GroupEntry } from "../state/config";
// --- Arg parsing --- // Flags as parsed by citty (index.ts is the source of truth for definitions).
export interface LaunchFlags {
interface LaunchArgs { name?: string;
name: string | null; role?: string;
role: string | null; groups?: string;
groups: string | null; // comma-separated, e.g. "frontend:lead,reviewers:member" join?: string;
joinLink: string | null; mesh?: string;
meshSlug: string | null; "message-mode"?: string;
messageMode: "push" | "inbox" | "off" | null; "system-prompt"?: string;
quiet: boolean; yes?: boolean;
skipPermConfirm: boolean; quiet?: boolean;
claudeArgs: string[];
}
function parseArgs(argv: string[]): LaunchArgs {
const result: LaunchArgs = {
name: null,
role: null,
groups: null,
joinLink: null,
meshSlug: null,
messageMode: null,
quiet: false,
skipPermConfirm: 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 === "--role" && i + 1 < argv.length) {
result.role = argv[++i]!;
} else if (arg.startsWith("--role=")) {
result.role = arg.slice("--role=".length);
} else if (arg === "--groups" && i + 1 < argv.length) {
result.groups = argv[++i]!;
} else if (arg.startsWith("--groups=")) {
result.groups = arg.slice("--groups=".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 === "--inbox") {
result.messageMode = "inbox";
} else if (arg === "--no-messages") {
result.messageMode = "off";
} else if (arg === "--quiet") {
result.quiet = true;
} else if (arg === "-y" || arg === "--yes") {
result.skipPermConfirm = true;
} else if (arg === "--") {
result.claudeArgs.push(...argv.slice(i + 1));
break;
} else {
result.claudeArgs.push(arg);
}
i++;
}
return result;
} }
// --- Interactive mesh picker --- // --- Interactive mesh picker ---
@@ -206,8 +153,26 @@ function printBanner(name: string, meshSlug: string, role: string | null, groups
// --- Main --- // --- Main ---
export async function runLaunch(extraArgs: string[]): Promise<void> { export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<void> {
const args = parseArgs(extraArgs); // Extract args that follow "--" — passed straight through to claude.
const dashIdx = rawArgs.indexOf("--");
const claudePassthrough = dashIdx >= 0 ? rawArgs.slice(dashIdx + 1) : [];
// Normalise flags into the internal shape used below.
const args = {
name: flags.name ?? null,
role: flags.role ?? null,
groups: flags.groups ?? null,
joinLink: flags.join ?? null,
meshSlug: flags.mesh ?? null,
messageMode: (["push", "inbox", "off"].includes(flags["message-mode"] ?? "")
? flags["message-mode"] as "push" | "inbox" | "off"
: null),
systemPrompt: flags["system-prompt"] ?? null,
quiet: flags.quiet ?? false,
skipPermConfirm: flags.yes ?? false,
claudeArgs: claudePassthrough,
};
// 1. If --join, run join flow first. // 1. If --join, run join flow first.
if (args.joinLink) { if (args.joinLink) {
@@ -318,6 +283,7 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
version: 1, version: 1,
meshes: [mesh], meshes: [mesh],
displayName, displayName,
...(role ? { role } : {}),
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}), ...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
messageMode, messageMode,
}; };
@@ -351,6 +317,7 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
"--dangerously-load-development-channels", "--dangerously-load-development-channels",
"server:claudemesh", "server:claudemesh",
"--dangerously-skip-permissions", "--dangerously-skip-permissions",
...(args.systemPrompt ? ["--system-prompt", args.systemPrompt] : []),
...filtered, ...filtered,
]; ];
@@ -362,6 +329,7 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
...process.env, ...process.env,
CLAUDEMESH_CONFIG_DIR: tmpDir, CLAUDEMESH_CONFIG_DIR: tmpDir,
CLAUDEMESH_DISPLAY_NAME: displayName, CLAUDEMESH_DISPLAY_NAME: displayName,
...(role ? { CLAUDEMESH_ROLE: role } : {}),
}, },
}); });

View File

@@ -0,0 +1,90 @@
/**
* File encryption for claudemesh E2E file sharing.
*
* Symmetric: crypto_secretbox_easy with random Kf (32-byte key).
* Key wrapping: crypto_box_seal to recipient's X25519 pub (converted from ed25519).
* Key opening: crypto_box_seal_open with own X25519 keypair.
*/
import { ensureSodium } from "./keypair";
export interface EncryptedFile {
ciphertext: Uint8Array; // secretbox ciphertext (includes MAC)
nonce: string; // base64 24-byte nonce
key: Uint8Array; // 32-byte symmetric Kf (keep in memory only)
}
/**
* Encrypt file bytes with a fresh random symmetric key.
* Returns ciphertext, nonce (base64), and the plaintext Kf.
*/
export async function encryptFile(plaintext: Uint8Array): Promise<EncryptedFile> {
const sodium = await ensureSodium();
const key = sodium.randombytes_buf(sodium.crypto_secretbox_KEYBYTES);
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key);
return {
ciphertext,
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
key,
};
}
/**
* Decrypt file bytes with the symmetric key Kf.
* Returns null if decryption fails.
*/
export async function decryptFile(
ciphertext: Uint8Array,
nonceB64: string,
key: Uint8Array,
): Promise<Uint8Array | null> {
const sodium = await ensureSodium();
try {
const nonce = sodium.from_base64(nonceB64, sodium.base64_variants.ORIGINAL);
return sodium.crypto_secretbox_open_easy(ciphertext, nonce, key);
} catch {
return null;
}
}
/**
* Seal Kf for a recipient using crypto_box_seal (ephemeral sender key).
* recipientPubkeyHex: ed25519 pubkey of recipient (64 hex chars).
* Returns base64 sealed box.
*/
export async function sealKeyForPeer(
kf: Uint8Array,
recipientPubkeyHex: string,
): Promise<string> {
const sodium = await ensureSodium();
const recipientCurve = sodium.crypto_sign_ed25519_pk_to_curve25519(
sodium.from_hex(recipientPubkeyHex),
);
const sealed = sodium.crypto_box_seal(kf, recipientCurve);
return sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL);
}
/**
* Open a sealed key blob using own ed25519 keypair (converted to X25519).
* Returns the 32-byte Kf or null if decryption fails.
*/
export async function openSealedKey(
sealedB64: string,
myPubkeyHex: string,
mySecretKeyHex: string,
): Promise<Uint8Array | null> {
const sodium = await ensureSodium();
try {
const myCurvePub = sodium.crypto_sign_ed25519_pk_to_curve25519(
sodium.from_hex(myPubkeyHex),
);
const myCurveSec = sodium.crypto_sign_ed25519_sk_to_curve25519(
sodium.from_hex(mySecretKeyHex),
);
const sealed = sodium.from_base64(sealedB64, sodium.base64_variants.ORIGINAL);
return sodium.crypto_box_seal_open(sealed, myCurvePub, myCurveSec);
} catch {
return null;
}
}

View File

@@ -1,13 +1,15 @@
/** /**
* claudemesh-cli entry point. * claudemesh-cli entry point.
* *
* Uses citty to define commands and flags. --help is generated from
* the command definitions — the flag list here IS the documentation.
*
* Dispatches between two modes: * Dispatches between two modes:
* - `claudemesh mcp` → MCP server (stdio transport) * - `claudemesh mcp` → MCP server (stdio transport)
* - `claudemesh <subcommand>` → CLI subcommand * - `claudemesh <subcommand>` → CLI subcommand
*
* Claude Code invokes the `mcp` mode via stdio. Humans use all others.
*/ */
import { defineCommand, runMain } from "citty";
import { startMcpServer } from "./mcp/server"; import { startMcpServer } from "./mcp/server";
import { runInstall, runUninstall } from "./commands/install"; import { runInstall, runUninstall } from "./commands/install";
import { runJoin } from "./commands/join"; import { runJoin } from "./commands/join";
@@ -21,96 +23,152 @@ import { runDoctor } from "./commands/doctor";
import { runWelcome } from "./commands/welcome"; import { runWelcome } from "./commands/welcome";
import { VERSION } from "./version"; import { VERSION } from "./version";
const HELP = `claudemesh v${VERSION} — peer mesh for Claude Code sessions const launch = defineCommand({
meta: {
Usage: name: "launch",
claudemesh <command> [args] description: "Launch Claude Code connected to a mesh with real-time peer messaging",
},
Commands: args: {
install Register MCP + Stop/UserPromptSubmit status hooks name: {
(add --no-hooks for bare MCP registration) type: "string",
uninstall Remove MCP server + hooks description: "Display name for this session",
launch [opts] Launch Claude Code with real-time push messages },
--name <name> Display name for this session role: {
--mesh <slug> Select mesh (picker if >1, omitted) type: "string",
--join <url> Join a mesh before launching description: "Role tag (dev, lead, analyst — free-form)",
--quiet Skip the info banner },
-- <args> Pass remaining args to claude groups: {
join <url> Join a mesh via https://claudemesh.com/join/... URL type: "string",
list Show all joined meshes description: 'Groups to join: "group:role,group2" — colon sets role. Hierarchy via slash: "eng/frontend:lead"',
leave <slug> Leave a joined mesh },
status Health report: broker reachability per joined mesh mesh: {
doctor Diagnostic checks (install, config, keypairs, PATH) type: "string",
seed-test-mesh Dev-only: inject a mesh into config (skips invite flow) description: "Select mesh by slug (interactive picker if omitted and >1 joined)",
mcp Start MCP server (stdio) — invoked by Claude Code },
--help, -h Show this help join: {
--version, -v Show the CLI version type: "string",
description: "Join a mesh via invite URL before launching",
Environment: },
CLAUDEMESH_BROKER_URL Override broker URL (default: wss://ic.claudemesh.com/ws) "message-mode": {
CLAUDEMESH_CONFIG_DIR Override config directory (default: ~/.claudemesh/) type: "string",
CLAUDEMESH_DEBUG=1 Verbose logging description: "push (default) | inbox | off — controls how peer messages are delivered",
`; },
"system-prompt": {
const cmd = process.argv[2]; type: "string",
const args = process.argv.slice(3); description: "Set Claude's system prompt for this session",
},
async function main(): Promise<void> { yes: {
switch (cmd) { type: "boolean",
case "mcp": alias: "y",
await startMcpServer(); description: "Skip permission confirmation",
return; default: false,
case "install": },
runInstall(args); quiet: {
return; type: "boolean",
case "uninstall": description: "Skip banner and all interactive prompts",
runUninstall(); default: false,
return; },
case "hook": },
await runHook(args); run({ args, rawArgs }) {
return; // Forward to the existing launch runner, preserving -- passthrough to claude.
case "launch": return runLaunch(args, rawArgs);
await runLaunch(args); },
return;
case "join":
await runJoin(args);
return;
case "list":
runList();
return;
case "leave":
runLeave(args);
return;
case "status":
await runStatus();
return;
case "doctor":
await runDoctor();
return;
case "seed-test-mesh":
runSeedTestMesh(args);
return;
case "--version":
case "-v":
case "version":
console.log(VERSION);
return;
case "--help":
case "-h":
case "help":
console.log(HELP);
return;
case undefined:
runWelcome();
return;
default:
console.error(`Unknown command: ${cmd}`);
console.error("Run `claudemesh --help` for usage.");
process.exit(1);
}
}
main().catch((e) => {
console.error(`claudemesh: ${e instanceof Error ? e.message : String(e)}`);
process.exit(1);
}); });
const install = defineCommand({
meta: {
name: "install",
description: "Register MCP server + status hooks with Claude Code",
},
args: {
"no-hooks": {
type: "boolean",
description: "Register MCP server only, skip hooks",
default: false,
},
},
run({ rawArgs }) {
runInstall(rawArgs);
},
});
const join = defineCommand({
meta: {
name: "join",
description: "Join a mesh via invite URL",
},
args: {
url: {
type: "positional",
description: "Invite URL (https://claudemesh.com/join/...)",
required: true,
},
},
run({ args }) {
return runJoin([args.url]);
},
});
const leave = defineCommand({
meta: {
name: "leave",
description: "Leave a joined mesh",
},
args: {
slug: {
type: "positional",
description: "Mesh slug to leave",
required: true,
},
},
run({ args }) {
runLeave([args.slug]);
},
});
const main = defineCommand({
meta: {
name: "claudemesh",
version: VERSION,
description: "Peer mesh for Claude Code sessions",
},
subCommands: {
launch,
install,
uninstall: defineCommand({
meta: { name: "uninstall", description: "Remove MCP server and hooks" },
run() { runUninstall(); },
}),
join,
list: defineCommand({
meta: { name: "list", description: "Show joined meshes and identities" },
run() { runList(); },
}),
leave,
status: defineCommand({
meta: { name: "status", description: "Check broker reachability for each joined mesh" },
async run() { await runStatus(); },
}),
doctor: defineCommand({
meta: { name: "doctor", description: "Diagnose install, config, keypairs, and PATH" },
async run() { await runDoctor(); },
}),
mcp: defineCommand({
meta: { name: "mcp", description: "Start MCP server (stdio — invoked by Claude Code, not users)" },
async run() { await startMcpServer(); },
}),
"seed-test-mesh": defineCommand({
meta: { name: "seed-test-mesh", description: "Dev only: inject a mesh into config (skips invite flow)" },
run({ rawArgs }) { runSeedTestMesh(rawArgs); },
}),
hook: defineCommand({
meta: { name: "hook", description: "Internal hook handler (invoked by Claude Code hooks)" },
async run({ rawArgs }) { await runHook(rawArgs); },
}),
},
run() {
runWelcome();
},
});
runMain(main);

View File

@@ -130,6 +130,7 @@ export async function startMcpServer(): Promise<void> {
const config = loadConfig(); const config = loadConfig();
const myName = config.displayName ?? "unnamed"; const myName = config.displayName ?? "unnamed";
const myRole = config.role ?? process.env.CLAUDEMESH_ROLE ?? null;
const myGroups = (config.groups ?? []).map(g => `@${g.name}${g.role ? ':' + g.role : ''}`).join(', ') || "none"; const myGroups = (config.groups ?? []).map(g => `@${g.name}${g.role ? ':' + g.role : ''}`).join(', ') || "none";
const messageMode = config.messageMode ?? "push"; const messageMode = config.messageMode ?? "push";
@@ -141,7 +142,7 @@ export async function startMcpServer(): Promise<void> {
tools: {}, tools: {},
}, },
instructions: `## Identity instructions: `## Identity
You are "${myName}" — a peer in the claudemesh network. Your groups: ${myGroups}. You are one of several Claude Code sessions connected to the same mesh. No orchestrator exists — peers are equals. Your identity comes from your name and group roles, not from a central authority. You are "${myName}"${myRole ? ` (${myRole})` : ""} — a peer in the claudemesh network. Your groups: ${myGroups}. You are one of several Claude Code sessions connected to the same mesh. No orchestrator exists — peers are equals. Your identity comes from your name and group roles, not from a central authority.
## Responding to messages ## Responding to messages
When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATELY. Pause your current task, reply via send_message, then resume. Read from_name, mesh_slug, and priority from the channel attributes. Reply by setting \`to\` to the sender's from_name (display name). Stay in character per your system prompt. Do not ignore low-priority messages — acknowledge them briefly even if you defer action. When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATELY. Pause your current task, reply via send_message, then resume. Read from_name, mesh_slug, and priority from the channel attributes. Reply by setting \`to\` to the sender's from_name (display name). Stay in character per your system prompt. Do not ignore low-priority messages — acknowledge them briefly even if you defer action.
@@ -439,17 +440,83 @@ Your message mode is "${messageMode}".
// --- Files --- // --- Files ---
case "share_file": { case "share_file": {
const { path: filePath, name: fileName, tags } = (args ?? {}) as { path?: string; name?: string; tags?: string[] }; const { path: filePath, name: fileName, tags, to: fileTo } = (args ?? {}) as { path?: string; name?: string; tags?: string[]; to?: string };
if (!filePath) return text("share_file: `path` required", true); if (!filePath) return text("share_file: `path` required", true);
const { existsSync } = await import("node:fs"); const { existsSync } = await import("node:fs");
if (!existsSync(filePath)) return text(`share_file: file not found: ${filePath}`, true); if (!existsSync(filePath)) return text(`share_file: file not found: ${filePath}`, true);
const client = allClients()[0]; const client = allClients()[0];
if (!client) return text("share_file: not connected", true); if (!client) return text("share_file: not connected", true);
const fileId = await client.uploadFile(filePath, client.meshId, client.meshSlug, {
name: fileName, tags, persistent: true, // If 'to' specified, do E2E encryption
}); if (fileTo) {
if (!fileId) return text("share_file: upload failed", true); const { encryptFile, sealKeyForPeer } = await import("../crypto/file-crypto");
return text(`Shared: ${fileName ?? filePath} (${fileId})`); const { readFileSync, writeFileSync, mkdtempSync, unlinkSync, rmdirSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join, basename } = await import("node:path");
// Resolve target peer pubkey
const peers = await client.listPeers();
const targetPeer = peers.find(p => p.pubkey === fileTo || p.displayName === fileTo);
if (!targetPeer) {
return text(`share_file: peer not found: ${fileTo}`, true);
}
// Read and encrypt file
const plaintext = readFileSync(filePath);
const { ciphertext, nonce, key } = await encryptFile(new Uint8Array(plaintext));
// Seal Kf for target peer
const sealedForTarget = await sealKeyForPeer(key, targetPeer.pubkey);
// Seal Kf for ourselves (owner)
const myPubkey = client.getSessionPubkey();
const sealedForSelf = myPubkey ? await sealKeyForPeer(key, myPubkey) : null;
const fileKeys = [
{ peerPubkey: targetPeer.pubkey, sealedKey: sealedForTarget },
...(sealedForSelf && myPubkey ? [{ peerPubkey: myPubkey, sealedKey: sealedForSelf }] : []),
];
// Build combined buffer: nonce (24 bytes) + ciphertext
const { ensureSodium } = await import("../crypto/keypair");
const sodium = await ensureSodium();
const nonceBytes = sodium.from_base64(nonce, sodium.base64_variants.ORIGINAL);
const combined = new Uint8Array(nonceBytes.length + ciphertext.length);
combined.set(nonceBytes, 0);
combined.set(ciphertext, nonceBytes.length);
const baseName = fileName ?? basename(filePath);
const tmpDir = mkdtempSync(join(tmpdir(), "cm-"));
const tmpPath = join(tmpDir, baseName);
writeFileSync(tmpPath, combined);
try {
const fileId = await client.uploadFile(tmpPath, client.meshId, client.meshSlug, {
name: baseName,
tags,
persistent: true,
encrypted: true,
ownerPubkey: myPubkey ?? undefined,
fileKeys,
});
return text(`Shared (E2E encrypted): ${baseName}${targetPeer.displayName} (${fileId})`);
} catch (e) {
return text(`share_file: upload failed — ${e instanceof Error ? e.message : String(e)}`, true);
} finally {
try { unlinkSync(tmpPath); } catch { /* ignore */ }
try { rmdirSync(tmpDir); } catch { /* ignore */ }
}
}
// Plain (unencrypted) upload — existing code
try {
const fileId = await client.uploadFile(filePath, client.meshId, client.meshSlug, {
name: fileName, tags, persistent: true,
});
return text(`Shared: ${fileName ?? filePath} (${fileId})`);
} catch (e) {
return text(`share_file: upload failed — ${e instanceof Error ? e.message : String(e)}`, true);
}
} }
case "get_file": { case "get_file": {
@@ -459,6 +526,43 @@ Your message mode is "${messageMode}".
if (!client) return text("get_file: not connected", true); if (!client) return text("get_file: not connected", true);
const result = await client.getFile(id); const result = await client.getFile(id);
if (!result) return text(`get_file: file ${id} not found`, true); if (!result) return text(`get_file: file ${id} not found`, true);
if (result.encrypted) {
if (!result.sealedKey) return text("get_file: encrypted file — no decryption key available for your session", true);
const { openSealedKey, decryptFile } = await import("../crypto/file-crypto");
const { ensureSodium } = await import("../crypto/keypair");
const myPubkey = client.getSessionPubkey();
const mySecret = client.getSessionSecretKey();
if (!myPubkey || !mySecret) {
return text("get_file: no session keypair — cannot decrypt", true);
}
const kf = await openSealedKey(result.sealedKey, myPubkey, mySecret);
if (!kf) return text("get_file: failed to open sealed key", true);
// Download file bytes from presigned URL
const resp = await fetch(result.url, { signal: AbortSignal.timeout(30_000) });
if (!resp.ok) return text(`get_file: download failed (${resp.status})`, true);
const buf = new Uint8Array(await resp.arrayBuffer());
// Wire format: first 24 bytes = nonce, rest = ciphertext
const sodium = await ensureSodium();
const NONCE_BYTES = sodium.crypto_secretbox_NONCEBYTES; // 24
const nonce = sodium.to_base64(buf.slice(0, NONCE_BYTES), sodium.base64_variants.ORIGINAL);
const ciphertext = buf.slice(NONCE_BYTES);
const plaintext = await decryptFile(ciphertext, nonce, kf);
if (!plaintext) return text("get_file: decryption failed", true);
const { writeFileSync, mkdirSync } = await import("node:fs");
const { dirname } = await import("node:path");
mkdirSync(dirname(save_to), { recursive: true });
writeFileSync(save_to, plaintext);
return text(`Downloaded and decrypted: ${result.name}${save_to}`);
}
// Unencrypted — existing download logic
const res = await fetch(result.url, { signal: AbortSignal.timeout(30_000) }); const res = await fetch(result.url, { signal: AbortSignal.timeout(30_000) });
if (!res.ok) return text(`get_file: download failed (${res.status})`, true); if (!res.ok) return text(`get_file: download failed (${res.status})`, true);
const { writeFileSync, mkdirSync } = await import("node:fs"); const { writeFileSync, mkdirSync } = await import("node:fs");
@@ -707,6 +811,86 @@ Your message mode is "${messageMode}".
return text(lines.join("\n")); return text(lines.join("\n"));
} }
case "ping_mesh": {
const { priorities: pingPriorities } = (args ?? {}) as { priorities?: string[] };
const toTest = (pingPriorities ?? ["now", "next"]) as Priority[];
const client = allClients()[0];
if (!client) return text("ping_mesh: not connected", true);
const results: string[] = [];
// Diagnostics: connection state
results.push(`WS status: ${client.status}`);
results.push(`Mesh: ${client.meshSlug}`);
// Check own peer status (explains priority gating)
const peers = await client.listPeers();
const selfPeer = peers.find(p => p.displayName === myName);
results.push(`Your status: ${selfPeer?.status ?? "not found in peer list"}`);
results.push(`Peers online: ${peers.length}`);
results.push(`Push buffer: ${client.pushHistory.length} buffered`);
// Test send→ack latency per priority (doesn't need round-trip)
for (const prio of toTest) {
const sendTime = Date.now();
// Send to a peer if one exists, otherwise broadcast
const target = peers.find(p => p.displayName !== myName);
const sendResult = await client.send(
target?.pubkey ?? "*",
`__ping__ ${prio} from ${myName} at ${new Date().toISOString()}`,
prio,
);
const ackTime = Date.now();
if (!sendResult.ok) {
results.push(`[${prio}] SEND FAILED: ${sendResult.error}`);
} else {
results.push(`[${prio}] send→ack: ${ackTime - sendTime}ms (msgId: ${sendResult.messageId?.slice(0, 12)})`);
if (prio !== "now" && selfPeer?.status === "working") {
results.push(` ⚠ peer status is "working" — broker holds "${prio}" until idle`);
}
}
}
// Check if notification pipeline works
results.push("");
results.push("Pipeline check:");
results.push(` onPush handlers: active`);
results.push(` messageMode: ${messageMode}`);
results.push(` server.notification: ${messageMode === "off" ? "disabled (mode=off)" : "enabled"}`);
return text(results.join("\n"));
}
case "grant_file_access": {
const { fileId, to: grantTo } = (args ?? {}) as { fileId?: string; to?: string };
if (!fileId || !grantTo) return text("grant_file_access: `fileId` and `to` required", true);
const client = allClients()[0];
if (!client) return text("grant_file_access: not connected", true);
const peers = await client.listPeers();
const targetPeer = peers.find(p => p.pubkey === grantTo || p.displayName === grantTo);
if (!targetPeer) return text(`grant_file_access: peer not found: ${grantTo}`, true);
const result = await client.getFile(fileId);
if (!result) return text("grant_file_access: file not found", true);
if (!result.encrypted) return text("grant_file_access: file is not encrypted", true);
if (!result.sealedKey) return text("grant_file_access: no key available (are you the owner?)", true);
const { openSealedKey, sealKeyForPeer } = await import("../crypto/file-crypto");
const myPubkey = client.getSessionPubkey();
const mySecret = client.getSessionSecretKey();
if (!myPubkey || !mySecret) return text("grant_file_access: no session keypair", true);
const kf = await openSealedKey(result.sealedKey, myPubkey, mySecret);
if (!kf) return text("grant_file_access: cannot decrypt your own key", true);
const sealedForPeer = await sealKeyForPeer(kf, targetPeer.pubkey);
const ok = await client.grantFileAccess(fileId, targetPeer.pubkey, sealedForPeer);
if (!ok) return text("grant_file_access: broker did not confirm", true);
return text(`Access granted: ${targetPeer.displayName} can now download file ${fileId}`);
}
default: default:
return text(`Unknown tool: ${name}`, true); return text(`Unknown tool: ${name}`, true);
} }
@@ -722,56 +906,56 @@ Your message mode is "${messageMode}".
// any mesh's broker connection becomes a <channel source="claudemesh"> // any mesh's broker connection becomes a <channel source="claudemesh">
// system reminder injected into Claude Code's context. // system reminder injected into Claude Code's context.
for (const client of allClients()) { for (const client of allClients()) {
// Poll-based push: drain pushBuffer every 1s and emit channel notifications. // Event-driven push: WS onPush fires immediately when a message arrives.
// This is the proven approach from claude-intercom. The WS onPush handler // Claude Code's setNotificationHandler → enqueue → React useEffect pipeline
// fires instantly but server.notification() may not flush stdio reliably // processes notifications instantly (no polling needed on Claude's side).
// from an async WS callback. Polling on a timer ensures consistent delivery. // The old poll-based approach was an overcorrection — Claude Code source
if (messageMode !== "off") { // confirms event-driven notification processing.
const pushPollTimer = setInterval(async () => { client.onPush(async (msg) => {
const buffered = client.drainPushBuffer(); if (messageMode === "off") return;
for (const msg of buffered) {
const fromPubkey = msg.senderPubkey || "";
const fromName = fromPubkey
? await resolvePeerName(client, fromPubkey)
: "unknown";
if (messageMode === "inbox") { const fromPubkey = msg.senderPubkey || "";
try { const fromName = fromPubkey
await server.notification({ ? await resolvePeerName(client, fromPubkey)
method: "notifications/claude/channel", : "unknown";
params: {
content: `[inbox] New message from ${fromName}. Use check_messages to read.`,
meta: { kind: "inbox_notification", from_name: fromName },
},
});
} catch { /* best effort */ }
continue;
}
// push mode — full content if (messageMode === "inbox") {
const content = msg.plaintext ?? decryptFailedWarning(fromPubkey); try {
try { await server.notification({
await server.notification({ method: "notifications/claude/channel",
method: "notifications/claude/channel", params: {
params: { content: `[inbox] New message from ${fromName}. Use check_messages to read.`,
content, meta: { kind: "inbox_notification", from_name: fromName },
meta: { },
from_id: fromPubkey, });
from_name: fromName, } catch { /* best effort */ }
mesh_slug: client.meshSlug, return;
mesh_id: client.meshId, }
priority: msg.priority,
sent_at: msg.createdAt, // push mode — full content
delivered_at: msg.receivedAt, const content = msg.plaintext ?? decryptFailedWarning(fromPubkey);
kind: msg.kind, try {
}, await server.notification({
}, method: "notifications/claude/channel",
}); params: {
} catch { /* best effort */ } content,
} meta: {
}, 1_000); from_id: fromPubkey,
pushPollTimer.unref(); from_name: fromName,
} mesh_slug: client.meshSlug,
mesh_id: client.meshId,
priority: msg.priority,
sent_at: msg.createdAt,
delivered_at: msg.receivedAt,
kind: msg.kind,
},
},
});
process.stderr.write(`[claudemesh] pushed: from=${fromName} content=${content.slice(0, 60)}\n`);
} catch (pushErr) {
process.stderr.write(`[claudemesh] push FAILED: ${pushErr}\n`);
}
});
client.onStreamData(async (evt) => { client.onStreamData(async (evt) => {
try { try {
@@ -806,7 +990,42 @@ Your message mode is "${messageMode}".
}); });
} }
// Welcome notification: give Claude immediate context on connect.
// Triggers Claude to call mesh_info/list_peers without user input.
setTimeout(async () => {
const client = allClients()[0];
if (!client || client.status !== "open") return;
try {
const peers = await client.listPeers();
const peerNames = peers
.filter(p => p.displayName !== myName)
.map(p => p.displayName)
.join(", ") || "none";
await server.notification({
method: "notifications/claude/channel",
params: {
content: `[system] Connected as ${myName} to mesh ${client.meshSlug}. ${peers.length} peer(s) online: ${peerNames}. Call mesh_info for full details or set_summary to announce yourself.`,
meta: { kind: "welcome", mesh_slug: client.meshSlug },
},
});
} catch { /* best effort */ }
}, 3_000); // 3s delay: let WS connect + hello_ack complete first
// Event loop keepalive: Node.js stdout to a pipe is buffered. Without
// periodic event loop activity, stdout.write() from WS callbacks may not
// flush until the next I/O event. This 1s interval keeps the event loop
// ticking so channel notifications flush promptly — same pattern that made
// claude-intercom's push delivery reliable (its 1s HTTP poll had this
// effect as a side effect). The interval does nothing except prevent the
// event loop from settling.
const keepalive = setInterval(() => {
// Intentionally empty — the interval itself keeps the event loop active.
// Do NOT call .unref() — that would defeat the purpose.
}, 1_000);
void keepalive; // suppress unused warning
const shutdown = (): void => { const shutdown = (): void => {
clearInterval(keepalive);
stopAll(); stopAll();
process.exit(0); process.exit(0);
}; };

View File

@@ -203,7 +203,7 @@ export const TOOLS: Tool[] = [
{ {
name: "share_file", name: "share_file",
description: description:
"Share a persistent file with the mesh. All current and future peers can access it.", "Share a persistent file with the mesh. All current and future peers can access it. If `to` is specified, the file is E2E encrypted and only accessible to that peer (and you).",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@@ -217,6 +217,10 @@ export const TOOLS: Tool[] = [
items: { type: "string" }, items: { type: "string" },
description: "Tags for categorization", description: "Tags for categorization",
}, },
to: {
type: "string",
description: "Peer display name or pubkey hex — if set, file is E2E encrypted for this peer only",
},
}, },
required: ["path"], required: ["path"],
}, },
@@ -269,6 +273,18 @@ export const TOOLS: Tool[] = [
required: ["id"], required: ["id"],
}, },
}, },
{
name: "grant_file_access",
description: "Grant a peer access to an E2E encrypted file you shared. You must be the owner.",
inputSchema: {
type: "object",
properties: {
fileId: { type: "string", description: "File ID" },
to: { type: "string", description: "Peer display name or pubkey hex to grant access to" },
},
required: ["fileId", "to"],
},
},
// --- Vector tools --- // --- Vector tools ---
{ {
@@ -555,4 +571,21 @@ export const TOOLS: Tool[] = [
"Get a complete overview of the mesh: peers, groups, state, memory, files, tasks, streams, tables. Call on session start for full situational awareness.", "Get a complete overview of the mesh: peers, groups, state, memory, files, tasks, streams, tables. Call on session start for full situational awareness.",
inputSchema: { type: "object", properties: {} }, inputSchema: { type: "object", properties: {} },
}, },
// --- Diagnostics ---
{
name: "ping_mesh",
description:
"Send test messages through the full pipeline and measure round-trip timing per priority. Diagnoses push delivery issues.",
inputSchema: {
type: "object",
properties: {
priorities: {
type: "array",
items: { type: "string", enum: ["now", "next", "low"] },
description: "Priorities to test (default: [\"now\", \"next\"])",
},
},
},
},
]; ];

View File

@@ -37,6 +37,7 @@ export interface Config {
version: 1; version: 1;
meshes: JoinedMesh[]; meshes: JoinedMesh[];
displayName?: string; // per-session override, written by `claudemesh launch --name` displayName?: string; // per-session override, written by `claudemesh launch --name`
role?: string; // per-session role tag (display + hello)
groups?: GroupEntry[]; groups?: GroupEntry[];
messageMode?: "push" | "inbox" | "off"; messageMode?: "push" | "inbox" | "off";
} }
@@ -54,7 +55,7 @@ export function loadConfig(): Config {
if (!parsed || !Array.isArray(parsed.meshes)) { if (!parsed || !Array.isArray(parsed.meshes)) {
return { version: 1, meshes: [] }; return { version: 1, meshes: [] };
} }
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, groups: parsed.groups, messageMode: parsed.messageMode }; return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, role: parsed.role, groups: parsed.groups, messageMode: parsed.messageMode };
} catch (e) { } catch (e) {
throw new Error( throw new Error(
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`, `Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,

View File

@@ -83,6 +83,7 @@ export class BrokerClient {
private stateChangeHandlers = new Set<(change: { key: string; value: unknown; updatedBy: string }) => void>(); private stateChangeHandlers = new Set<(change: { key: string; value: unknown; updatedBy: string }) => void>();
private sessionPubkey: string | null = null; private sessionPubkey: string | null = null;
private sessionSecretKey: string | null = null; private sessionSecretKey: string | null = null;
private grantFileAccessResolvers: Array<(ok: boolean) => void> = [];
private closed = false; private closed = false;
private reconnectAttempt = 0; private reconnectAttempt = 0;
private helloTimer: NodeJS.Timeout | null = null; private helloTimer: NodeJS.Timeout | null = null;
@@ -110,6 +111,11 @@ export class BrokerClient {
return this.pushBuffer; return this.pushBuffer;
} }
/** Session public key hex (null before first connection). */
getSessionPubkey(): string | null { return this.sessionPubkey; }
/** Session secret key hex (null before first connection). */
getSessionSecretKey(): string | null { return this.sessionSecretKey; }
/** Open WS, send hello, resolve when hello_ack received. */ /** Open WS, send hello, resolve when hello_ack received. */
async connect(): Promise<void> { async connect(): Promise<void> {
if (this.closed) throw new Error("client is closed"); if (this.closed) throw new Error("client is closed");
@@ -412,7 +418,7 @@ export class BrokerClient {
/** Check delivery status of a sent message. */ /** Check delivery status of a sent message. */
private messageStatusResolvers: Array<(result: { messageId: string; targetSpec: string; delivered: boolean; deliveredAt: string | null; recipients: Array<{ name: string; pubkey: string; status: string }> } | null) => void> = []; private messageStatusResolvers: Array<(result: { messageId: string; targetSpec: string; delivered: boolean; deliveredAt: string | null; recipients: Array<{ name: string; pubkey: string; status: string }> } | null) => void> = [];
private fileUrlResolvers: Array<(result: { url: string; name: string } | null) => void> = []; private fileUrlResolvers: Array<(result: { url: string; name: string; encrypted?: boolean; sealedKey?: string } | null) => void> = [];
private fileListResolvers: Array<(files: Array<{ id: string; name: string; size: number; tags: string[]; uploadedBy: string; uploadedAt: string; persistent: boolean }>) => void> = []; private fileListResolvers: Array<(files: Array<{ id: string; name: string; size: number; tags: string[]; uploadedBy: string; uploadedAt: string; persistent: boolean }>) => void> = [];
private fileStatusResolvers: Array<(accesses: Array<{ peerName: string; accessedAt: string }>) => void> = []; private fileStatusResolvers: Array<(accesses: Array<{ peerName: string; accessedAt: string }>) => void> = [];
private vectorStoredResolvers: Array<(id: string | null) => void> = []; private vectorStoredResolvers: Array<(id: string | null) => void> = [];
@@ -444,7 +450,7 @@ export class BrokerClient {
// --- Files --- // --- Files ---
/** Get a download URL for a shared file. */ /** Get a download URL for a shared file. */
async getFile(fileId: string): Promise<{ url: string; name: string } | null> { async getFile(fileId: string): Promise<{ url: string; name: string; encrypted?: boolean; sealedKey?: string } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null; if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => { return new Promise((resolve) => {
this.fileUrlResolvers.push(resolve); this.fileUrlResolvers.push(resolve);
@@ -497,10 +503,11 @@ export class BrokerClient {
this.ws.send(JSON.stringify({ type: "delete_file", fileId })); this.ws.send(JSON.stringify({ type: "delete_file", fileId }));
} }
/** Upload a file to the broker via HTTP POST. Returns file ID or null. */ /** Upload a file to the broker via HTTP POST. Returns file ID. */
async uploadFile(filePath: string, meshId: string, memberId: string, opts: { async uploadFile(filePath: string, meshId: string, memberId: string, opts: {
name?: string; tags?: string[]; persistent?: boolean; targetSpec?: string; name?: string; tags?: string[]; persistent?: boolean; targetSpec?: string;
}): Promise<string | null> { encrypted?: boolean; ownerPubkey?: string; fileKeys?: Array<{ peerPubkey: string; sealedKey: string }>;
}): Promise<string> {
const { readFileSync } = await import("node:fs"); const { readFileSync } = await import("node:fs");
const { basename } = await import("node:path"); const { basename } = await import("node:path");
const data = readFileSync(filePath); const data = readFileSync(filePath);
@@ -522,12 +529,32 @@ export class BrokerClient {
"X-Tags": JSON.stringify(opts.tags ?? []), "X-Tags": JSON.stringify(opts.tags ?? []),
"X-Persistent": String(opts.persistent ?? true), "X-Persistent": String(opts.persistent ?? true),
"X-Target-Spec": opts.targetSpec ?? "", "X-Target-Spec": opts.targetSpec ?? "",
...(opts.encrypted ? { "X-Encrypted": "true" } : {}),
...(opts.ownerPubkey ? { "X-Owner-Pubkey": opts.ownerPubkey } : {}),
...(opts.fileKeys?.length ? { "X-File-Keys": JSON.stringify(opts.fileKeys) } : {}),
}, },
body: data, body: data,
signal: AbortSignal.timeout(30_000), signal: AbortSignal.timeout(30_000),
}); });
const body = await res.json() as { ok?: boolean; fileId?: string }; const body = await res.json() as { ok?: boolean; fileId?: string; error?: string };
return body.fileId ?? null; if (!res.ok || !body.fileId) {
throw new Error(body.error ?? `HTTP ${res.status}`);
}
return body.fileId;
}
/** Grant a peer access to an encrypted file (owner only). */
async grantFileAccess(fileId: string, peerPubkey: string, sealedKey: string): Promise<boolean> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return false;
return new Promise((resolve) => {
const resolvers = this.grantFileAccessResolvers;
resolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "grant_file_access", fileId, peerPubkey, sealedKey }));
setTimeout(() => {
const idx = resolvers.indexOf(resolve);
if (idx !== -1) { resolvers.splice(idx, 1); resolve(false); }
}, 5_000);
});
} }
// --- Vectors --- // --- Vectors ---
@@ -942,7 +969,12 @@ export class BrokerClient {
const resolver = this.fileUrlResolvers.shift(); const resolver = this.fileUrlResolvers.shift();
if (resolver) { if (resolver) {
if (msg.url) { if (msg.url) {
resolver({ url: String(msg.url), name: String(msg.name ?? "") }); resolver({
url: String(msg.url),
name: String(msg.name ?? ""),
encrypted: msg.encrypted ? true : undefined,
sealedKey: msg.sealedKey ? String(msg.sealedKey) : undefined,
});
} else { } else {
resolver(null); resolver(null);
} }
@@ -961,6 +993,11 @@ export class BrokerClient {
if (resolver) resolver(accesses); if (resolver) resolver(accesses);
return; return;
} }
if (msg.type === "grant_file_access_ok") {
const resolver = this.grantFileAccessResolvers.shift();
if (resolver) resolver(true);
return;
}
if (msg.type === "vector_stored") { if (msg.type === "vector_stored") {
const resolver = this.vectorStoredResolvers.shift(); const resolver = this.vectorStoredResolvers.shift();
if (resolver) resolver(msg.id ? String(msg.id) : null); if (resolver) resolver(msg.id ? String(msg.id) : null);

View File

@@ -12,6 +12,7 @@ import { env } from "../env";
const clients = new Map<string, BrokerClient>(); const clients = new Map<string, BrokerClient>();
let configDisplayName: string | undefined; let configDisplayName: string | undefined;
let configGroups: Config["groups"] = [];
/** Ensure a BrokerClient exists + is connecting/open for this mesh. */ /** Ensure a BrokerClient exists + is connecting/open for this mesh. */
export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> { export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
@@ -21,6 +22,10 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
clients.set(mesh.meshId, client); clients.set(mesh.meshId, client);
try { try {
await client.connect(); await client.connect();
// Auto-join groups declared at launch time (--groups flag or config).
for (const g of configGroups ?? []) {
try { await client.joinGroup(g.name, g.role); } catch { /* best effort */ }
}
} catch { } catch {
// Connect failed → client is in "reconnecting" state, leave it // Connect failed → client is in "reconnecting" state, leave it
// wired so tool calls can surface the status. // wired so tool calls can surface the status.
@@ -31,6 +36,7 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
/** Start clients for every joined mesh. Called once on MCP server start. */ /** Start clients for every joined mesh. Called once on MCP server start. */
export async function startClients(config: Config): Promise<void> { export async function startClients(config: Config): Promise<void> {
configDisplayName = config.displayName; configDisplayName = config.displayName;
configGroups = config.groups ?? [];
await Promise.allSettled(config.meshes.map(ensureClient)); await Promise.allSettled(config.meshes.map(ensureClient));
} }

View File

@@ -25,6 +25,9 @@ ENV NEXT_PUBLIC_URL=$NEXT_PUBLIC_URL
ENV NEXT_PUBLIC_PRODUCT_NAME=$NEXT_PUBLIC_PRODUCT_NAME ENV NEXT_PUBLIC_PRODUCT_NAME=$NEXT_PUBLIC_PRODUCT_NAME
ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
# TURBOPACK=0 forces webpack for production build — Payload CMS's
# richtext-lexical CSS imports fail under Turbopack.
ENV TURBOPACK=0
RUN npx turbo run build --filter=web... RUN npx turbo run build --filter=web...
# Stage 2: runtime — standalone output only # Stage 2: runtime — standalone output only

View File

@@ -4,7 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "next build", "build": "next build --no-turbopack",
"clean": "git clean -xdf .cache .next .turbo node_modules", "clean": "git clean -xdf .cache .next .turbo node_modules",
"dev": "next dev", "dev": "next dev",
"format": "prettier --check . --ignore-path ../../.gitignore", "format": "prettier --check . --ignore-path ../../.gitignore",

9036
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
ALTER TABLE "mesh"."file" ADD COLUMN "encrypted" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "mesh"."file" ADD COLUMN "owner_pubkey" text;--> statement-breakpoint
CREATE TABLE "mesh"."file_key" (
"id" text PRIMARY KEY NOT NULL,
"file_id" text NOT NULL,
"peer_pubkey" text NOT NULL,
"sealed_key" text NOT NULL,
"granted_at" timestamp DEFAULT now() NOT NULL,
"granted_by_pubkey" text
);
--> statement-breakpoint
ALTER TABLE "mesh"."file_key" ADD CONSTRAINT "file_key_file_id_fkey" FOREIGN KEY ("file_id") REFERENCES "mesh"."file"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "file_key_file_peer_idx" ON "mesh"."file_key" ("file_id","peer_pubkey");

View File

@@ -305,6 +305,8 @@ export const meshFile = meshSchema.table("file", {
minioKey: text().notNull(), minioKey: text().notNull(),
tags: text().array().default([]), tags: text().array().default([]),
persistent: boolean().notNull().default(true), persistent: boolean().notNull().default(true),
encrypted: boolean().notNull().default(false),
ownerPubkey: text(),
uploadedByName: text(), uploadedByName: text(),
uploadedByMember: text().references(() => meshMember.id), uploadedByMember: text().references(() => meshMember.id),
targetSpec: text(), // null = entire mesh targetSpec: text(), // null = entire mesh
@@ -327,6 +329,29 @@ export const meshFileAccess = meshSchema.table("file_access", {
accessedAt: timestamp().defaultNow().notNull(), accessedAt: timestamp().defaultNow().notNull(),
}); });
/**
* Per-peer encrypted symmetric keys for E2E encrypted files.
* The file body is encrypted with a random key (Kf); Kf is sealed
* (crypto_box_seal) to each authorized peer's X25519 pubkey and stored here.
*/
export const meshFileKey = meshSchema.table("file_key", {
id: text().primaryKey().notNull().$defaultFn(generateId),
fileId: text()
.references(() => meshFile.id, { onDelete: "cascade" })
.notNull(),
peerPubkey: text().notNull(),
sealedKey: text().notNull(),
grantedAt: timestamp().defaultNow().notNull(),
grantedByPubkey: text(),
});
export const meshFileKeyRelations = relations(meshFileKey, ({ one }) => ({
file: one(meshFile, {
fields: [meshFileKey.fileId],
references: [meshFile.id],
}),
}));
/** /**
* Per-peer context snapshot. Each peer (presence) has at most one context * Per-peer context snapshot. Each peer (presence) has at most one context
* entry per mesh, upserted on each share_context call. Allows peers to * entry per mesh, upserted on each share_context call. Allows peers to
@@ -531,6 +556,10 @@ export type SelectMeshFile = typeof meshFile.$inferSelect;
export type InsertMeshFile = typeof meshFile.$inferInsert; export type InsertMeshFile = typeof meshFile.$inferInsert;
export type SelectMeshFileAccess = typeof meshFileAccess.$inferSelect; export type SelectMeshFileAccess = typeof meshFileAccess.$inferSelect;
export type InsertMeshFileAccess = typeof meshFileAccess.$inferInsert; export type InsertMeshFileAccess = typeof meshFileAccess.$inferInsert;
export const selectMeshFileKeySchema = createSelectSchema(meshFileKey);
export const insertMeshFileKeySchema = createInsertSchema(meshFileKey);
export type SelectMeshFileKey = typeof meshFileKey.$inferSelect;
export type InsertMeshFileKey = typeof meshFileKey.$inferInsert;
export const selectMeshContextSchema = createSelectSchema(meshContext); export const selectMeshContextSchema = createSelectSchema(meshContext);
export const insertMeshContextSchema = createInsertSchema(meshContext); export const insertMeshContextSchema = createInsertSchema(meshContext);
export const selectMeshTaskSchema = createSelectSchema(meshTask); export const selectMeshTaskSchema = createSelectSchema(meshTask);