feat(ndr): add NDR channel plugin for nostr-double-ratchet

Adds NDR (Nostr Double Ratchet) as a channel extension providing
forward-secure E2E encrypted messaging over Nostr relays.

Features:
- Full channel implementation with send/receive/react
- Onboarding flow with invite URL acceptance and hello message
- Auto-generated identity if not configured
- Configurable relays (defaults to popular Nostr relays)
- Owner pubkey authentication for message filtering

Requires external `ndr` CLI (cargo install ndr).

Control surface: chat.iris.to

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Iris Deploy 2026-01-27 12:08:32 +01:00
parent f4004054ab
commit e954a55f1f
16 changed files with 1389 additions and 95 deletions

181
extensions/ndr/README.md Normal file
View File

@ -0,0 +1,181 @@
# @clawdbot/ndr
Clawdbot channel plugin for [nostr-double-ratchet](https://files.iris.to/#/npub1xndmdgymsf4a34rzr7346vp8qcptxf75pjqweh8naa8rklgxpfqqmfjtce/nostr-double-ratchet) - forward-secure end-to-end encrypted messaging over Nostr.
Compatible with [chat.iris.to](https://chat.iris.to).
## Features
- **Forward secrecy** - Past messages remain secure even if keys are compromised
- **Double ratchet encryption** - Based on Signal's proven protocol
- **Nostr transport** - Messages sent via Nostr relays
- **CLI integration** - Uses the `ndr` CLI for encryption/decryption
## Prerequisites
Install Rust and the required CLIs:
```bash
curl -sSf https://sh.rustup.rs | sh && cargo install ndr hashtree-cli
```
- **ndr** - Required for double ratchet encryption
- **hashtree-cli** - Optional, for encrypted media uploads via [hashtree](https://github.com/mmalmi/hashtree)
## Installation
```bash
clawdbot plugins install @clawdbot/ndr
```
Or link for development:
```bash
clawdbot plugins install -l ./extensions/ndr
```
## Configuration
Add to your `~/.clawdbot/clawdbot.json`:
```json5
{
channels: {
ndr: {
// Owner's pubkey - only messages from this npub are handled as commands
ownerPubkey: "npub1...",
// Optional: Nostr relays (defaults shown below)
relays: [
"wss://temp.iris.to",
"wss://relay.snort.social",
"wss://relay.primal.net",
"wss://relay.damus.io",
"wss://offchain.pub"
],
// Optional: Path to ndr CLI (default: "ndr" in PATH)
ndrPath: "/path/to/ndr",
// Optional: Custom data directory for ndr
dataDir: "~/.ndr-clawdbot",
// Optional: Private key (hex or nsec). If not provided, ndr auto-generates one.
// privateKey: "nsec1...",
}
}
}
```
**Authorization:**
- Only messages from `ownerPubkey` are handled as agent commands
- Messages from other pubkeys are logged but ignored (for now)
- If `ownerPubkey` is not set, all messages are handled (legacy behavior)
## Setup
The ndr CLI auto-generates an identity on first use.
### 1. Check your identity
```bash
ndr whoami
```
This shows your npub. Share this with people who want to message you.
### 2. Create an invite (to let others connect to you)
```bash
ndr invite create --label "clawdbot"
```
Share the invite URL with the person you want to chat with. They accept it with `ndr chat join <url>`.
### 3. Join someone else's invite
```bash
ndr chat join <invite_url>
```
This creates a chat session. You can now send/receive messages.
### 4. Configure your owner pubkey
Add your npub to the config so only you can control the agent:
```json5
{
channels: {
ndr: {
ownerPubkey: "npub1..." // your npub from step 1
}
}
}
```
### 5. Start the gateway
```bash
clawdbot gateway run
```
## Usage
### Check channel status
```bash
clawdbot channels status --channel ndr
```
### List active chats
```bash
ndr chat list
```
### Send a message
```bash
clawdbot message send --channel ndr --to <chat_id> --message "Hello!"
```
## How it works
1. **Initialization** - ndr auto-generates an identity if not logged in
2. **Listening** - Runs `ndr listen` to receive incoming messages
3. **Receiving** - Decrypts messages using the double ratchet session
4. **Sending** - Uses `ndr send` to encrypt and publish messages
5. **Session management** - ndr handles key rotation automatically
## Security
- **No key exposure** - Private keys are only passed to the ndr CLI
- **Forward secrecy** - Each message uses a unique encryption key
- **Session isolation** - Each chat has its own ratchet state
## Comparison with Nostr NIP-04
| Feature | NDR (Double Ratchet) | Nostr NIP-04 |
|---------|---------------------|--------------|
| Forward secrecy | Yes | No |
| Key rotation | Automatic | None |
| Session state | Required | Stateless |
| Complexity | Higher | Lower |
Use NDR for high-security conversations where forward secrecy matters.
Use NIP-04 for simpler use cases where stateless encryption is acceptable.
## Troubleshooting
### "ndr: command not found"
Install ndr CLI: `cargo install ndr`
### "Failed to send message"
Check that:
1. You have an active chat session with the recipient
2. The relay is reachable
3. ndr CLI is working: `ndr chat list`

View File

@ -0,0 +1,11 @@
{
"id": "ndr",
"channels": [
"ndr"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

18
extensions/ndr/index.ts Normal file
View File

@ -0,0 +1,18 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { ndrPlugin } from "./src/channel.js";
import { setNdrRuntime } from "./src/runtime.js";
const plugin = {
id: "ndr",
name: "NDR",
description: "Forward-secure E2E encryption via nostr-double-ratchet",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setNdrRuntime(api.runtime);
api.registerChannel({ plugin: ndrPlugin });
},
};
export default plugin;

View File

@ -0,0 +1,28 @@
{
"name": "ndr",
"version": "0.0.1",
"type": "module",
"description": "Clawdbot channel plugin for nostr-double-ratchet (forward-secure E2E encryption)",
"keywords": ["clawdbot", "nostr", "double-ratchet", "ndr", "e2e", "encryption", "chat"],
"repository": {
"type": "git",
"url": "https://github.com/mmalmi/nostr-double-ratchet"
},
"license": "MIT",
"clawdbot": {
"id": "ndr",
"extensions": [
"./index.ts"
]
},
"dependencies": {
"nostr-tools": "^2.20.0",
"zod": "^4.3.6"
},
"devDependencies": {
"clawdbot": "workspace:*"
},
"peerDependencies": {
"clawdbot": "*"
}
}

View File

@ -0,0 +1,360 @@
import {
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
type ChannelPlugin,
createReplyPrefixContext,
} from "clawdbot/plugin-sdk";
import { NdrConfigSchema } from "./config-schema.js";
import { getNdrRuntime } from "./runtime.js";
import {
listNdrAccountIds,
resolveDefaultNdrAccountId,
resolveNdrAccount,
type ResolvedNdrAccount,
} from "./types.js";
import { startNdrBus, type NdrBusHandle } from "./ndr-bus.js";
import { ndrOnboardingAdapter } from "./onboarding.js";
// Store active bus handles per account
const activeBuses = new Map<string, NdrBusHandle>();
export const ndrPlugin: ChannelPlugin<ResolvedNdrAccount> = {
id: "ndr",
meta: {
id: "ndr",
label: "NDR",
selectionLabel: "NDR (Nostr Double Ratchet)",
docsPath: "/channels/ndr",
docsLabel: "ndr",
blurb: "Forward-secure E2E encryption via double ratchet over Nostr (chat.iris.to).",
order: 56,
selectionExtras: ["https://chat.iris.to"],
quickstartAllowFrom: true,
},
capabilities: {
chatTypes: ["direct"], // DMs only
media: false, // No media support yet
},
reload: { configPrefixes: ["channels.ndr"] },
configSchema: buildChannelConfigSchema(NdrConfigSchema),
onboarding: ndrOnboardingAdapter,
config: {
listAccountIds: (cfg) => listNdrAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveNdrAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultNdrAccountId(cfg),
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
publicKey: account.publicKey,
}),
},
// Authorization is handled by NDR's invite/accept flow.
// Only users with an established double ratchet session can message.
// No pairing/allowFrom config needed - the invite exchange IS the authorization.
messaging: {
normalizeTarget: (target) => {
// NDR uses chat IDs, not pubkeys directly
return target.trim();
},
targetResolver: {
looksLikeId: (input) => {
const trimmed = input.trim();
// Chat IDs are short hex strings
return /^[0-9a-fA-F]{8}$/.test(trimmed) || trimmed.startsWith("npub1");
},
hint: "<chat_id|npub>",
},
},
outbound: {
deliveryMode: "direct",
textChunkLimit: 4000,
sendText: async ({ to, text, accountId }) => {
const core = getNdrRuntime();
const aid = accountId ?? DEFAULT_ACCOUNT_ID;
const bus = activeBuses.get(aid);
if (!bus) {
throw new Error(`NDR bus not running for account ${aid}`);
}
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: core.config.loadConfig(),
channel: "ndr",
accountId: aid,
});
const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode);
await bus.sendMessage(to, message);
return { channel: "ndr", to };
},
sendMedia: async ({ to, media, accountId }) => {
const core = getNdrRuntime();
const aid = accountId ?? DEFAULT_ACCOUNT_ID;
const bus = activeBuses.get(aid);
if (!bus) {
throw new Error(`NDR bus not running for account ${aid}`);
}
const caption = media.caption ? `${media.caption}\n` : "";
// Try to upload via htree if we have a local file path
let mediaLink = media.url ?? "[media attachment]";
if (media.path) {
try {
const { execSync } = await import("child_process");
// Properly escape the file path for shell
const escapedPath = media.path.replace(/'/g, "'\\''");
const output = execSync(`htree add '${escapedPath}'`, {
encoding: "utf-8",
timeout: 60000,
});
// Parse "url: nhash1.../filename" from output
const urlMatch = output.match(/url:\s+(nhash1[^\s]+)/);
if (urlMatch) {
mediaLink = urlMatch[1];
}
} catch {
// htree not available or failed - fall back to URL or placeholder
mediaLink = media.url ?? "[media: upload failed]";
}
}
const message = `${caption}${mediaLink}`;
await bus.sendMessage(to, message);
return { channel: "ndr", to };
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: (accounts) =>
accounts.flatMap((account) => {
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
if (!lastError) return [];
return [
{
channel: "ndr",
accountId: account.accountId,
kind: "runtime" as const,
message: `Channel error: ${lastError}`,
},
];
}),
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
publicKey: snapshot.publicKey ?? null,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
}),
buildAccountSnapshot: ({ account, runtime }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
publicKey: account.publicKey,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
}),
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
ctx.setStatus({
accountId: account.accountId,
publicKey: account.publicKey,
});
ctx.log?.info(`[${account.accountId}] starting NDR provider (pubkey: ${account.publicKey})`);
// Note: account.configured is always true since ndr auto-generates identity
const runtime = getNdrRuntime();
const bus = await startNdrBus({
accountId: account.accountId,
privateKey: account.privateKey,
relays: account.relays,
ndrPath: account.ndrPath,
dataDir: account.dataDir,
onMessage: async (chatId, messageId, senderPubkey, text, replyFn) => {
ctx.log?.debug(`[${account.accountId}] Message from ${senderPubkey} in chat ${chatId}: ${text.slice(0, 50)}...`);
// React with "eyes" emoji to indicate we're processing (like WhatsApp "typing" indicator)
if (messageId) {
try {
await bus.react(chatId, messageId, "👀");
} catch {
// Reaction failed, continue anyway
}
}
// Check if sender is the owner
// Note: senderPubkey is the ephemeral key used in the message, not the identity key.
// We need to look up the chat's their_pubkey (identity) to compare with ownerPubkey.
let identityPubkey = senderPubkey; // fallback
try {
const chats = await bus.listChats();
const chat = chats.find((c) => c.id === chatId);
if (chat) {
identityPubkey = chat.their_pubkey;
}
} catch {
// If lookup fails, fall back to senderPubkey
}
const isOwner = account.ownerPubkey && identityPubkey === account.ownerPubkey;
if (!isOwner && account.ownerPubkey) {
// Non-owner message - log and ignore
ctx.log?.info(`[${account.accountId}] Ignoring message from non-owner ${identityPubkey}`);
return;
}
// Process the message through clawdbot's reply pipeline
const cfg = runtime.config.loadConfig();
const ndrTo = `ndr:${chatId}`;
// Resolve agent route for this chat
const route = runtime.channel.routing.resolveAgentRoute({
cfg,
channel: "ndr",
accountId: account.accountId,
peer: { kind: "dm", id: chatId },
});
// Build the envelope for the message
const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg);
const body = runtime.channel.reply.formatInboundEnvelope({
channel: "NDR",
from: identityPubkey.slice(0, 16) + "...",
body: text,
chatType: "direct",
sender: { name: identityPubkey.slice(0, 8), id: identityPubkey },
envelope: envelopeOptions,
});
// Finalize the inbound context
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
Body: body,
RawBody: text,
CommandBody: text,
From: `ndr:${identityPubkey}`,
To: ndrTo,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: "direct" as const,
ConversationLabel: `NDR chat ${chatId}`,
SenderName: identityPubkey.slice(0, 8),
SenderId: identityPubkey,
Provider: "ndr" as const,
Surface: "ndr" as const,
MessageSid: `${chatId}-${Date.now()}`,
CommandAuthorized: true, // Owner is always authorized
OriginatingChannel: "ndr" as const,
OriginatingTo: ndrTo,
});
// Record the session
const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
await runtime.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
updateLastRoute: {
sessionKey: route.mainSessionKey,
channel: "ndr",
to: chatId,
accountId: route.accountId,
},
});
// Create reply prefix context
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
// Create dispatcher with typing (simplified - no typing indicator for NDR)
const { dispatcher, replyOptions, markDispatchIdle } = runtime.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: runtime.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload) => {
ctx.log?.info(`[${account.accountId}] NDR deliver called with payload: ${JSON.stringify(payload).slice(0, 200)}`);
const responseText = payload.text ?? "";
if (responseText) {
ctx.log?.info(`[${account.accountId}] NDR sending reply: ${responseText.slice(0, 100)}...`);
await replyFn(responseText);
ctx.log?.info(`[${account.accountId}] NDR reply sent successfully`);
} else {
ctx.log?.warn(`[${account.accountId}] NDR deliver called but no text in payload`);
}
},
onError: (err, info) => {
ctx.log?.error(`[${account.accountId}] NDR reply failed (${info.kind}): ${String(err)}`);
},
});
// Dispatch the message
await runtime.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
onModelSelected: (modelCtx) => {
prefixContext.onModelSelected(modelCtx);
},
},
});
markDispatchIdle();
},
onError: (error, context) => {
ctx.log?.error(`[${account.accountId}] NDR error (${context}): ${error.message}`);
},
onConnect: () => {
ctx.log?.info(`[${account.accountId}] NDR listener started`);
},
onDisconnect: () => {
ctx.log?.warn(`[${account.accountId}] NDR listener disconnected`);
},
});
// Store the bus handle
activeBuses.set(account.accountId, bus);
ctx.log?.info(`[${account.accountId}] NDR provider started`);
// Return cleanup function
return {
stop: () => {
bus.close();
activeBuses.delete(account.accountId);
ctx.log?.info(`[${account.accountId}] NDR provider stopped`);
},
};
},
},
};
/**
* Get all active NDR bus handles
*/
export function getActiveNdrBuses(): Map<string, NdrBusHandle> {
return new Map(activeBuses);
}

