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.
@@ -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(`agentcollar mandates не принимает аргументов: ${args.join(" ")}`);
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("Брокер ещё не запускался. Запусти: agcl server");
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
- 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
- }
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("Настройка AgentCollar broker\n");
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(`Нашёл старые данные:\n${oldFiles.map((f) => ` ${f}`).join("\n")}`);
72
- if (yes(await ask(`Перенести в ${homeDir}/ (папка доступна только тебе)? [y/N] `))) {
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 ? `✓ Перенесено: ${moved.join(", ")}\n` : "Там уже есть свои файлы, старые оставил на месте.\n");
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} уже есть. Перезаписать настройки Telegram? [y/N] `))) {
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("Токен бота (от @BotFather, при вводе не виден): ");
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(`✓ Бот найден: @${username}\n`);
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(`Открой ссылку и нажми Start: https://t.me/${username}?start=${code}`);
98
- console.log("Жду (до 5 минут)...");
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("Время вышло. Запусти agcl setup ещё раз.");
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(`✓ Это ты? ${who} [y/N] `))) {
113
- console.log("Ничего не записал. Запусти agcl setup ещё раз.");
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, "✅ Брокер настроен. Запросы мандатов будут приходить сюда.").catch(() => { });
121
- console.log(`✓ Записано в ${envFile} (права 600: читать может только твой пользователь macOS)`);
122
- console.log("Дальше: agcl server (или просто agcl)");
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 ещё не настроен. Настроить сейчас? [Y/n] (n — одобрять запросы в этом терминале) ");
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(`agentcollar watch не принимает аргументов: ${args.join(" ")}`);
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
- console.log(styleText("dim", "проверки: 1 токен · 2 одобрен · 3 срок · 4 не отозван · 5 действие · 6 лимит"));
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: настройки ещё в broker/.env — запусти agcl setup, чтобы перенести их в ~/.agentcollar/");
12
+ console.error("AgentCollar: settings are still in broker/.env, run agcl setup to move them to ~/.agentcollar/");
13
13
  }
@@ -1,27 +1,26 @@
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.
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 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;
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
+ }
@@ -0,0 +1,4 @@
1
+ // One plain address: no commas (several recipients), no spaces or line breaks (header injection).
2
+ export function isEmailAddress(text) {
3
+ return /^[^\s@<>,;"()]+@[^\s@<>,;"()]+\.[^\s@<>,;"()]+$/.test(text);
4
+ }
@@ -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. Poll mandate_status until it is approved, then pass the mandateId " +
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 });