agentcollar 0.1.0 → 0.2.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/README.md +2 -1
- package/dist/approval.js +6 -6
- package/dist/cli/format.js +57 -10
- package/dist/cli/gmail-guide.js +87 -0
- package/dist/cli/gmail.js +125 -0
- package/dist/cli/intro-frames.js +15 -0
- package/dist/cli/intro.js +33 -17
- package/dist/cli/logs.js +3 -3
- package/dist/cli/main.js +26 -16
- package/dist/cli/mandates.js +47 -22
- package/dist/cli/setup.js +17 -17
- package/dist/cli/start.js +1 -1
- package/dist/cli/watch.js +9 -6
- package/dist/env.js +1 -1
- package/dist/gmail-fake.js +24 -25
- package/dist/google/connection.js +36 -0
- package/dist/google/gmail.js +73 -0
- package/dist/google/keychain.js +40 -0
- package/dist/google/oauth.js +134 -0
- package/dist/mailbox.js +4 -0
- package/dist/mcp/protocol.js +2 -1
- package/dist/mcp/tools.js +38 -3
- package/dist/paths.js +2 -0
- package/dist/revoke.js +4 -4
- package/dist/server.js +35 -4
- package/dist/setup.js +3 -3
- package/dist/telegram.js +15 -15
- package/package.json +1 -1
package/dist/cli/mandates.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { styleText } from "node:util";
|
|
3
3
|
import { oneLine } from "../approval.js";
|
|
4
4
|
import { readMandatesSnapshot } from "../snapshot.js";
|
|
5
|
+
import { fit, termWidth } from "./format.js";
|
|
5
6
|
function duration(ms) {
|
|
6
7
|
const total = Math.ceil(ms / 1000);
|
|
7
8
|
const h = Math.floor(total / 3600);
|
|
@@ -11,18 +12,18 @@ function duration(ms) {
|
|
|
11
12
|
}
|
|
12
13
|
// The same order as check(): the first thing that ends a mandate decides its name.
|
|
13
14
|
export function mandateRow(m, now = Date.now()) {
|
|
14
|
-
let state = "
|
|
15
|
+
let state = "active";
|
|
15
16
|
if (m.status === "pending")
|
|
16
|
-
state = "
|
|
17
|
+
state = "waiting";
|
|
17
18
|
else if (m.status === "denied")
|
|
18
|
-
state = "
|
|
19
|
+
state = "denied";
|
|
19
20
|
else if (now > m.expiresAt)
|
|
20
|
-
state = "
|
|
21
|
+
state = "expired";
|
|
21
22
|
else if (m.revoked)
|
|
22
|
-
state = "
|
|
23
|
+
state = "revoked";
|
|
23
24
|
else if (m.used >= m.limit)
|
|
24
|
-
state = "
|
|
25
|
-
const live = state === "
|
|
25
|
+
state = "limit used up";
|
|
26
|
+
const live = state === "active";
|
|
26
27
|
return {
|
|
27
28
|
id: m.id,
|
|
28
29
|
agent: oneLine(m.agent, 24), // agent text: control characters removed before printing
|
|
@@ -43,34 +44,58 @@ function isRunning(pid) {
|
|
|
43
44
|
return error.code === "EPERM"; // exists, but belongs to someone else
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
|
-
const COLUMNS = ["ID", "
|
|
47
|
+
const COLUMNS = ["ID", "AGENT", "ACTIONS", "STATE", "TIME LEFT", "USED"];
|
|
48
|
+
function colorState(row, text) {
|
|
49
|
+
if (row.live)
|
|
50
|
+
return styleText("green", text);
|
|
51
|
+
if (row.state === "waiting")
|
|
52
|
+
return styleText("yellow", text);
|
|
53
|
+
return text;
|
|
54
|
+
}
|
|
55
|
+
// A table when it fits the window, otherwise one small "card" (two lines) per mandate.
|
|
56
|
+
export function formatMandates(mandates, now = Date.now(), width = termWidth()) {
|
|
57
|
+
const rows = [...mandates].sort((a, b) => b.createdAt - a.createdAt).map((m) => mandateRow(m, now));
|
|
58
|
+
const cells = (r) => [r.id, r.agent, r.actions, r.state, r.timeLeft, r.limit];
|
|
59
|
+
const widths = COLUMNS.map((title, i) => Math.max(title.length, ...rows.map((r) => cells(r)[i].length)));
|
|
60
|
+
const tableWidth = widths.reduce((sum, w) => sum + w, 0) + 2 * (widths.length - 1);
|
|
61
|
+
const dimIfEnded = (row, text) => (row.live || row.state === "waiting" ? text : styleText("dim", text));
|
|
62
|
+
if (tableWidth <= width) {
|
|
63
|
+
// pad first, color after: color codes are invisible but would break the padding
|
|
64
|
+
const lines = [styleText("dim", COLUMNS.map((title, i) => title.padEnd(widths[i])).join(" "))];
|
|
65
|
+
for (const row of rows) {
|
|
66
|
+
const line = cells(row).map((c, i) => c.padEnd(widths[i]));
|
|
67
|
+
line[3] = colorState(row, line[3]);
|
|
68
|
+
lines.push(dimIfEnded(row, line.join(" ")));
|
|
69
|
+
}
|
|
70
|
+
return lines.join("\n");
|
|
71
|
+
}
|
|
72
|
+
return rows
|
|
73
|
+
.map((row) => {
|
|
74
|
+
const time = row.timeLeft === "—" ? "" : ` ${row.timeLeft} left`;
|
|
75
|
+
const head = fit(`${row.id} ${row.state}${time} ${row.limit} used`, width);
|
|
76
|
+
const colored = head.replace(row.state, colorState(row, row.state));
|
|
77
|
+
return dimIfEnded(row, `${colored}\n ${fit(`${row.agent} · ${row.actions}`, width - 2)}`);
|
|
78
|
+
})
|
|
79
|
+
.join("\n");
|
|
80
|
+
}
|
|
47
81
|
export async function runMandates(args) {
|
|
48
82
|
if (args.length > 0) {
|
|
49
|
-
console.error(`
|
|
83
|
+
console.error(`agcl mandates takes no arguments: ${args.join(" ")}`);
|
|
50
84
|
return 1;
|
|
51
85
|
}
|
|
52
86
|
const snapshot = readMandatesSnapshot();
|
|
53
87
|
if (snapshot === null) {
|
|
54
|
-
console.log("
|
|
88
|
+
console.log("The broker has not been started yet. Run: agcl server");
|
|
55
89
|
return 0;
|
|
56
90
|
}
|
|
57
91
|
if (!isRunning(snapshot.pid)) {
|
|
58
|
-
console.log("
|
|
92
|
+
console.log("The broker is not running. Mandates live in its memory, so they are gone once it stops.");
|
|
59
93
|
return 0;
|
|
60
94
|
}
|
|
61
95
|
if (snapshot.mandates.length === 0) {
|
|
62
|
-
console.log("
|
|
96
|
+
console.log("No mandates yet.");
|
|
63
97
|
return 0;
|
|
64
98
|
}
|
|
65
|
-
|
|
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
|
-
}
|
|
99
|
+
console.log(formatMandates(snapshot.mandates));
|
|
75
100
|
return 0;
|
|
76
101
|
}
|
package/dist/cli/setup.js
CHANGED
|
@@ -13,7 +13,7 @@ async function ask(question) {
|
|
|
13
13
|
// If the input ends before an answer (Ctrl+D, closed pipe), fail loudly instead of exiting silently.
|
|
14
14
|
const answer = await new Promise((resolve, reject) => {
|
|
15
15
|
rl.question(question).then(resolve, reject);
|
|
16
|
-
rl.once("close", () => reject(new Error("
|
|
16
|
+
rl.once("close", () => reject(new Error("input ended, nothing was written")));
|
|
17
17
|
});
|
|
18
18
|
return answer.trim();
|
|
19
19
|
}
|
|
@@ -64,25 +64,25 @@ export async function runSetup() {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
async function setup() {
|
|
67
|
-
console.log("
|
|
67
|
+
console.log("AgentCollar setup\n");
|
|
68
68
|
// Older versions kept the data inside the code folder (broker/.env, broker/data/audit.log).
|
|
69
69
|
const oldFiles = [legacyFiles.env, legacyFiles.audit].filter((file) => existsSync(file));
|
|
70
70
|
if (oldFiles.length > 0) {
|
|
71
|
-
console.log(
|
|
72
|
-
if (yes(await ask(
|
|
71
|
+
console.log(`Found data from an older version:\n${oldFiles.map((f) => ` ${f}`).join("\n")}`);
|
|
72
|
+
if (yes(await ask(`Move it to ${homeDir}/ (only you can open that folder)? [y/N] `))) {
|
|
73
73
|
const moved = migrateLegacy();
|
|
74
|
-
console.log(moved.length > 0 ? `✓
|
|
74
|
+
console.log(moved.length > 0 ? `✓ Moved: ${moved.join(", ")}\n` : "Files already exist there; the old ones stay where they are.\n");
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
|
-
if (existsSync(envFile) && !yes(await ask(`${envFile}
|
|
78
|
-
console.log("
|
|
77
|
+
if (existsSync(envFile) && !yes(await ask(`${envFile} already exists. Replace the Telegram settings? [y/N] `))) {
|
|
78
|
+
console.log("Nothing changed.");
|
|
79
79
|
return 0;
|
|
80
80
|
}
|
|
81
81
|
// 1. Token
|
|
82
82
|
let token = "";
|
|
83
83
|
let username = "";
|
|
84
84
|
while (username === "") {
|
|
85
|
-
token = await askHidden("
|
|
85
|
+
token = await askHidden("Bot token (from @BotFather, hidden while you type): ");
|
|
86
86
|
try {
|
|
87
87
|
username = await getBotUsername(token);
|
|
88
88
|
}
|
|
@@ -90,12 +90,12 @@ async function setup() {
|
|
|
90
90
|
console.log(`✗ ${error.message}\n`);
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
|
-
console.log(`✓
|
|
93
|
+
console.log(`✓ Bot found: @${username}\n`);
|
|
94
94
|
// 2. Start with a one-time code
|
|
95
95
|
const code = newStartCode();
|
|
96
96
|
let offset = await skipOldUpdates(token);
|
|
97
|
-
console.log(
|
|
98
|
-
console.log("
|
|
97
|
+
console.log(`Open this link and press Start: https://t.me/${username}?start=${code}`);
|
|
98
|
+
console.log("Waiting (up to 5 minutes)...");
|
|
99
99
|
let user;
|
|
100
100
|
const deadline = Date.now() + WAIT_MS;
|
|
101
101
|
while (user === undefined && Date.now() < deadline) {
|
|
@@ -105,20 +105,20 @@ async function setup() {
|
|
|
105
105
|
}
|
|
106
106
|
await skipOldUpdates(token); // tell Telegram we handled these messages
|
|
107
107
|
if (user === undefined) {
|
|
108
|
-
console.log("
|
|
108
|
+
console.log("Timed out. Run agcl setup again.");
|
|
109
109
|
return 1;
|
|
110
110
|
}
|
|
111
111
|
const who = [user.firstName, user.username ? `@${user.username}` : "", `id ${user.id}`].filter(Boolean).join(", ");
|
|
112
|
-
if (!yes(await ask(`✓
|
|
113
|
-
console.log("
|
|
112
|
+
if (!yes(await ask(`✓ Is this you? ${who} [y/N] `))) {
|
|
113
|
+
console.log("Nothing was written. Run agcl setup again.");
|
|
114
114
|
return 1;
|
|
115
115
|
}
|
|
116
116
|
// 3. ~/.agentcollar/.env, readable only by you
|
|
117
117
|
ensureHome();
|
|
118
118
|
const existing = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
|
|
119
119
|
writeEnvFile(envFile, buildEnv(existing, { TELEGRAM_BOT_TOKEN: token, TELEGRAM_USER_ID: String(user.id) }));
|
|
120
|
-
await sendText(token, user.id, "✅
|
|
121
|
-
console.log(`✓
|
|
122
|
-
console.log("
|
|
120
|
+
await sendText(token, user.id, "✅ AgentCollar is set up. Mandate requests will arrive here.").catch(() => { });
|
|
121
|
+
console.log(`✓ Saved to ${envFile} (mode 600: only your macOS user can read it)`);
|
|
122
|
+
console.log("Next: agcl server (or just agcl)");
|
|
123
123
|
return 0;
|
|
124
124
|
}
|
package/dist/cli/start.js
CHANGED
|
@@ -15,7 +15,7 @@ export async function runStart(flags) {
|
|
|
15
15
|
// Ask only a person at a terminal; a script or pipe goes straight to the server.
|
|
16
16
|
const configured = (process.env.TELEGRAM_BOT_TOKEN ?? "") !== "";
|
|
17
17
|
if (!configured && process.stdin.isTTY) {
|
|
18
|
-
const answer = await ask("Telegram
|
|
18
|
+
const answer = await ask("Telegram is not set up yet. Set it up now? [Y/n] (n = approve requests in this terminal) ");
|
|
19
19
|
if (answer === "" || answer === "y") {
|
|
20
20
|
const code = await (await import("./setup.js")).runSetup();
|
|
21
21
|
if (code !== 0)
|
package/dist/cli/watch.js
CHANGED
|
@@ -4,7 +4,7 @@ import { mkdirSync, watch } from "node:fs";
|
|
|
4
4
|
import { basename, dirname } from "node:path";
|
|
5
5
|
import { styleText } from "node:util";
|
|
6
6
|
import { auditLogFile } from "../audit.js";
|
|
7
|
-
import { formatEntry, isToday } from "./format.js";
|
|
7
|
+
import { fit, formatEntry, isToday, termWidth } from "./format.js";
|
|
8
8
|
import { createTail } from "./tail.js";
|
|
9
9
|
const RECENT = 10; // how many past events to show first, for context
|
|
10
10
|
function print(entry) {
|
|
@@ -12,18 +12,21 @@ function print(entry) {
|
|
|
12
12
|
}
|
|
13
13
|
export async function runWatch(args) {
|
|
14
14
|
if (args.length > 0) {
|
|
15
|
-
console.error(`
|
|
15
|
+
console.error(`agcl watch takes no arguments: ${args.join(" ")}`);
|
|
16
16
|
return 1;
|
|
17
17
|
}
|
|
18
|
-
console.log(`${styleText("bold", "AgentCollar · watch")} ${styleText("dim", "Ctrl+C
|
|
19
|
-
|
|
18
|
+
console.log(`${styleText("bold", "AgentCollar · watch")} ${styleText("dim", "Ctrl+C to quit")}`);
|
|
19
|
+
const legend = termWidth() >= 90
|
|
20
|
+
? "checks: 1 token · 2 approved · 3 not expired · 4 not revoked · 5 action allowed · 6 limit"
|
|
21
|
+
: "✓✗· = checks: token, approved, time, revoked, action, limit";
|
|
22
|
+
console.log(styleText("dim", fit(legend, termWidth())));
|
|
20
23
|
const tail = createTail(auditLogFile);
|
|
21
24
|
const past = tail.readNew().slice(-RECENT);
|
|
22
25
|
if (past.length > 0) {
|
|
23
|
-
console.log(styleText("dim", "—
|
|
26
|
+
console.log(styleText("dim", "— recent —"));
|
|
24
27
|
past.forEach(print);
|
|
25
28
|
}
|
|
26
|
-
console.log(styleText("dim", "—
|
|
29
|
+
console.log(styleText("dim", "— live —"));
|
|
27
30
|
// Watch the FOLDER, not the file: it also works before the log exists, and after it is replaced.
|
|
28
31
|
const folder = dirname(auditLogFile);
|
|
29
32
|
mkdirSync(folder, { recursive: true });
|
package/dist/env.js
CHANGED
|
@@ -9,5 +9,5 @@ else if (process.env.AGENTCOLLAR_HOME === undefined && existsSync(legacyFiles.en
|
|
|
9
9
|
// Older setups kept it in broker/.env. Still works; `agcl setup` offers to move it.
|
|
10
10
|
// (Not when AGENTCOLLAR_HOME is set: an explicit home means "look only there".)
|
|
11
11
|
process.loadEnvFile(legacyFiles.env);
|
|
12
|
-
console.error("AgentCollar:
|
|
12
|
+
console.error("AgentCollar: settings are still in broker/.env, run agcl setup to move them to ~/.agentcollar/");
|
|
13
13
|
}
|
package/dist/gmail-fake.js
CHANGED
|
@@ -1,27 +1,26 @@
|
|
|
1
|
-
// A pretend
|
|
2
|
-
// The broker talks to it exactly like it will talk to the real one:
|
|
3
|
-
// only AFTER check() has said yes.
|
|
1
|
+
// A pretend mailbox in memory: the broker uses it until you run `agcl gmail connect`.
|
|
4
2
|
import { randomBytes } from "node:crypto";
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
3
|
+
const newId = () => randomBytes(4).toString("hex");
|
|
4
|
+
export function createFakeMailbox() {
|
|
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
|
+
return {
|
|
13
|
+
kind: "fake",
|
|
14
|
+
listInbox: async () => inbox,
|
|
15
|
+
async createDraft(to, subject, body) {
|
|
16
|
+
const draft = { id: newId(), to, subject, body };
|
|
17
|
+
drafts.push(draft);
|
|
18
|
+
return draft;
|
|
19
|
+
},
|
|
20
|
+
async sendEmail(to, subject, body) {
|
|
21
|
+
const email = { id: newId(), to, subject, body };
|
|
22
|
+
sent.push(email);
|
|
23
|
+
return email;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
27
26
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Which mailbox the broker uses: your real Gmail if `agcl gmail connect` was done, otherwise the fake one.
|
|
2
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createFakeMailbox } from "../gmail-fake.js";
|
|
4
|
+
import { ensureHome, gmailInfoFile, googleClientFile } from "../paths.js";
|
|
5
|
+
import { createGmailMailbox } from "./gmail.js";
|
|
6
|
+
import { keychain } from "./keychain.js";
|
|
7
|
+
import { parseClientJson } from "./oauth.js";
|
|
8
|
+
export const REFRESH_TOKEN_ACCOUNT = "gmail-refresh-token";
|
|
9
|
+
export function readGmailInfo() {
|
|
10
|
+
if (!existsSync(gmailInfoFile))
|
|
11
|
+
return null;
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(readFileSync(gmailInfoFile, "utf8"));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function saveConnection(info, refreshToken, store = keychain) {
|
|
20
|
+
ensureHome();
|
|
21
|
+
store.set(REFRESH_TOKEN_ACCOUNT, refreshToken); // the secret goes to the Keychain…
|
|
22
|
+
writeFileSync(gmailInfoFile, JSON.stringify(info, null, 2), { mode: 0o600 }); // …the file has only the address
|
|
23
|
+
}
|
|
24
|
+
export function forgetConnection(store = keychain) {
|
|
25
|
+
const token = store.get(REFRESH_TOKEN_ACCOUNT);
|
|
26
|
+
store.remove(REFRESH_TOKEN_ACCOUNT);
|
|
27
|
+
if (existsSync(gmailInfoFile))
|
|
28
|
+
unlinkSync(gmailInfoFile);
|
|
29
|
+
return token;
|
|
30
|
+
}
|
|
31
|
+
export function loadMailbox(store = keychain) {
|
|
32
|
+
const refreshToken = store.get(REFRESH_TOKEN_ACCOUNT);
|
|
33
|
+
if (refreshToken === null || !existsSync(googleClientFile))
|
|
34
|
+
return createFakeMailbox();
|
|
35
|
+
return createGmailMailbox(parseClientJson(readFileSync(googleClientFile, "utf8")), refreshToken);
|
|
36
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Your real Gmail, through the Gmail API. Same shape as the fake mailbox, so the broker does not care
|
|
2
|
+
// which one it talks to. The broker calls these ONLY after check() said yes.
|
|
3
|
+
import { isEmailAddress } from "../mailbox.js";
|
|
4
|
+
import { refreshAccessToken } from "./oauth.js";
|
|
5
|
+
const API = "https://gmail.googleapis.com/gmail/v1/users/me";
|
|
6
|
+
// Builds the email in the format Gmail expects ("raw" = base64url of the whole message).
|
|
7
|
+
// The subject comes from an agent, so line breaks are removed: otherwise "Hi\r\nBcc: thief@evil"
|
|
8
|
+
// would add a hidden recipient (header injection).
|
|
9
|
+
export function buildRawEmail(to, subject, body) {
|
|
10
|
+
if (!isEmailAddress(to))
|
|
11
|
+
throw new Error(`"to" must be one email address, got: ${JSON.stringify(to)}`);
|
|
12
|
+
const cleanSubject = subject.replace(/[\r\n]+/g, " ").trim();
|
|
13
|
+
// non-ASCII subjects are encoded (RFC 2047) so every mail client shows them correctly
|
|
14
|
+
const encodedSubject = /^[\x20-\x7e]*$/.test(cleanSubject)
|
|
15
|
+
? cleanSubject
|
|
16
|
+
: `=?UTF-8?B?${Buffer.from(cleanSubject, "utf8").toString("base64")}?=`;
|
|
17
|
+
const message = [
|
|
18
|
+
`To: ${to}`,
|
|
19
|
+
`Subject: ${encodedSubject}`,
|
|
20
|
+
"MIME-Version: 1.0",
|
|
21
|
+
'Content-Type: text/plain; charset="UTF-8"',
|
|
22
|
+
"Content-Transfer-Encoding: base64",
|
|
23
|
+
"",
|
|
24
|
+
Buffer.from(body, "utf8").toString("base64"),
|
|
25
|
+
].join("\r\n");
|
|
26
|
+
return Buffer.from(message, "utf8").toString("base64url");
|
|
27
|
+
}
|
|
28
|
+
export function createGmailMailbox(client, refreshToken) {
|
|
29
|
+
// The access token lives only in this process's memory, never on disk.
|
|
30
|
+
let accessToken = "";
|
|
31
|
+
let expiresAt = 0;
|
|
32
|
+
async function token() {
|
|
33
|
+
if (Date.now() > expiresAt - 60_000) {
|
|
34
|
+
const fresh = await refreshAccessToken(client, refreshToken);
|
|
35
|
+
accessToken = fresh.accessToken;
|
|
36
|
+
expiresAt = fresh.expiresAt;
|
|
37
|
+
}
|
|
38
|
+
return accessToken;
|
|
39
|
+
}
|
|
40
|
+
async function api(method, path, body) {
|
|
41
|
+
const response = await fetch(API + path, {
|
|
42
|
+
method,
|
|
43
|
+
headers: { authorization: `Bearer ${await token()}`, "content-type": "application/json" },
|
|
44
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
45
|
+
});
|
|
46
|
+
const data = (await response.json());
|
|
47
|
+
if (!response.ok)
|
|
48
|
+
throw new Error(`Gmail API: ${data.error?.message ?? response.status}`);
|
|
49
|
+
return data;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
kind: "gmail",
|
|
53
|
+
async listInbox() {
|
|
54
|
+
const list = await api("GET", "/messages?labelIds=INBOX&maxResults=10");
|
|
55
|
+
const ids = (list.messages ?? []).map((m) => m.id);
|
|
56
|
+
return Promise.all(ids.map(async (id) => {
|
|
57
|
+
const m = await api("GET", `/messages/${id}?format=metadata&metadataHeaders=From&metadataHeaders=Subject`);
|
|
58
|
+
const headers = m.payload?.headers ?? [];
|
|
59
|
+
const header = (name) => headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ?? "";
|
|
60
|
+
return { id, from: header("From"), subject: header("Subject"), body: String(m.snippet ?? "") };
|
|
61
|
+
}));
|
|
62
|
+
},
|
|
63
|
+
async createDraft(to, subject, body) {
|
|
64
|
+
const draft = await api("POST", "/drafts", { message: { raw: buildRawEmail(to, subject, body) } });
|
|
65
|
+
return { id: String(draft.id), to, subject, body };
|
|
66
|
+
},
|
|
67
|
+
// AgentCollar asks Google only for read + create drafts, so this connection cannot send.
|
|
68
|
+
// Even a mandate with gmail.send ends here: the email stays a draft for you to send yourself.
|
|
69
|
+
async sendEmail() {
|
|
70
|
+
throw new Error("This Gmail connection cannot send: AgentCollar only has read + create-drafts access. Create a draft instead; the human sends it.");
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Secrets in the macOS Keychain (encrypted by macOS, outside the repo and outside ~/.agentcollar/),
|
|
2
|
+
// through the built-in `security` tool. Used for the Gmail refresh token.
|
|
3
|
+
// Honest limit: other programs running as YOUR macOS user can also ask `security` for it.
|
|
4
|
+
// The Keychain protects against leaks through files, backups and git, not against malware you run.
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
const SERVICE = "agentcollar";
|
|
7
|
+
export const keychain = {
|
|
8
|
+
get(account) {
|
|
9
|
+
try {
|
|
10
|
+
return execFileSync("security", ["find-generic-password", "-s", SERVICE, "-a", account, "-w"], {
|
|
11
|
+
encoding: "utf8",
|
|
12
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
13
|
+
}).trimEnd();
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null; // not there (or not macOS)
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
set(account, value) {
|
|
20
|
+
// -U: update if it already exists
|
|
21
|
+
execFileSync("security", ["add-generic-password", "-U", "-s", SERVICE, "-a", account, "-w", value], { stdio: "ignore" });
|
|
22
|
+
},
|
|
23
|
+
remove(account) {
|
|
24
|
+
try {
|
|
25
|
+
execFileSync("security", ["delete-generic-password", "-s", SERVICE, "-a", account], { stdio: "ignore" });
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// already gone
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
// For tests: the same interface, kept in memory.
|
|
33
|
+
export function memoryStore() {
|
|
34
|
+
const values = new Map();
|
|
35
|
+
return {
|
|
36
|
+
get: (account) => values.get(account) ?? null,
|
|
37
|
+
set: (account, value) => void values.set(account, value),
|
|
38
|
+
remove: (account) => void values.delete(account),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Google sign-in for a desktop program (OAuth 2.0 "installed app" flow with PKCE):
|
|
2
|
+
// we open Google's consent page in your browser, Google sends the browser back to a tiny
|
|
3
|
+
// server on 127.0.0.1 with a one-time code, and we swap that code for tokens.
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
// Read + create drafts, nothing more. gmail.drafts.create cannot send: so sending is blocked twice,
|
|
7
|
+
// by the broker (no mandate, no send) AND by Google (this connection has no right to send at all).
|
|
8
|
+
export const SCOPES = ["https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/gmail.drafts.create"];
|
|
9
|
+
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
10
|
+
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
11
|
+
// The JSON file you download from Google Cloud → Clients → Desktop app.
|
|
12
|
+
export function parseClientJson(text) {
|
|
13
|
+
let data;
|
|
14
|
+
try {
|
|
15
|
+
data = JSON.parse(text);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new Error("This is not a Google client_secret JSON file.");
|
|
19
|
+
}
|
|
20
|
+
if (data.web !== undefined)
|
|
21
|
+
throw new Error("This client is a Web app. Create a client of type Desktop app instead.");
|
|
22
|
+
const installed = data.installed;
|
|
23
|
+
if (typeof installed?.client_id !== "string" || typeof installed.client_secret !== "string") {
|
|
24
|
+
throw new Error("This is not a Google client_secret JSON file (no installed.client_id).");
|
|
25
|
+
}
|
|
26
|
+
return { clientId: installed.client_id, clientSecret: installed.client_secret };
|
|
27
|
+
}
|
|
28
|
+
// PKCE: we keep a random secret (verifier) and send Google only its hash (challenge).
|
|
29
|
+
// A stolen code is useless without the verifier, which never leaves this process.
|
|
30
|
+
export function newPkce() {
|
|
31
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
32
|
+
return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") };
|
|
33
|
+
}
|
|
34
|
+
export function buildAuthUrl(client, redirectUri, challenge, state) {
|
|
35
|
+
const url = new URL(AUTH_URL);
|
|
36
|
+
url.search = new URLSearchParams({
|
|
37
|
+
client_id: client.clientId,
|
|
38
|
+
redirect_uri: redirectUri,
|
|
39
|
+
response_type: "code",
|
|
40
|
+
scope: SCOPES.join(" "),
|
|
41
|
+
code_challenge: challenge,
|
|
42
|
+
code_challenge_method: "S256",
|
|
43
|
+
state, // a random value: proves the answer belongs to OUR request
|
|
44
|
+
access_type: "offline", // we want a refresh token
|
|
45
|
+
prompt: "consent", // always show what is being granted (and always return a refresh token)
|
|
46
|
+
}).toString();
|
|
47
|
+
return url.toString();
|
|
48
|
+
}
|
|
49
|
+
async function postToken(params) {
|
|
50
|
+
const response = await fetch(TOKEN_URL, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
53
|
+
body: new URLSearchParams(params).toString(),
|
|
54
|
+
});
|
|
55
|
+
const data = (await response.json());
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
if (data.error === "invalid_grant") {
|
|
58
|
+
throw new Error("Google no longer accepts this sign-in (in Testing mode it expires after 7 days). Run: agcl gmail connect");
|
|
59
|
+
}
|
|
60
|
+
throw new Error(`Google sign-in failed: ${String(data.error_description ?? data.error ?? response.status)}`);
|
|
61
|
+
}
|
|
62
|
+
return data;
|
|
63
|
+
}
|
|
64
|
+
export async function exchangeCode(client, code, verifier, redirectUri) {
|
|
65
|
+
const data = await postToken({
|
|
66
|
+
client_id: client.clientId,
|
|
67
|
+
client_secret: client.clientSecret,
|
|
68
|
+
code,
|
|
69
|
+
code_verifier: verifier,
|
|
70
|
+
redirect_uri: redirectUri,
|
|
71
|
+
grant_type: "authorization_code",
|
|
72
|
+
});
|
|
73
|
+
if (typeof data.refresh_token !== "string")
|
|
74
|
+
throw new Error("Google did not return a refresh token. Run agcl gmail connect again.");
|
|
75
|
+
return {
|
|
76
|
+
accessToken: String(data.access_token),
|
|
77
|
+
refreshToken: data.refresh_token,
|
|
78
|
+
expiresAt: Date.now() + Number(data.expires_in ?? 3600) * 1000,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// The access token lives about an hour and only in memory; the refresh token gets a new one.
|
|
82
|
+
export async function refreshAccessToken(client, refreshToken) {
|
|
83
|
+
const data = await postToken({
|
|
84
|
+
client_id: client.clientId,
|
|
85
|
+
client_secret: client.clientSecret,
|
|
86
|
+
refresh_token: refreshToken,
|
|
87
|
+
grant_type: "refresh_token",
|
|
88
|
+
});
|
|
89
|
+
return { accessToken: String(data.access_token), expiresAt: Date.now() + Number(data.expires_in ?? 3600) * 1000 };
|
|
90
|
+
}
|
|
91
|
+
export async function revokeToken(token) {
|
|
92
|
+
await fetch(`https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(token)}`, { method: "POST" }).catch(() => { });
|
|
93
|
+
}
|
|
94
|
+
// Starts the loopback server, opens the consent page, waits (up to 5 minutes) for Google's redirect.
|
|
95
|
+
export function authorizeInBrowser(client, openUrl, timeoutMs = 5 * 60 * 1000) {
|
|
96
|
+
const { verifier, challenge } = newPkce();
|
|
97
|
+
const state = randomBytes(16).toString("hex");
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
const server = createServer((req, res) => {
|
|
100
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
101
|
+
if (url.pathname !== "/callback") {
|
|
102
|
+
res.writeHead(404).end();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (url.searchParams.get("state") !== state) {
|
|
106
|
+
res.writeHead(400, { "content-type": "text/plain" }).end("This sign-in answer does not belong to AgentCollar's request.");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const code = url.searchParams.get("code");
|
|
110
|
+
if (code === null) {
|
|
111
|
+
res.writeHead(400, { "content-type": "text/plain" }).end(`Google sign-in was cancelled: ${url.searchParams.get("error") ?? "no code"}`);
|
|
112
|
+
finish(() => reject(new Error("Google sign-in was cancelled.")));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end("<!doctype html><title>AgentCollar</title><body style=\"font-family:system-ui;padding:3rem\">" +
|
|
116
|
+
"<h1>AgentCollar is connected to Gmail.</h1><p>You can close this tab and go back to the terminal.</p>");
|
|
117
|
+
finish(() => resolve({ code, verifier, redirectUri }));
|
|
118
|
+
});
|
|
119
|
+
let redirectUri = "";
|
|
120
|
+
const timer = setTimeout(() => finish(() => reject(new Error("No answer from Google in 5 minutes."))), timeoutMs);
|
|
121
|
+
function finish(then) {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
server.close();
|
|
124
|
+
then();
|
|
125
|
+
}
|
|
126
|
+
// port 0 = any free port; 127.0.0.1 = reachable only from this computer
|
|
127
|
+
server.listen(0, "127.0.0.1", () => {
|
|
128
|
+
const address = server.address();
|
|
129
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
130
|
+
redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
131
|
+
openUrl(buildAuthUrl(client, redirectUri, challenge, state));
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
package/dist/mailbox.js
ADDED
package/dist/mcp/protocol.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
export const SUPPORTED_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
6
6
|
const SERVER_INFO = { name: "agentcollar", version: "0.1.0" };
|
|
7
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.
|
|
8
|
+
"the human approves it in Telegram. Then call mandate_status once with waitSeconds: 90 (it returns as soon " +
|
|
9
|
+
"as the human decides) and, if approved, pass the mandateId " +
|
|
9
10
|
"to the gmail_* tools. Refusals are final: explain them to the human instead of retrying.";
|
|
10
11
|
function reply(id, result) {
|
|
11
12
|
return JSON.stringify({ jsonrpc: "2.0", id, result });
|