View File

@ -0,0 +1,38 @@
import { z } from "zod";
/**
* NDR channel configuration schema
*/
export const NdrConfigSchema = z.object({
/** Private key for signing (hex or nsec format). If not provided, ndr auto-generates one. */
privateKey: z.string().optional(),
/** Owner's pubkey (npub or hex). Only messages from this pubkey are handled as commands. */
ownerPubkey: z.string().optional(),
/** Nostr relays to connect to */
relays: z.array(z.string()).optional(),
/** Whether the channel is enabled */
enabled: z.boolean().optional(),
/** Display name for the account */
name: z.string().optional(),
/** Path to ndr CLI binary (defaults to 'ndr' in PATH) */
ndrPath: z.string().optional(),
/** Custom data directory for ndr */
dataDir: z.string().optional(),
});
export type NdrConfig = z.infer<typeof NdrConfigSchema>;
/** Default relays if none configured */
export const DEFAULT_RELAYS = [
"wss://temp.iris.to",
"wss://relay.snort.social",
"wss://relay.primal.net",
"wss://relay.damus.io",
"wss://offchain.pub",
];

View File

@ -0,0 +1,301 @@
import { spawn, type ChildProcess } from "child_process";
export interface NdrBusOptions {
accountId: string;
privateKey: string;
relays: string[];
ndrPath: string;
dataDir: string | null;
onMessage: (chatId: string, messageId: string, senderPubkey: string, text: string, reply: (text: string) => Promise<void>) => Promise<void>;
onNewSession?: (chatId: string, theirPubkey: string) => Promise<void>;
onError?: (error: Error, context: string) => void;
onConnect?: () => void;
onDisconnect?: () => void;
}
export interface NdrBusHandle {
sendMessage: (chatId: string, text: string) => Promise<void>;
react: (chatId: string, messageId: string, emoji: string) => Promise<void>;
createInvite: () => Promise<{ inviteUrl: string; inviteId: string }>;
joinInvite: (inviteUrl: string) => Promise<{ chatId: string; theirPubkey: string }>;
listChats: () => Promise<Array<{ id: string; their_pubkey: string }>>;
close: () => void;
isRunning: () => boolean;
}
/**
* Start the NDR bus - manages ndr CLI process for listening and sending
*
* The `ndr listen` command handles both incoming messages AND invite responses,
* so we only need a single listener process.
*/
export async function startNdrBus(options: NdrBusOptions): Promise<NdrBusHandle> {
const {
privateKey,
relays,
ndrPath,
dataDir,
onMessage,
onNewSession,
onError,
onConnect,
onDisconnect,
} = options;
let listenProcess: ChildProcess | null = null;
let running = false;
// Build common args
const baseArgs: string[] = ["--json"];
if (dataDir) {
baseArgs.push("--data-dir", dataDir);
}
// Initialize: login with provided key, or let ndr auto-generate on first use
if (privateKey) {
await runNdrCommand(ndrPath, [...baseArgs, "login", privateKey]);
}
// If no privateKey, ndr will auto-generate identity when needed (invite/listen/send)
// Update relay config if needed (preserve existing config like private_key)
if (dataDir && relays.length > 0) {
const configPath = `${dataDir}/config.json`;
const fs = await import("fs/promises");
try {
let existingConfig: Record<string, unknown> = {};
try {
const content = await fs.readFile(configPath, "utf-8");
existingConfig = JSON.parse(content);
} catch {
// Config doesn't exist yet, start fresh
}
// Merge relays into existing config, preserving other fields like private_key
const mergedConfig = { ...existingConfig, relays };
await fs.writeFile(configPath, JSON.stringify(mergedConfig, null, 2));
} catch {
// Config dir may not exist yet, ndr will create it
}
}
// Start listening for messages and invite responses (both handled by `ndr listen`)
const startListening = () => {
listenProcess = spawn(ndrPath, [...baseArgs, "listen"], {
stdio: ["ignore", "pipe", "pipe"],
});
listenProcess.stdout?.on("data", (data: Buffer) => {
const lines = data.toString().split("\n").filter(Boolean);
for (const line of lines) {
try {
const json = JSON.parse(line);
// Handle incoming messages
if (json.event === "message") {
const chatId = json.chat_id;
const messageId = json.message_id ?? "";
const senderPubkey = json.from_pubkey;
const content = json.content;
// Create reply function
const reply = async (text: string) => {
await runNdrCommand(ndrPath, [...baseArgs, "send", chatId, text]);
};
onMessage(chatId, messageId, senderPubkey, content, reply).catch((err) => {
onError?.(err, "message_handler");
});
}
// Handle new sessions from invite responses
if (json.event === "session_created") {
const chatId = json.chat_id;
const theirPubkey = json.their_pubkey;
onNewSession?.(chatId, theirPubkey).catch((err) => {
onError?.(err, "new_session_handler");
});
}
} catch {
// Ignore non-JSON lines
}
}
});
listenProcess.stderr?.on("data", (data: Buffer) => {
const text = data.toString().trim();
if (text && !text.includes("Listening")) {
onError?.(new Error(text), "listen_stderr");
}
});
listenProcess.on("exit", (code) => {
if (running && code !== 0) {
onError?.(new Error(`ndr listen exited with code ${code}`), "listen_exit");
setTimeout(() => running && startListening(), 5000);
}
});
listenProcess.on("error", (err) => {
onError?.(err, "listen_spawn");
});
};
running = true;
onConnect?.();
startListening();
return {
sendMessage: async (chatId: string, text: string) => {
const result = await runNdrCommand(ndrPath, [...baseArgs, "send", chatId, text]);
if (result.status !== "ok") {
throw new Error(result.error || "Failed to send message");
}
},
react: async (chatId: string, messageId: string, emoji: string) => {
const result = await runNdrCommand(ndrPath, [...baseArgs, "react", chatId, messageId, emoji]);
if (result.status !== "ok") {
throw new Error(result.error || "Failed to send reaction");
}
},
createInvite: async () => {
const result = await runNdrCommand(ndrPath, [...baseArgs, "invite", "create"]);
if (result.status !== "ok") {
throw new Error(result.error || "Failed to create invite");
}
const data = result.data as { url: string; id: string };
return { inviteUrl: data.url, inviteId: data.id };
},
joinInvite: async (inviteUrl: string) => {
const result = await runNdrCommand(ndrPath, [...baseArgs, "chat", "join", inviteUrl]);
if (result.status !== "ok") {
throw new Error(result.error || "Failed to join invite");
}
const data = result.data as { id: string; their_pubkey: string };
return { chatId: data.id, theirPubkey: data.their_pubkey };
},
listChats: async () => {
const result = await runNdrCommand(ndrPath, [...baseArgs, "chat", "list"]);
if (result.status === "ok" && result.data) {
const data = result.data as { chats: Array<{ id: string; their_pubkey: string }> };
return data.chats || [];
}
return [];
},
close: () => {
running = false;
onDisconnect?.();
if (listenProcess) {
listenProcess.kill();
listenProcess = null;
}
},
isRunning: () => running,
};
}
/**
* Run an ndr CLI command and return parsed JSON output
*/
async function runNdrCommand(ndrPath: string, args: string[]): Promise<{ status: string; error?: string; data?: unknown }> {
return new Promise((resolve, reject) => {
const proc = spawn(ndrPath, args, {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
proc.stdout?.on("data", (data: Buffer) => {
stdout += data.toString();
});
proc.stderr?.on("data", (data: Buffer) => {
stderr += data.toString();
});
proc.on("exit", (code) => {
if (code === 0) {
try {
const json = JSON.parse(stdout.trim());
resolve(json);
} catch {
resolve({ status: "ok", data: stdout.trim() });
}
} else {
resolve({ status: "error", error: stderr.trim() || `Exit code ${code}` });
}
});
proc.on("error", (err) => {
reject(err);
});
});
}
/**
* Get chat list from ndr
*/
export async function listChats(ndrPath: string, dataDir: string | null): Promise<Array<{ id: string; their_pubkey: string }>> {
const args = ["--json"];
if (dataDir) {
args.push("--data-dir", dataDir);
}
args.push("chat", "list");
const result = await runNdrCommand(ndrPath, args);
if (result.status === "ok" && Array.isArray(result.data)) {
return result.data as Array<{ id: string; their_pubkey: string }>;
}
return [];
}
/**
* Join a chat via invite URL
*/
export async function joinChat(
ndrPath: string,
dataDir: string | null,
inviteUrl: string
): Promise<{ chatId: string; theirPubkey: string }> {
const args = ["--json"];
if (dataDir) {
args.push("--data-dir", dataDir);
}
args.push("chat", "join", inviteUrl);
const result = await runNdrCommand(ndrPath, args);
if (result.status !== "ok") {
throw new Error(result.error || "Failed to join chat");
}
const data = result.data as { id: string; their_pubkey: string };
return { chatId: data.id, theirPubkey: data.their_pubkey };
}
/**
* Create an invite
*/
export async function createInvite(
ndrPath: string,
dataDir: string | null
): Promise<{ inviteUrl: string; inviteId: string }> {
const args = ["--json"];
if (dataDir) {
args.push("--data-dir", dataDir);
}
args.push("invite", "create");
const result = await runNdrCommand(ndrPath, args);
if (result.status !== "ok") {
throw new Error(result.error || "Failed to create invite");
}
const data = result.data as { url: string; id: string };
return { inviteUrl: data.url, inviteId: data.id };
}

View File

@ -0,0 +1,245 @@
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { listNdrAccountIds, resolveNdrAccount, resolveDefaultNdrAccountId } from "./types.js";
const channel = "ndr" as const;
type ChannelOnboardingAdapter = {
channel: typeof channel;
getStatus: (ctx: {
cfg: ClawdbotConfig;
}) => Promise<{
channel: typeof channel;
configured: boolean;
statusLines: string[];
selectionHint?: string;
quickstartScore?: number;
}>;
configure: (ctx: {
cfg: ClawdbotConfig;
prompter: {
note: (message: string, title?: string) => Promise<void>;
text: (opts: {
message: string;
placeholder?: string;
initialValue?: string;
validate?: (value: string | undefined) => string | undefined;
}) => Promise<string>;
confirm: (opts: { message: string; initialValue?: boolean }) => Promise<boolean>;
};
}) => Promise<{ cfg: ClawdbotConfig; accountId?: string }>;
disable?: (cfg: ClawdbotConfig) => ClawdbotConfig;
};
/**
* Parse an NDR invite URL to extract the inviter's hex pubkey.
* Invite URL format: https://iris.to#{"inviter":"<hex>","ephemeralKey":"...","sharedSecret":"..."}
*/
function parseInviteUrl(url: string): { inviter: string } | null {
try {
const trimmed = url.trim();
// Handle both full URL and just the fragment
let fragment = trimmed;
if (trimmed.includes("#")) {
fragment = trimmed.split("#")[1] ?? "";
}
const decoded = decodeURIComponent(fragment);
const data = JSON.parse(decoded) as { inviter?: string };
if (data.inviter && /^[0-9a-fA-F]{64}$/.test(data.inviter)) {
return { inviter: data.inviter };
}
} catch {
// Invalid URL format
}
return null;
}
export const ndrOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const accountIds = listNdrAccountIds(cfg);
const hasAccount = accountIds.length > 0;
const defaultAccountId = resolveDefaultNdrAccountId(cfg);
const account = resolveNdrAccount({ cfg, accountId: defaultAccountId });
const enabled = account.enabled;
return {
channel,
configured: hasAccount && enabled,
statusLines: [
`NDR: ${hasAccount ? (enabled ? "configured" : "disabled") : "not configured"}`,
],
selectionHint: hasAccount
? enabled
? "configured"
: "disabled"
: "E2E encrypted · Nostr",
quickstartScore: hasAccount && enabled ? 1 : 5,
};
},
configure: async ({ cfg, prompter }) => {
await prompter.note(
[
"NDR (Nostr Double Ratchet) provides forward-secure E2E encrypted messaging.",
"",
"Install dependencies (requires Rust):",
" curl -sSf https://sh.rustup.rs | sh && cargo install ndr hashtree-cli",
"",
"Control surface: chat.iris.to",
].join("\n"),
"NDR Setup",
);
// Check if ndr CLI is available
let ndrAvailable = false;
try {
const { execSync } = await import("child_process");
execSync("ndr --version", { stdio: "ignore" });
ndrAvailable = true;
} catch {
// ndr not found
}
if (!ndrAvailable) {
await prompter.note(
[
"ndr CLI not found in PATH.",
"",
"Install: cargo install ndr",
"",
"You can configure now and install ndr later.",
].join("\n"),
"Warning",
);
}
// Ask for invite URL from chat.iris.to
await prompter.note(
[
"To connect your bot to your account:",
"",
"1. Go to chat.iris.to",
"2. Click 'New Chat' (+ button)",
"3. Click 'Copy your chat link'",
"4. Paste the invite URL below",
"",
"The bot will accept your invite and send a hello message.",
].join("\n"),
"Chat Invite",
);
const inviteUrl = await prompter.text({
message: "Paste your chat invite URL from chat.iris.to",
placeholder: "https://chat.iris.to/#...",
validate: (value) => {
if (!value?.trim()) return "Required";
const parsed = parseInviteUrl(value);
if (!parsed) return "Invalid invite URL. Should be from chat.iris.to";
return undefined;
},
});
const parsed = parseInviteUrl(inviteUrl);
if (!parsed) {
await prompter.note("Failed to parse invite URL.", "Error");
return { cfg };
}
const ownerPubkey = parsed.inviter;
// Try to accept the invite and send hello
let chatId: string | null = null;
let joinError: string | null = null;
let sendError: string | null = null;
if (ndrAvailable) {
const { execSync } = await import("child_process");
const os = await import("os");
const path = await import("path");
// Use ~/.clawdbot/ndr-data to match channel plugin's default dataDir
const ndrDataDir = path.join(os.homedir(), ".clawdbot", "ndr-data");
const ndrCmd = `ndr --data-dir "${ndrDataDir}" --json`;
// Accept the invite
try {
const joinOutput = execSync(`${ndrCmd} chat join "${inviteUrl.trim()}"`, {
encoding: "utf-8",
timeout: 60000,
});
// Parse JSON output for chat ID
// Format: { "status": "ok", "command": "chat.join", "data": { "id": "..." } }
try {
const joinResult = JSON.parse(joinOutput) as {
data?: { id?: string; chat_id?: string };
id?: string;
chat_id?: string;
};
chatId = joinResult.data?.id ?? joinResult.data?.chat_id ?? joinResult.id ?? joinResult.chat_id ?? null;
} catch {
// Fallback: parse from text output
const chatMatch = joinOutput.match(/chat[:\s_]+([0-9a-fA-F]{8})/i);
if (chatMatch) chatId = chatMatch[1];
}
} catch (err) {
joinError = err instanceof Error ? err.message : String(err);
}
// Send hello message if we got a chat ID
if (chatId) {
try {
execSync(`${ndrCmd} send "${chatId}" "Hello! I'm your clawdbot agent. 🤖"`, {
encoding: "utf-8",
timeout: 30000,
});
} catch (err) {
sendError = err instanceof Error ? err.message : String(err);
}
}
}
const next: ClawdbotConfig = {
...cfg,
channels: {
...cfg.channels,
ndr: {
...cfg.channels?.ndr,
enabled: true,
ownerPubkey,
},
},
};
const successMsg: string[] = ["NDR channel configured!", "", `Owner: ${ownerPubkey.slice(0, 16)}...`];
if (chatId) {
successMsg.push("", `Chat established: ${chatId}`);
if (sendError) {
successMsg.push("", `Warning: Failed to send hello message: ${sendError.slice(0, 100)}`);
} else {
successMsg.push("", "Hello message sent! Check chat.iris.to");
}
} else if (joinError) {
successMsg.push("", `Warning: Failed to join chat: ${joinError.slice(0, 100)}`);
successMsg.push("", "Join manually:", ` ndr chat join "${inviteUrl.trim()}"`);
} else if (!ndrAvailable) {
successMsg.push("", "Install ndr CLI and join manually:", ` ndr chat join "${inviteUrl.trim()}"`);
}
successMsg.push("", "Start the gateway: clawdbot gateway run");
await prompter.note(successMsg.join("\n"), "Setup Complete");
return { cfg: next };
},
disable: (cfg) => ({
...cfg,
channels: {
...cfg.channels,
ndr: { ...cfg.channels?.ndr, enabled: false },
},
}),
};

