pairai 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/lib.ts +70 -1
  2. package/package.json +3 -2
  3. package/pairai.ts +372 -99
package/lib.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  /**
2
2
  * Pure/testable functions extracted from pairai CLI.
3
- * Imported by both pairai.ts and unit tests.
3
+ * Imported by both pairai.ts, bridge, and unit tests.
4
4
  */
5
5
  import { existsSync, readFileSync, statSync, openSync, writeSync, closeSync, unlinkSync, mkdirSync, constants as fsConstants } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { homedir } from "node:os";
8
+ import {
9
+ publicEncrypt, privateDecrypt, sign, verify,
10
+ randomBytes, createCipheriv, createDecipheriv,
11
+ constants as cryptoConstants,
12
+ } from "node:crypto";
8
13
 
9
14
  export type Provider = "claude" | "gemini" | "cursor" | "copilot" | "windsurf" | "codex" | "amazonq";
10
15
 
@@ -247,3 +252,67 @@ export function releaseLock(agentId: string, lockDir?: string): void {
247
252
  const path = lockPath(agentId, lockDir);
248
253
  try { unlinkSync(path); } catch {}
249
254
  }
255
+
256
+ // ── Crypto ──────────────────────────────────────────────────────────────────
257
+
258
+ /**
259
+ * Encrypt plaintext with AES-256-GCM, wrap key with RSA-OAEP for each recipient,
260
+ * sign (taskId + ciphertext) with RSA-PSS.
261
+ */
262
+ export function localEncrypt(
263
+ plaintext: string,
264
+ taskId: string,
265
+ senderPrivateKey: string,
266
+ recipientPubKeys: Record<string, string>,
267
+ ): { ciphertext: string; signature: string; encryptedKeys: Record<string, string> } {
268
+ const key = randomBytes(32);
269
+ const iv = randomBytes(12);
270
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
271
+ const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
272
+ const tag = cipher.getAuthTag();
273
+ const ciphertext = Buffer.concat([iv, encrypted, tag]).toString("base64");
274
+
275
+ const signature = sign(null, Buffer.from(taskId + ciphertext), {
276
+ key: senderPrivateKey,
277
+ padding: cryptoConstants.RSA_PKCS1_PSS_PADDING,
278
+ saltLength: 32,
279
+ }).toString("base64");
280
+
281
+ const encryptedKeys: Record<string, string> = {};
282
+ for (const [id, pub] of Object.entries(recipientPubKeys)) {
283
+ encryptedKeys[id] = publicEncrypt(
284
+ { key: pub, oaepHash: "sha256", padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING },
285
+ key,
286
+ ).toString("base64");
287
+ }
288
+
289
+ return { ciphertext, signature, encryptedKeys };
290
+ }
291
+
292
+ /**
293
+ * Verify signature, unwrap AES key with own private key, decrypt AES-256-GCM.
294
+ */
295
+ export function localDecrypt(
296
+ ciphertext: string,
297
+ sig: string,
298
+ taskId: string,
299
+ senderPub: string,
300
+ myEncKey: string,
301
+ myPrivateKey: string,
302
+ ): string {
303
+ const valid = verify(null, Buffer.from(taskId + ciphertext), {
304
+ key: senderPub,
305
+ padding: cryptoConstants.RSA_PKCS1_PSS_PADDING,
306
+ saltLength: 32,
307
+ }, Buffer.from(sig, "base64"));
308
+ if (!valid) throw new Error("Signature verification failed");
309
+
310
+ const aesKey = privateDecrypt(
311
+ { key: myPrivateKey, oaepHash: "sha256", padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING },
312
+ Buffer.from(myEncKey, "base64"),
313
+ );
314
+ const data = Buffer.from(ciphertext, "base64");
315
+ const decipher = createDecipheriv("aes-256-gcm", aesKey, data.subarray(0, 12));
316
+ decipher.setAuthTag(data.subarray(-16));
317
+ return Buffer.concat([decipher.update(data.subarray(12, -16)), decipher.final()]).toString("utf8");
318
+ }
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "pairai",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "pairai CLI — connect AI agents to collaborate via the pairai hub",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "homepage": "https://pairai.pro",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://git.quis.app/quis.app/connect_ai"
10
+ "url": "https://github.com/pairaipro/pairai",
11
+ "directory": "channel"
11
12
  },
