agentcollar 0.0.1 → 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/LICENSE +27 -0
- package/README.md +83 -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 +86 -0
- package/dist/cli/gmail-guide.js +87 -0
- package/dist/cli/gmail.js +125 -0
- package/dist/cli/intro-frames.js +40 -0
- package/dist/cli/intro.js +121 -0
- package/dist/cli/logs.js +46 -0
- package/dist/cli/main.js +104 -0
- package/dist/cli/mandates.js +101 -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 +44 -0
- package/dist/cli.js +4 -0
- package/dist/env.js +13 -0
- package/dist/gmail-fake.js +26 -0
- 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/mandate.js +82 -0
- package/dist/mcp/protocol.js +73 -0
- package/dist/mcp/server.js +22 -0
- package/dist/mcp/tools.js +147 -0
- package/dist/paths.js +44 -0
- package/dist/revoke.js +18 -0
- package/dist/server.js +269 -0
- package/dist/setup.js +100 -0
- package/dist/snapshot.js +42 -0
- package/dist/telegram.js +124 -0
- package/package.json +50 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ank8dev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
The MIT license above applies to the source code in this repository.
|
|
26
|
+
It does NOT apply to the brand assets in the brand/ folder (logos, marks and
|
|
27
|
+
hand-drawn illustrations): those are © 2026 ank8dev, all rights reserved.
|
package/README.md
CHANGED
|
@@ -1,5 +1,84 @@
|
|
|
1
|
-
#
|
|
1
|
+
# AgentCollar
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
**Let agents work. Keep the keys.** A small broker that runs on your own computer, between your AI
|
|
4
|
+
agents and your accounts. Agents get a short-lived, task-scoped *mandate* that you approve in
|
|
5
|
+
Telegram — never your password or tokens.
|
|
6
|
+
|
|
7
|
+
> **Hobby project. Use at your own risk. Do not connect accounts you can't afford to lose.**
|
|
8
|
+
> **Status: paused.** It works end to end (Claude Code → Telegram approval → real Gmail drafts), but
|
|
9
|
+
> development is paused and may never resume. The code stays open (MIT): fork it if you need it.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
Requires **Node.js 22+**.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx agentcollar # try it without installing
|
|
17
|
+
npm install -g agentcollar # or install: then the short name `agcl` works too
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`agcl` and `agentcollar` are the same program: `agcl watch` == `agentcollar watch`.
|
|
21
|
+
|
|
22
|
+
## First run
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
agcl
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The intro plays, then the setup wizard connects **your own** Telegram bot (create one with
|
|
29
|
+
[@BotFather](https://t.me/BotFather); the wizard takes your user id from the Start message, with a
|
|
30
|
+
one-time code), then the broker starts on `127.0.0.1:8787`.
|
|
31
|
+
|
|
32
|
+
## Commands
|
|
33
|
+
|
|
34
|
+
| Command | What it does |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `agcl` | intro → setup (if not set up yet) → broker |
|
|
37
|
+
| `agcl setup` | connect your Telegram bot |
|
|
38
|
+
| `agcl server` | start the broker |
|
|
39
|
+
| `agcl watch` | live screen: every agent request and which of the 6 checks passed or failed |
|
|
40
|
+
| `agcl mandates` | mandates of the running broker: state, time left, actions left |
|
|
41
|
+
| `agcl logs [--agent <name>] [--denied] [--today]` | the audit log, filtered |
|
|
42
|
+
| `agcl revoke <id>` | kill switch: revoke a mandate instantly |
|
|
43
|
+
| `agcl mcp` | MCP server (stdio) for Claude Code and other agents |
|
|
44
|
+
|
|
45
|
+
Flags: `--no-intro`, `-h` / `--help`.
|
|
46
|
+
|
|
47
|
+
## Use it from Claude Code (MCP)
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
claude mcp add agentcollar -- npx -y agentcollar mcp
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Tools: `request_mandate`, `mandate_status`, `gmail_read_inbox`, `gmail_create_draft`, `gmail_send`.
|
|
54
|
+
Every call goes through the broker with a mandate token. The token stays inside the MCP server and
|
|
55
|
+
is never shown to the model.
|
|
56
|
+
|
|
57
|
+
## How a request is checked
|
|
58
|
+
|
|
59
|
+
Every request runs 6 checks in order; the first failure stops it:
|
|
60
|
+
|
|
61
|
+
1. the token is known → `401`
|
|
62
|
+
2. a human approved the mandate → `403`
|
|
63
|
+
3. not expired → `403`
|
|
64
|
+
4. not revoked → `403`
|
|
65
|
+
5. this action is in the mandate → `403`
|
|
66
|
+
6. the action limit is not reached → `429`
|
|
67
|
+
|
|
68
|
+
## Security
|
|
69
|
+
|
|
70
|
+
- Listens on `127.0.0.1` only; refuses browser requests (`Host` check against DNS rebinding,
|
|
71
|
+
no `Origin`, JSON only).
|
|
72
|
+
- **Approval is never possible over HTTP or through files**: only in Telegram (only your user id)
|
|
73
|
+
or in the broker's own terminal. A local agent cannot approve itself.
|
|
74
|
+
- Your data lives in `~/.agentcollar/` (folder `700`, files `600`): `.env`, `audit.log`,
|
|
75
|
+
`mandates.json` (no tokens in it).
|
|
76
|
+
- Append-only audit log; text from agents is escaped and stripped of terminal control characters.
|
|
77
|
+
- **No runtime dependencies.** Published from GitHub Actions with npm provenance.
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
Code: MIT. The AgentCollar brand assets (logo, the intro medallion derived from it) are
|
|
82
|
+
© ank8dev, all rights reserved. See [LICENSE](LICENSE).
|
|
83
|
+
|
|
84
|
+
Source, roadmap and full docs: <https://github.com/ank8dev/agentcollar>
|
package/dist/approval.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Phase 3: a human decides about every mandate request.
|
|
2
|
+
// Two channels: Telegram (if configured) or the terminal where the server runs.
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { writeAudit } from "./audit.js";
|
|
5
|
+
import { approve, deny, findStalePending, revoke } from "./mandate.js";
|
|
6
|
+
// The single place where a human decision is applied and written to the audit log.
|
|
7
|
+
// Telegram and the terminal both end up here.
|
|
8
|
+
export function decide(id, decision, by) {
|
|
9
|
+
const mandate = decision === "approve" ? approve(id) : decision === "deny" ? deny(id) : revoke(id);
|
|
10
|
+
if (mandate !== undefined) {
|
|
11
|
+
writeAudit(mandate.agent, `mandate.${decision}`, decision === "approve", `${decision} by ${by}, mandate ${id}`);
|
|
12
|
+
}
|
|
13
|
+
return mandate;
|
|
14
|
+
}
|
|
15
|
+
// Fail closed: a request nobody answered in time is denied, never left hanging.
|
|
16
|
+
// Returns the mandates it denied, so the server can update their Telegram messages.
|
|
17
|
+
export function denyStalePending(maxAgeMs, now = Date.now()) {
|
|
18
|
+
const denied = [];
|
|
19
|
+
for (const mandate of findStalePending(maxAgeMs, now)) {
|
|
20
|
+
const result = decide(mandate.id, "deny", "timeout");
|
|
21
|
+
if (result !== undefined)
|
|
22
|
+
denied.push(result);
|
|
23
|
+
}
|
|
24
|
+
return denied;
|
|
25
|
+
}
|
|
26
|
+
// Text written by the agent (its name, the task) is shown to the human, so a bad agent
|
|
27
|
+
// could try to fake lines like "Actions: gmail.read" with line breaks, or hide text with
|
|
28
|
+
// invisible / right-to-left characters. We squash it into one short, plain line.
|
|
29
|
+
export function oneLine(text, max) {
|
|
30
|
+
const plain = text
|
|
31
|
+
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ") // line breaks, control chars
|
|
32
|
+
.replace(/[\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, "") // invisible, direction tricks
|
|
33
|
+
.replace(/\s+/g, " ")
|
|
34
|
+
.trim();
|
|
35
|
+
return plain.length > max ? plain.slice(0, max) + "…" : plain;
|
|
36
|
+
}
|
|
37
|
+
// Human-readable summary, used in the terminal and in Telegram.
|
|
38
|
+
// The facts the broker enforces come FIRST; the agent's own words come last, in quotes.
|
|
39
|
+
export function describe(mandate) {
|
|
40
|
+
const lines = [];
|
|
41
|
+
if (mandate.allowedActions.some((action) => action.endsWith(".send"))) {
|
|
42
|
+
lines.push("⚠️ The agent asks for the right to SEND in your name");
|
|
43
|
+
}
|
|
44
|
+
lines.push(`Actions: ${mandate.allowedActions.join(", ")}`, `Lifetime: ${mandate.expiresInSeconds} s after approval, limit ${mandate.limit} actions`, `id: ${mandate.id}`, `Agent: ${oneLine(mandate.agent, 64)}`, `Task (agent's own words): “${oneLine(mandate.task, 200)}”`);
|
|
45
|
+
return lines.join("\n");
|
|
46
|
+
}
|
|
47
|
+
// Terminal channel. Questions are asked one at a time: if two agents ask at once,
|
|
48
|
+
// the second question waits until the first one is answered.
|
|
49
|
+
let queue = Promise.resolve();
|
|
50
|
+
export function askInTerminal(mandate) {
|
|
51
|
+
queue = queue.then(async () => {
|
|
52
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
53
|
+
const answer = await rl.question(`\nNew mandate request:\n${describe(mandate)}\nApprove? [y/N] `);
|
|
54
|
+
rl.close();
|
|
55
|
+
console.log(applyTerminalAnswer(mandate, answer));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
// Applies the human's terminal answer and says what REALLY happened.
|
|
59
|
+
// The question may have waited so long that the mandate already timed out
|
|
60
|
+
// (or was decided in Telegram): then the answer changes nothing, and we say so.
|
|
61
|
+
export function applyTerminalAnswer(mandate, answer) {
|
|
62
|
+
const decision = answer.trim().toLowerCase() === "y" ? "approve" : "deny";
|
|
63
|
+
if (decide(mandate.id, decision, "terminal") === undefined) {
|
|
64
|
+
return " ⌛ already decided (timed out or answered in Telegram), your answer was not applied";
|
|
65
|
+
}
|
|
66
|
+
return decision === "approve" ? " ✅ approved" : " ❌ denied";
|
|
67
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Reads data/audit.log back into objects, for `agentcollar logs` and `agentcollar watch`.
|
|
2
|
+
// Line format (see audit.ts): time | "agent" | "action" | ALLOWED|DENIED | "reason" [| code]
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
// A JSON string: quote, then any characters except an unescaped quote, then quote.
|
|
5
|
+
// Matching whole strings (not splitting on " | ") keeps a " | " inside the text in one piece.
|
|
6
|
+
const QUOTED = String.raw `"(?:[^"\\]|\\.)*"`;
|
|
7
|
+
const LINE = new RegExp(String.raw `^(\S+) \| (${QUOTED}) \| (${QUOTED}) \| (ALLOWED|DENIED) \| (${QUOTED})(?: \| ([a-z_]+))?$`);
|
|
8
|
+
// The very first lines (before escaping was added in step 4.1) had no quotes at all.
|
|
9
|
+
const OLD_LINE = /^(\S+) \| ([^|"]+) \| ([^|"]+) \| (ALLOWED|DENIED) \| ([^|"]*)$/;
|
|
10
|
+
export function parseAuditLine(line) {
|
|
11
|
+
const match = LINE.exec(line);
|
|
12
|
+
if (match !== null) {
|
|
13
|
+
const [, time, agent, action, verdict, reason, code] = match;
|
|
14
|
+
return {
|
|
15
|
+
time: time,
|
|
16
|
+
agent: JSON.parse(agent),
|
|
17
|
+
action: JSON.parse(action),
|
|
18
|
+
allowed: verdict === "ALLOWED",
|
|
19
|
+
reason: JSON.parse(reason),
|
|
20
|
+
code: code ?? null,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const old = OLD_LINE.exec(line);
|
|
24
|
+
if (old !== null) {
|
|
25
|
+
const [, time, agent, action, verdict, reason] = old;
|
|
26
|
+
return { time: time, agent: agent, action: action, allowed: verdict === "ALLOWED", reason: reason, code: null };
|
|
27
|
+
}
|
|
28
|
+
return null; // not a log line: skip it rather than guess
|
|
29
|
+
}
|
|
30
|
+
export function readAuditLog(file) {
|
|
31
|
+
if (!existsSync(file))
|
|
32
|
+
return [];
|
|
33
|
+
return readFileSync(file, "utf8")
|
|
34
|
+
.split("\n")
|
|
35
|
+
.map(parseAuditLine)
|
|
36
|
+
.filter((entry) => entry !== null);
|
|
37
|
+
}
|
package/dist/audit.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
import { auditLogFile, ensureHome } from "./paths.js";
|
|
3
|
+
// ~/.agentcollar/audit.log (see paths.ts). Re-exported for the modules that read the log.
|
|
4
|
+
export { auditLogFile };
|
|
5
|
+
// Text that came from outside (agent name, action) could contain a line break
|
|
6
|
+
// and fake a second log line. JSON.stringify wraps it in quotes and turns a line
|
|
7
|
+
// break into a visible \n, so one event is always exactly one line.
|
|
8
|
+
function safe(text) {
|
|
9
|
+
return JSON.stringify(text);
|
|
10
|
+
}
|
|
11
|
+
// Appends one line per event: every check, and every human decision.
|
|
12
|
+
// We only ever add lines, never change or delete them.
|
|
13
|
+
// The token is NOT written: it is a secret, and the log is not.
|
|
14
|
+
// `code` (only for checks) says which check decided, e.g. "expired": `agentcollar watch` shows it.
|
|
15
|
+
export function writeAudit(agent, action, allowed, reason, code) {
|
|
16
|
+
ensureHome(); // ~/.agentcollar/ with rights 700
|
|
17
|
+
const time = new Date().toISOString(); // e.g. 2026-10-04T07:00:00.000Z (UTC)
|
|
18
|
+
const verdict = allowed ? "ALLOWED" : "DENIED";
|
|
19
|
+
const fields = [time, safe(agent), safe(action), verdict, safe(reason)];
|
|
20
|
+
if (code !== undefined)
|
|
21
|
+
fields.push(code); // our own fixed word: needs no quoting
|
|
22
|
+
appendFileSync(auditLogFile, fields.join(" | ") + "\n", { mode: 0o600 }); // 600 when the file is created
|
|
23
|
+
}
|
package/dist/check.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { writeAudit } from "./audit.js";
|
|
2
|
+
import { mandates, mandatesChanged } from "./mandate.js";
|
|
3
|
+
// The order of the checks. A failure code tells how far a request got: "expired" = check 3.
|
|
4
|
+
export const CHECK_ORDER = ["unknown_token", "not_approved", "expired", "revoked", "action_not_allowed", "limit_reached"];
|
|
5
|
+
// The one function the outside world calls: decide, then write it down.
|
|
6
|
+
// Every answer goes to the audit log, allowed or denied.
|
|
7
|
+
export function check(token, action) {
|
|
8
|
+
const result = runChecks(token, action);
|
|
9
|
+
// For an unknown token there is no mandate, so no agent name either.
|
|
10
|
+
const agent = mandates.get(token)?.agent ?? "unknown";
|
|
11
|
+
writeAudit(agent, action, result.allowed, result.reason, result.code);
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
function deny(code, reason) {
|
|
15
|
+
return { allowed: false, code, reason };
|
|
16
|
+
}
|
|
17
|
+
// Runs the checks in order. The first failed check stops everything (early return).
|
|
18
|
+
function runChecks(token, action) {
|
|
19
|
+
// 1. Token is known: we issued it ourselves
|
|
20
|
+
const mandate = mandates.get(token);
|
|
21
|
+
if (mandate === undefined) {
|
|
22
|
+
return deny("unknown_token", "unknown token");
|
|
23
|
+
}
|
|
24
|
+
// 2. A human approved it (Phase 3). Must come before "expired":
|
|
25
|
+
// a pending mandate has no expiry time yet.
|
|
26
|
+
if (mandate.status === "pending") {
|
|
27
|
+
return deny("not_approved", "mandate is waiting for human approval");
|
|
28
|
+
}
|
|
29
|
+
if (mandate.status === "denied") {
|
|
30
|
+
return deny("not_approved", "mandate was denied (by the human, or no answer in time)");
|
|
31
|
+
}
|
|
32
|
+
// 3. Not expired
|
|
33
|
+
if (Date.now() > mandate.expiresAt) {
|
|
34
|
+
return deny("expired", "mandate expired");
|
|
35
|
+
}
|
|
36
|
+
// 4. Not revoked by the human (kill switch)
|
|
37
|
+
if (mandate.revoked) {
|
|
38
|
+
return deny("revoked", "mandate revoked");
|
|
39
|
+
}
|
|
40
|
+
// 5. This action is on the list
|
|
41
|
+
if (!mandate.allowedActions.includes(action)) {
|
|
42
|
+
return deny("action_not_allowed", `action "${action}" is not allowed`);
|
|
43
|
+
}
|
|
44
|
+
// 6. Limit not reached
|
|
45
|
+
if (mandate.used >= mandate.limit) {
|
|
46
|
+
return deny("limit_reached", `limit of ${mandate.limit} actions reached`);
|
|
47
|
+
}
|
|
48
|
+
// All checks passed. Only allowed actions use up the limit.
|
|
49
|
+
mandate.used += 1;
|
|
50
|
+
mandatesChanged();
|
|
51
|
+
return { allowed: true, code: "ok", reason: "ok" };
|
|
52
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// How one audit entry looks on screen. Shared by `agentcollar logs` and `agentcollar watch`.
|
|
2
|
+
import { styleText } from "node:util";
|
|
3
|
+
import { oneLine } from "../approval.js";
|
|
4
|
+
import { CHECK_ORDER } from "../check.js";
|
|
5
|
+
// ✓ passed, ✗ failed here, · not reached. Empty for human decisions and old lines without a code.
|
|
6
|
+
export function checkMarks(entry) {
|
|
7
|
+
if (entry.code === null)
|
|
8
|
+
return "";
|
|
9
|
+
const failedAt = entry.code === "ok" ? CHECK_ORDER.length : CHECK_ORDER.indexOf(entry.code);
|
|
10
|
+
return CHECK_ORDER.map((_, i) => (i < failedAt ? "✓" : i === failedAt ? "✗" : "·")).join(" ");
|
|
11
|
+
}
|
|
12
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
13
|
+
export function isSameLocalDay(a, b) {
|
|
14
|
+
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
15
|
+
}
|
|
16
|
+
export function isToday(iso, now = new Date()) {
|
|
17
|
+
return isSameLocalDay(new Date(iso), now);
|
|
18
|
+
}
|
|
19
|
+
// Local time: "15:42:07", or "2026-10-04 15:42:07" when the date matters.
|
|
20
|
+
export function localTime(iso, showDate) {
|
|
21
|
+
const d = new Date(iso);
|
|
22
|
+
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
23
|
+
return showDate ? `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}` : time;
|
|
24
|
+
}
|
|
25
|
+
// The terminal width right now (it changes when you resize the window). COLUMNS: for pipes and tests.
|
|
26
|
+
export function termWidth() {
|
|
27
|
+
return process.stdout.columns || Number(process.env.COLUMNS) || 100;
|
|
28
|
+
}
|
|
29
|
+
// Cuts text to `max` visible characters, ending with "…" when something was cut.
|
|
30
|
+
export function fit(text, max) {
|
|
31
|
+
const chars = [...text];
|
|
32
|
+
if (max <= 0)
|
|
33
|
+
return "";
|
|
34
|
+
return chars.length <= max ? text : chars.slice(0, max - 1).join("") + "…";
|
|
35
|
+
}
|
|
36
|
+
const cell = (text, width) => fit(text, width).padEnd(width);
|
|
37
|
+
// Breaks text into lines of at most `width` characters, at spaces.
|
|
38
|
+
export function wrap(text, width) {
|
|
39
|
+
const lines = [];
|
|
40
|
+
let line = "";
|
|
41
|
+
for (const word of text.split(" ")) {
|
|
42
|
+
if (line !== "" && [...line].length + 1 + [...word].length > width) {
|
|
43
|
+
lines.push(line);
|
|
44
|
+
line = "";
|
|
45
|
+
}
|
|
46
|
+
line = line === "" ? fit(word, width) : `${line} ${word}`;
|
|
47
|
+
}
|
|
48
|
+
if (line !== "")
|
|
49
|
+
lines.push(line);
|
|
50
|
+
return lines;
|
|
51
|
+
}
|
|
52
|
+
// Agent names, actions and reasons come from agents. Printed raw, a text like "\u001b[2J" would
|
|
53
|
+
// be a COMMAND to the terminal (clear screen, change title...). oneLine() strips control characters.
|
|
54
|
+
// Layout follows the window width: wide = one roomy line, medium = one tight line, narrow = two lines.
|
|
55
|
+
export function formatEntry(entry, options) {
|
|
56
|
+
const width = options.width ?? termWidth();
|
|
57
|
+
const iso = localTime(entry.time, options.showDate);
|
|
58
|
+
const time = options.showDate && width < 100 ? iso.slice(5, 16) : iso; // "10-04 15:42" when tight
|
|
59
|
+
const agent = oneLine(entry.agent, 64);
|
|
60
|
+
const action = oneLine(entry.action, 64);
|
|
61
|
+
const reason = oneLine(entry.reason, 300);
|
|
62
|
+
const human = entry.action.startsWith("mandate."); // approve / deny / revoke by a person
|
|
63
|
+
const verdict = entry.allowed ? styleText("green", "ALLOWED") : styleText("red", "DENIED ");
|
|
64
|
+
const dimTime = styleText("dim", time);
|
|
65
|
+
if (width >= 70) {
|
|
66
|
+
const wide = width >= 100;
|
|
67
|
+
const [agentW, actionW] = wide ? [16, 18] : [12, 14];
|
|
68
|
+
const marks = wide ? checkMarks(entry) : checkMarks(entry).replaceAll(" ", "");
|
|
69
|
+
const marksW = wide ? 11 : 6;
|
|
70
|
+
if (human) {
|
|
71
|
+
const humanActionW = Math.max(actionW, 16); // "mandate.approve" fits
|
|
72
|
+
const rest = width - time.length - agentW - humanActionW - 6;
|
|
73
|
+
return `${dimTime} ${styleText("yellow", `${cell(agent, agentW)} ${cell(action, humanActionW)} ${fit(reason, rest)}`)}`;
|
|
74
|
+
}
|
|
75
|
+
const rest = width - time.length - agentW - actionW - 7 - marksW - 10;
|
|
76
|
+
return `${dimTime} ${cell(agent, agentW)} ${cell(action, actionW)} ${verdict} ${marks.padEnd(marksW)} ${styleText("dim", fit(reason, rest))}`;
|
|
77
|
+
}
|
|
78
|
+
// narrow window: two short lines
|
|
79
|
+
if (human) {
|
|
80
|
+
return `${dimTime} ${styleText("yellow", fit(action, width - time.length - 2))}\n ${styleText("yellow", fit(`${agent} · ${reason}`, width - 2))}`;
|
|
81
|
+
}
|
|
82
|
+
const first = `${dimTime} ${verdict} ${fit(action, width - time.length - 11)}`;
|
|
83
|
+
const marks = checkMarks(entry).replaceAll(" ", "");
|
|
84
|
+
const second = ` ${marks} ${styleText("dim", fit(`${agent} · ${reason}`, width - marks.length - 4))}`;
|
|
85
|
+
return `${first}\n${second}`;
|
|
86
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// A guided walk through Google Cloud for `agcl gmail connect`, for people who have no OAuth client yet.
|
|
2
|
+
// Each step opens the right console page in the browser and says in one line what to click.
|
|
3
|
+
// (Google moves its menus around now and then, so every step also names the page.)
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { createInterface } from "node:readline/promises";
|
|
8
|
+
import { styleText } from "node:util";
|
|
9
|
+
import { SCOPES } from "../google/oauth.js";
|
|
10
|
+
export const GUIDE_STEPS = [
|
|
11
|
+
{
|
|
12
|
+
title: "Create a project",
|
|
13
|
+
url: "https://console.cloud.google.com/projectcreate",
|
|
14
|
+
todo: 'Project name: AgentCollar → Create. Then make sure "AgentCollar" is selected in the top bar.',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
title: "Turn on the Gmail API",
|
|
18
|
+
url: "https://console.cloud.google.com/apis/library/gmail.googleapis.com",
|
|
19
|
+
todo: "Click Enable.",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
title: "Consent screen (Google Auth Platform → Overview)",
|
|
23
|
+
url: "https://console.cloud.google.com/auth/overview",
|
|
24
|
+
todo: "Get started → App name: AgentCollar (local), your email → Audience: External → contact email → Create.",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
title: "Add yourself as a test user (Audience)",
|
|
28
|
+
url: "https://console.cloud.google.com/auth/audience",
|
|
29
|
+
todo: "Test users → Add users → your Gmail address → Save. The app stays in Testing: no Google review needed.",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
title: "Permissions (Data Access)",
|
|
33
|
+
url: "https://console.cloud.google.com/auth/scopes",
|
|
34
|
+
todo: "Add or remove scopes → paste these two into “Manually add scopes” → Add to table → Update → Save:",
|
|
35
|
+
copy: SCOPES,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
title: "Create the client (Clients)",
|
|
39
|
+
url: "https://console.cloud.google.com/auth/clients/create",
|
|
40
|
+
todo: "Application type: Desktop app → Name: agcl → Create → Download JSON.",
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
// Waits for a client_secret*.json that appears in `folder` after `since` (an older one is ignored).
|
|
44
|
+
export async function waitForClientFile(folder, since, timeoutMs, pollMs = 1000) {
|
|
45
|
+
const deadline = Date.now() + timeoutMs;
|
|
46
|
+
while (Date.now() < deadline) {
|
|
47
|
+
if (existsSync(folder)) {
|
|
48
|
+
const fresh = readdirSync(folder)
|
|
49
|
+
.filter((name) => name.startsWith("client_secret") && name.endsWith(".json"))
|
|
50
|
+
.map((name) => join(folder, name))
|
|
51
|
+
.filter((file) => statSync(file).mtimeMs >= since - 1000);
|
|
52
|
+
if (fresh[0] !== undefined)
|
|
53
|
+
return fresh[0];
|
|
54
|
+
}
|
|
55
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
// Runs the guide in the terminal. Returns the downloaded client file, or null if the user stopped.
|
|
60
|
+
export async function runGuide(downloads) {
|
|
61
|
+
const started = Date.now();
|
|
62
|
+
console.log(styleText("bold", "\nGoogle Cloud setup — 6 steps, about 3 minutes."));
|
|
63
|
+
console.log("Each step opens a page in your browser. Do what it says, then press Enter here.\n");
|
|
64
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
65
|
+
try {
|
|
66
|
+
for (const [i, step] of GUIDE_STEPS.entries()) {
|
|
67
|
+
console.log(`${styleText("cyan", `[${i + 1}/${GUIDE_STEPS.length}]`)} ${styleText("bold", step.title)}`);
|
|
68
|
+
console.log(` ${step.todo}`);
|
|
69
|
+
for (const line of step.copy ?? [])
|
|
70
|
+
console.log(` ${styleText("yellow", line)}`);
|
|
71
|
+
console.log(styleText("dim", ` ${step.url}`));
|
|
72
|
+
execFile("open", [step.url], () => { });
|
|
73
|
+
const answer = await rl.question(styleText("dim", " Enter = done · q = stop "));
|
|
74
|
+
if (answer.trim().toLowerCase() === "q")
|
|
75
|
+
return null;
|
|
76
|
+
console.log("");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
rl.close();
|
|
81
|
+
}
|
|
82
|
+
console.log("Waiting for client_secret_….json in your Downloads folder…");
|
|
83
|
+
const file = await waitForClientFile(downloads, started, 10 * 60 * 1000);
|
|
84
|
+
if (file === null)
|
|
85
|
+
console.log("No file in 10 minutes. Run agcl gmail connect again when you have it.");
|
|
86
|
+
return file;
|
|
87
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// agcl gmail connect | status | disconnect — connect YOUR Gmail (read + drafts).
|
|
2
|
+
// The refresh token goes to the macOS Keychain; the access token only ever lives in memory.
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { copyFileSync, chmodSync, existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { createInterface } from "node:readline/promises";
|
|
8
|
+
import { styleText } from "node:util";
|
|
9
|
+
import { forgetConnection, readGmailInfo, REFRESH_TOKEN_ACCOUNT, saveConnection } from "../google/connection.js";
|
|
10
|
+
import { keychain } from "../google/keychain.js";
|
|
11
|
+
import { authorizeInBrowser, exchangeCode, parseClientJson, refreshAccessToken, revokeToken } from "../google/oauth.js";
|
|
12
|
+
import { ensureHome, googleClientFile } from "../paths.js";
|
|
13
|
+
async function ask(question) {
|
|
14
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
15
|
+
const answer = await rl.question(question);
|
|
16
|
+
rl.close();
|
|
17
|
+
return answer.trim().toLowerCase();
|
|
18
|
+
}
|
|
19
|
+
// The newest client_secret_*.json in ~/Downloads, where Google Cloud puts it.
|
|
20
|
+
function findDownloadedClient() {
|
|
21
|
+
const downloads = join(homedir(), "Downloads");
|
|
22
|
+
if (!existsSync(downloads))
|
|
23
|
+
return null;
|
|
24
|
+
const files = readdirSync(downloads)
|
|
25
|
+
.filter((name) => name.startsWith("client_secret") && name.endsWith(".json"))
|
|
26
|
+
.map((name) => join(downloads, name))
|
|
27
|
+
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
|
|
28
|
+
return files[0] ?? null;
|
|
29
|
+
}
|
|
30
|
+
function loadClient() {
|
|
31
|
+
if (!existsSync(googleClientFile))
|
|
32
|
+
throw new Error("No Google client yet. Run: agcl gmail connect");
|
|
33
|
+
return parseClientJson(readFileSync(googleClientFile, "utf8"));
|
|
34
|
+
}
|
|
35
|
+
async function connect(args) {
|
|
36
|
+
// 1. The Desktop-app client file from Google Cloud
|
|
37
|
+
let file = args[0] ?? null;
|
|
38
|
+
if (file === null) {
|
|
39
|
+
const found = findDownloadedClient();
|
|
40
|
+
if (found !== null && (await ask(`Use ${found}? [Y/n] `)) !== "n")
|
|
41
|
+
file = found;
|
|
42
|
+
}
|
|
43
|
+
if (file === null && existsSync(googleClientFile))
|
|
44
|
+
file = googleClientFile;
|
|
45
|
+
if (file === null) {
|
|
46
|
+
// No OAuth client yet: walk the person through Google Cloud, page by page.
|
|
47
|
+
if (!process.stdin.isTTY) {
|
|
48
|
+
console.error("No Google client yet. Run agcl gmail connect in a terminal for the guided setup,");
|
|
49
|
+
console.error("or pass the file: agcl gmail connect ~/Downloads/client_secret_….json");
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
if ((await ask("No Google client found. Set one up now, step by step? [Y/n] ")) === "n")
|
|
53
|
+
return 1;
|
|
54
|
+
file = await (await import("./gmail-guide.js")).runGuide(join(homedir(), "Downloads"));
|
|
55
|
+
if (file === null)
|
|
56
|
+
return 1;
|
|
57
|
+
console.log(`${styleText("green", "✓")} Found ${file}\n`);
|
|
58
|
+
}
|
|
59
|
+
const client = parseClientJson(readFileSync(file, "utf8")); // fails early if it is the wrong file
|
|
60
|
+
ensureHome();
|
|
61
|
+
if (file !== googleClientFile) {
|
|
62
|
+
copyFileSync(file, googleClientFile);
|
|
63
|
+
chmodSync(googleClientFile, 0o600);
|
|
64
|
+
}
|
|
65
|
+
// 2. Sign in with Google in the browser
|
|
66
|
+
console.log("Opening Google in your browser. Allow: read your email + create drafts.");
|
|
67
|
+
const { code, verifier, redirectUri } = await authorizeInBrowser(client, (url) => {
|
|
68
|
+
console.log(styleText("dim", `If the browser did not open: ${url}`));
|
|
69
|
+
execFile("open", [url], () => { });
|
|
70
|
+
});
|
|
71
|
+
const tokens = await exchangeCode(client, code, verifier, redirectUri);
|
|
72
|
+
// 3. Which Gmail is this?
|
|
73
|
+
const profile = (await (await fetch("https://gmail.googleapis.com/gmail/v1/users/me/profile", { headers: { authorization: `Bearer ${tokens.accessToken}` } })).json());
|
|
74
|
+
const email = profile.emailAddress ?? "unknown";
|
|
75
|
+
saveConnection({ email, connectedAt: new Date().toISOString() }, tokens.refreshToken);
|
|
76
|
+
console.log(`\n${styleText("green", "✓")} Gmail connected: ${styleText("bold", email)}`);
|
|
77
|
+
console.log(" Read + create drafts. This connection cannot send at all: Google itself blocks it.");
|
|
78
|
+
console.log(" Refresh token: macOS Keychain. Access token: memory only.");
|
|
79
|
+
console.log(styleText("yellow", " Testing mode: Google ends this sign-in after 7 days. Then run agcl gmail connect again."));
|
|
80
|
+
if (file !== googleClientFile)
|
|
81
|
+
console.log(styleText("dim", ` You can delete ${file} now (a copy is in ${googleClientFile}).`));
|
|
82
|
+
console.log(" Restart the broker to use it: agcl server");
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
async function status() {
|
|
86
|
+
const info = readGmailInfo();
|
|
87
|
+
const refreshToken = keychain.get(REFRESH_TOKEN_ACCOUNT);
|
|
88
|
+
if (info === null || refreshToken === null) {
|
|
89
|
+
console.log("Gmail: not connected (the broker uses a fake inbox). Connect: agcl gmail connect");
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
await refreshAccessToken(loadClient(), refreshToken);
|
|
94
|
+
console.log(`${styleText("green", "✓")} Gmail: ${info.email}, connected ${info.connectedAt.slice(0, 10)}, access works.`);
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
console.log(`${styleText("red", "✗")} Gmail: ${info.email}: ${error.message}`);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function disconnect() {
|
|
103
|
+
const token = forgetConnection();
|
|
104
|
+
if (token !== null)
|
|
105
|
+
await revokeToken(token); // also tell Google to cancel the access
|
|
106
|
+
console.log("Gmail disconnected: the Keychain entry is deleted and Google access is revoked.");
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
export async function runGmail(args) {
|
|
110
|
+
const [sub, ...rest] = args;
|
|
111
|
+
try {
|
|
112
|
+
if (sub === "connect")
|
|
113
|
+
return await connect(rest);
|
|
114
|
+
if (sub === "status")
|
|
115
|
+
return await status();
|
|
116
|
+
if (sub === "disconnect")
|
|
117
|
+
return await disconnect();
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
console.error(`${styleText("red", "✗")} ${error.message}`);
|
|
121
|
+
return 1;
|
|
122
|
+
}
|
|
123
|
+
console.error("Usage: agcl gmail connect [client_secret.json] | agcl gmail status | agcl gmail disconnect");
|
|
124
|
+
return 1;
|
|
125
|
+
}
|