View File

@ -0,0 +1,14 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setNdrRuntime(r: PluginRuntime): void {
runtime = r;
}
export function getNdrRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("NDR runtime not initialized");
}
return runtime;
}

136
extensions/ndr/src/types.ts Normal file
View File

@ -0,0 +1,136 @@
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { getPublicKey } from "nostr-tools";
import { homedir } from "os";
import { join } from "path";
import { DEFAULT_RELAYS, type NdrConfig } from "./config-schema.js";
/**
* Expand ~ to home directory
*/
function expandTilde(p: string): string {
if (p.startsWith("~/")) {
return join(homedir(), p.slice(2));
}
return p;
}
export interface ResolvedNdrAccount {
accountId: string;
name: string;
enabled: boolean;
configured: boolean;
privateKey: string;
publicKey: string | null;
ownerPubkey: string | null;
relays: string[];
ndrPath: string;
dataDir: string | null;
config: NdrConfig;
}
/**
* List all configured NDR account IDs
*/
export function listNdrAccountIds(cfg: ClawdbotConfig): string[] {
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
const ndrConfig = channels.ndr;
if (!ndrConfig || typeof ndrConfig !== "object") {
return [];
}
// For now, only support single "default" account
return ["default"];
}
/**
* Resolve the default NDR account ID
*/
export function resolveDefaultNdrAccountId(cfg: ClawdbotConfig): string | undefined {
const ids = listNdrAccountIds(cfg);
return ids.length > 0 ? ids[0] : undefined;
}
/**
* Resolve NDR account configuration
*/
export function resolveNdrAccount(opts: {
cfg: ClawdbotConfig;
accountId?: string;
}): ResolvedNdrAccount {
const { cfg, accountId = "default" } = opts;
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
const ndrConfig = (channels.ndr ?? {}) as NdrConfig;
const privateKey = ndrConfig.privateKey ?? "";
let publicKey: string | null = null;
if (privateKey) {
try {
// Handle hex format
const keyBytes = hexToBytes(privateKey);
publicKey = getPublicKey(keyBytes);
} catch {
// Invalid key format
}
}
// ndr auto-generates identity if not provided, so we're always "configured"
// The actual identity will be created/loaded by ndr on first use
const configured = true;
const relays = ndrConfig.relays ?? DEFAULT_RELAYS;
// Normalize owner pubkey to hex if provided
let ownerPubkey: string | null = null;
if (ndrConfig.ownerPubkey) {
ownerPubkey = normalizePubkey(ndrConfig.ownerPubkey);
}
return {
accountId,
name: ndrConfig.name ?? "NDR",
enabled: ndrConfig.enabled !== false,
configured,
privateKey,
publicKey,
ownerPubkey,
relays,
ndrPath: ndrConfig.ndrPath ?? "ndr",
// Default to ~/.clawdbot/ndr-data for persistence (container mounts ~/.clawdbot)
dataDir: expandTilde(ndrConfig.dataDir ?? "~/.clawdbot/ndr-data"),
config: ndrConfig,
};
}
/**
* Convert hex string to Uint8Array
*/
function hexToBytes(hex: string): Uint8Array {
const cleaned = hex.replace(/^0x/, "");
const bytes = new Uint8Array(cleaned.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(cleaned.substr(i * 2, 2), 16);
}
return bytes;
}
/**
* Normalize a pubkey to hex format
*/
function normalizePubkey(input: string): string {
const trimmed = input.trim();
// Already hex
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) {
return trimmed.toLowerCase();
}
// npub format - decode bech32
if (trimmed.startsWith("npub1")) {
const { nip19 } = require("nostr-tools");
const decoded = nip19.decode(trimmed);
if (decoded.type === "npub") {
return decoded.data as string;
}
}
throw new Error(`Invalid pubkey format: ${input}`);
}