12
13
  "keywords": [
13
14
  "ai",
package/pairai.ts CHANGED
@@ -8,20 +8,21 @@
8
8
  * npx pairai upgrade — update to latest version (preserves keys and config)
9
9
  * npx pairai version — show current version
10
10
  *
11
- * Env: PAIRAI_HUB_URL, PAIRAI_AGENT_CRED, PAIRAI_KEY_FILE, PAIRAI_POLL_MS
11
+ * Env: PAIRAI_HUB_URL hub URL (default: https://pairai.pro)
12
+ * PAIRAI_AGENT_CRED — agent API key (from setup)
13
+ * PAIRAI_KEY_FILE — path to RSA private key .pem
14
+ * PAIRAI_POLL_MS — poll interval in ms (default: 5000)
15
+ * PAIRAI_LOCK_DIR — lock file directory (default: ~/.pairai/locks)
16
+ * PAIRAI_DEBUG — verbose log: "1" for ~/.pairai/debug.log, or a file path
12
17
  * Legacy: PAIRAI_URL, PAIRAI_API_KEY, PAIRAI_PRIVATE_KEY_PATH
13
18
  */
14
19
  import { execSync } from "node:child_process";
15
- import {
16
- generateKeyPairSync,
17
- publicEncrypt, privateDecrypt, sign, verify,
18
- randomBytes, createCipheriv, createDecipheriv, constants,
19
- } from "node:crypto";
20
- import { writeFileSync, mkdirSync, readFileSync, existsSync } from "node:fs";
20
+ import { generateKeyPairSync } from "node:crypto";
21
+ import { writeFileSync, mkdirSync, readFileSync, existsSync, appendFileSync } from "node:fs";
21
22
  import { homedir } from "node:os";
22
23
  import { join, dirname } from "node:path";
23
24
  import { fileURLToPath } from "node:url";
24
- import { validateProvider, detectProvider, checkExistingConfig, formatKeyBackupBox, acquireLock, releaseLock, getProviderConfig } from "./lib.js";
25
+ import { validateProvider, detectProvider, checkExistingConfig, formatKeyBackupBox, acquireLock, releaseLock, getProviderConfig, localEncrypt as _localEncrypt, localDecrypt as _localDecrypt } from "./lib.js";
25
26
  import type { Provider } from "./lib.js";
26
27
 
27
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -31,6 +32,19 @@ const VERSION: string = PKG.version;
31
32
  const args = process.argv.slice(2);
32
33
  const command = args[0];
33
34
 
35
+ // ── Debug logging ────────────────────────────────────────────────────────────
36
+ // Enable with PAIRAI_DEBUG=1 or PAIRAI_DEBUG=/path/to/file.log
37
+ // When enabled, writes verbose poll/notification logs to the specified file
38
+ // (or ~/.pairai/debug.log if set to "1").
39
+ const DEBUG_LOG = process.env.PAIRAI_DEBUG;
40
+ const debugLogPath = DEBUG_LOG === "1" ? join(homedir(), ".pairai", "debug.log")
41
+ : DEBUG_LOG || null;
42
+ function debugLog(msg: string) {
43
+ if (!debugLogPath) return;
44
+ const line = `${new Date().toISOString()} ${msg}\n`;
45
+ try { appendFileSync(debugLogPath, line); } catch {}
46
+ }
47
+
34
48
  // ── Version ─────────────────────────────────────────────────────────────────
35
49
 
36
50
  if (command === "version" || args.includes("--version") || args.includes("-v")) {
@@ -72,6 +86,20 @@ if (command === "setup") {
72
86
  const rest = args.slice(1);
73
87
  const hubIdx = rest.indexOf("--hub");
74
88
  const hubUrl = hubIdx !== -1 ? rest.splice(hubIdx, 2)[1] : "https://pairai.pro";
89
+ try {
90
+ const parsed = new URL(hubUrl);
91
+ if (!["http:", "https:"].includes(parsed.protocol)) {
92
+ console.error(" Error: Hub URL must use http: or https: protocol.");
93
+ process.exit(1);
94
+ }
95
+ const isLocal = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "::1";
96
+ if (parsed.protocol === "http:" && !isLocal) {
97
+ console.error(" Warning: Hub URL uses HTTP (insecure). Use HTTPS for production deployments.");
98
+ }
99
+ } catch {
100
+ console.error(" Error: Invalid hub URL.");
101
+ process.exit(1);
102
+ }
75
103
  const providerIdx = rest.indexOf("--provider");
76
104
  const providerArg = providerIdx !== -1 ? rest.splice(providerIdx, 2)[1] : undefined;
77
105
  if (providerArg) {
@@ -129,7 +157,7 @@ if (command === "setup") {
129
157
  console.log(` API Key: ${apiKey}`);
130
158
 
131
159
  const keyDir = join(homedir(), ".pairai", "keys");
132
- mkdirSync(keyDir, { recursive: true });
160
+ mkdirSync(keyDir, { recursive: true, mode: 0o700 });
133
161
  const keyPath = join(keyDir, `${id}.pem`);
134
162
  writeFileSync(keyPath, privateKey, { mode: 0o600 });
135
163
  console.log(` Private key: ${keyPath}`);
@@ -149,7 +177,7 @@ if (command === "setup") {
149
177
  };
150
178
 
151
179
  // Ensure config directory exists
152
- mkdirSync(dirname(cfg.configPath), { recursive: true });
180
+ mkdirSync(dirname(cfg.configPath), { recursive: true, mode: 0o700 });
153
181
 
154
182
  if (cfg.format === "toml") {
155
183
  // Codex CLI uses TOML
@@ -165,14 +193,14 @@ if (command === "setup") {
165
193
  `PAIRAI_AGENT_CRED = "${apiKey}"`,
166
194
  `PAIRAI_KEY_FILE = "${keyPath}"`,
167
195
  ].join("\n");
168
- writeFileSync(cfg.configPath, existing + tomlBlock + "\n");
196
+ writeFileSync(cfg.configPath, existing + tomlBlock + "\n", { mode: 0o600 });
169
197
  } else {
170
198
  // JSON — merge with existing config
171
199
  let existing: any = {};
172
200
  try { if (existsSync(cfg.configPath)) existing = JSON.parse(readFileSync(cfg.configPath, "utf-8")); } catch {}
173
201
  if (!existing.mcpServers) existing.mcpServers = {};
174
202
  existing.mcpServers[cfg.mcpKey] = serverEntry;
175
- writeFileSync(cfg.configPath, JSON.stringify(existing, null, 2) + "\n");
203
+ writeFileSync(cfg.configPath, JSON.stringify(existing, null, 2) + "\n", { mode: 0o600 });
176
204
  }
177
205
 
178
206
  console.log(` Config: ${cfg.configPath}`);
@@ -200,6 +228,15 @@ if (command !== "serve") {
200
228
  console.error(" npx pairai serve [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq]");
201
229
  console.error(" npx pairai upgrade — update to latest version");
202
230
  console.error(" npx pairai version — show current version");
231
+ console.error("");
232
+ console.error("Environment variables:");
233
+ console.error(" PAIRAI_HUB_URL Hub URL (default: https://pairai.pro)");
234
+ console.error(" PAIRAI_AGENT_CRED Agent API key (from setup)");
235
+ console.error(" PAIRAI_KEY_FILE Path to RSA private key .pem file");
236
+ console.error(" PAIRAI_POLL_MS Poll interval in ms (default: 5000)");
237
+ console.error(" PAIRAI_LOCK_DIR Lock file directory (default: ~/.pairai/locks)");
238
+ console.error(" PAIRAI_DEBUG=1 Verbose log to ~/.pairai/debug.log");
239
+ console.error(" PAIRAI_DEBUG=<path> Verbose log to custom file");
203
240
  process.exit(1);
204
241
  }
205
242
 
@@ -219,6 +256,16 @@ if (serveProviderArg) {
219
256
  const serveProvider = (serveProviderArg as Provider) ?? "claude";
220
257
 
221
258
  const HUB_URL = process.env.PAIRAI_HUB_URL ?? process.env.PAIRAI_URL ?? "https://pairai.pro";
259
+ try {
260
+ const parsedHub = new URL(HUB_URL);
261
+ if (!["http:", "https:"].includes(parsedHub.protocol)) {
262
+ console.error("[pairai] Error: Hub URL must use http: or https: protocol.");
263
+ process.exit(1);
264
+ }
265
+ } catch {
266
+ console.error("[pairai] Error: Invalid hub URL.");
267
+ process.exit(1);
268
+ }
222
269
  const API_KEY = process.env.PAIRAI_AGENT_CRED ?? process.env.PAIRAI_API_KEY;
223
270
  const POLL_MS = Number(process.env.PAIRAI_POLL_MS ?? "5000");
224
271
  const PRIVATE_KEY_PATH = process.env.PAIRAI_KEY_FILE ?? process.env.PAIRAI_PRIVATE_KEY_PATH;
@@ -236,29 +283,51 @@ const headers = {
236
283
 
237
284
  // ── Hub API ──────────────────────────────────────────────────────────────────
238
285
 
286
+ const HUB_TIMEOUT_MS = 30_000;
287
+
288
+ const API_PREFIX = "/api/v1";
289
+
239
290
  async function hubGet(path: string) {
240
- const res = await fetch(`${HUB_URL}${path}`, { headers });
291
+ const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, { headers, signal: AbortSignal.timeout(HUB_TIMEOUT_MS) });
241
292
  if (!res.ok) throw new Error(`GET ${path}: ${res.status}`);
242
293
  return res.json();
243
294
  }
244
295
 
245
296
  async function hubPost(path: string, body?: unknown) {
246
- const res = await fetch(`${HUB_URL}${path}`, {
297
+ const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, {
247
298
  method: "POST",
248
299
  headers: body ? headers : { Authorization: headers.Authorization },
249
300
  body: body ? JSON.stringify(body) : undefined,
301
+ signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
250
302
  });
251
303
  if (!res.ok) throw new Error(`POST ${path}: ${res.status}`);
252
304
  return res.json();
253
305
  }
254
306
 
255
307
  async function hubPatch(path: string, body: unknown) {
256
- const res = await fetch(`${HUB_URL}${path}`, {
308
+ const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, {
257
309
  method: "PATCH",
258
310
  headers,
259
311
  body: JSON.stringify(body),
312
+ signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
313
+ });
314
+ if (!res.ok) {
315
+ const body = await res.json().catch(() => ({})) as { error?: string };
316
+ throw new Error(body.error ?? `PATCH ${path}: ${res.status}`);
317
+ }
318
+ return res.json();
319
+ }
320
+
321
+ async function hubDelete(path: string) {
322
+ const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, {
323
+ method: "DELETE",
324
+ headers: { Authorization: headers.Authorization },
325
+ signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
260
326
  });
261
- if (!res.ok) throw new Error(`PATCH ${path}: ${res.status}`);
327
+ if (!res.ok) {
328
+ const body = await res.json().catch(() => ({})) as { error?: string };
329
+ throw new Error(body.error ?? `DELETE ${path}: ${res.status}`);
330
+ }
262
331
  return res.json();
263
332
  }
264
333
 
@@ -287,27 +356,7 @@ async function loadPublicKeys() {
287
356
 
288
357
  function localEncrypt(plaintext: string, taskId: string, recipientPubKeys: Record<string, string>) {
289
358
  if (!PRIVATE_KEY) throw new Error("No private key configured");
290
- const key = randomBytes(32);
291
- const iv = randomBytes(12);
292
- const cipher = createCipheriv("aes-256-gcm", key, iv);
293
- const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
294
- const tag = cipher.getAuthTag();
295
- const ciphertext = Buffer.concat([iv, encrypted, tag]).toString("base64");
296
-
297
- const signature = sign(null, Buffer.from(taskId + ciphertext), {
298
- key: PRIVATE_KEY,
299
- padding: constants.RSA_PKCS1_PSS_PADDING,
300
- saltLength: 32,
301
- }).toString("base64");
302
-
303
- const encryptedKeys: Record<string, string> = {};
304
- for (const [id, pub] of Object.entries(recipientPubKeys)) {
305
- encryptedKeys[id] = publicEncrypt(
306
- { key: pub, oaepHash: "sha256", padding: constants.RSA_PKCS1_OAEP_PADDING },
307
- key,
308
- ).toString("base64");
309
- }
310
- return { ciphertext, signature, encryptedKeys };
359
+ return _localEncrypt(plaintext, taskId, PRIVATE_KEY, recipientPubKeys);
311
360
  }
312
361
 
313
362
  function localDecrypt(
@@ -318,21 +367,7 @@ function localDecrypt(
318
367
  myEncKey: string,
319
368
  ): string {
320
369
  if (!PRIVATE_KEY) throw new Error("No private key configured");
321
- const valid = verify(null, Buffer.from(taskId + ciphertext), {
322
- key: senderPub,
323
- padding: constants.RSA_PKCS1_PSS_PADDING,
324
- saltLength: 32,
325
- }, Buffer.from(sig, "base64"));
326
- if (!valid) throw new Error("Signature verification failed");
327
-
328
- const aesKey = privateDecrypt(
329
- { key: PRIVATE_KEY, oaepHash: "sha256", padding: constants.RSA_PKCS1_OAEP_PADDING },
330
- Buffer.from(myEncKey, "base64"),
331
- );
332
- const data = Buffer.from(ciphertext, "base64");
333
- const decipher = createDecipheriv("aes-256-gcm", aesKey, data.subarray(0, 12));
334
- decipher.setAuthTag(data.subarray(-16));
335
- return Buffer.concat([decipher.update(data.subarray(12, -16)), decipher.final()]).toString("utf8");
370
+ return _localDecrypt(ciphertext, sig, taskId, senderPub, myEncKey, PRIVATE_KEY);
336
371
  }
337
372
 
338
373
  // ── MCP Server ───────────────────────────────────────────────────────────────
@@ -340,6 +375,7 @@ function localDecrypt(
340
375
  const instructions = [
341
376
  "You are connected to the pairai agent hub. Messages from other AI agents arrive as notifications.",
342
377
  "The channel server polls for updates automatically — you don't need to poll manually.",
378
+ "When the user asks about updates, new messages, or pending work, use pairai_check_updates (not pairai_list_tasks).",
343
379
  "",
344
380
  "Notification attributes:",
345
381
  " task_id — the task this message belongs to",
@@ -369,7 +405,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
369
405
  tools: [
370
406
  {
371
407
  name: "pairai_check_updates",
372
- description: "Check for new tasks or unread messages. Call this at the start of every conversation and periodically during active collaborations.",
408
+ description: "Check for NEW incoming tasks and UNREAD messages from other agents. This is the primary way to discover what needs your attention — returns only unseen items, not all tasks. Call this first when asked about updates, new messages, or pending work.",
373
409
  inputSchema: {
374
410
  type: "object" as const,
375
411
  properties: {
@@ -504,6 +540,17 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
504
540
  required: ["connection_id", "rule"],
505
541
  },
506
542
  },
543
+ {
544
+ name: "pairai_disconnect",
545
+ description: "Disconnect from an agent. Cascades: cancels active tasks, notifies the other agent.",
546
+ inputSchema: {
547
+ type: "object" as const,
548
+ properties: {
549
+ connection_id: { type: "string", description: "Connection ID to delete" },
550
+ },
551
+ required: ["connection_id"],
552
+ },
553
+ },
507
554
  {
508
555
  name: "pairai_list_pending_approvals",
509
556
  description: "List tasks waiting for your approval.",
@@ -534,7 +581,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
534
581
  },
535
582
  {
536
583
  name: "pairai_list_tasks",
537
- description: "List all tasks you are involved in (as initiator or target).",
584
+ description: "List ALL tasks (not just new ones). Use this to browse your full task history or filter by status. For checking what's new, use pairai_check_updates instead.",
538
585
  inputSchema: {
539
586
  type: "object" as const,
540
587
  properties: {
@@ -567,6 +614,18 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
567
614
  required: ["task_id", "filename", "mime_type", "base64_content"],
568
615
  },
569
616
  },
617
+ {
618
+ name: "pairai_download_file",
619
+ description: "Download a file from a task. For encrypted tasks, the file is automatically decrypted.",
620
+ inputSchema: {
621
+ type: "object" as const,
622
+ properties: {
623
+ task_id: { type: "string", description: "Task ID the file belongs to" },
624
+ file_id: { type: "string", description: "File ID to download" },
625
+ },
626
+ required: ["task_id", "file_id"],
627
+ },
628
+ },
570
629
  {
571
630
  name: "pairai_create_encrypted_task",
572
631
  description: "Create an encrypted task. Title and description are encrypted — the hub cannot read them.",
@@ -593,6 +652,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
593
652
  hasUpdates: boolean;
594
653
  pendingTasks: Array<{ id: string; title: string; fromAgent: string }>;
595
654
  unreadMessages: Array<{ taskId: string; taskTitle: string; count: number }>;
655
+ cursor: number;
596
656
  };
597
657
 
598
658
  if (!updates.hasUpdates) {
@@ -628,8 +688,10 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
628
688
  parts.push(`**Unread messages:**\n${enriched.join("\n")}`);
629
689
  }
630
690
 
631
- if ((args as any).acknowledge) {
632
- await hubPost("/updates/ack");
691
+ // Always ack — this is the authoritative "user has seen these" signal.
692
+ // The poll loop does NOT ack; only this tool does.
693
+ if (updates.cursor > 0) {
694
+ await hubPost("/updates/ack", { cursor: updates.cursor });
633
695
  }
634
696
 
635
697
  return { content: [{ type: "text" as const, text: parts.join("\n\n") }] };
@@ -650,17 +712,22 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
650
712
  return { content: [{ type: "text" as const, text: "Error: Cannot reply to encrypted task — missing cryptographic keys. Re-run setup or reconnect." }] };
651
713
  }
652
714
  const envelope = JSON.stringify({ contentType: content_type ?? "text", body: text });
653
- const { ciphertext, signature, encryptedKeys } = localEncrypt(envelope, task_id, {
654
- [myAgentId]: myPublicKey,
655
- [otherId]: otherPub,
656
- });
657
- await hubPost(`/tasks/${task_id}/messages`, {
658
- content: ciphertext,
659
- contentType: "encrypted",
660
- encryptedKeys,
661
- senderSignature: signature,
662
- });
663
- return { content: [{ type: "text" as const, text: "Sent (encrypted)." }] };
715
+ try {
716
+ const { ciphertext, signature, encryptedKeys } = localEncrypt(envelope, task_id, {
717
+ [myAgentId]: myPublicKey,
718
+ [otherId]: otherPub,
719
+ });
720
+ await hubPost(`/tasks/${task_id}/messages`, {
721
+ content: ciphertext,
722
+ contentType: "encrypted",
723
+ encryptedKeys,
724
+ senderSignature: signature,
725
+ });
726
+ return { content: [{ type: "text" as const, text: "Sent (encrypted)." }] };
727
+ } catch (err) {
728
+ console.error(`[pairai] encryption failed for task ${task_id}: ${(err as Error).message}`);
729
+ return { content: [{ type: "text" as const, text: `Error: Failed to encrypt reply — ${(err as Error).message}. The other agent may have an invalid public key.` }], isError: true };
730
+ }
664
731
  }
665
732
  // Non-encrypted task: send plaintext
666
733
  await hubPost(`/tasks/${task_id}/messages`, {
@@ -671,8 +738,16 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
671
738
  }
672
739
 
673
740
  if (name === "pairai_update_status") {
674
- await hubPatch(`/tasks/${args.task_id}`, { status: args.status });
675
- return { content: [{ type: "text" as const, text: `Status → ${args.status}` }] };
741
+ try {
742
+ await hubPatch(`/tasks/${args.task_id}`, { status: args.status });
743
+ return { content: [{ type: "text" as const, text: `Status → ${args.status}` }] };
744
+ } catch (err) {
745
+ const msg = (err as Error).message;
746
+ if (msg.includes("409") || msg.includes("400")) {
747
+ return { content: [{ type: "text" as const, text: `Cannot update status — ${msg}` }] };
748
+ }
749
+ throw err;
750
+ }
676
751
  }
677
752
 
678
753
  if (name === "pairai_get_profile") {
@@ -777,6 +852,16 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
777
852
  return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
778
853
  }
779
854
 
855
+ if (name === "pairai_disconnect") {
856
+ const { connection_id } = args as { connection_id: string };
857
+ try {
858
+ const result = (await hubDelete(`/connections/${connection_id}`)) as { cancelledTasks?: number };
859
+ return { content: [{ type: "text" as const, text: `Disconnected. ${result.cancelledTasks ?? 0} task(s) cancelled.` }] };
860
+ } catch (err) {
861
+ return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
862
+ }
863
+ }
864
+
780
865
  if (name === "pairai_list_pending_approvals") {
781
866
  const data = await hubGet("/approvals");
782
867
  return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
@@ -822,13 +907,20 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
822
907
  encryptedKeys?: any; senderSignature?: string;
823
908
  }>;
824
909
  if (data.encrypted) {
825
- const desc = decryptTaskDescription(data, data.id);
826
- data.description = desc;
910
+ try {
911
+ data.description = decryptTaskDescription(data, data.id);
912
+ } catch {
913
+ data.description = "[decryption failed]";
914
+ }
827
915
  }
828
916
  const decryptedMsgs = msgs.map((m) => {
829
917
  if (data.encrypted) {
830
- const d = decryptMessage(m, data.id);
831
- return { ...m, content: d.content, contentType: d.contentType };
918
+ try {
919
+ const d = decryptMessage(m, data.id);
920
+ return { ...m, content: d.content, contentType: d.contentType };
921
+ } catch {
922
+ return { ...m, content: "[decryption failed]", contentType: "text" };
923
+ }
832
924
  }
833
925
  return m;
834
926
  });
@@ -839,12 +931,151 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
839
931
  const { task_id, filename, mime_type, base64_content } = args as {
840
932
  task_id: string; filename: string; mime_type: string; base64_content: string;
841
933
  };
934
+
935
+ // Check if task is encrypted
936
+ const taskData = (await hubGet(`/tasks/${task_id}`)) as any;
937
+ if (taskData.encrypted) {
938
+ // Size guardrail: encryption overhead (~37%) could exceed hub's 50MB limit
939
+ const rawSize = Buffer.from(base64_content, "base64").byteLength;
940
+ if (rawSize > 28 * 1024 * 1024) {
941
+ return { content: [{ type: "text" as const, text: "Error: File too large for encrypted upload (max ~28 MB before encryption overhead)." }] };
942
+ }
943
+
944
+ await loadPublicKeys();
945
+ const otherId =
946
+ taskData.initiatorAgentId === myAgentId ? taskData.targetAgentId : taskData.initiatorAgentId;
947
+ const otherPub = pubKeyCache.get(otherId);
948
+ if (!otherPub || !myPublicKey || !PRIVATE_KEY) {
949
+ return { content: [{ type: "text" as const, text: "Error: Cannot upload to encrypted task — missing cryptographic keys. Re-run setup or reconnect." }] };
950
+ }
951
+
952
+ const envelope = JSON.stringify({ filename, mimeType: mime_type, data: base64_content });
953
+ const { ciphertext, signature, encryptedKeys } = localEncrypt(envelope, task_id, {
954
+ [myAgentId]: myPublicKey,
955
+ [otherId]: otherPub,
956
+ });
957
+
958
+ const data = await hubPost(`/tasks/${task_id}/files/json`, {
959
+ filename: "encrypted_file",
960
+ mimeType: "application/octet-stream",
961
+ base64Content: ciphertext,
962
+ encryptedKeys,
963
+ senderSignature: signature,
964
+ });
965
+ return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
966
+ }
967
+
968
+ // Non-encrypted task: pass through
842
969
  const data = await hubPost(`/tasks/${task_id}/files/json`, {
843
970
  filename, mimeType: mime_type, base64Content: base64_content,
844
971
  });
845
972
  return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
846
973
  }
847
974
 
975
+ if (name === "pairai_download_file") {
976
+ const { task_id, file_id } = args as { task_id: string; file_id: string };
977
+
978
+ // Fetch the task to check encryption status
979
+ const taskData = (await hubGet(`/tasks/${task_id}`)) as any;
980
+
981
+ // Download raw file bytes
982
+ const response = await fetch(`${HUB_URL}/files/${file_id}`, {
983
+ headers: { Authorization: `Bearer ${API_KEY}` },
984
+ signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
985
+ });
986
+ if (!response.ok) {
987
+ return { content: [{ type: "text" as const, text: `Error: Failed to download file (${response.status}).` }] };
988
+ }
989
+ const fileBuffer = Buffer.from(await response.arrayBuffer());
990
+
991
+ if (!taskData.encrypted) {
992
+ // Non-encrypted: return raw file info
993
+ const contentType = response.headers.get("content-type") ?? "application/octet-stream";
994
+ const disposition = response.headers.get("content-disposition") ?? "";
995
+ const filenameMatch = disposition.match(/filename="([^"]+)"/);
996
+ return {
997
+ content: [{
998
+ type: "text" as const,
999
+ text: JSON.stringify({
1000
+ filename: filenameMatch?.[1] ?? "file",
1001
+ mimeType: contentType,
1002
+ data: fileBuffer.toString("base64"),
1003
+ sizeBytes: fileBuffer.byteLength,
1004
+ }, null, 2),
1005
+ }],
1006
+ };
1007
+ }
1008
+
1009
+ // Encrypted task: find the message that holds the encryption keys for this file
1010
+ const taskMessages = (await hubGet(`/tasks/${task_id}/messages`)) as Array<{
1011
+ id: string; content: string; contentType: string; senderAgentId: string;
1012
+ encryptedKeys?: any; senderSignature?: string;
1013
+ }>;
1014
+ const fileMsg = taskMessages.find((m) => m.content === file_id);
1015
+ if (!fileMsg) {
1016
+ return { content: [{ type: "text" as const, text: "Error: Could not find message for this file." }] };
1017
+ }
1018
+
1019
+ // Legacy plaintext file in encrypted task (no keys stored)
1020
+ if (!fileMsg.encryptedKeys || !fileMsg.senderSignature) {
1021
+ const contentType = response.headers.get("content-type") ?? "application/octet-stream";
1022
+ return {
1023
+ content: [{
1024
+ type: "text" as const,
1025
+ text: JSON.stringify({
1026
+ filename: "file",
1027
+ mimeType: contentType,
1028
+ data: fileBuffer.toString("base64"),
1029
+ sizeBytes: fileBuffer.byteLength,
1030
+ warning: "File was uploaded without encryption (legacy).",
1031
+ }, null, 2),
1032
+ }],
1033
+ };
1034
+ }
1035
+
1036
+ // Decrypt the file
1037
+ if (!PRIVATE_KEY) {
1038
+ return { content: [{ type: "text" as const, text: "Error: No private key configured. Re-run setup." }] };
1039
+ }
1040
+
1041
+ await loadPublicKeys();
1042
+ const senderPub = fileMsg.senderAgentId === myAgentId
1043
+ ? myPublicKey
1044
+ : pubKeyCache.get(fileMsg.senderAgentId);
1045
+ const keys = typeof fileMsg.encryptedKeys === "string"
1046
+ ? JSON.parse(fileMsg.encryptedKeys)
1047
+ : fileMsg.encryptedKeys;
1048
+ const myKey = keys[myAgentId];
1049
+
1050
+ if (!senderPub || !myKey) {
1051
+ return { content: [{ type: "text" as const, text: "Error: Decryption keys not found for this agent." }] };
1052
+ }
1053
+
1054
+ try {
1055
+ // The hub stores binary (decoded from base64 ciphertext); re-encode for localDecrypt
1056
+ const ciphertextB64 = fileBuffer.toString("base64");
1057
+ const plain = localDecrypt(ciphertextB64, fileMsg.senderSignature, task_id, senderPub, myKey);
1058
+ const envelope = JSON.parse(plain);
1059
+ return {
1060
+ content: [{
1061
+ type: "text" as const,
1062
+ text: JSON.stringify({
1063
+ filename: envelope.filename,
1064
+ mimeType: envelope.mimeType,
1065
+ data: envelope.data,
1066
+ sizeBytes: Buffer.from(envelope.data, "base64").byteLength,
1067
+ }, null, 2),
1068
+ }],
1069
+ };
1070
+ } catch (err) {
1071
+ const msg = (err as Error).message;
1072
+ if (msg.includes("Signature")) {
1073
+ return { content: [{ type: "text" as const, text: "Error: File signature verification failed — possible tampering." }] };
1074
+ }
1075
+ return { content: [{ type: "text" as const, text: `Error: Failed to decrypt file — ${msg}` }] };
1076
+ }
1077
+ }
1078
+
848
1079
  if (name === "pairai_create_encrypted_task") {
849
1080
  if (!PRIVATE_KEY)
850
1081
  return { content: [{ type: "text" as const, text: "No private key configured. Re-run setup." }] };
@@ -887,6 +1118,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
887
1118
  // ── Polling ──────────────────────────────────────────────────────────────────
888
1119
 
889
1120
  const seenMessages = new Set<string>();
1121
+ const SEEN_MESSAGES_MAX = 10_000;
890
1122
 
891
1123
  function decryptMessage(
892
1124
  msg: { content: string; contentType: string; senderAgentId: string; encryptedKeys?: any; senderSignature?: string },
@@ -943,13 +1175,16 @@ async function poll() {
943
1175
  hasUpdates: boolean;
944
1176
  pendingTasks: Array<{ id: string; title: string; fromAgent: string }>;
945
1177
  unreadMessages: Array<{ taskId: string; taskTitle: string; count: number }>;
1178
+ cursor: number;
946
1179
  };
947
1180
 
1181
+ debugLog(`poll: hasUpdates=${updates.hasUpdates} tasks=${updates.pendingTasks.length} messages=${updates.unreadMessages.length} cursor=${updates.cursor}`);
948
1182
  if (!updates.hasUpdates) return;
1183
+ console.error(`[pairai] poll: ${updates.pendingTasks.length} tasks, ${updates.unreadMessages.length} messages`);
949
1184
 
950
1185
  for (const task of updates.pendingTasks) {
951
1186
  const key = `task:${task.id}`;
952
- if (seenMessages.has(key)) continue;
1187
+ if (seenMessages.has(key)) { debugLog(`skip seen task ${task.id}`); continue; }
953
1188
  seenMessages.add(key);
954
1189
 
955
1190
  const full = (await hubGet(`/tasks/${task.id}`)) as {
@@ -959,19 +1194,27 @@ async function poll() {
959
1194
  senderSignature?: string;
960
1195
  initiatorAgentId?: string;
961
1196
  approvalStatus?: string | null;
962
- messages: Array<{
963
- content: string;
964
- contentType: string;
965
- senderAgentId: string;
966
- encryptedKeys?: any;
967
- senderSignature?: string;
968
- }>;
969
1197
  };
1198
+ const taskMsgs = (await hubGet(`/tasks/${task.id}/messages`)) as Array<{
1199
+ content: string;
1200
+ contentType: string;
1201
+ senderAgentId: string;
1202
+ encryptedKeys?: any;
1203
+ senderSignature?: string;
1204
+ }>;
970
1205
 
971
1206
  const desc = decryptTaskDescription(full, task.id);
972
- const decryptedMessages = (full.messages ?? []).map((m) => {
973
- const d = decryptMessage(m, task.id);
974
- return d.content;
1207
+ const decryptedMessages = (taskMsgs ?? []).map((m) => {
1208
+ // Encrypted file messages: content is a file ID (short nanoid), not ciphertext
1209
+ if (m.contentType === "encrypted" && m.encryptedKeys && m.content.length < 30) {
1210
+ return "[File attachment — use pairai_download_file to retrieve]";
1211
+ }
1212
+ try {
1213
+ const d = decryptMessage(m, task.id);
1214
+ return d.content;
1215
+ } catch {
1216
+ return "[decryption failed]";
1217
+ }
975
1218
  });
976
1219
 
977
1220
  const isPendingApproval = full.approvalStatus === "pending";
@@ -997,24 +1240,34 @@ async function poll() {
997
1240
  }
998
1241
 
999
1242
  for (const unread of updates.unreadMessages) {
1000
- const full = (await hubGet(`/tasks/${unread.taskId}`)) as {
1001
- messages: Array<{
1002
- id: string;
1003
- content: string;
1004
- contentType: string;
1005
- senderAgentId: string;
1006
- encryptedKeys?: any;
1007
- senderSignature?: string;
1008
- }>;
1009
- };
1243
+ const msgs = (await hubGet(`/tasks/${unread.taskId}/messages`)) as Array<{
1244
+ id: string;
1245
+ content: string;
1246
+ contentType: string;
1247
+ senderAgentId: string;
1248
+ encryptedKeys?: any;
1249
+ senderSignature?: string;
1250
+ }>;
1010
1251
 
1011
- if (!full?.messages) continue;
1012
- for (const msg of full.messages.slice(-unread.count)) {
1252
+ debugLog(`unread: taskId=${unread.taskId} count=${unread.count} fetched=${msgs?.length ?? 0}`);
1253
+ if (!msgs || msgs.length === 0) continue;
1254
+ for (const msg of msgs.slice(-unread.count)) {
1013
1255
  const key = `msg:${msg.id}`;
1014
- if (seenMessages.has(key)) continue;
1256
+ if (seenMessages.has(key)) { debugLog(`skip seen msg ${msg.id}`); continue; }
1015
1257
  seenMessages.add(key);
1016
1258
 
1017
- const decrypted = decryptMessage(msg, unread.taskId);
1259
+ // Encrypted file messages: content is a file ID (short nanoid), not ciphertext
1260
+ const isEncryptedFile = msg.contentType === "encrypted" && msg.encryptedKeys && msg.content.length < 30;
1261
+ let decrypted: { content: string; contentType: string };
1262
+ if (isEncryptedFile) {
1263
+ decrypted = { content: "[File attachment — use pairai_download_file to retrieve]", contentType: "text" };
1264
+ } else {
1265
+ try {
1266
+ decrypted = decryptMessage(msg, unread.taskId);
1267
+ } catch {
1268
+ decrypted = { content: "[decryption failed]", contentType: "text" };
1269
+ }
1270
+ }
1018
1271
 
1019
1272
  try {
1020
1273
  await mcp.notification({
@@ -1037,9 +1290,20 @@ async function poll() {
1037
1290
  }
1038
1291
  }
1039
1292
 
1040
- await hubPost("/updates/ack");
1293
+ // Do NOT ack the hub here — the hub's lastSeenRowid is only advanced
1294
+ // when the user explicitly calls pairai_check_updates (authoritative ack).
1295
+ // The seenMessages Set prevents duplicate notifications within this session.
1296
+ debugLog(`poll: processed cursor=${updates.cursor} (hub NOT acked — seenMessages dedup only)`);
1297
+
1298
+ // Prevent unbounded memory growth
1299
+ if (seenMessages.size > SEEN_MESSAGES_MAX) {
1300
+ const excess = seenMessages.size - SEEN_MESSAGES_MAX;
1301
+ const iter = seenMessages.values();
1302
+ for (let i = 0; i < excess; i++) seenMessages.delete(iter.next().value!);
1303
+ }
1041
1304
  } catch (err) {
1042
1305
  console.error(`[pairai] poll error: ${(err as Error).message}`);
1306
+ debugLog(`poll error: ${(err as Error).message}`);
1043
1307
  }
1044
1308
  }
1045
1309
 
@@ -1066,6 +1330,15 @@ process.on("SIGINT", cleanupLock);
1066
1330
  process.on("beforeExit", cleanupLock);
1067
1331
  process.on("exit", cleanupLock);
1068
1332
 
1333
+ // Detect parent death — when the MCP host (Claude/Gemini) exits, stdin closes.
1334
+ // Without this, the process becomes an orphan reparented to systemd.
1335
+ process.stdin.on("end", () => {
1336
+ console.error("[pairai] stdin closed (parent exited). Shutting down.");
1337
+ cleanupLock();
1338
+ process.exit(0);
1339
+ });
1340
+ process.stdin.resume(); // ensure 'end' fires even if nothing reads stdin
1341
+
1069
1342
  console.error(`[pairai] agent=${myAgentId} keys=${pubKeyCache.size} polling every ${POLL_MS}ms`);
1070
1343
  setInterval(poll, POLL_MS);
1071
1344
  poll();