pairai 0.3.0 → 0.4.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.
- package/lib.ts +80 -9
- package/package.json +5 -2
- package/pairai.ts +578 -105
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
|
|
|
@@ -23,16 +28,18 @@ export function validateProvider(value: string): Provider {
|
|
|
23
28
|
|
|
24
29
|
/**
|
|
25
30
|
* Auto-detect provider based on environment and filesystem.
|
|
31
|
+
* Returns null when detection is ambiguous (0 or 2+ matches).
|
|
26
32
|
*/
|
|
27
|
-
export function detectProvider(): Provider {
|
|
33
|
+
export function detectProvider(): Provider | null {
|
|
28
34
|
if (process.env.GEMINI_CLI) return "gemini";
|
|
29
|
-
|
|
30
|
-
try { if (statSync(".
|
|
31
|
-
try { if (statSync(".
|
|
32
|
-
try { if (statSync(".
|
|
33
|
-
try { if (statSync(".
|
|
34
|
-
try { if (statSync(".
|
|
35
|
-
|
|
35
|
+
const found: Provider[] = [];
|
|
36
|
+
try { if (statSync(".cursor").isDirectory()) found.push("cursor"); } catch {}
|
|
37
|
+
try { if (statSync(".windsurf").isDirectory()) found.push("windsurf"); } catch {}
|
|
38
|
+
try { if (statSync(".vscode").isDirectory()) found.push("copilot"); } catch {}
|
|
39
|
+
try { if (statSync(".codex").isDirectory()) found.push("codex"); } catch {}
|
|
40
|
+
try { if (statSync(".amazonq").isDirectory()) found.push("amazonq"); } catch {}
|
|
41
|
+
try { if (statSync(".gemini").isDirectory()) found.push("gemini"); } catch {}
|
|
42
|
+
return found.length === 1 ? found[0] : null;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
export interface ProviderConfig {
|
|
@@ -247,3 +254,67 @@ export function releaseLock(agentId: string, lockDir?: string): void {
|
|
|
247
254
|
const path = lockPath(agentId, lockDir);
|
|
248
255
|
try { unlinkSync(path); } catch {}
|
|
249
256
|
}
|
|
257
|
+
|
|
258
|
+
// ── Crypto ──────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Encrypt plaintext with AES-256-GCM, wrap key with RSA-OAEP for each recipient,
|
|
262
|
+
* sign (taskId + ciphertext) with RSA-PSS.
|
|
263
|
+
*/
|
|
264
|
+
export function localEncrypt(
|
|
265
|
+
plaintext: string,
|
|
266
|
+
taskId: string,
|
|
267
|
+
senderPrivateKey: string,
|
|
268
|
+
recipientPubKeys: Record<string, string>,
|
|
269
|
+
): { ciphertext: string; signature: string; encryptedKeys: Record<string, string> } {
|
|
270
|
+
const key = randomBytes(32);
|
|
271
|
+
const iv = randomBytes(12);
|
|
272
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
273
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
274
|
+
const tag = cipher.getAuthTag();
|
|
275
|
+
const ciphertext = Buffer.concat([iv, encrypted, tag]).toString("base64");
|
|
276
|
+
|
|
277
|
+
const signature = sign(null, Buffer.from(taskId + ciphertext), {
|
|
278
|
+
key: senderPrivateKey,
|
|
279
|
+
padding: cryptoConstants.RSA_PKCS1_PSS_PADDING,
|
|
280
|
+
saltLength: 32,
|
|
281
|
+
}).toString("base64");
|
|
282
|
+
|
|
283
|
+
const encryptedKeys: Record<string, string> = {};
|
|
284
|
+
for (const [id, pub] of Object.entries(recipientPubKeys)) {
|
|
285
|
+
encryptedKeys[id] = publicEncrypt(
|
|
286
|
+
{ key: pub, oaepHash: "sha256", padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING },
|
|
287
|
+
key,
|
|
288
|
+
).toString("base64");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { ciphertext, signature, encryptedKeys };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Verify signature, unwrap AES key with own private key, decrypt AES-256-GCM.
|
|
296
|
+
*/
|
|
297
|
+
export function localDecrypt(
|
|
298
|
+
ciphertext: string,
|
|
299
|
+
sig: string,
|
|
300
|
+
taskId: string,
|
|
301
|
+
senderPub: string,
|
|
302
|
+
myEncKey: string,
|
|
303
|
+
myPrivateKey: string,
|
|
304
|
+
): string {
|
|
305
|
+
const valid = verify(null, Buffer.from(taskId + ciphertext), {
|
|
306
|
+
key: senderPub,
|
|
307
|
+
padding: cryptoConstants.RSA_PKCS1_PSS_PADDING,
|
|
308
|
+
saltLength: 32,
|
|
309
|
+
}, Buffer.from(sig, "base64"));
|
|
310
|
+
if (!valid) throw new Error("Signature verification failed");
|
|
311
|
+
|
|
312
|
+
const aesKey = privateDecrypt(
|
|
313
|
+
{ key: myPrivateKey, oaepHash: "sha256", padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING },
|
|
314
|
+
Buffer.from(myEncKey, "base64"),
|
|
315
|
+
);
|
|
316
|
+
const data = Buffer.from(ciphertext, "base64");
|
|
317
|
+
const decipher = createDecipheriv("aes-256-gcm", aesKey, data.subarray(0, 12));
|
|
318
|
+
decipher.setAuthTag(data.subarray(-16));
|
|
319
|
+
return Buffer.concat([decipher.update(data.subarray(12, -16)), decipher.final()]).toString("utf8");
|
|
320
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pairai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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://
|
|
10
|
+
"url": "https://github.com/pairaipro/pairai",
|
|
11
|
+
"directory": "channel"
|
|
11
12
|
},
|
|
12
13
|
"keywords": [
|
|
13
14
|
"ai",
|
|
@@ -28,6 +29,8 @@
|
|
|
28
29
|
"README.md"
|
|
29
30
|
],
|
|
30
31
|
"dependencies": {
|
|
32
|
+
"@inquirer/input": "^5.0.10",
|
|
33
|
+
"@inquirer/select": "^5.1.2",
|
|
31
34
|
"@modelcontextprotocol/sdk": "^1.10.0",
|
|
32
35
|
"nanoid": "^5.0.0",
|
|
33
36
|
"tsx": "^4.0.0"
|
package/pairai.ts
CHANGED
|
@@ -8,29 +8,55 @@
|
|
|
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
|
|
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
|
-
|
|
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";
|
|
27
|
+
import select from "@inquirer/select";
|
|
28
|
+
import input from "@inquirer/input";
|
|
26
29
|
|
|
27
30
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
28
31
|
const PKG = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8"));
|
|
29
32
|
const VERSION: string = PKG.version;
|
|
30
33
|
|
|
34
|
+
const PROVIDER_CHOICES: { name: string; value: Provider }[] = [
|
|
35
|
+
{ name: "Claude Code", value: "claude" },
|
|
36
|
+
{ name: "Gemini CLI", value: "gemini" },
|
|
37
|
+
{ name: "Cursor", value: "cursor" },
|
|
38
|
+
{ name: "GitHub Copilot (VS Code)", value: "copilot" },
|
|
39
|
+
{ name: "Windsurf", value: "windsurf" },
|
|
40
|
+
{ name: "OpenAI Codex CLI", value: "codex" },
|
|
41
|
+
{ name: "Amazon Q", value: "amazonq" },
|
|
42
|
+
];
|
|
43
|
+
|
|
31
44
|
const args = process.argv.slice(2);
|
|
32
45
|
const command = args[0];
|
|
33
46
|
|
|
47
|
+
// ── Debug logging ────────────────────────────────────────────────────────────
|
|
48
|
+
// Enable with PAIRAI_DEBUG=1 or PAIRAI_DEBUG=/path/to/file.log
|
|
49
|
+
// When enabled, writes verbose poll/notification logs to the specified file
|
|
50
|
+
// (or ~/.pairai/debug.log if set to "1").
|
|
51
|
+
const DEBUG_LOG = process.env.PAIRAI_DEBUG;
|
|
52
|
+
const debugLogPath = DEBUG_LOG === "1" ? join(homedir(), ".pairai", "debug.log")
|
|
53
|
+
: DEBUG_LOG || null;
|
|
54
|
+
function debugLog(msg: string) {
|
|
55
|
+
if (!debugLogPath) return;
|
|
56
|
+
const line = `${new Date().toISOString()} ${msg}\n`;
|
|
57
|
+
try { appendFileSync(debugLogPath, line); } catch {}
|
|
58
|
+
}
|
|
59
|
+
|
|
34
60
|
// ── Version ─────────────────────────────────────────────────────────────────
|
|
35
61
|
|
|
36
62
|
if (command === "version" || args.includes("--version") || args.includes("-v")) {
|
|
@@ -72,6 +98,20 @@ if (command === "setup") {
|
|
|
72
98
|
const rest = args.slice(1);
|
|
73
99
|
const hubIdx = rest.indexOf("--hub");
|
|
74
100
|
const hubUrl = hubIdx !== -1 ? rest.splice(hubIdx, 2)[1] : "https://pairai.pro";
|
|
101
|
+
try {
|
|
102
|
+
const parsed = new URL(hubUrl);
|
|
103
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
104
|
+
console.error(" Error: Hub URL must use http: or https: protocol.");
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
const isLocal = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "::1";
|
|
108
|
+
if (parsed.protocol === "http:" && !isLocal) {
|
|
109
|
+
console.error(" Warning: Hub URL uses HTTP (insecure). Use HTTPS for production deployments.");
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
console.error(" Error: Invalid hub URL.");
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
75
115
|
const providerIdx = rest.indexOf("--provider");
|
|
76
116
|
const providerArg = providerIdx !== -1 ? rest.splice(providerIdx, 2)[1] : undefined;
|
|
77
117
|
if (providerArg) {
|
|
@@ -80,16 +120,39 @@ if (command === "setup") {
|
|
|
80
120
|
process.exit(1);
|
|
81
121
|
}
|
|
82
122
|
}
|
|
83
|
-
|
|
123
|
+
let provider: Provider;
|
|
124
|
+
if (providerArg) {
|
|
125
|
+
provider = providerArg as Provider;
|
|
126
|
+
} else {
|
|
127
|
+
const detected = detectProvider();
|
|
128
|
+
if (detected) {
|
|
129
|
+
provider = detected;
|
|
130
|
+
} else if (process.stdin.isTTY) {
|
|
131
|
+
provider = await select({
|
|
132
|
+
message: "Select your AI tool:",
|
|
133
|
+
choices: PROVIDER_CHOICES,
|
|
134
|
+
});
|
|
135
|
+
} else {
|
|
136
|
+
console.error('Cannot auto-detect provider. Use --provider flag (e.g. npx pairai setup "My Agent" --provider cursor)');
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
84
140
|
const globalIdx = rest.indexOf("--global");
|
|
85
141
|
const useGlobal = globalIdx !== -1 ? (rest.splice(globalIdx, 1), true) : false;
|
|
86
|
-
|
|
142
|
+
let agentName = rest.find((a) => !a.startsWith("--"));
|
|
87
143
|
|
|
88
144
|
const forceIdx = rest.indexOf("--force");
|
|
89
145
|
const useForce = forceIdx !== -1 ? (rest.splice(forceIdx, 1), true) : false;
|
|
90
146
|
if (!agentName) {
|
|
91
|
-
|
|
92
|
-
|
|
147
|
+
if (process.stdin.isTTY) {
|
|
148
|
+
agentName = await input({
|
|
149
|
+
message: 'What should we call your agent? Other agents and users will see this name. (e.g. "Alice\'s Assistant", "Travel Bot")',
|
|
150
|
+
validate: (v) => v.trim().length > 0 && v.trim().length <= 64 ? true : "Name must be 1-64 characters",
|
|
151
|
+
});
|
|
152
|
+
} else {
|
|
153
|
+
console.error('Usage: npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--global] [--force]');
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
93
156
|
}
|
|
94
157
|
|
|
95
158
|
// Check for existing config to avoid accidental overwrites
|
|
@@ -129,7 +192,7 @@ if (command === "setup") {
|
|
|
129
192
|
console.log(` API Key: ${apiKey}`);
|
|
130
193
|
|
|
131
194
|
const keyDir = join(homedir(), ".pairai", "keys");
|
|
132
|
-
mkdirSync(keyDir, { recursive: true });
|
|
195
|
+
mkdirSync(keyDir, { recursive: true, mode: 0o700 });
|
|
133
196
|
const keyPath = join(keyDir, `${id}.pem`);
|
|
134
197
|
writeFileSync(keyPath, privateKey, { mode: 0o600 });
|
|
135
198
|
console.log(` Private key: ${keyPath}`);
|
|
@@ -149,7 +212,7 @@ if (command === "setup") {
|
|
|
149
212
|
};
|
|
150
213
|
|
|
151
214
|
// Ensure config directory exists
|
|
152
|
-
mkdirSync(dirname(cfg.configPath), { recursive: true });
|
|
215
|
+
mkdirSync(dirname(cfg.configPath), { recursive: true, mode: 0o700 });
|
|
153
216
|
|
|
154
217
|
if (cfg.format === "toml") {
|
|
155
218
|
// Codex CLI uses TOML
|
|
@@ -165,14 +228,14 @@ if (command === "setup") {
|
|
|
165
228
|
`PAIRAI_AGENT_CRED = "${apiKey}"`,
|
|
166
229
|
`PAIRAI_KEY_FILE = "${keyPath}"`,
|
|
167
230
|
].join("\n");
|
|
168
|
-
writeFileSync(cfg.configPath, existing + tomlBlock + "\n");
|
|
231
|
+
writeFileSync(cfg.configPath, existing + tomlBlock + "\n", { mode: 0o600 });
|
|
169
232
|
} else {
|
|
170
233
|
// JSON — merge with existing config
|
|
171
234
|
let existing: any = {};
|
|
172
235
|
try { if (existsSync(cfg.configPath)) existing = JSON.parse(readFileSync(cfg.configPath, "utf-8")); } catch {}
|
|
173
236
|
if (!existing.mcpServers) existing.mcpServers = {};
|
|
174
237
|
existing.mcpServers[cfg.mcpKey] = serverEntry;
|
|
175
|
-
writeFileSync(cfg.configPath, JSON.stringify(existing, null, 2) + "\n");
|
|
238
|
+
writeFileSync(cfg.configPath, JSON.stringify(existing, null, 2) + "\n", { mode: 0o600 });
|
|
176
239
|
}
|
|
177
240
|
|
|
178
241
|
console.log(` Config: ${cfg.configPath}`);
|
|
@@ -200,6 +263,15 @@ if (command !== "serve") {
|
|
|
200
263
|
console.error(" npx pairai serve [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq]");
|
|
201
264
|
console.error(" npx pairai upgrade — update to latest version");
|
|
202
265
|
console.error(" npx pairai version — show current version");
|
|
266
|
+
console.error("");
|
|
267
|
+
console.error("Environment variables:");
|
|
268
|
+
console.error(" PAIRAI_HUB_URL Hub URL (default: https://pairai.pro)");
|
|
269
|
+
console.error(" PAIRAI_AGENT_CRED Agent API key (from setup)");
|
|
270
|
+
console.error(" PAIRAI_KEY_FILE Path to RSA private key .pem file");
|
|
271
|
+
console.error(" PAIRAI_POLL_MS Poll interval in ms (default: 5000)");
|
|
272
|
+
console.error(" PAIRAI_LOCK_DIR Lock file directory (default: ~/.pairai/locks)");
|
|
273
|
+
console.error(" PAIRAI_DEBUG=1 Verbose log to ~/.pairai/debug.log");
|
|
274
|
+
console.error(" PAIRAI_DEBUG=<path> Verbose log to custom file");
|
|
203
275
|
process.exit(1);
|
|
204
276
|
}
|
|
205
277
|
|
|
@@ -219,6 +291,16 @@ if (serveProviderArg) {
|
|
|
219
291
|
const serveProvider = (serveProviderArg as Provider) ?? "claude";
|
|
220
292
|
|
|
221
293
|
const HUB_URL = process.env.PAIRAI_HUB_URL ?? process.env.PAIRAI_URL ?? "https://pairai.pro";
|
|
294
|
+
try {
|
|
295
|
+
const parsedHub = new URL(HUB_URL);
|
|
296
|
+
if (!["http:", "https:"].includes(parsedHub.protocol)) {
|
|
297
|
+
console.error("[pairai] Error: Hub URL must use http: or https: protocol.");
|
|
298
|
+
process.exit(1);
|
|
299
|
+
}
|
|
300
|
+
} catch {
|
|
301
|
+
console.error("[pairai] Error: Invalid hub URL.");
|
|
302
|
+
process.exit(1);
|
|
303
|
+
}
|
|
222
304
|
const API_KEY = process.env.PAIRAI_AGENT_CRED ?? process.env.PAIRAI_API_KEY;
|
|
223
305
|
const POLL_MS = Number(process.env.PAIRAI_POLL_MS ?? "5000");
|
|
224
306
|
const PRIVATE_KEY_PATH = process.env.PAIRAI_KEY_FILE ?? process.env.PAIRAI_PRIVATE_KEY_PATH;
|
|
@@ -236,29 +318,51 @@ const headers = {
|
|
|
236
318
|
|
|
237
319
|
// ── Hub API ──────────────────────────────────────────────────────────────────
|
|
238
320
|
|
|
321
|
+
const HUB_TIMEOUT_MS = 30_000;
|
|
322
|
+
|
|
323
|
+
const API_PREFIX = "/api/v1";
|
|
324
|
+
|
|
239
325
|
async function hubGet(path: string) {
|
|
240
|
-
const res = await fetch(`${HUB_URL}${path}`, { headers });
|
|
326
|
+
const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, { headers, signal: AbortSignal.timeout(HUB_TIMEOUT_MS) });
|
|
241
327
|
if (!res.ok) throw new Error(`GET ${path}: ${res.status}`);
|
|
242
328
|
return res.json();
|
|
243
329
|
}
|
|
244
330
|
|
|
245
331
|
async function hubPost(path: string, body?: unknown) {
|
|
246
|
-
const res = await fetch(`${HUB_URL}${path}`, {
|
|
332
|
+
const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, {
|
|
247
333
|
method: "POST",
|
|
248
334
|
headers: body ? headers : { Authorization: headers.Authorization },
|
|
249
335
|
body: body ? JSON.stringify(body) : undefined,
|
|
336
|
+
signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
|
|
250
337
|
});
|
|
251
338
|
if (!res.ok) throw new Error(`POST ${path}: ${res.status}`);
|
|
252
339
|
return res.json();
|
|
253
340
|
}
|
|
254
341
|
|
|
255
342
|
async function hubPatch(path: string, body: unknown) {
|
|
256
|
-
const res = await fetch(`${HUB_URL}${path}`, {
|
|
343
|
+
const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, {
|
|
257
344
|
method: "PATCH",
|
|
258
345
|
headers,
|
|
259
346
|
body: JSON.stringify(body),
|
|
347
|
+
signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
|
|
348
|
+
});
|
|
349
|
+
if (!res.ok) {
|
|
350
|
+
const body = await res.json().catch(() => ({})) as { error?: string };
|
|
351
|
+
throw new Error(body.error ?? `PATCH ${path}: ${res.status}`);
|
|
352
|
+
}
|
|
353
|
+
return res.json();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function hubDelete(path: string) {
|
|
357
|
+
const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, {
|
|
358
|
+
method: "DELETE",
|
|
359
|
+
headers: { Authorization: headers.Authorization },
|
|
360
|
+
signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
|
|
260
361
|
});
|
|
261
|
-
if (!res.ok)
|
|
362
|
+
if (!res.ok) {
|
|
363
|
+
const body = await res.json().catch(() => ({})) as { error?: string };
|
|
364
|
+
throw new Error(body.error ?? `DELETE ${path}: ${res.status}`);
|
|
365
|
+
}
|
|
262
366
|
return res.json();
|
|
263
367
|
}
|
|
264
368
|
|
|
@@ -273,7 +377,9 @@ async function loadAgentInfo() {
|
|
|
273
377
|
const me = (await hubGet("/agents/me")) as { id: string; name: string; publicKey?: string };
|
|
274
378
|
myAgentId = me.id;
|
|
275
379
|
myPublicKey = me.publicKey ?? "";
|
|
276
|
-
} catch {
|
|
380
|
+
} catch (err) {
|
|
381
|
+
console.error("[pairai] failed to load agent info:", (err as Error).message);
|
|
382
|
+
}
|
|
277
383
|
}
|
|
278
384
|
|
|
279
385
|
async function loadPublicKeys() {
|
|
@@ -282,32 +388,14 @@ async function loadPublicKeys() {
|
|
|
282
388
|
for (const c of conns) {
|
|
283
389
|
if (c.publicKey) pubKeyCache.set(c.agentId, c.publicKey);
|
|
284
390
|
}
|
|
285
|
-
} catch {
|
|
391
|
+
} catch (err) {
|
|
392
|
+
console.error("[pairai] failed to load public keys:", (err as Error).message);
|
|
393
|
+
}
|
|
286
394
|
}
|
|
287
395
|
|
|
288
396
|
function localEncrypt(plaintext: string, taskId: string, recipientPubKeys: Record<string, string>) {
|
|
289
397
|
if (!PRIVATE_KEY) throw new Error("No private key configured");
|
|
290
|
-
|
|
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 };
|
|
398
|
+
return _localEncrypt(plaintext, taskId, PRIVATE_KEY, recipientPubKeys);
|
|
311
399
|
}
|
|
312
400
|
|
|
313
401
|
function localDecrypt(
|
|
@@ -318,21 +406,7 @@ function localDecrypt(
|
|
|
318
406
|
myEncKey: string,
|
|
319
407
|
): string {
|
|
320
408
|
if (!PRIVATE_KEY) throw new Error("No private key configured");
|
|
321
|
-
|
|
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");
|
|
409
|
+
return _localDecrypt(ciphertext, sig, taskId, senderPub, myEncKey, PRIVATE_KEY);
|
|
336
410
|
}
|
|
337
411
|
|
|
338
412
|
// ── MCP Server ───────────────────────────────────────────────────────────────
|
|
@@ -340,6 +414,7 @@ function localDecrypt(
|
|
|
340
414
|
const instructions = [
|
|
341
415
|
"You are connected to the pairai agent hub. Messages from other AI agents arrive as notifications.",
|
|
342
416
|
"The channel server polls for updates automatically — you don't need to poll manually.",
|
|
417
|
+
"When the user asks about updates, new messages, or pending work, use pairai_check_updates (not pairai_list_tasks).",
|
|
343
418
|
"",
|
|
344
419
|
"Notification attributes:",
|
|
345
420
|
" task_id — the task this message belongs to",
|
|
@@ -369,7 +444,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
369
444
|
tools: [
|
|
370
445
|
{
|
|
371
446
|
name: "pairai_check_updates",
|
|
372
|
-
description: "Check for
|
|
447
|
+
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
448
|
inputSchema: {
|
|
374
449
|
type: "object" as const,
|
|
375
450
|
properties: {
|
|
@@ -441,6 +516,17 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
441
516
|
required: ["code"],
|
|
442
517
|
},
|
|
443
518
|
},
|
|
519
|
+
{
|
|
520
|
+
name: "pairai_connect_directly",
|
|
521
|
+
description: "Connect directly to an agent that has auto-accept enabled — no pairing code needed. Use pairai_discover_agents to find agents with autoAccept: true.",
|
|
522
|
+
inputSchema: {
|
|
523
|
+
type: "object" as const,
|
|
524
|
+
properties: {
|
|
525
|
+
agent_id: { type: "string", description: "ID of the agent to connect with" },
|
|
526
|
+
},
|
|
527
|
+
required: ["agent_id"],
|
|
528
|
+
},
|
|
529
|
+
},
|
|
444
530
|
{
|
|
445
531
|
name: "pairai_update_profile",
|
|
446
532
|
description: "Update your agent's profile — name, description, capabilities, and metadata. Returns the updated profile.",
|
|
@@ -452,6 +538,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
452
538
|
capabilities: { type: "array", items: { type: "string" }, description: "List of capabilities, e.g. ['scheduling', 'code-review']" },
|
|
453
539
|
metadata: { type: "object", description: "Arbitrary JSON metadata (max 4KB)" },
|
|
454
540
|
discoverable: { type: "boolean", description: "Whether to appear in the public agent directory" },
|
|
541
|
+
autoAccept: { type: "boolean", description: "Whether to accept direct connections without pairing codes" },
|
|
455
542
|
defaultApprovalRule: { type: "string", enum: ["auto", "require"], description: "Default approval rule for new connections" },
|
|
456
543
|
},
|
|
457
544
|
},
|
|
@@ -504,6 +591,17 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
504
591
|
required: ["connection_id", "rule"],
|
|
505
592
|
},
|
|
506
593
|
},
|
|
594
|
+
{
|
|
595
|
+
name: "pairai_disconnect",
|
|
596
|
+
description: "Disconnect from an agent. Cascades: cancels active tasks, notifies the other agent.",
|
|
597
|
+
inputSchema: {
|
|
598
|
+
type: "object" as const,
|
|
599
|
+
properties: {
|
|
600
|
+
connection_id: { type: "string", description: "Connection ID to delete" },
|
|
601
|
+
},
|
|
602
|
+
required: ["connection_id"],
|
|
603
|
+
},
|
|
604
|
+
},
|
|
507
605
|
{
|
|
508
606
|
name: "pairai_list_pending_approvals",
|
|
509
607
|
description: "List tasks waiting for your approval.",
|
|
@@ -534,7 +632,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
534
632
|
},
|
|
535
633
|
{
|
|
536
634
|
name: "pairai_list_tasks",
|
|
537
|
-
description: "List
|
|
635
|
+
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
636
|
inputSchema: {
|
|
539
637
|
type: "object" as const,
|
|
540
638
|
properties: {
|
|
@@ -567,6 +665,18 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
567
665
|
required: ["task_id", "filename", "mime_type", "base64_content"],
|
|
568
666
|
},
|
|
569
667
|
},
|
|
668
|
+
{
|
|
669
|
+
name: "pairai_download_file",
|
|
670
|
+
description: "Download a file from a task. For encrypted tasks, the file is automatically decrypted.",
|
|
671
|
+
inputSchema: {
|
|
672
|
+
type: "object" as const,
|
|
673
|
+
properties: {
|
|
674
|
+
task_id: { type: "string", description: "Task ID the file belongs to" },
|
|
675
|
+
file_id: { type: "string", description: "File ID to download" },
|
|
676
|
+
},
|
|
677
|
+
required: ["task_id", "file_id"],
|
|
678
|
+
},
|
|
679
|
+
},
|
|
570
680
|
{
|
|
571
681
|
name: "pairai_create_encrypted_task",
|
|
572
682
|
description: "Create an encrypted task. Title and description are encrypted — the hub cannot read them.",
|
|
@@ -580,6 +690,78 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
580
690
|
required: ["target_agent_id", "title"],
|
|
581
691
|
},
|
|
582
692
|
},
|
|
693
|
+
{
|
|
694
|
+
name: "pairai_delete_message",
|
|
695
|
+
description: "Delete (tombstone) a message you sent. The message content is replaced with [deleted].",
|
|
696
|
+
inputSchema: {
|
|
697
|
+
type: "object" as const,
|
|
698
|
+
properties: {
|
|
699
|
+
task_id: { type: "string", description: "Task ID" },
|
|
700
|
+
message_id: { type: "string", description: "Message ID to delete" },
|
|
701
|
+
},
|
|
702
|
+
required: ["task_id", "message_id"],
|
|
703
|
+
},
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
name: "pairai_delete_file",
|
|
707
|
+
description: "Delete a file you uploaded. Removes from disk and tombstones the associated message.",
|
|
708
|
+
inputSchema: {
|
|
709
|
+
type: "object" as const,
|
|
710
|
+
properties: {
|
|
711
|
+
file_id: { type: "string", description: "File ID to delete" },
|
|
712
|
+
},
|
|
713
|
+
required: ["file_id"],
|
|
714
|
+
},
|
|
715
|
+
},
|
|
716
|
+
{
|
|
717
|
+
name: "pairai_delete_task",
|
|
718
|
+
description: "Permanently delete a terminal task (completed, failed, cancelled) and all its messages and files.",
|
|
719
|
+
inputSchema: {
|
|
720
|
+
type: "object" as const,
|
|
721
|
+
properties: {
|
|
722
|
+
task_id: { type: "string", description: "Task ID to delete" },
|
|
723
|
+
},
|
|
724
|
+
required: ["task_id"],
|
|
725
|
+
},
|
|
726
|
+
},
|
|
727
|
+
{
|
|
728
|
+
name: "pairai_rotate_api_key",
|
|
729
|
+
description: "Generate a new API key. WARNING: old key immediately invalidated. Save the new key before doing anything else.",
|
|
730
|
+
inputSchema: {
|
|
731
|
+
type: "object" as const,
|
|
732
|
+
properties: {},
|
|
733
|
+
},
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
name: "pairai_delete_account",
|
|
737
|
+
description: "PERMANENTLY delete your agent and ALL associated data. IRREVERSIBLE.",
|
|
738
|
+
inputSchema: {
|
|
739
|
+
type: "object" as const,
|
|
740
|
+
properties: {},
|
|
741
|
+
},
|
|
742
|
+
},
|
|
743
|
+
{
|
|
744
|
+
name: "pairai_block_agent",
|
|
745
|
+
description: "Block an agent. They cannot discover or connect with you. Disconnects if connected.",
|
|
746
|
+
inputSchema: {
|
|
747
|
+
type: "object" as const,
|
|
748
|
+
properties: {
|
|
749
|
+
agent_id: { type: "string", description: "ID of the agent to block" },
|
|
750
|
+
},
|
|
751
|
+
required: ["agent_id"],
|
|
752
|
+
},
|
|
753
|
+
},
|
|
754
|
+
{
|
|
755
|
+
name: "pairai_unblock_agent",
|
|
756
|
+
description: "Unblock a previously blocked agent.",
|
|
757
|
+
inputSchema: {
|
|
758
|
+
type: "object" as const,
|
|
759
|
+
properties: {
|
|
760
|
+
agent_id: { type: "string", description: "ID of the agent to unblock" },
|
|
761
|
+
},
|
|
762
|
+
required: ["agent_id"],
|
|
763
|
+
},
|
|
764
|
+
},
|
|
583
765
|
],
|
|
584
766
|
}));
|
|
585
767
|
|
|
@@ -593,6 +775,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
593
775
|
hasUpdates: boolean;
|
|
594
776
|
pendingTasks: Array<{ id: string; title: string; fromAgent: string }>;
|
|
595
777
|
unreadMessages: Array<{ taskId: string; taskTitle: string; count: number }>;
|
|
778
|
+
cursor: number;
|
|
596
779
|
};
|
|
597
780
|
|
|
598
781
|
if (!updates.hasUpdates) {
|
|
@@ -628,8 +811,10 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
628
811
|
parts.push(`**Unread messages:**\n${enriched.join("\n")}`);
|
|
629
812
|
}
|
|
630
813
|
|
|
631
|
-
|
|
632
|
-
|
|
814
|
+
// Always ack — this is the authoritative "user has seen these" signal.
|
|
815
|
+
// The poll loop does NOT ack; only this tool does.
|
|
816
|
+
if (updates.cursor > 0) {
|
|
817
|
+
await hubPost("/updates/ack", { cursor: updates.cursor });
|
|
633
818
|
}
|
|
634
819
|
|
|
635
820
|
return { content: [{ type: "text" as const, text: parts.join("\n\n") }] };
|
|
@@ -650,17 +835,22 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
650
835
|
return { content: [{ type: "text" as const, text: "Error: Cannot reply to encrypted task — missing cryptographic keys. Re-run setup or reconnect." }] };
|
|
651
836
|
}
|
|
652
837
|
const envelope = JSON.stringify({ contentType: content_type ?? "text", body: text });
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
838
|
+
try {
|
|
839
|
+
const { ciphertext, signature, encryptedKeys } = localEncrypt(envelope, task_id, {
|
|
840
|
+
[myAgentId]: myPublicKey,
|
|
841
|
+
[otherId]: otherPub,
|
|
842
|
+
});
|
|
843
|
+
await hubPost(`/tasks/${task_id}/messages`, {
|
|
844
|
+
content: ciphertext,
|
|
845
|
+
contentType: "encrypted",
|
|
846
|
+
encryptedKeys,
|
|
847
|
+
senderSignature: signature,
|
|
848
|
+
});
|
|
849
|
+
return { content: [{ type: "text" as const, text: "Sent (encrypted)." }] };
|
|
850
|
+
} catch (err) {
|
|
851
|
+
console.error(`[pairai] encryption failed for task ${task_id}: ${(err as Error).message}`);
|
|
852
|
+
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 };
|
|
853
|
+
}
|
|
664
854
|
}
|
|
665
855
|
// Non-encrypted task: send plaintext
|
|
666
856
|
await hubPost(`/tasks/${task_id}/messages`, {
|
|
@@ -671,8 +861,16 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
671
861
|
}
|
|
672
862
|
|
|
673
863
|
if (name === "pairai_update_status") {
|
|
674
|
-
|
|
675
|
-
|
|
864
|
+
try {
|
|
865
|
+
await hubPatch(`/tasks/${args.task_id}`, { status: args.status });
|
|
866
|
+
return { content: [{ type: "text" as const, text: `Status → ${args.status}` }] };
|
|
867
|
+
} catch (err) {
|
|
868
|
+
const msg = (err as Error).message;
|
|
869
|
+
if (msg.includes("409") || msg.includes("400")) {
|
|
870
|
+
return { content: [{ type: "text" as const, text: `Cannot update status — ${msg}` }] };
|
|
871
|
+
}
|
|
872
|
+
throw err;
|
|
873
|
+
}
|
|
676
874
|
}
|
|
677
875
|
|
|
678
876
|
if (name === "pairai_get_profile") {
|
|
@@ -734,6 +932,14 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
734
932
|
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
735
933
|
}
|
|
736
934
|
|
|
935
|
+
if (name === "pairai_connect_directly") {
|
|
936
|
+
const { agent_id } = args as { agent_id: string };
|
|
937
|
+
const data = await hubPost(`/connect/${agent_id}`);
|
|
938
|
+
// Refresh public keys after new connection
|
|
939
|
+
await loadPublicKeys();
|
|
940
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
941
|
+
}
|
|
942
|
+
|
|
737
943
|
if (name === "pairai_update_profile") {
|
|
738
944
|
const body: Record<string, unknown> = {};
|
|
739
945
|
if (args.name !== undefined) body.name = args.name;
|
|
@@ -741,6 +947,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
741
947
|
if (args.capabilities !== undefined) body.capabilities = args.capabilities;
|
|
742
948
|
if (args.metadata !== undefined) body.metadata = args.metadata;
|
|
743
949
|
if (args.discoverable !== undefined) body.discoverable = args.discoverable === "true" || args.discoverable === true;
|
|
950
|
+
if (args.autoAccept !== undefined) body.autoAccept = args.autoAccept === "true" || args.autoAccept === true;
|
|
744
951
|
if (args.defaultApprovalRule !== undefined) body.defaultApprovalRule = args.defaultApprovalRule;
|
|
745
952
|
const data = await hubPatch("/agents/me", body);
|
|
746
953
|
return { content: [{ type: "text" as const, text: "Profile updated.\n" + JSON.stringify(data, null, 2) }] };
|
|
@@ -777,6 +984,16 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
777
984
|
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
778
985
|
}
|
|
779
986
|
|
|
987
|
+
if (name === "pairai_disconnect") {
|
|
988
|
+
const { connection_id } = args as { connection_id: string };
|
|
989
|
+
try {
|
|
990
|
+
const result = (await hubDelete(`/connections/${connection_id}`)) as { cancelledTasks?: number };
|
|
991
|
+
return { content: [{ type: "text" as const, text: `Disconnected. ${result.cancelledTasks ?? 0} task(s) cancelled.` }] };
|
|
992
|
+
} catch (err) {
|
|
993
|
+
return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
780
997
|
if (name === "pairai_list_pending_approvals") {
|
|
781
998
|
const data = await hubGet("/approvals");
|
|
782
999
|
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
@@ -822,13 +1039,20 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
822
1039
|
encryptedKeys?: any; senderSignature?: string;
|
|
823
1040
|
}>;
|
|
824
1041
|
if (data.encrypted) {
|
|
825
|
-
|
|
826
|
-
|
|
1042
|
+
try {
|
|
1043
|
+
data.description = decryptTaskDescription(data, data.id);
|
|
1044
|
+
} catch {
|
|
1045
|
+
data.description = "[decryption failed]";
|
|
1046
|
+
}
|
|
827
1047
|
}
|
|
828
1048
|
const decryptedMsgs = msgs.map((m) => {
|
|
829
1049
|
if (data.encrypted) {
|
|
830
|
-
|
|
831
|
-
|
|
1050
|
+
try {
|
|
1051
|
+
const d = decryptMessage(m, data.id);
|
|
1052
|
+
return { ...m, content: d.content, contentType: d.contentType };
|
|
1053
|
+
} catch {
|
|
1054
|
+
return { ...m, content: "[decryption failed]", contentType: "text" };
|
|
1055
|
+
}
|
|
832
1056
|
}
|
|
833
1057
|
return m;
|
|
834
1058
|
});
|
|
@@ -839,12 +1063,151 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
839
1063
|
const { task_id, filename, mime_type, base64_content } = args as {
|
|
840
1064
|
task_id: string; filename: string; mime_type: string; base64_content: string;
|
|
841
1065
|
};
|
|
1066
|
+
|
|
1067
|
+
// Check if task is encrypted
|
|
1068
|
+
const taskData = (await hubGet(`/tasks/${task_id}`)) as any;
|
|
1069
|
+
if (taskData.encrypted) {
|
|
1070
|
+
// Size guardrail: encryption overhead (~37%) could exceed hub's 50MB limit
|
|
1071
|
+
const rawSize = Buffer.from(base64_content, "base64").byteLength;
|
|
1072
|
+
if (rawSize > 28 * 1024 * 1024) {
|
|
1073
|
+
return { content: [{ type: "text" as const, text: "Error: File too large for encrypted upload (max ~28 MB before encryption overhead)." }] };
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
await loadPublicKeys();
|
|
1077
|
+
const otherId =
|
|
1078
|
+
taskData.initiatorAgentId === myAgentId ? taskData.targetAgentId : taskData.initiatorAgentId;
|
|
1079
|
+
const otherPub = pubKeyCache.get(otherId);
|
|
1080
|
+
if (!otherPub || !myPublicKey || !PRIVATE_KEY) {
|
|
1081
|
+
return { content: [{ type: "text" as const, text: "Error: Cannot upload to encrypted task — missing cryptographic keys. Re-run setup or reconnect." }] };
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
const envelope = JSON.stringify({ filename, mimeType: mime_type, data: base64_content });
|
|
1085
|
+
const { ciphertext, signature, encryptedKeys } = localEncrypt(envelope, task_id, {
|
|
1086
|
+
[myAgentId]: myPublicKey,
|
|
1087
|
+
[otherId]: otherPub,
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
const data = await hubPost(`/tasks/${task_id}/files/json`, {
|
|
1091
|
+
filename: "encrypted_file",
|
|
1092
|
+
mimeType: "application/octet-stream",
|
|
1093
|
+
base64Content: ciphertext,
|
|
1094
|
+
encryptedKeys,
|
|
1095
|
+
senderSignature: signature,
|
|
1096
|
+
});
|
|
1097
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// Non-encrypted task: pass through
|
|
842
1101
|
const data = await hubPost(`/tasks/${task_id}/files/json`, {
|
|
843
1102
|
filename, mimeType: mime_type, base64Content: base64_content,
|
|
844
1103
|
});
|
|
845
1104
|
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
846
1105
|
}
|
|
847
1106
|
|
|
1107
|
+
if (name === "pairai_download_file") {
|
|
1108
|
+
const { task_id, file_id } = args as { task_id: string; file_id: string };
|
|
1109
|
+
|
|
1110
|
+
// Fetch the task to check encryption status
|
|
1111
|
+
const taskData = (await hubGet(`/tasks/${task_id}`)) as any;
|
|
1112
|
+
|
|
1113
|
+
// Download raw file bytes
|
|
1114
|
+
const response = await fetch(`${HUB_URL}/files/${file_id}`, {
|
|
1115
|
+
headers: { Authorization: `Bearer ${API_KEY}` },
|
|
1116
|
+
signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
|
|
1117
|
+
});
|
|
1118
|
+
if (!response.ok) {
|
|
1119
|
+
return { content: [{ type: "text" as const, text: `Error: Failed to download file (${response.status}).` }] };
|
|
1120
|
+
}
|
|
1121
|
+
const fileBuffer = Buffer.from(await response.arrayBuffer());
|
|
1122
|
+
|
|
1123
|
+
if (!taskData.encrypted) {
|
|
1124
|
+
// Non-encrypted: return raw file info
|
|
1125
|
+
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
|
|
1126
|
+
const disposition = response.headers.get("content-disposition") ?? "";
|
|
1127
|
+
const filenameMatch = disposition.match(/filename="([^"]+)"/);
|
|
1128
|
+
return {
|
|
1129
|
+
content: [{
|
|
1130
|
+
type: "text" as const,
|
|
1131
|
+
text: JSON.stringify({
|
|
1132
|
+
filename: filenameMatch?.[1] ?? "file",
|
|
1133
|
+
mimeType: contentType,
|
|
1134
|
+
data: fileBuffer.toString("base64"),
|
|
1135
|
+
sizeBytes: fileBuffer.byteLength,
|
|
1136
|
+
}, null, 2),
|
|
1137
|
+
}],
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// Encrypted task: find the message that holds the encryption keys for this file
|
|
1142
|
+
const taskMessages = (await hubGet(`/tasks/${task_id}/messages`)) as Array<{
|
|
1143
|
+
id: string; content: string; contentType: string; senderAgentId: string;
|
|
1144
|
+
encryptedKeys?: any; senderSignature?: string;
|
|
1145
|
+
}>;
|
|
1146
|
+
const fileMsg = taskMessages.find((m) => m.content === file_id);
|
|
1147
|
+
if (!fileMsg) {
|
|
1148
|
+
return { content: [{ type: "text" as const, text: "Error: Could not find message for this file." }] };
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// Legacy plaintext file in encrypted task (no keys stored)
|
|
1152
|
+
if (!fileMsg.encryptedKeys || !fileMsg.senderSignature) {
|
|
1153
|
+
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
|
|
1154
|
+
return {
|
|
1155
|
+
content: [{
|
|
1156
|
+
type: "text" as const,
|
|
1157
|
+
text: JSON.stringify({
|
|
1158
|
+
filename: "file",
|
|
1159
|
+
mimeType: contentType,
|
|
1160
|
+
data: fileBuffer.toString("base64"),
|
|
1161
|
+
sizeBytes: fileBuffer.byteLength,
|
|
1162
|
+
warning: "File was uploaded without encryption (legacy).",
|
|
1163
|
+
}, null, 2),
|
|
1164
|
+
}],
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// Decrypt the file
|
|
1169
|
+
if (!PRIVATE_KEY) {
|
|
1170
|
+
return { content: [{ type: "text" as const, text: "Error: No private key configured. Re-run setup." }] };
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
await loadPublicKeys();
|
|
1174
|
+
const senderPub = fileMsg.senderAgentId === myAgentId
|
|
1175
|
+
? myPublicKey
|
|
1176
|
+
: pubKeyCache.get(fileMsg.senderAgentId);
|
|
1177
|
+
const keys = typeof fileMsg.encryptedKeys === "string"
|
|
1178
|
+
? JSON.parse(fileMsg.encryptedKeys)
|
|
1179
|
+
: fileMsg.encryptedKeys;
|
|
1180
|
+
const myKey = keys[myAgentId];
|
|
1181
|
+
|
|
1182
|
+
if (!senderPub || !myKey) {
|
|
1183
|
+
return { content: [{ type: "text" as const, text: "Error: Decryption keys not found for this agent." }] };
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
try {
|
|
1187
|
+
// The hub stores binary (decoded from base64 ciphertext); re-encode for localDecrypt
|
|
1188
|
+
const ciphertextB64 = fileBuffer.toString("base64");
|
|
1189
|
+
const plain = localDecrypt(ciphertextB64, fileMsg.senderSignature, task_id, senderPub, myKey);
|
|
1190
|
+
const envelope = JSON.parse(plain);
|
|
1191
|
+
return {
|
|
1192
|
+
content: [{
|
|
1193
|
+
type: "text" as const,
|
|
1194
|
+
text: JSON.stringify({
|
|
1195
|
+
filename: envelope.filename,
|
|
1196
|
+
mimeType: envelope.mimeType,
|
|
1197
|
+
data: envelope.data,
|
|
1198
|
+
sizeBytes: Buffer.from(envelope.data, "base64").byteLength,
|
|
1199
|
+
}, null, 2),
|
|
1200
|
+
}],
|
|
1201
|
+
};
|
|
1202
|
+
} catch (err) {
|
|
1203
|
+
const msg = (err as Error).message;
|
|
1204
|
+
if (msg.includes("Signature")) {
|
|
1205
|
+
return { content: [{ type: "text" as const, text: "Error: File signature verification failed — possible tampering." }] };
|
|
1206
|
+
}
|
|
1207
|
+
return { content: [{ type: "text" as const, text: `Error: Failed to decrypt file — ${msg}` }] };
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
848
1211
|
if (name === "pairai_create_encrypted_task") {
|
|
849
1212
|
if (!PRIVATE_KEY)
|
|
850
1213
|
return { content: [{ type: "text" as const, text: "No private key configured. Re-run setup." }] };
|
|
@@ -881,12 +1244,81 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
881
1244
|
return { content: [{ type: "text" as const, text: `Encrypted task created. ID: ${taskId}` }] };
|
|
882
1245
|
}
|
|
883
1246
|
|
|
1247
|
+
if (name === "pairai_delete_message") {
|
|
1248
|
+
const { task_id, message_id } = args as { task_id: string; message_id: string };
|
|
1249
|
+
try {
|
|
1250
|
+
await hubDelete(`/tasks/${task_id}/messages/${message_id}`);
|
|
1251
|
+
return { content: [{ type: "text" as const, text: "Message deleted." }] };
|
|
1252
|
+
} catch (err) {
|
|
1253
|
+
return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
if (name === "pairai_delete_file") {
|
|
1258
|
+
const { file_id } = args as { file_id: string };
|
|
1259
|
+
try {
|
|
1260
|
+
await hubDelete(`/files/${file_id}`);
|
|
1261
|
+
return { content: [{ type: "text" as const, text: "File deleted." }] };
|
|
1262
|
+
} catch (err) {
|
|
1263
|
+
return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
if (name === "pairai_delete_task") {
|
|
1268
|
+
const { task_id } = args as { task_id: string };
|
|
1269
|
+
try {
|
|
1270
|
+
const result = (await hubDelete(`/tasks/${task_id}`)) as { deletedMessages?: number; deletedFiles?: number };
|
|
1271
|
+
return { content: [{ type: "text" as const, text: `Task deleted. ${result.deletedMessages ?? 0} message(s) and ${result.deletedFiles ?? 0} file(s) removed.` }] };
|
|
1272
|
+
} catch (err) {
|
|
1273
|
+
return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
if (name === "pairai_rotate_api_key") {
|
|
1278
|
+
try {
|
|
1279
|
+
const result = (await hubPost("/agents/me/rotate-key")) as { apiKey: string };
|
|
1280
|
+
return { content: [{ type: "text" as const, text: `New API key: ${result.apiKey}\n\nWARNING: Your old key is now invalid. Save this key immediately — it will not be shown again.` }] };
|
|
1281
|
+
} catch (err) {
|
|
1282
|
+
return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
if (name === "pairai_delete_account") {
|
|
1287
|
+
try {
|
|
1288
|
+
await hubDelete("/agents/me");
|
|
1289
|
+
return { content: [{ type: "text" as const, text: "Account deleted." }] };
|
|
1290
|
+
} catch (err) {
|
|
1291
|
+
return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
if (name === "pairai_block_agent") {
|
|
1296
|
+
const { agent_id } = args as { agent_id: string };
|
|
1297
|
+
try {
|
|
1298
|
+
await hubPost("/agents/me/block", { agentId: agent_id });
|
|
1299
|
+
return { content: [{ type: "text" as const, text: "Agent blocked." }] };
|
|
1300
|
+
} catch (err) {
|
|
1301
|
+
return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
if (name === "pairai_unblock_agent") {
|
|
1306
|
+
const { agent_id } = args as { agent_id: string };
|
|
1307
|
+
try {
|
|
1308
|
+
await hubDelete(`/agents/me/block/${agent_id}`);
|
|
1309
|
+
return { content: [{ type: "text" as const, text: "Agent unblocked." }] };
|
|
1310
|
+
} catch (err) {
|
|
1311
|
+
return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
884
1315
|
throw new Error(`Unknown tool: ${name}`);
|
|
885
1316
|
});
|
|
886
1317
|
|
|
887
1318
|
// ── Polling ──────────────────────────────────────────────────────────────────
|
|
888
1319
|
|
|
889
1320
|
const seenMessages = new Set<string>();
|
|
1321
|
+
const SEEN_MESSAGES_MAX = 10_000;
|
|
890
1322
|
|
|
891
1323
|
function decryptMessage(
|
|
892
1324
|
msg: { content: string; contentType: string; senderAgentId: string; encryptedKeys?: any; senderSignature?: string },
|
|
@@ -943,13 +1375,16 @@ async function poll() {
|
|
|
943
1375
|
hasUpdates: boolean;
|
|
944
1376
|
pendingTasks: Array<{ id: string; title: string; fromAgent: string }>;
|
|
945
1377
|
unreadMessages: Array<{ taskId: string; taskTitle: string; count: number }>;
|
|
1378
|
+
cursor: number;
|
|
946
1379
|
};
|
|
947
1380
|
|
|
1381
|
+
debugLog(`poll: hasUpdates=${updates.hasUpdates} tasks=${updates.pendingTasks.length} messages=${updates.unreadMessages.length} cursor=${updates.cursor}`);
|
|
948
1382
|
if (!updates.hasUpdates) return;
|
|
1383
|
+
console.error(`[pairai] poll: ${updates.pendingTasks.length} tasks, ${updates.unreadMessages.length} messages`);
|
|
949
1384
|
|
|
950
1385
|
for (const task of updates.pendingTasks) {
|
|
951
1386
|
const key = `task:${task.id}`;
|
|
952
|
-
if (seenMessages.has(key)) continue;
|
|
1387
|
+
if (seenMessages.has(key)) { debugLog(`skip seen task ${task.id}`); continue; }
|
|
953
1388
|
seenMessages.add(key);
|
|
954
1389
|
|
|
955
1390
|
const full = (await hubGet(`/tasks/${task.id}`)) as {
|
|
@@ -959,19 +1394,27 @@ async function poll() {
|
|
|
959
1394
|
senderSignature?: string;
|
|
960
1395
|
initiatorAgentId?: string;
|
|
961
1396
|
approvalStatus?: string | null;
|
|
962
|
-
messages: Array<{
|
|
963
|
-
content: string;
|
|
964
|
-
contentType: string;
|
|
965
|
-
senderAgentId: string;
|
|
966
|
-
encryptedKeys?: any;
|
|
967
|
-
senderSignature?: string;
|
|
968
|
-
}>;
|
|
969
1397
|
};
|
|
1398
|
+
const taskMsgs = (await hubGet(`/tasks/${task.id}/messages`)) as Array<{
|
|
1399
|
+
content: string;
|
|
1400
|
+
contentType: string;
|
|
1401
|
+
senderAgentId: string;
|
|
1402
|
+
encryptedKeys?: any;
|
|
1403
|
+
senderSignature?: string;
|
|
1404
|
+
}>;
|
|
970
1405
|
|
|
971
1406
|
const desc = decryptTaskDescription(full, task.id);
|
|
972
|
-
const decryptedMessages = (
|
|
973
|
-
|
|
974
|
-
|
|
1407
|
+
const decryptedMessages = (taskMsgs ?? []).map((m) => {
|
|
1408
|
+
// Encrypted file messages: content is a file ID (short nanoid), not ciphertext
|
|
1409
|
+
if (m.contentType === "encrypted" && m.encryptedKeys && m.content.length < 30) {
|
|
1410
|
+
return "[File attachment — use pairai_download_file to retrieve]";
|
|
1411
|
+
}
|
|
1412
|
+
try {
|
|
1413
|
+
const d = decryptMessage(m, task.id);
|
|
1414
|
+
return d.content;
|
|
1415
|
+
} catch {
|
|
1416
|
+
return "[decryption failed]";
|
|
1417
|
+
}
|
|
975
1418
|
});
|
|
976
1419
|
|
|
977
1420
|
const isPendingApproval = full.approvalStatus === "pending";
|
|
@@ -997,24 +1440,34 @@ async function poll() {
|
|
|
997
1440
|
}
|
|
998
1441
|
|
|
999
1442
|
for (const unread of updates.unreadMessages) {
|
|
1000
|
-
const
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
}>;
|
|
1009
|
-
};
|
|
1443
|
+
const msgs = (await hubGet(`/tasks/${unread.taskId}/messages`)) as Array<{
|
|
1444
|
+
id: string;
|
|
1445
|
+
content: string;
|
|
1446
|
+
contentType: string;
|
|
1447
|
+
senderAgentId: string;
|
|
1448
|
+
encryptedKeys?: any;
|
|
1449
|
+
senderSignature?: string;
|
|
1450
|
+
}>;
|
|
1010
1451
|
|
|
1011
|
-
|
|
1012
|
-
|
|
1452
|
+
debugLog(`unread: taskId=${unread.taskId} count=${unread.count} fetched=${msgs?.length ?? 0}`);
|
|
1453
|
+
if (!msgs || msgs.length === 0) continue;
|
|
1454
|
+
for (const msg of msgs.slice(-unread.count)) {
|
|
1013
1455
|
const key = `msg:${msg.id}`;
|
|
1014
|
-
if (seenMessages.has(key)) continue;
|
|
1456
|
+
if (seenMessages.has(key)) { debugLog(`skip seen msg ${msg.id}`); continue; }
|
|
1015
1457
|
seenMessages.add(key);
|
|
1016
1458
|
|
|
1017
|
-
|
|
1459
|
+
// Encrypted file messages: content is a file ID (short nanoid), not ciphertext
|
|
1460
|
+
const isEncryptedFile = msg.contentType === "encrypted" && msg.encryptedKeys && msg.content.length < 30;
|
|
1461
|
+
let decrypted: { content: string; contentType: string };
|
|
1462
|
+
if (isEncryptedFile) {
|
|
1463
|
+
decrypted = { content: "[File attachment — use pairai_download_file to retrieve]", contentType: "text" };
|
|
1464
|
+
} else {
|
|
1465
|
+
try {
|
|
1466
|
+
decrypted = decryptMessage(msg, unread.taskId);
|
|
1467
|
+
} catch {
|
|
1468
|
+
decrypted = { content: "[decryption failed]", contentType: "text" };
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1018
1471
|
|
|
1019
1472
|
try {
|
|
1020
1473
|
await mcp.notification({
|
|
@@ -1037,9 +1490,20 @@ async function poll() {
|
|
|
1037
1490
|
}
|
|
1038
1491
|
}
|
|
1039
1492
|
|
|
1040
|
-
|
|
1493
|
+
// Do NOT ack the hub here — the hub's lastSeenRowid is only advanced
|
|
1494
|
+
// when the user explicitly calls pairai_check_updates (authoritative ack).
|
|
1495
|
+
// The seenMessages Set prevents duplicate notifications within this session.
|
|
1496
|
+
debugLog(`poll: processed cursor=${updates.cursor} (hub NOT acked — seenMessages dedup only)`);
|
|
1497
|
+
|
|
1498
|
+
// Prevent unbounded memory growth
|
|
1499
|
+
if (seenMessages.size > SEEN_MESSAGES_MAX) {
|
|
1500
|
+
const excess = seenMessages.size - SEEN_MESSAGES_MAX;
|
|
1501
|
+
const iter = seenMessages.values();
|
|
1502
|
+
for (let i = 0; i < excess; i++) seenMessages.delete(iter.next().value!);
|
|
1503
|
+
}
|
|
1041
1504
|
} catch (err) {
|
|
1042
1505
|
console.error(`[pairai] poll error: ${(err as Error).message}`);
|
|
1506
|
+
debugLog(`poll error: ${(err as Error).message}`);
|
|
1043
1507
|
}
|
|
1044
1508
|
}
|
|
1045
1509
|
|
|
@@ -1066,6 +1530,15 @@ process.on("SIGINT", cleanupLock);
|
|
|
1066
1530
|
process.on("beforeExit", cleanupLock);
|
|
1067
1531
|
process.on("exit", cleanupLock);
|
|
1068
1532
|
|
|
1533
|
+
// Detect parent death — when the MCP host (Claude/Gemini) exits, stdin closes.
|
|
1534
|
+
// Without this, the process becomes an orphan reparented to systemd.
|
|
1535
|
+
process.stdin.on("end", () => {
|
|
1536
|
+
console.error("[pairai] stdin closed (parent exited). Shutting down.");
|
|
1537
|
+
cleanupLock();
|
|
1538
|
+
process.exit(0);
|
|
1539
|
+
});
|
|
1540
|
+
process.stdin.resume(); // ensure 'end' fires even if nothing reads stdin
|
|
1541
|
+
|
|
1069
1542
|
console.error(`[pairai] agent=${myAgentId} keys=${pubKeyCache.size} polling every ${POLL_MS}ms`);
|
|
1070
1543
|
setInterval(poll, POLL_MS);
|
|
1071
1544
|
poll();
|