View File

@ -248,7 +248,8 @@
"overrides": {
"@sinclair/typebox": "0.34.47",
"hono": "4.11.4",
"tar": "7.5.4"
"tar": "7.5.4",
"clawdbot": "workspace:moltbot@*"
}
},
"vitest": {

117
pnpm-lock.yaml generated
View File

@ -8,6 +8,7 @@ overrides:
'@sinclair/typebox': 0.34.47
hono: 4.11.4
tar: 7.5.4
clawdbot: workspace:moltbot@*
importers:
@ -315,7 +316,7 @@ importers:
version: 10.5.0
devDependencies:
clawdbot:
specifier: workspace:*
specifier: workspace:moltbot@*
version: link:../..
extensions/imessage: {}
@ -323,7 +324,7 @@ importers:
extensions/line:
devDependencies:
clawdbot:
specifier: workspace:*
specifier: workspace:moltbot@*
version: link:../..
extensions/llm-task: {}
@ -349,7 +350,7 @@ importers:
version: 4.3.6
devDependencies:
clawdbot:
specifier: workspace:*
specifier: workspace:moltbot@*
version: link:../..
extensions/mattermost: {}
@ -357,8 +358,8 @@ importers:
extensions/memory-core:
dependencies:
clawdbot:
specifier: '>=2026.1.24-3'
version: 2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3)
specifier: workspace:moltbot@*
version: link:../..
extensions/memory-lancedb:
dependencies:
@ -384,7 +385,7 @@ importers:
specifier: ^1.2.2
version: 1.2.2
clawdbot:
specifier: workspace:*
specifier: workspace:moltbot@*
version: link:../..
express:
specifier: ^5.2.1
@ -393,12 +394,25 @@ importers:
specifier: ^4.1.2
version: 4.1.2
extensions/ndr:
dependencies:
nostr-tools:
specifier: ^2.20.0
version: 2.20.0(typescript@5.9.3)
zod:
specifier: ^4.3.6
version: 4.3.6
devDependencies:
clawdbot:
specifier: workspace:moltbot@*
version: link:../..
extensions/nextcloud-talk: {}
extensions/nostr:
dependencies:
clawdbot:
specifier: workspace:*
specifier: workspace:moltbot@*
version: link:../..
nostr-tools:
specifier: ^2.20.0
@ -440,7 +454,7 @@ importers:
version: 4.3.6
devDependencies:
clawdbot:
specifier: workspace:*
specifier: workspace:moltbot@*
version: link:../..
extensions/voice-call:
@ -460,7 +474,7 @@ importers:
extensions/zalo:
dependencies:
clawdbot:
specifier: workspace:*
specifier: workspace:moltbot@*
version: link:../..
undici:
specifier: 7.19.0
@ -472,7 +486,7 @@ importers:
specifier: 0.34.47
version: 0.34.47
clawdbot:
specifier: workspace:*
specifier: workspace:moltbot@*
version: link:../..
ui:
@ -3208,11 +3222,6 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
clawdbot@2026.1.24-3:
resolution: {integrity: sha512-zt9BzhWXduq8ZZR4rfzQDurQWAgmijTTyPZCQGrn5ew6wCEwhxxEr2/NHG7IlCwcfRsKymsY4se9KMhoNz0JtQ==}
engines: {node: '>=22.12.0'}
hasBin: true
cli-cursor@5.0.0:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'}
@ -9092,84 +9101,6 @@ snapshots:
dependencies:
clsx: 2.1.1
clawdbot@2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3):
dependencies:
'@agentclientprotocol/sdk': 0.13.1(zod@4.3.6)
'@aws-sdk/client-bedrock': 3.975.0
'@buape/carbon': 0.14.0(hono@4.11.4)
'@clack/prompts': 0.11.0
'@grammyjs/runner': 2.0.3(grammy@1.39.3)
'@grammyjs/transformer-throttler': 1.2.1(grammy@1.39.3)
'@homebridge/ciao': 1.3.4
'@line/bot-sdk': 10.6.0
'@lydell/node-pty': 1.2.0-beta.3
'@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-coding-agent': 0.49.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-tui': 0.49.3
'@mozilla/readability': 0.6.0
'@sinclair/typebox': 0.34.47
'@slack/bolt': 4.6.0(@types/express@5.0.6)
'@slack/web-api': 7.13.0
'@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)
ajv: 8.17.1
body-parser: 2.2.2
chalk: 5.6.2
chokidar: 5.0.0
chromium-bidi: 13.0.1(devtools-protocol@0.0.1561482)
cli-highlight: 2.1.11
commander: 14.0.2
croner: 9.1.0
detect-libc: 2.1.2
discord-api-types: 0.38.37
dotenv: 17.2.3
express: 5.2.1
file-type: 21.3.0
grammy: 1.39.3
hono: 4.11.4
jiti: 2.6.1
json5: 2.2.3
jszip: 3.10.1
linkedom: 0.18.12
long: 5.3.2
markdown-it: 14.1.0
node-edge-tts: 1.2.9
osc-progress: 0.3.0
pdfjs-dist: 5.4.530
playwright-core: 1.58.0
proper-lockfile: 4.1.2
qrcode-terminal: 0.12.0
sharp: 0.34.5
sqlite-vec: 0.1.7-alpha.2
tar: 7.5.4
tslog: 4.10.2
undici: 7.19.0
ws: 8.19.0
yaml: 2.8.2
zod: 4.3.6
optionalDependencies:
'@napi-rs/canvas': 0.1.88
node-llama-cpp: 3.15.0(typescript@5.9.3)
transitivePeerDependencies:
- '@discordjs/opus'
- '@modelcontextprotocol/sdk'
- '@types/express'
- audio-decode
- aws-crt
- bufferutil
- canvas
- debug
- devtools-protocol
- encoding
- ffmpeg-static
- jimp
- link-preview-js
- node-opus
- opusscript
- supports-color
- typescript
- utf-8-validate
cli-cursor@5.0.0:
dependencies:
restore-cursor: 5.1.0

