agentcollar 0.0.1 → 0.1.0
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/LICENSE +27 -0
- package/README.md +82 -4
- package/dist/approval.js +67 -0
- package/dist/audit-log.js +37 -0
- package/dist/audit.js +23 -0
- package/dist/check.js +52 -0
- package/dist/cli/format.js +39 -0
- package/dist/cli/intro-frames.js +25 -0
- package/dist/cli/intro.js +105 -0
- package/dist/cli/logs.js +46 -0
- package/dist/cli/main.js +94 -0
- package/dist/cli/mandates.js +76 -0
- package/dist/cli/revoke.js +8 -0
- package/dist/cli/setup.js +124 -0
- package/dist/cli/start.js +29 -0
- package/dist/cli/tail.js +46 -0
- package/dist/cli/watch.js +41 -0
- package/dist/cli.js +4 -0
- package/dist/env.js +13 -0
- package/dist/gmail-fake.js +27 -0
- package/dist/mandate.js +82 -0
- package/dist/mcp/protocol.js +72 -0
- package/dist/mcp/server.js +22 -0
- package/dist/mcp/tools.js +112 -0
- package/dist/paths.js +42 -0
- package/dist/revoke.js +18 -0
- package/dist/server.js +238 -0
- package/dist/setup.js +100 -0
- package/dist/snapshot.js +42 -0
- package/dist/telegram.js +124 -0
- package/package.json +50 -5
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// agentcollar mandates — the mandates of the running broker: who, what, state, time and actions left.
|
|
2
|
+
import { styleText } from "node:util";
|
|
3
|
+
import { oneLine } from "../approval.js";
|
|
4
|
+
import { readMandatesSnapshot } from "../snapshot.js";
|
|
5
|
+
function duration(ms) {
|
|
6
|
+
const total = Math.ceil(ms / 1000);
|
|
7
|
+
const h = Math.floor(total / 3600);
|
|
8
|
+
const m = Math.floor((total % 3600) / 60);
|
|
9
|
+
const s = String(total % 60).padStart(2, "0");
|
|
10
|
+
return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
|
|
11
|
+
}
|
|
12
|
+
// The same order as check(): the first thing that ends a mandate decides its name.
|
|
13
|
+
export function mandateRow(m, now = Date.now()) {
|
|
14
|
+
let state = "активен";
|
|
15
|
+
if (m.status === "pending")
|
|
16
|
+
state = "ждёт решения";
|
|
17
|
+
else if (m.status === "denied")
|
|
18
|
+
state = "отклонён";
|
|
19
|
+
else if (now > m.expiresAt)
|
|
20
|
+
state = "истёк";
|
|
21
|
+
else if (m.revoked)
|
|
22
|
+
state = "отозван";
|
|
23
|
+
else if (m.used >= m.limit)
|
|
24
|
+
state = "лимит исчерпан";
|
|
25
|
+
const live = state === "активен";
|
|
26
|
+
return {
|
|
27
|
+
id: m.id,
|
|
28
|
+
agent: oneLine(m.agent, 24), // agent text: control characters removed before printing
|
|
29
|
+
actions: m.allowedActions.join(", "),
|
|
30
|
+
state,
|
|
31
|
+
timeLeft: live ? duration(m.expiresAt - now) : "—",
|
|
32
|
+
limit: `${m.used}/${m.limit}`,
|
|
33
|
+
live,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
// Is the server process still running? Signal 0 checks without touching it.
|
|
37
|
+
function isRunning(pid) {
|
|
38
|
+
try {
|
|
39
|
+
process.kill(pid, 0);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
return error.code === "EPERM"; // exists, but belongs to someone else
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const COLUMNS = ["ID", "АГЕНТ", "ДЕЙСТВИЯ", "СТАТУС", "ОСТАЛОСЬ", "ЛИМИТ"];
|
|
47
|
+
export async function runMandates(args) {
|
|
48
|
+
if (args.length > 0) {
|
|
49
|
+
console.error(`agentcollar mandates не принимает аргументов: ${args.join(" ")}`);
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
const snapshot = readMandatesSnapshot();
|
|
53
|
+
if (snapshot === null) {
|
|
54
|
+
console.log("Брокер ещё не запускался. Запусти: agcl server");
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
if (!isRunning(snapshot.pid)) {
|
|
58
|
+
console.log("Брокер не запущен. Мандаты живут в его памяти, поэтому после остановки их нет.");
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
if (snapshot.mandates.length === 0) {
|
|
62
|
+
console.log("Мандатов пока нет.");
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
const rows = [...snapshot.mandates].sort((a, b) => b.createdAt - a.createdAt).map((m) => mandateRow(m));
|
|
66
|
+
const cells = (r) => [r.id, r.agent, r.actions, r.state, r.timeLeft, r.limit];
|
|
67
|
+
const widths = COLUMNS.map((title, i) => Math.max(title.length, ...rows.map((r) => cells(r)[i].length)));
|
|
68
|
+
// pad first, color after: color codes are invisible but would break the padding
|
|
69
|
+
console.log(styleText("dim", COLUMNS.map((title, i) => title.padEnd(widths[i])).join(" ")));
|
|
70
|
+
for (const row of rows) {
|
|
71
|
+
const line = cells(row).map((cell, i) => cell.padEnd(widths[i]));
|
|
72
|
+
line[3] = row.live ? styleText("green", line[3]) : row.state === "ждёт решения" ? styleText("yellow", line[3]) : line[3];
|
|
73
|
+
console.log(row.live || row.state === "ждёт решения" ? line.join(" ") : styleText("dim", line.join(" ")));
|
|
74
|
+
}
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// agentcollar revoke <id> — the local kill switch from the terminal.
|
|
2
|
+
import "../env.js";
|
|
3
|
+
import { revokeMandate } from "../revoke.js";
|
|
4
|
+
export async function runRevoke(args) {
|
|
5
|
+
const result = await revokeMandate(args[0] ?? "", Number(process.env.BROKER_PORT ?? 8787));
|
|
6
|
+
console.log(result.message);
|
|
7
|
+
return result.ok ? 0 : 1;
|
|
8
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// agcl setup (= agentcollar setup, npm run setup) — connects the broker to YOUR Telegram bot, without editing files by hand.
|
|
2
|
+
// 1) asks for the bot token and checks it with Telegram,
|
|
3
|
+
// 2) you press Start in the bot: we take your user id from that message,
|
|
4
|
+
// 3) writes ~/.agentcollar/.env readable only by you (600).
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
7
|
+
import { ensureHome, envFile, homeDir, legacyFiles, migrateLegacy } from "../paths.js";
|
|
8
|
+
import { buildEnv, getBotUsername, newStartCode, pollForStart, sendText, skipOldUpdates, writeEnvFile, } from "../setup.js";
|
|
9
|
+
const WAIT_MS = 5 * 60 * 1000;
|
|
10
|
+
async function ask(question) {
|
|
11
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
12
|
+
try {
|
|
13
|
+
// If the input ends before an answer (Ctrl+D, closed pipe), fail loudly instead of exiting silently.
|
|
14
|
+
const answer = await new Promise((resolve, reject) => {
|
|
15
|
+
rl.question(question).then(resolve, reject);
|
|
16
|
+
rl.once("close", () => reject(new Error("ввод закончился, ничего не записано")));
|
|
17
|
+
});
|
|
18
|
+
return answer.trim();
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
rl.close();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const yes = (answer) => answer.toLowerCase() === "y";
|
|
25
|
+
// Reads a line without showing it (for the token). Node has no ready-made "password input",
|
|
26
|
+
// so we switch the terminal to raw mode and read key by key, printing nothing.
|
|
27
|
+
function askHidden(question) {
|
|
28
|
+
const stdin = process.stdin;
|
|
29
|
+
if (!stdin.isTTY)
|
|
30
|
+
return ask(question); // input is piped, not typed: nothing to hide
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
process.stdout.write(question);
|
|
33
|
+
stdin.setRawMode(true);
|
|
34
|
+
stdin.setEncoding("utf8");
|
|
35
|
+
stdin.resume();
|
|
36
|
+
let value = "";
|
|
37
|
+
const onData = (chunk) => {
|
|
38
|
+
for (const char of chunk) {
|
|
39
|
+
if (char === "\r" || char === "\n") {
|
|
40
|
+
stdin.setRawMode(false);
|
|
41
|
+
stdin.pause();
|
|
42
|
+
stdin.off("data", onData);
|
|
43
|
+
process.stdout.write("\n");
|
|
44
|
+
return resolve(value.trim());
|
|
45
|
+
}
|
|
46
|
+
if (char === "\u0003")
|
|
47
|
+
process.exit(130); // Ctrl+C
|
|
48
|
+
if (char === "\u007f")
|
|
49
|
+
value = value.slice(0, -1); // Backspace
|
|
50
|
+
else
|
|
51
|
+
value += char;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
stdin.on("data", onData);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
export async function runSetup() {
|
|
58
|
+
try {
|
|
59
|
+
return await setup();
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
console.error(`\n✗ ${error.message}`);
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function setup() {
|
|
67
|
+
console.log("Настройка AgentCollar broker\n");
|
|
68
|
+
// Older versions kept the data inside the code folder (broker/.env, broker/data/audit.log).
|
|
69
|
+
const oldFiles = [legacyFiles.env, legacyFiles.audit].filter((file) => existsSync(file));
|
|
70
|
+
if (oldFiles.length > 0) {
|
|
71
|
+
console.log(`Нашёл старые данные:\n${oldFiles.map((f) => ` ${f}`).join("\n")}`);
|
|
72
|
+
if (yes(await ask(`Перенести в ${homeDir}/ (папка доступна только тебе)? [y/N] `))) {
|
|
73
|
+
const moved = migrateLegacy();
|
|
74
|
+
console.log(moved.length > 0 ? `✓ Перенесено: ${moved.join(", ")}\n` : "Там уже есть свои файлы, старые оставил на месте.\n");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (existsSync(envFile) && !yes(await ask(`${envFile} уже есть. Перезаписать настройки Telegram? [y/N] `))) {
|
|
78
|
+
console.log("Ничего не меняю.");
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
// 1. Token
|
|
82
|
+
let token = "";
|
|
83
|
+
let username = "";
|
|
84
|
+
while (username === "") {
|
|
85
|
+
token = await askHidden("Токен бота (от @BotFather, при вводе не виден): ");
|
|
86
|
+
try {
|
|
87
|
+
username = await getBotUsername(token);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
console.log(`✗ ${error.message}\n`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
console.log(`✓ Бот найден: @${username}\n`);
|
|
94
|
+
// 2. Start with a one-time code
|
|
95
|
+
const code = newStartCode();
|
|
96
|
+
let offset = await skipOldUpdates(token);
|
|
97
|
+
console.log(`Открой ссылку и нажми Start: https://t.me/${username}?start=${code}`);
|
|
98
|
+
console.log("Жду (до 5 минут)...");
|
|
99
|
+
let user;
|
|
100
|
+
const deadline = Date.now() + WAIT_MS;
|
|
101
|
+
while (user === undefined && Date.now() < deadline) {
|
|
102
|
+
const round = await pollForStart(token, code, offset);
|
|
103
|
+
user = round.user;
|
|
104
|
+
offset = round.offset;
|
|
105
|
+
}
|
|
106
|
+
await skipOldUpdates(token); // tell Telegram we handled these messages
|
|
107
|
+
if (user === undefined) {
|
|
108
|
+
console.log("Время вышло. Запусти agcl setup ещё раз.");
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
const who = [user.firstName, user.username ? `@${user.username}` : "", `id ${user.id}`].filter(Boolean).join(", ");
|
|
112
|
+
if (!yes(await ask(`✓ Это ты? ${who} [y/N] `))) {
|
|
113
|
+
console.log("Ничего не записал. Запусти agcl setup ещё раз.");
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
// 3. ~/.agentcollar/.env, readable only by you
|
|
117
|
+
ensureHome();
|
|
118
|
+
const existing = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
|
|
119
|
+
writeEnvFile(envFile, buildEnv(existing, { TELEGRAM_BOT_TOKEN: token, TELEGRAM_USER_ID: String(user.id) }));
|
|
120
|
+
await sendText(token, user.id, "✅ Брокер настроен. Запросы мандатов будут приходить сюда.").catch(() => { });
|
|
121
|
+
console.log(`✓ Записано в ${envFile} (права 600: читать может только твой пользователь macOS)`);
|
|
122
|
+
console.log("Дальше: agcl server (или просто agcl)");
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// `agcl` with no command: the intro, then the setup wizard if Telegram is not set up yet,
|
|
2
|
+
// then the broker itself. One word from install to a running broker.
|
|
3
|
+
import "../env.js";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { envFile } from "../paths.js";
|
|
7
|
+
async function ask(question) {
|
|
8
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
9
|
+
const answer = await rl.question(question);
|
|
10
|
+
rl.close();
|
|
11
|
+
return answer.trim().toLowerCase();
|
|
12
|
+
}
|
|
13
|
+
export async function runStart(flags) {
|
|
14
|
+
await (await import("./intro.js")).maybeIntro(flags.noIntro);
|
|
15
|
+
// Ask only a person at a terminal; a script or pipe goes straight to the server.
|
|
16
|
+
const configured = (process.env.TELEGRAM_BOT_TOKEN ?? "") !== "";
|
|
17
|
+
if (!configured && process.stdin.isTTY) {
|
|
18
|
+
const answer = await ask("Telegram ещё не настроен. Настроить сейчас? [Y/n] (n — одобрять запросы в этом терминале) ");
|
|
19
|
+
if (answer === "" || answer === "y") {
|
|
20
|
+
const code = await (await import("./setup.js")).runSetup();
|
|
21
|
+
if (code !== 0)
|
|
22
|
+
return code;
|
|
23
|
+
if (existsSync(envFile))
|
|
24
|
+
process.loadEnvFile(envFile); // the server reads the new settings
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
await import("../server.js");
|
|
28
|
+
return new Promise(() => { }); // the server runs until Ctrl+C
|
|
29
|
+
}
|
package/dist/cli/tail.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Like `tail -f`: each readNew() returns only the entries appended since the previous call.
|
|
2
|
+
import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
|
|
3
|
+
import { parseAuditLine } from "../audit-log.js";
|
|
4
|
+
const NEWLINE = 0x0a; // the byte of "\n"
|
|
5
|
+
export function createTail(file) {
|
|
6
|
+
let offset = 0; // how many bytes we have already read
|
|
7
|
+
let rest = Buffer.alloc(0); // bytes after the last "\n": a line the server is still writing
|
|
8
|
+
return {
|
|
9
|
+
readNew() {
|
|
10
|
+
if (!existsSync(file))
|
|
11
|
+
return [];
|
|
12
|
+
const size = statSync(file).size;
|
|
13
|
+
if (size < offset) {
|
|
14
|
+
// the file got shorter: it was cut or replaced, so start over
|
|
15
|
+
offset = 0;
|
|
16
|
+
rest = Buffer.alloc(0);
|
|
17
|
+
}
|
|
18
|
+
if (size === offset)
|
|
19
|
+
return [];
|
|
20
|
+
const chunk = Buffer.alloc(size - offset);
|
|
21
|
+
const fd = openSync(file, "r");
|
|
22
|
+
try {
|
|
23
|
+
readSync(fd, chunk, 0, chunk.length, offset);
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
closeSync(fd);
|
|
27
|
+
}
|
|
28
|
+
offset = size;
|
|
29
|
+
// Work with BYTES up to the last "\n". Turning a half line into text could cut a
|
|
30
|
+
// Cyrillic letter (2 bytes) in the middle and break it; the rest waits for the next call.
|
|
31
|
+
const bytes = Buffer.concat([rest, chunk]);
|
|
32
|
+
const end = bytes.lastIndexOf(NEWLINE);
|
|
33
|
+
if (end === -1) {
|
|
34
|
+
rest = bytes;
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
rest = bytes.subarray(end + 1);
|
|
38
|
+
return bytes
|
|
39
|
+
.subarray(0, end)
|
|
40
|
+
.toString("utf8")
|
|
41
|
+
.split("\n")
|
|
42
|
+
.map(parseAuditLine)
|
|
43
|
+
.filter((entry) => entry !== null);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// agentcollar watch — the audit log live: every agent request as it happens, with the 6 checks.
|
|
2
|
+
// Read-only: it only watches data/audit.log, no HTTP and no way to change anything.
|
|
3
|
+
import { mkdirSync, watch } from "node:fs";
|
|
4
|
+
import { basename, dirname } from "node:path";
|
|
5
|
+
import { styleText } from "node:util";
|
|
6
|
+
import { auditLogFile } from "../audit.js";
|
|
7
|
+
import { formatEntry, isToday } from "./format.js";
|
|
8
|
+
import { createTail } from "./tail.js";
|
|
9
|
+
const RECENT = 10; // how many past events to show first, for context
|
|
10
|
+
function print(entry) {
|
|
11
|
+
console.log(formatEntry(entry, { showDate: !isToday(entry.time) }));
|
|
12
|
+
}
|
|
13
|
+
export async function runWatch(args) {
|
|
14
|
+
if (args.length > 0) {
|
|
15
|
+
console.error(`agentcollar watch не принимает аргументов: ${args.join(" ")}`);
|
|
16
|
+
return 1;
|
|
17
|
+
}
|
|
18
|
+
console.log(`${styleText("bold", "AgentCollar · watch")} ${styleText("dim", "Ctrl+C — выход")}`);
|
|
19
|
+
console.log(styleText("dim", "проверки: 1 токен · 2 одобрен · 3 срок · 4 не отозван · 5 действие · 6 лимит"));
|
|
20
|
+
const tail = createTail(auditLogFile);
|
|
21
|
+
const past = tail.readNew().slice(-RECENT);
|
|
22
|
+
if (past.length > 0) {
|
|
23
|
+
console.log(styleText("dim", "— последние события —"));
|
|
24
|
+
past.forEach(print);
|
|
25
|
+
}
|
|
26
|
+
console.log(styleText("dim", "— дальше в реальном времени —"));
|
|
27
|
+
// Watch the FOLDER, not the file: it also works before the log exists, and after it is replaced.
|
|
28
|
+
const folder = dirname(auditLogFile);
|
|
29
|
+
mkdirSync(folder, { recursive: true });
|
|
30
|
+
const watcher = watch(folder, (_event, name) => {
|
|
31
|
+
if (name === null || name === basename(auditLogFile))
|
|
32
|
+
tail.readNew().forEach(print);
|
|
33
|
+
});
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
process.once("SIGINT", () => {
|
|
36
|
+
watcher.close();
|
|
37
|
+
process.stdout.write("\n");
|
|
38
|
+
resolve(0);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
package/dist/cli.js
ADDED
package/dist/env.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Loads ~/.agentcollar/.env into process.env (secrets live there, never in the code).
|
|
2
|
+
// Imported first by the server and by the CLI commands.
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { envFile, legacyFiles } from "./paths.js";
|
|
5
|
+
if (existsSync(envFile)) {
|
|
6
|
+
process.loadEnvFile(envFile); // built into Node: puts the lines of .env into process.env
|
|
7
|
+
}
|
|
8
|
+
else if (process.env.AGENTCOLLAR_HOME === undefined && existsSync(legacyFiles.env)) {
|
|
9
|
+
// Older setups kept it in broker/.env. Still works; `agcl setup` offers to move it.
|
|
10
|
+
// (Not when AGENTCOLLAR_HOME is set: an explicit home means "look only there".)
|
|
11
|
+
process.loadEnvFile(legacyFiles.env);
|
|
12
|
+
console.error("AgentCollar: настройки ещё в broker/.env — запусти agcl setup, чтобы перенести их в ~/.agentcollar/");
|
|
13
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// A pretend Gmail that lives in memory. Phase 4 replaces it with the real Gmail API.
|
|
2
|
+
// The broker talks to it exactly like it will talk to the real one:
|
|
3
|
+
// only AFTER check() has said yes.
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
const inbox = [
|
|
6
|
+
{ id: "m1", from: "anna@example.com", subject: "Lunch on Friday?", body: "Are you free at 13:00?" },
|
|
7
|
+
{ id: "m2", from: "bank@example.com", subject: "Your statement", body: "Your October statement is ready." },
|
|
8
|
+
{ id: "m3", from: "boss@example.com", subject: "Report", body: "Please send the report by Monday." },
|
|
9
|
+
];
|
|
10
|
+
const drafts = [];
|
|
11
|
+
const sent = [];
|
|
12
|
+
function newId() {
|
|
13
|
+
return randomBytes(4).toString("hex");
|
|
14
|
+
}
|
|
15
|
+
export function listInbox() {
|
|
16
|
+
return inbox;
|
|
17
|
+
}
|
|
18
|
+
export function createDraft(to, subject, body) {
|
|
19
|
+
const draft = { id: newId(), to, subject, body };
|
|
20
|
+
drafts.push(draft);
|
|
21
|
+
return draft;
|
|
22
|
+
}
|
|
23
|
+
export function sendEmail(to, subject, body) {
|
|
24
|
+
const email = { id: newId(), to, subject, body };
|
|
25
|
+
sent.push(email);
|
|
26
|
+
return email;
|
|
27
|
+
}
|
package/dist/mandate.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
// In-memory store: token -> mandate.
|
|
3
|
+
// It disappears when the program stops. That is fine for now.
|
|
4
|
+
export const mandates = new Map();
|
|
5
|
+
// Called after every change. The server uses it to write data/mandates.json for `agentcollar mandates`.
|
|
6
|
+
let changeListener = () => { };
|
|
7
|
+
export function onMandatesChanged(listener) {
|
|
8
|
+
changeListener = listener;
|
|
9
|
+
}
|
|
10
|
+
export function mandatesChanged() {
|
|
11
|
+
changeListener();
|
|
12
|
+
}
|
|
13
|
+
// An agent asks for a mandate. It stays "pending" until a human decides.
|
|
14
|
+
export function requestMandate(agent, task, allowedActions, expiresInSeconds, limit) {
|
|
15
|
+
const mandate = {
|
|
16
|
+
id: randomBytes(4).toString("hex"), // 8 characters, easy to read; not a secret
|
|
17
|
+
// 32 random bytes = 256 bits: impossible to guess. As hex text it is 64 characters.
|
|
18
|
+
token: randomBytes(32).toString("hex"),
|
|
19
|
+
agent,
|
|
20
|
+
task,
|
|
21
|
+
allowedActions,
|
|
22
|
+
status: "pending",
|
|
23
|
+
expiresInSeconds,
|
|
24
|
+
expiresAt: 0,
|
|
25
|
+
limit,
|
|
26
|
+
used: 0,
|
|
27
|
+
revoked: false,
|
|
28
|
+
createdAt: Date.now(),
|
|
29
|
+
};
|
|
30
|
+
mandates.set(mandate.token, mandate);
|
|
31
|
+
mandatesChanged();
|
|
32
|
+
return mandate;
|
|
33
|
+
}
|
|
34
|
+
// Shortcut used by the Phase 1 demo: request + approve at once, returns the token.
|
|
35
|
+
export function issueMandate(agent, task, allowedActions, expiresInSeconds, limit) {
|
|
36
|
+
const mandate = requestMandate(agent, task, allowedActions, expiresInSeconds, limit);
|
|
37
|
+
approve(mandate.id);
|
|
38
|
+
return mandate.token;
|
|
39
|
+
}
|
|
40
|
+
// The human side only knows the public id, never the token.
|
|
41
|
+
export function findById(id) {
|
|
42
|
+
for (const mandate of mandates.values()) {
|
|
43
|
+
if (mandate.id === id)
|
|
44
|
+
return mandate;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
// Pending mandates nobody answered for longer than maxAgeMs (the server uses 10 minutes).
|
|
49
|
+
export function findStalePending(maxAgeMs, now = Date.now()) {
|
|
50
|
+
return [...mandates.values()].filter((mandate) => mandate.status === "pending" && now - mandate.createdAt > maxAgeMs);
|
|
51
|
+
}
|
|
52
|
+
// Human says yes. Only a pending mandate can be decided, and only once.
|
|
53
|
+
export function approve(id) {
|
|
54
|
+
const mandate = findById(id);
|
|
55
|
+
if (mandate === undefined || mandate.status !== "pending")
|
|
56
|
+
return undefined;
|
|
57
|
+
mandate.status = "approved";
|
|
58
|
+
mandate.expiresAt = Date.now() + mandate.expiresInSeconds * 1000;
|
|
59
|
+
mandatesChanged();
|
|
60
|
+
return mandate;
|
|
61
|
+
}
|
|
62
|
+
// Human says no.
|
|
63
|
+
export function deny(id) {
|
|
64
|
+
const mandate = findById(id);
|
|
65
|
+
if (mandate === undefined || mandate.status !== "pending")
|
|
66
|
+
return undefined;
|
|
67
|
+
mandate.status = "denied";
|
|
68
|
+
mandatesChanged();
|
|
69
|
+
return mandate;
|
|
70
|
+
}
|
|
71
|
+
// Kill switch: the human stops a mandate immediately.
|
|
72
|
+
// We mark it instead of deleting it, so check() can still say "revoked" (not "unknown")
|
|
73
|
+
// and the audit log still knows which agent it belonged to.
|
|
74
|
+
// Phase 1 passed the token; now the human side uses the public id.
|
|
75
|
+
export function revoke(id) {
|
|
76
|
+
const mandate = findById(id);
|
|
77
|
+
if (mandate === undefined)
|
|
78
|
+
return undefined;
|
|
79
|
+
mandate.revoked = true;
|
|
80
|
+
mandatesChanged();
|
|
81
|
+
return mandate;
|
|
82
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The small part of MCP (Model Context Protocol) we need: tools over JSON-RPC 2.0.
|
|
2
|
+
// One message = one line of JSON. A request has an "id" and gets a reply with the same id;
|
|
3
|
+
// a notification has no "id" and gets no reply. This file knows nothing about the broker.
|
|
4
|
+
// Newest first. If the client asks for one of these, we answer with it; otherwise with the newest.
|
|
5
|
+
export const SUPPORTED_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
6
|
+
const SERVER_INFO = { name: "agentcollar", version: "0.1.0" };
|
|
7
|
+
const INSTRUCTIONS = "AgentCollar guards the human's accounts. First call request_mandate with only the actions you need; " +
|
|
8
|
+
"the human approves it in Telegram. Poll mandate_status until it is approved, then pass the mandateId " +
|
|
9
|
+
"to the gmail_* tools. Refusals are final: explain them to the human instead of retrying.";
|
|
10
|
+
function reply(id, result) {
|
|
11
|
+
return JSON.stringify({ jsonrpc: "2.0", id, result });
|
|
12
|
+
}
|
|
13
|
+
function error(id, code, message) {
|
|
14
|
+
return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } });
|
|
15
|
+
}
|
|
16
|
+
// Handles one incoming line. Returns the reply line, or null when no reply is due.
|
|
17
|
+
export async function handleMessage(line, context) {
|
|
18
|
+
if (line.trim() === "")
|
|
19
|
+
return null;
|
|
20
|
+
let message;
|
|
21
|
+
try {
|
|
22
|
+
message = JSON.parse(line);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return error(null, -32700, "Parse error");
|
|
26
|
+
}
|
|
27
|
+
if (typeof message !== "object" || message === null || Array.isArray(message)) {
|
|
28
|
+
return error(null, -32600, "Invalid Request");
|
|
29
|
+
}
|
|
30
|
+
const m = message;
|
|
31
|
+
const id = typeof m.id === "string" || typeof m.id === "number" ? m.id : null;
|
|
32
|
+
if (m.jsonrpc !== "2.0" || typeof m.method !== "string")
|
|
33
|
+
return error(id, -32600, "Invalid Request");
|
|
34
|
+
if (m.id === undefined)
|
|
35
|
+
return null; // a notification, e.g. "notifications/initialized"
|
|
36
|
+
const params = (typeof m.params === "object" && m.params !== null ? m.params : {});
|
|
37
|
+
switch (m.method) {
|
|
38
|
+
case "initialize": {
|
|
39
|
+
const requested = params.protocolVersion;
|
|
40
|
+
const version = typeof requested === "string" && SUPPORTED_VERSIONS.includes(requested) ? requested : SUPPORTED_VERSIONS[0];
|
|
41
|
+
const clientInfo = params.clientInfo;
|
|
42
|
+
if (typeof clientInfo?.name === "string")
|
|
43
|
+
context.onInitialize?.(clientInfo.name);
|
|
44
|
+
return reply(id, { protocolVersion: version, capabilities: { tools: {} }, serverInfo: SERVER_INFO, instructions: INSTRUCTIONS });
|
|
45
|
+
}
|
|
46
|
+
case "ping":
|
|
47
|
+
return reply(id, {});
|
|
48
|
+
case "tools/list":
|
|
49
|
+
return reply(id, {
|
|
50
|
+
tools: context.tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
|
|
51
|
+
});
|
|
52
|
+
case "tools/call": {
|
|
53
|
+
const tool = context.tools.find((t) => t.name === params.name);
|
|
54
|
+
if (tool === undefined)
|
|
55
|
+
return error(id, -32602, `Unknown tool: ${String(params.name)}`);
|
|
56
|
+
const args = params.arguments ?? {};
|
|
57
|
+
if (typeof args !== "object" || args === null || Array.isArray(args)) {
|
|
58
|
+
return error(id, -32602, "arguments must be an object");
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
return reply(id, await tool.call(args));
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
// Details go to stderr for the human; the model only learns that it failed.
|
|
65
|
+
console.error(`agentcollar mcp: tool ${tool.name} failed: ${e.message}`);
|
|
66
|
+
return reply(id, { content: [{ type: "text", text: `Tool ${tool.name} failed inside AgentCollar.` }], isError: true });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
default:
|
|
70
|
+
return error(id, -32601, `Method not found: ${m.method}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// AgentCollar MCP server over stdio. Claude Code (or another agent) starts this file and
|
|
2
|
+
// talks to it through stdin/stdout, one JSON message per line.
|
|
3
|
+
// Register: claude mcp add agentcollar -- npx tsx <path>/broker/src/mcp/server.ts
|
|
4
|
+
// IMPORTANT: stdout belongs to the protocol. Anything else printed there breaks the connection,
|
|
5
|
+
// so every log line goes to stderr (console.error).
|
|
6
|
+
import "../env.js";
|
|
7
|
+
import { createInterface } from "node:readline";
|
|
8
|
+
import { handleMessage } from "./protocol.js";
|
|
9
|
+
import { createTools } from "./tools.js";
|
|
10
|
+
const port = Number(process.env.BROKER_PORT ?? 8787);
|
|
11
|
+
let clientName = "mcp-agent"; // replaced by the client's name from "initialize"
|
|
12
|
+
const tools = createTools({ brokerUrl: `http://127.0.0.1:${port}`, agentName: () => clientName });
|
|
13
|
+
const lines = createInterface({ input: process.stdin });
|
|
14
|
+
lines.on("line", (line) => {
|
|
15
|
+
handleMessage(line, { tools, onInitialize: (name) => (clientName = name) })
|
|
16
|
+
.then((reply) => {
|
|
17
|
+
if (reply !== null)
|
|
18
|
+
process.stdout.write(reply + "\n");
|
|
19
|
+
})
|
|
20
|
+
.catch((error) => console.error(`agentcollar mcp: ${error.message}`));
|
|
21
|
+
});
|
|
22
|
+
console.error(`agentcollar MCP server ready (stdio), broker at 127.0.0.1:${port}`);
|