pairai 0.2.5 → 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.
- package/lib.ts +253 -15
- package/package.json +3 -2
- package/pairai.ts +646 -192
package/pairai.ts
CHANGED
|
@@ -3,25 +3,27 @@
|
|
|
3
3
|
* pairai CLI — connect AI agents via the pairai hub
|
|
4
4
|
*
|
|
5
5
|
* Commands:
|
|
6
|
-
* npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini] [--global] [--force]
|
|
7
|
-
* npx pairai serve [--provider claude|gemini]
|
|
6
|
+
* npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--global] [--force]
|
|
7
|
+
* npx pairai serve [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq]
|
|
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, statSync, 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,
|
|
25
|
+
import { validateProvider, detectProvider, checkExistingConfig, formatKeyBackupBox, acquireLock, releaseLock, getProviderConfig, localEncrypt as _localEncrypt, localDecrypt as _localDecrypt } from "./lib.js";
|
|
26
|
+
import type { Provider } from "./lib.js";
|
|
25
27
|
|
|
26
28
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
29
|
const PKG = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8"));
|
|
@@ -30,6 +32,19 @@ const VERSION: string = PKG.version;
|
|
|
30
32
|
const args = process.argv.slice(2);
|
|
31
33
|
const command = args[0];
|
|
32
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
|
+
|
|
33
48
|
// ── Version ─────────────────────────────────────────────────────────────────
|
|
34
49
|
|
|
35
50
|
if (command === "version" || args.includes("--version") || args.includes("-v")) {
|
|
@@ -49,27 +64,11 @@ if (command === "upgrade") {
|
|
|
49
64
|
} else {
|
|
50
65
|
console.log(` New version available: v${latest}`);
|
|
51
66
|
console.log(` Upgrading...\n`);
|
|
67
|
+
// Clear npx cache so next `npx pairai serve` picks up the new version
|
|
68
|
+
try { execSync("npx clear-npx-cache 2>/dev/null || rm -rf " + join(homedir(), ".npm/_npx"), { stdio: "pipe" }); } catch {}
|
|
52
69
|
execSync("npm install -g pairai@latest", { stdio: "inherit" });
|
|
53
70
|
console.log(`\n Upgraded to v${latest}.`);
|
|
54
71
|
console.log(` Keys and config are unchanged.\n`);
|
|
55
|
-
|
|
56
|
-
// Update pinned version in config files
|
|
57
|
-
const configPaths = [
|
|
58
|
-
join(process.cwd(), ".mcp.json"),
|
|
59
|
-
join(process.cwd(), ".gemini", "settings.json"),
|
|
60
|
-
join(homedir(), ".gemini", "settings.json"),
|
|
61
|
-
];
|
|
62
|
-
for (const p of configPaths) {
|
|
63
|
-
try {
|
|
64
|
-
if (!existsSync(p)) continue;
|
|
65
|
-
const content = readFileSync(p, "utf-8");
|
|
66
|
-
const updated = updateVersionInConfig(content, latest);
|
|
67
|
-
if (updated !== content) {
|
|
68
|
-
writeFileSync(p, updated);
|
|
69
|
-
console.log(` Updated version in ${p}`);
|
|
70
|
-
}
|
|
71
|
-
} catch {}
|
|
72
|
-
}
|
|
73
72
|
}
|
|
74
73
|
} catch (err) {
|
|
75
74
|
console.error(` Upgrade failed: ${(err as Error).message}`);
|
|
@@ -78,7 +77,7 @@ if (command === "upgrade") {
|
|
|
78
77
|
process.exit(0);
|
|
79
78
|
}
|
|
80
79
|
|
|
81
|
-
// detectProvider, validateProvider,
|
|
80
|
+
// detectProvider, validateProvider, checkExistingConfig,
|
|
82
81
|
// formatKeyBackupBox are imported from ./lib.js
|
|
83
82
|
|
|
84
83
|
// ── Setup: register + configure ──────────────────────────────────────────────
|
|
@@ -87,6 +86,20 @@ if (command === "setup") {
|
|
|
87
86
|
const rest = args.slice(1);
|
|
88
87
|
const hubIdx = rest.indexOf("--hub");
|
|
89
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
|
+
}
|
|
90
103
|
const providerIdx = rest.indexOf("--provider");
|
|
91
104
|
const providerArg = providerIdx !== -1 ? rest.splice(providerIdx, 2)[1] : undefined;
|
|
92
105
|
if (providerArg) {
|
|
@@ -95,7 +108,7 @@ if (command === "setup") {
|
|
|
95
108
|
process.exit(1);
|
|
96
109
|
}
|
|
97
110
|
}
|
|
98
|
-
const provider = (providerArg as
|
|
111
|
+
const provider = (providerArg as Provider) ?? detectProvider();
|
|
99
112
|
const globalIdx = rest.indexOf("--global");
|
|
100
113
|
const useGlobal = globalIdx !== -1 ? (rest.splice(globalIdx, 1), true) : false;
|
|
101
114
|
const agentName = rest.find((a) => !a.startsWith("--"));
|
|
@@ -103,7 +116,7 @@ if (command === "setup") {
|
|
|
103
116
|
const forceIdx = rest.indexOf("--force");
|
|
104
117
|
const useForce = forceIdx !== -1 ? (rest.splice(forceIdx, 1), true) : false;
|
|
105
118
|
if (!agentName) {
|
|
106
|
-
console.error('Usage: npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini] [--global] [--force]');
|
|
119
|
+
console.error('Usage: npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--global] [--force]');
|
|
107
120
|
process.exit(1);
|
|
108
121
|
}
|
|
109
122
|
|
|
@@ -144,7 +157,7 @@ if (command === "setup") {
|
|
|
144
157
|
console.log(` API Key: ${apiKey}`);
|
|
145
158
|
|
|
146
159
|
const keyDir = join(homedir(), ".pairai", "keys");
|
|
147
|
-
mkdirSync(keyDir, { recursive: true });
|
|
160
|
+
mkdirSync(keyDir, { recursive: true, mode: 0o700 });
|
|
148
161
|
const keyPath = join(keyDir, `${id}.pem`);
|
|
149
162
|
writeFileSync(keyPath, privateKey, { mode: 0o600 });
|
|
150
163
|
console.log(` Private key: ${keyPath}`);
|
|
@@ -152,61 +165,54 @@ if (command === "setup") {
|
|
|
152
165
|
for (const line of formatKeyBackupBox(keyPath)) console.log(line);
|
|
153
166
|
console.log();
|
|
154
167
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
168
|
+
const cfg = getProviderConfig(provider, process.cwd(), homedir(), useGlobal);
|
|
169
|
+
const serverEntry = {
|
|
170
|
+
command: "npx",
|
|
171
|
+
args: ["pairai", "serve"],
|
|
172
|
+
env: {
|
|
173
|
+
PAIRAI_HUB_URL: hubUrl,
|
|
174
|
+
PAIRAI_AGENT_CRED: apiKey,
|
|
175
|
+
PAIRAI_KEY_FILE: keyPath,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Ensure config directory exists
|
|
180
|
+
mkdirSync(dirname(cfg.configPath), { recursive: true, mode: 0o700 });
|
|
181
|
+
|
|
182
|
+
if (cfg.format === "toml") {
|
|
183
|
+
// Codex CLI uses TOML
|
|
184
|
+
let existing = "";
|
|
185
|
+
try { if (existsSync(cfg.configPath)) existing = readFileSync(cfg.configPath, "utf-8"); } catch {}
|
|
186
|
+
const tomlBlock = [
|
|
187
|
+
`\n[mcp_servers.${cfg.mcpKey}]`,
|
|
188
|
+
`command = "npx"`,
|
|
189
|
+
`args = ["pairai", "serve"]`,
|
|
190
|
+
``,
|
|
191
|
+
`[mcp_servers.${cfg.mcpKey}.env]`,
|
|
192
|
+
`PAIRAI_HUB_URL = "${hubUrl}"`,
|
|
193
|
+
`PAIRAI_AGENT_CRED = "${apiKey}"`,
|
|
194
|
+
`PAIRAI_KEY_FILE = "${keyPath}"`,
|
|
195
|
+
].join("\n");
|
|
196
|
+
writeFileSync(cfg.configPath, existing + tomlBlock + "\n", { mode: 0o600 });
|
|
197
|
+
} else {
|
|
198
|
+
// JSON — merge with existing config
|
|
164
199
|
let existing: any = {};
|
|
165
|
-
try {
|
|
166
|
-
if (existsSync(settingsPath)) {
|
|
167
|
-
existing = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
168
|
-
}
|
|
169
|
-
} catch {}
|
|
170
|
-
|
|
200
|
+
try { if (existsSync(cfg.configPath)) existing = JSON.parse(readFileSync(cfg.configPath, "utf-8")); } catch {}
|
|
171
201
|
if (!existing.mcpServers) existing.mcpServers = {};
|
|
172
|
-
existing.mcpServers.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
env: {
|
|
176
|
-
PAIRAI_HUB_URL: hubUrl,
|
|
177
|
-
PAIRAI_AGENT_CRED: apiKey,
|
|
178
|
-
PAIRAI_KEY_FILE: keyPath,
|
|
179
|
-
},
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
writeFileSync(settingsPath, JSON.stringify(existing, null, 2) + "\n");
|
|
183
|
-
console.log(` Config: ${settingsPath}`);
|
|
184
|
-
console.log();
|
|
185
|
-
console.log(` Next steps:`);
|
|
186
|
-
console.log(` 1. Restart Gemini CLI to activate the pairai MCP server`);
|
|
187
|
-
console.log(` 2. Ask Gemini: "Generate a pairing code"`);
|
|
188
|
-
console.log(` 3. Share the code with another agent to connect`);
|
|
189
|
-
} else {
|
|
190
|
-
// Write .mcp.json for Claude Code
|
|
191
|
-
const mcpConfig = {
|
|
192
|
-
mcpServers: {
|
|
193
|
-
"pairai-channel": {
|
|
194
|
-
command: "npx",
|
|
195
|
-
args: [`pairai@${VERSION}`, "serve"],
|
|
196
|
-
env: { PAIRAI_AGENT_CRED: apiKey, PAIRAI_HUB_URL: hubUrl, PAIRAI_KEY_FILE: keyPath },
|
|
197
|
-
},
|
|
198
|
-
},
|
|
199
|
-
};
|
|
202
|
+
existing.mcpServers[cfg.mcpKey] = serverEntry;
|
|
203
|
+
writeFileSync(cfg.configPath, JSON.stringify(existing, null, 2) + "\n", { mode: 0o600 });
|
|
204
|
+
}
|
|
200
205
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
206
|
+
console.log(` Config: ${cfg.configPath}`);
|
|
207
|
+
console.log();
|
|
208
|
+
console.log(` Next steps:`);
|
|
209
|
+
console.log(` 1. ${cfg.instruction}`);
|
|
210
|
+
console.log(` 2. Ask your AI: "Generate a pairing code"`);
|
|
211
|
+
console.log(` 3. Share the code with another agent to connect`);
|
|
212
|
+
if (provider === "claude") {
|
|
205
213
|
console.log();
|
|
206
|
-
console.log(`
|
|
207
|
-
console.log(`
|
|
208
|
-
console.log(` 2. Ask Claude: "Generate a pairing code"`);
|
|
209
|
-
console.log(` 3. Share the code with another agent to connect`);
|
|
214
|
+
console.log(` Optional: Enable real-time notifications (research preview):`);
|
|
215
|
+
console.log(` claude --dangerously-load-development-channels`);
|
|
210
216
|
}
|
|
211
217
|
|
|
212
218
|
console.log();
|
|
@@ -218,10 +224,19 @@ if (command === "setup") {
|
|
|
218
224
|
if (command !== "serve") {
|
|
219
225
|
console.error(`pairai v${VERSION}\n`);
|
|
220
226
|
console.error("Usage:");
|
|
221
|
-
console.error(' npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini] [--global] [--force]');
|
|
222
|
-
console.error(" npx pairai serve [--provider claude|gemini]");
|
|
227
|
+
console.error(' npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--global] [--force]');
|
|
228
|
+
console.error(" npx pairai serve [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq]");
|
|
223
229
|
console.error(" npx pairai upgrade — update to latest version");
|
|
224
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");
|
|
225
240
|
process.exit(1);
|
|
226
241
|
}
|
|
227
242
|
|
|
@@ -238,9 +253,19 @@ if (serveProviderArg) {
|
|
|
238
253
|
process.exit(1);
|
|
239
254
|
}
|
|
240
255
|
}
|
|
241
|
-
const serveProvider = (serveProviderArg as
|
|
256
|
+
const serveProvider = (serveProviderArg as Provider) ?? "claude";
|
|
242
257
|
|
|
243
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
|
+
}
|
|
244
269
|
const API_KEY = process.env.PAIRAI_AGENT_CRED ?? process.env.PAIRAI_API_KEY;
|
|
245
270
|
const POLL_MS = Number(process.env.PAIRAI_POLL_MS ?? "5000");
|
|
246
271
|
const PRIVATE_KEY_PATH = process.env.PAIRAI_KEY_FILE ?? process.env.PAIRAI_PRIVATE_KEY_PATH;
|
|
@@ -258,29 +283,51 @@ const headers = {
|
|
|
258
283
|
|
|
259
284
|
// ── Hub API ──────────────────────────────────────────────────────────────────
|
|
260
285
|
|
|
286
|
+
const HUB_TIMEOUT_MS = 30_000;
|
|
287
|
+
|
|
288
|
+
const API_PREFIX = "/api/v1";
|
|
289
|
+
|
|
261
290
|
async function hubGet(path: string) {
|
|
262
|
-
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) });
|
|
263
292
|
if (!res.ok) throw new Error(`GET ${path}: ${res.status}`);
|
|
264
293
|
return res.json();
|
|
265
294
|
}
|
|
266
295
|
|
|
267
296
|
async function hubPost(path: string, body?: unknown) {
|
|
268
|
-
const res = await fetch(`${HUB_URL}${path}`, {
|
|
297
|
+
const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, {
|
|
269
298
|
method: "POST",
|
|
270
299
|
headers: body ? headers : { Authorization: headers.Authorization },
|
|
271
300
|
body: body ? JSON.stringify(body) : undefined,
|
|
301
|
+
signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
|
|
272
302
|
});
|
|
273
303
|
if (!res.ok) throw new Error(`POST ${path}: ${res.status}`);
|
|
274
304
|
return res.json();
|
|
275
305
|
}
|
|
276
306
|
|
|
277
307
|
async function hubPatch(path: string, body: unknown) {
|
|
278
|
-
const res = await fetch(`${HUB_URL}${path}`, {
|
|
308
|
+
const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, {
|
|
279
309
|
method: "PATCH",
|
|
280
310
|
headers,
|
|
281
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),
|
|
282
326
|
});
|
|
283
|
-
if (!res.ok)
|
|
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
|
+
}
|
|
284
331
|
return res.json();
|
|
285
332
|
}
|
|
286
333
|
|
|
@@ -309,27 +356,7 @@ async function loadPublicKeys() {
|
|
|
309
356
|
|
|
310
357
|
function localEncrypt(plaintext: string, taskId: string, recipientPubKeys: Record<string, string>) {
|
|
311
358
|
if (!PRIVATE_KEY) throw new Error("No private key configured");
|
|
312
|
-
|
|
313
|
-
const iv = randomBytes(12);
|
|
314
|
-
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
315
|
-
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
316
|
-
const tag = cipher.getAuthTag();
|
|
317
|
-
const ciphertext = Buffer.concat([iv, encrypted, tag]).toString("base64");
|
|
318
|
-
|
|
319
|
-
const signature = sign(null, Buffer.from(taskId + ciphertext), {
|
|
320
|
-
key: PRIVATE_KEY,
|
|
321
|
-
padding: constants.RSA_PKCS1_PSS_PADDING,
|
|
322
|
-
saltLength: 32,
|
|
323
|
-
}).toString("base64");
|
|
324
|
-
|
|
325
|
-
const encryptedKeys: Record<string, string> = {};
|
|
326
|
-
for (const [id, pub] of Object.entries(recipientPubKeys)) {
|
|
327
|
-
encryptedKeys[id] = publicEncrypt(
|
|
328
|
-
{ key: pub, oaepHash: "sha256", padding: constants.RSA_PKCS1_OAEP_PADDING },
|
|
329
|
-
key,
|
|
330
|
-
).toString("base64");
|
|
331
|
-
}
|
|
332
|
-
return { ciphertext, signature, encryptedKeys };
|
|
359
|
+
return _localEncrypt(plaintext, taskId, PRIVATE_KEY, recipientPubKeys);
|
|
333
360
|
}
|
|
334
361
|
|
|
335
362
|
function localDecrypt(
|
|
@@ -340,21 +367,7 @@ function localDecrypt(
|
|
|
340
367
|
myEncKey: string,
|
|
341
368
|
): string {
|
|
342
369
|
if (!PRIVATE_KEY) throw new Error("No private key configured");
|
|
343
|
-
|
|
344
|
-
key: senderPub,
|
|
345
|
-
padding: constants.RSA_PKCS1_PSS_PADDING,
|
|
346
|
-
saltLength: 32,
|
|
347
|
-
}, Buffer.from(sig, "base64"));
|
|
348
|
-
if (!valid) throw new Error("Signature verification failed");
|
|
349
|
-
|
|
350
|
-
const aesKey = privateDecrypt(
|
|
351
|
-
{ key: PRIVATE_KEY, oaepHash: "sha256", padding: constants.RSA_PKCS1_OAEP_PADDING },
|
|
352
|
-
Buffer.from(myEncKey, "base64"),
|
|
353
|
-
);
|
|
354
|
-
const data = Buffer.from(ciphertext, "base64");
|
|
355
|
-
const decipher = createDecipheriv("aes-256-gcm", aesKey, data.subarray(0, 12));
|
|
356
|
-
decipher.setAuthTag(data.subarray(-16));
|
|
357
|
-
return Buffer.concat([decipher.update(data.subarray(12, -16)), decipher.final()]).toString("utf8");
|
|
370
|
+
return _localDecrypt(ciphertext, sig, taskId, senderPub, myEncKey, PRIVATE_KEY);
|
|
358
371
|
}
|
|
359
372
|
|
|
360
373
|
// ── MCP Server ───────────────────────────────────────────────────────────────
|
|
@@ -362,6 +375,7 @@ function localDecrypt(
|
|
|
362
375
|
const instructions = [
|
|
363
376
|
"You are connected to the pairai agent hub. Messages from other AI agents arrive as notifications.",
|
|
364
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).",
|
|
365
379
|
"",
|
|
366
380
|
"Notification attributes:",
|
|
367
381
|
" task_id — the task this message belongs to",
|
|
@@ -381,7 +395,7 @@ const capabilities = serveProvider === "claude"
|
|
|
381
395
|
: { tools: {} };
|
|
382
396
|
|
|
383
397
|
const mcp = new Server(
|
|
384
|
-
{ name: "pairai", version:
|
|
398
|
+
{ name: "pairai", version: VERSION },
|
|
385
399
|
{ capabilities, instructions }
|
|
386
400
|
);
|
|
387
401
|
|
|
@@ -389,6 +403,16 @@ const mcp = new Server(
|
|
|
389
403
|
|
|
390
404
|
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
391
405
|
tools: [
|
|
406
|
+
{
|
|
407
|
+
name: "pairai_check_updates",
|
|
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.",
|
|
409
|
+
inputSchema: {
|
|
410
|
+
type: "object" as const,
|
|
411
|
+
properties: {
|
|
412
|
+
acknowledge: { type: "boolean", description: "If true, marks all current updates as seen after returning them" },
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
},
|
|
392
416
|
{
|
|
393
417
|
name: "pairai_reply",
|
|
394
418
|
description: "Send a message to the other agent in a task.",
|
|
@@ -463,6 +487,8 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
463
487
|
description: { type: "string", description: "What this agent does (max 500 chars)" },
|
|
464
488
|
capabilities: { type: "array", items: { type: "string" }, description: "List of capabilities, e.g. ['scheduling', 'code-review']" },
|
|
465
489
|
metadata: { type: "object", description: "Arbitrary JSON metadata (max 4KB)" },
|
|
490
|
+
discoverable: { type: "boolean", description: "Whether to appear in the public agent directory" },
|
|
491
|
+
defaultApprovalRule: { type: "string", enum: ["auto", "require"], description: "Default approval rule for new connections" },
|
|
466
492
|
},
|
|
467
493
|
},
|
|
468
494
|
},
|
|
@@ -478,9 +504,84 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
478
504
|
required: ["connection_id"],
|
|
479
505
|
},
|
|
480
506
|
},
|
|
507
|
+
{
|
|
508
|
+
name: "pairai_update_webhook",
|
|
509
|
+
description: "Configure a webhook URL to receive events. Set url to null to disable.",
|
|
510
|
+
inputSchema: {
|
|
511
|
+
type: "object" as const,
|
|
512
|
+
properties: {
|
|
513
|
+
url: { type: ["string", "null"], description: "HTTPS webhook endpoint, or null to disable" },
|
|
514
|
+
secret: { type: "string", description: "Shared secret for HMAC-SHA256 signature (min 16 chars)" },
|
|
515
|
+
events: { type: "array", items: { type: "string" }, description: "Event types to receive (empty = all)" },
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
name: "pairai_discover_agents",
|
|
521
|
+
description: "Search the public directory of discoverable agents by capability or name.",
|
|
522
|
+
inputSchema: {
|
|
523
|
+
type: "object" as const,
|
|
524
|
+
properties: {
|
|
525
|
+
capability: { type: "string", description: "Filter by capability tag" },
|
|
526
|
+
query: { type: "string", description: "Search name and description" },
|
|
527
|
+
limit: { type: "number", description: "Max results (default 20)" },
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
name: "pairai_set_approval_rule",
|
|
533
|
+
description: "Set whether incoming tasks from a connection require human approval. 'auto' accepts automatically, 'require' holds tasks pending.",
|
|
534
|
+
inputSchema: {
|
|
535
|
+
type: "object" as const,
|
|
536
|
+
properties: {
|
|
537
|
+
connection_id: { type: "string", description: "Connection ID" },
|
|
538
|
+
rule: { type: "string", enum: ["auto", "require"], description: "Approval rule" },
|
|
539
|
+
},
|
|
540
|
+
required: ["connection_id", "rule"],
|
|
541
|
+
},
|
|
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
|
+
},
|
|
554
|
+
{
|
|
555
|
+
name: "pairai_list_pending_approvals",
|
|
556
|
+
description: "List tasks waiting for your approval.",
|
|
557
|
+
inputSchema: { type: "object" as const, properties: {} },
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
name: "pairai_approve_task",
|
|
561
|
+
description: "Approve a task that is pending your approval.",
|
|
562
|
+
inputSchema: {
|
|
563
|
+
type: "object" as const,
|
|
564
|
+
properties: {
|
|
565
|
+
task_id: { type: "string", description: "Task ID" },
|
|
566
|
+
},
|
|
567
|
+
required: ["task_id"],
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
name: "pairai_reject_task",
|
|
572
|
+
description: "Reject a task pending your approval. Optionally provide a reason.",
|
|
573
|
+
inputSchema: {
|
|
574
|
+
type: "object" as const,
|
|
575
|
+
properties: {
|
|
576
|
+
task_id: { type: "string", description: "Task ID" },
|
|
577
|
+
reason: { type: "string", description: "Reason for rejection" },
|
|
578
|
+
},
|
|
579
|
+
required: ["task_id"],
|
|
580
|
+
},
|
|
581
|
+
},
|
|
481
582
|
{
|
|
482
583
|
name: "pairai_list_tasks",
|
|
483
|
-
description: "List
|
|
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.",
|
|
484
585
|
inputSchema: {
|
|
485
586
|
type: "object" as const,
|
|
486
587
|
properties: {
|
|
@@ -513,6 +614,18 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
513
614
|
required: ["task_id", "filename", "mime_type", "base64_content"],
|
|
514
615
|
},
|
|
515
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
|
+
},
|
|
516
629
|
{
|
|
517
630
|
name: "pairai_create_encrypted_task",
|
|
518
631
|
description: "Create an encrypted task. Title and description are encrypted — the hub cannot read them.",
|
|
@@ -531,7 +644,58 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
531
644
|
|
|
532
645
|
mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
533
646
|
const { name, arguments: a } = req.params;
|
|
534
|
-
const args = a as Record<string,
|
|
647
|
+
const args = a as Record<string, unknown>;
|
|
648
|
+
|
|
649
|
+
if (name === "pairai_check_updates") {
|
|
650
|
+
await loadPublicKeys();
|
|
651
|
+
const updates = (await hubGet("/updates")) as {
|
|
652
|
+
hasUpdates: boolean;
|
|
653
|
+
pendingTasks: Array<{ id: string; title: string; fromAgent: string }>;
|
|
654
|
+
unreadMessages: Array<{ taskId: string; taskTitle: string; count: number }>;
|
|
655
|
+
cursor: number;
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
if (!updates.hasUpdates) {
|
|
659
|
+
return { content: [{ type: "text" as const, text: "No updates. You're all caught up." }] };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const parts: string[] = [];
|
|
663
|
+
|
|
664
|
+
if (updates.pendingTasks.length > 0) {
|
|
665
|
+
const enriched: string[] = [];
|
|
666
|
+
for (const task of updates.pendingTasks) {
|
|
667
|
+
const full = (await hubGet(`/tasks/${task.id}`)) as any;
|
|
668
|
+
const desc = full.encrypted ? decryptTaskDescription(full, task.id) : (full.description ?? "");
|
|
669
|
+
const title = desc.split("\n")[0] || task.title;
|
|
670
|
+
enriched.push(`- "${title}" from ${task.fromAgent} (task ID: ${task.id})`);
|
|
671
|
+
}
|
|
672
|
+
parts.push(`**${updates.pendingTasks.length} pending task(s):**\n${enriched.join("\n")}`);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (updates.unreadMessages.length > 0) {
|
|
676
|
+
const enriched: string[] = [];
|
|
677
|
+
for (const unread of updates.unreadMessages) {
|
|
678
|
+
const full = (await hubGet(`/tasks/${unread.taskId}`)) as any;
|
|
679
|
+
const msgs = (full.messages ?? []) as Array<any>;
|
|
680
|
+
const recent = msgs.slice(-unread.count);
|
|
681
|
+
const previews: string[] = [];
|
|
682
|
+
for (const m of recent) {
|
|
683
|
+
const d = full.encrypted ? decryptMessage(m, unread.taskId) : { content: m.content, contentType: m.contentType };
|
|
684
|
+
previews.push(d.content.slice(0, 100));
|
|
685
|
+
}
|
|
686
|
+
enriched.push(`- ${unread.count} new in "${unread.taskTitle}" (task ID: ${unread.taskId})\n Preview: ${previews.join(" | ")}`);
|
|
687
|
+
}
|
|
688
|
+
parts.push(`**Unread messages:**\n${enriched.join("\n")}`);
|
|
689
|
+
}
|
|
690
|
+
|
|
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 });
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return { content: [{ type: "text" as const, text: parts.join("\n\n") }] };
|
|
698
|
+
}
|
|
535
699
|
|
|
536
700
|
if (name === "pairai_reply") {
|
|
537
701
|
const { task_id, text, content_type } = args as { task_id: string; text: string; content_type?: string };
|
|
@@ -548,17 +712,22 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
548
712
|
return { content: [{ type: "text" as const, text: "Error: Cannot reply to encrypted task — missing cryptographic keys. Re-run setup or reconnect." }] };
|
|
549
713
|
}
|
|
550
714
|
const envelope = JSON.stringify({ contentType: content_type ?? "text", body: text });
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
+
}
|
|
562
731
|
}
|
|
563
732
|
// Non-encrypted task: send plaintext
|
|
564
733
|
await hubPost(`/tasks/${task_id}/messages`, {
|
|
@@ -569,8 +738,16 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
569
738
|
}
|
|
570
739
|
|
|
571
740
|
if (name === "pairai_update_status") {
|
|
572
|
-
|
|
573
|
-
|
|
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
|
+
}
|
|
574
751
|
}
|
|
575
752
|
|
|
576
753
|
if (name === "pairai_get_profile") {
|
|
@@ -638,6 +815,8 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
638
815
|
if (args.description !== undefined) body.description = args.description;
|
|
639
816
|
if (args.capabilities !== undefined) body.capabilities = args.capabilities;
|
|
640
817
|
if (args.metadata !== undefined) body.metadata = args.metadata;
|
|
818
|
+
if (args.discoverable !== undefined) body.discoverable = args.discoverable === "true" || args.discoverable === true;
|
|
819
|
+
if (args.defaultApprovalRule !== undefined) body.defaultApprovalRule = args.defaultApprovalRule;
|
|
641
820
|
const data = await hubPatch("/agents/me", body);
|
|
642
821
|
return { content: [{ type: "text" as const, text: "Profile updated.\n" + JSON.stringify(data, null, 2) }] };
|
|
643
822
|
}
|
|
@@ -648,6 +827,57 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
648
827
|
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
649
828
|
}
|
|
650
829
|
|
|
830
|
+
if (name === "pairai_update_webhook") {
|
|
831
|
+
const body: Record<string, unknown> = {};
|
|
832
|
+
if (args.url !== undefined) body.webhookUrl = args.url;
|
|
833
|
+
if (args.secret !== undefined) body.webhookSecret = args.secret;
|
|
834
|
+
if (args.events !== undefined) body.webhookEvents = args.events;
|
|
835
|
+
const data = await hubPatch("/agents/me", body);
|
|
836
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (name === "pairai_discover_agents") {
|
|
840
|
+
const params = new URLSearchParams();
|
|
841
|
+
if (args.capability) params.set("capability", args.capability);
|
|
842
|
+
if (args.query) params.set("q", args.query);
|
|
843
|
+
if (args.limit) params.set("limit", args.limit);
|
|
844
|
+
const qs = params.toString();
|
|
845
|
+
const data = await hubGet(`/agents/discover${qs ? `?${qs}` : ""}`);
|
|
846
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (name === "pairai_set_approval_rule") {
|
|
850
|
+
const { connection_id, rule } = args as { connection_id: string; rule: string };
|
|
851
|
+
const data = await hubPatch(`/connections/${connection_id}`, { approval: rule });
|
|
852
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
853
|
+
}
|
|
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
|
+
|
|
865
|
+
if (name === "pairai_list_pending_approvals") {
|
|
866
|
+
const data = await hubGet("/approvals");
|
|
867
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if (name === "pairai_approve_task") {
|
|
871
|
+
const data = await hubPost(`/approvals/${args.task_id}/approve`);
|
|
872
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (name === "pairai_reject_task") {
|
|
876
|
+
const { task_id, reason } = args as { task_id: string; reason?: string };
|
|
877
|
+
const data = await hubPost(`/approvals/${task_id}/reject`, reason ? { reason } : undefined);
|
|
878
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
879
|
+
}
|
|
880
|
+
|
|
651
881
|
if (name === "pairai_list_tasks") {
|
|
652
882
|
await loadPublicKeys();
|
|
653
883
|
const data = (await hubGet("/tasks")) as Array<{
|
|
@@ -677,13 +907,20 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
677
907
|
encryptedKeys?: any; senderSignature?: string;
|
|
678
908
|
}>;
|
|
679
909
|
if (data.encrypted) {
|
|
680
|
-
|
|
681
|
-
|
|
910
|
+
try {
|
|
911
|
+
data.description = decryptTaskDescription(data, data.id);
|
|
912
|
+
} catch {
|
|
913
|
+
data.description = "[decryption failed]";
|
|
914
|
+
}
|
|
682
915
|
}
|
|
683
916
|
const decryptedMsgs = msgs.map((m) => {
|
|
684
917
|
if (data.encrypted) {
|
|
685
|
-
|
|
686
|
-
|
|
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
|
+
}
|
|
687
924
|
}
|
|
688
925
|
return m;
|
|
689
926
|
});
|
|
@@ -694,12 +931,151 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
694
931
|
const { task_id, filename, mime_type, base64_content } = args as {
|
|
695
932
|
task_id: string; filename: string; mime_type: string; base64_content: string;
|
|
696
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
|
|
697
969
|
const data = await hubPost(`/tasks/${task_id}/files/json`, {
|
|
698
970
|
filename, mimeType: mime_type, base64Content: base64_content,
|
|
699
971
|
});
|
|
700
972
|
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
701
973
|
}
|
|
702
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
|
+
|
|
703
1079
|
if (name === "pairai_create_encrypted_task") {
|
|
704
1080
|
if (!PRIVATE_KEY)
|
|
705
1081
|
return { content: [{ type: "text" as const, text: "No private key configured. Re-run setup." }] };
|
|
@@ -742,6 +1118,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
742
1118
|
// ── Polling ──────────────────────────────────────────────────────────────────
|
|
743
1119
|
|
|
744
1120
|
const seenMessages = new Set<string>();
|
|
1121
|
+
const SEEN_MESSAGES_MAX = 10_000;
|
|
745
1122
|
|
|
746
1123
|
function decryptMessage(
|
|
747
1124
|
msg: { content: string; contentType: string; senderAgentId: string; encryptedKeys?: any; senderSignature?: string },
|
|
@@ -798,13 +1175,16 @@ async function poll() {
|
|
|
798
1175
|
hasUpdates: boolean;
|
|
799
1176
|
pendingTasks: Array<{ id: string; title: string; fromAgent: string }>;
|
|
800
1177
|
unreadMessages: Array<{ taskId: string; taskTitle: string; count: number }>;
|
|
1178
|
+
cursor: number;
|
|
801
1179
|
};
|
|
802
1180
|
|
|
1181
|
+
debugLog(`poll: hasUpdates=${updates.hasUpdates} tasks=${updates.pendingTasks.length} messages=${updates.unreadMessages.length} cursor=${updates.cursor}`);
|
|
803
1182
|
if (!updates.hasUpdates) return;
|
|
1183
|
+
console.error(`[pairai] poll: ${updates.pendingTasks.length} tasks, ${updates.unreadMessages.length} messages`);
|
|
804
1184
|
|
|
805
1185
|
for (const task of updates.pendingTasks) {
|
|
806
1186
|
const key = `task:${task.id}`;
|
|
807
|
-
if (seenMessages.has(key)) continue;
|
|
1187
|
+
if (seenMessages.has(key)) { debugLog(`skip seen task ${task.id}`); continue; }
|
|
808
1188
|
seenMessages.add(key);
|
|
809
1189
|
|
|
810
1190
|
const full = (await hubGet(`/tasks/${task.id}`)) as {
|
|
@@ -813,78 +1193,152 @@ async function poll() {
|
|
|
813
1193
|
descriptionKeys?: any;
|
|
814
1194
|
senderSignature?: string;
|
|
815
1195
|
initiatorAgentId?: string;
|
|
816
|
-
|
|
817
|
-
content: string;
|
|
818
|
-
contentType: string;
|
|
819
|
-
senderAgentId: string;
|
|
820
|
-
encryptedKeys?: any;
|
|
821
|
-
senderSignature?: string;
|
|
822
|
-
}>;
|
|
1196
|
+
approvalStatus?: string | null;
|
|
823
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
|
+
}>;
|
|
824
1205
|
|
|
825
1206
|
const desc = decryptTaskDescription(full, task.id);
|
|
826
|
-
const decryptedMessages = (
|
|
827
|
-
|
|
828
|
-
|
|
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
|
+
}
|
|
829
1218
|
});
|
|
830
1219
|
|
|
831
|
-
const
|
|
1220
|
+
const isPendingApproval = full.approvalStatus === "pending";
|
|
1221
|
+
const approvalPrefix = isPendingApproval ? "[APPROVAL REQUIRED] " : "";
|
|
1222
|
+
const approvalSuffix = isPendingApproval
|
|
1223
|
+
? `\n\nThis task requires your approval before the agent will act on it.\nUse pairai_approve_task or pairai_reject_task with task ID: ${task.id}`
|
|
1224
|
+
: "";
|
|
832
1225
|
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1226
|
+
const body = approvalPrefix + [desc || task.title, ...decryptedMessages.map((c) => `> ${c}`)].join("\n\n") + approvalSuffix;
|
|
1227
|
+
|
|
1228
|
+
try {
|
|
1229
|
+
await mcp.notification({
|
|
1230
|
+
method: "notifications/claude/channel",
|
|
1231
|
+
params: {
|
|
1232
|
+
content: body,
|
|
1233
|
+
meta: { task_id: task.id, task_title: task.title, from_agent: task.fromAgent, event_type: "new_task" },
|
|
1234
|
+
},
|
|
1235
|
+
});
|
|
1236
|
+
console.error(`[pairai] channel notification sent: new_task ${task.id} from ${task.fromAgent}${isPendingApproval ? " [APPROVAL REQUIRED]" : ""}`);
|
|
1237
|
+
} catch (err) {
|
|
1238
|
+
console.error(`[pairai] channel notification FAILED: ${(err as Error).message}`);
|
|
1239
|
+
}
|
|
840
1240
|
}
|
|
841
1241
|
|
|
842
1242
|
for (const unread of updates.unreadMessages) {
|
|
843
|
-
const
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
}>;
|
|
852
|
-
};
|
|
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
|
+
}>;
|
|
853
1251
|
|
|
854
|
-
|
|
855
|
-
|
|
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)) {
|
|
856
1255
|
const key = `msg:${msg.id}`;
|
|
857
|
-
if (seenMessages.has(key)) continue;
|
|
1256
|
+
if (seenMessages.has(key)) { debugLog(`skip seen msg ${msg.id}`); continue; }
|
|
858
1257
|
seenMessages.add(key);
|
|
859
1258
|
|
|
860
|
-
|
|
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
|
+
}
|
|
861
1271
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
1272
|
+
try {
|
|
1273
|
+
await mcp.notification({
|
|
1274
|
+
method: "notifications/claude/channel",
|
|
1275
|
+
params: {
|
|
1276
|
+
content: decrypted.content,
|
|
1277
|
+
meta: {
|
|
1278
|
+
task_id: unread.taskId,
|
|
1279
|
+
task_title: unread.taskTitle,
|
|
1280
|
+
from_agent: msg.senderAgentId,
|
|
1281
|
+
event_type: "new_message",
|
|
1282
|
+
content_type: decrypted.contentType,
|
|
1283
|
+
},
|
|
872
1284
|
},
|
|
873
|
-
}
|
|
874
|
-
|
|
1285
|
+
});
|
|
1286
|
+
console.error(`[pairai] channel notification sent: new_message in ${unread.taskId}`);
|
|
1287
|
+
} catch (err) {
|
|
1288
|
+
console.error(`[pairai] channel notification FAILED: ${(err as Error).message}`);
|
|
1289
|
+
}
|
|
875
1290
|
}
|
|
876
1291
|
}
|
|
877
1292
|
|
|
878
|
-
|
|
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
|
+
}
|
|
879
1304
|
} catch (err) {
|
|
880
1305
|
console.error(`[pairai] poll error: ${(err as Error).message}`);
|
|
1306
|
+
debugLog(`poll error: ${(err as Error).message}`);
|
|
881
1307
|
}
|
|
882
1308
|
}
|
|
883
1309
|
|
|
884
1310
|
// ── Start ────────────────────────────────────────────────────────────────────
|
|
885
1311
|
|
|
886
1312
|
await mcp.connect(new StdioServerTransport());
|
|
1313
|
+
console.error(`[pairai] connected. provider=${serveProvider} channel=${!!capabilities.experimental?.["claude/channel"]} agent=${myAgentId || "(loading)"}`);
|
|
887
1314
|
await loadAgentInfo();
|
|
1315
|
+
if (!myAgentId) {
|
|
1316
|
+
console.error("[pairai] failed to load agent info from hub. Cannot start polling.");
|
|
1317
|
+
process.exit(1);
|
|
1318
|
+
}
|
|
888
1319
|
await loadPublicKeys();
|
|
1320
|
+
|
|
1321
|
+
// Acquire polling lock — only one channel process per agent
|
|
1322
|
+
const lockDir = process.env.PAIRAI_LOCK_DIR || undefined;
|
|
1323
|
+
if (!acquireLock(myAgentId, lockDir)) {
|
|
1324
|
+
console.error(`[pairai] another instance is already polling for agent ${myAgentId}. Exiting.`);
|
|
1325
|
+
process.exit(0);
|
|
1326
|
+
}
|
|
1327
|
+
const cleanupLock = () => { try { releaseLock(myAgentId, lockDir); } catch {} };
|
|
1328
|
+
process.on("SIGTERM", cleanupLock);
|
|
1329
|
+
process.on("SIGINT", cleanupLock);
|
|
1330
|
+
process.on("beforeExit", cleanupLock);
|
|
1331
|
+
process.on("exit", cleanupLock);
|
|
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
|
+
|
|
1342
|
+
console.error(`[pairai] agent=${myAgentId} keys=${pubKeyCache.size} polling every ${POLL_MS}ms`);
|
|
889
1343
|
setInterval(poll, POLL_MS);
|
|
890
1344
|
poll();
|