View File

@ -37,6 +37,18 @@ compute_hash() {
| awk '{print $1}'
}
# In Docker builds (vendor/ and apps/ excluded), skip bundling if output exists
for path in "${INPUT_PATHS[@]}"; do
if [[ ! -e "$path" ]]; then
if [[ -f "$OUTPUT_FILE" ]]; then
echo "A2UI bundle exists; source files unavailable (docker build?); skipping."
exit 0
fi
echo "Error: Required path $path not found and no pre-built bundle exists." >&2
exit 1
fi
done
current_hash="$(compute_hash)"
if [[ -f "$HASH_FILE" ]]; then
previous_hash="$(cat "$HASH_FILE")"

View File

@ -1 +1 @@
2567ca5bbc065b922d96717a488d5db3120b5b033c5d0508682d1aa8fbba470a
53ab7a34ec406ea6b1a3170a79b3dde01b33a676bb8870215a9f230bf007bf94

View File

@ -369,6 +369,13 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
},
},
},
ndr: {
id: "ndr",
capabilities: {
chatTypes: ["direct"],
},
outbound: { textChunkLimit: 4000 },
},
};
function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock {

View File

@ -12,6 +12,7 @@ export const CHAT_CHANNEL_ORDER = [
"slack",
"signal",
"imessage",
"ndr",
] as const;
export type ChatChannelId = (typeof CHAT_CHANNEL_ORDER)[number];
@ -98,6 +99,16 @@ const CHAT_CHANNEL_META: Record<ChatChannelId, ChannelMeta> = {
blurb: "this is still a work in progress.",
systemImage: "message.fill",
},
ndr: {
id: "ndr",
label: "NDR",
selectionLabel: "NDR (Nostr Double Ratchet)",
detailLabel: "NDR",
docsPath: "/channels/ndr",
docsLabel: "ndr",
blurb: "forward-secure E2E encryption via double ratchet over Nostr (chat.iris.to).",
selectionExtras: ["https://chat.iris.to"],
},
};
export const CHAT_CHANNEL_ALIASES: Record<string, ChatChannelId> = {