@grknbyk/agent-wire 0.4.1 → 0.5.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 +276 -243
- package/bin/agent-wire.mjs +127 -115
- package/manifest.json +31 -38
- package/package.json +3 -2
- package/src/config.mjs +114 -68
- package/src/identity.mjs +101 -73
- package/src/inbox.mjs +99 -76
- package/src/mcp.mjs +382 -309
- package/src/protocol.mjs +77 -68
- package/src/setup.mjs +171 -262
- package/src/slack.mjs +322 -236
package/src/identity.mjs
CHANGED
|
@@ -1,73 +1,101 @@
|
|
|
1
|
-
// Every agent in a workspace shares one bot token, so Slack's own bot_id proves
|
|
2
|
-
// only "agent-wire posted this" — not which agent did. The header line is plain
|
|
3
|
-
// text anyone in the channel can type, so it cannot carry identity either.
|
|
4
|
-
// Each install therefore signs what it sends with its own Ed25519 key and
|
|
5
|
-
// publishes the public half alongside the message. A nickname is bound to the
|
|
6
|
-
// first key seen using it (trust on first use); a later message claiming that
|
|
7
|
-
// nickname with a different key is reported, not believed.
|
|
8
|
-
import { createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto';
|
|
9
|
-
|
|
10
|
-
import { paths,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
export
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
1
|
+
// Every agent in a workspace shares one bot token, so Slack's own bot_id proves
|
|
2
|
+
// only "agent-wire posted this" — not which agent did. The header line is plain
|
|
3
|
+
// text anyone in the channel can type, so it cannot carry identity either.
|
|
4
|
+
// Each install therefore signs what it sends with its own Ed25519 key and
|
|
5
|
+
// publishes the public half alongside the message. A nickname is bound to the
|
|
6
|
+
// first key seen using it (trust on first use); a later message claiming that
|
|
7
|
+
// nickname with a different key is reported, not believed.
|
|
8
|
+
import { createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
import { paths, readJsonCached, writeJson } from './config.mjs';
|
|
11
|
+
|
|
12
|
+
// A public key is 44 base64 characters. The prefix is what a human compares out
|
|
13
|
+
// loud when two agents disagree about who somebody is, so it is one decision and
|
|
14
|
+
// it is made once rather than at each of the four places that print it.
|
|
15
|
+
export const FINGERPRINT_CHARS = 12;
|
|
16
|
+
|
|
17
|
+
const DER_PRIVATE = { type: 'pkcs8', format: 'der' };
|
|
18
|
+
const DER_PUBLIC = { type: 'spki', format: 'der' };
|
|
19
|
+
|
|
20
|
+
// Parsing DER into a key object costs more than the Ed25519 check that follows
|
|
21
|
+
// it, and one poll verifies a page of messages against the same handful of keys.
|
|
22
|
+
// The object is derived from the bytes alone, so keying the cache on those bytes
|
|
23
|
+
// verifies exactly what it verified before.
|
|
24
|
+
//
|
|
25
|
+
// ponytail: bounded by clearing, not by evicting the oldest. The keys come out of
|
|
26
|
+
// a Slack channel, so an unbounded map is somebody else's memory budget; an LRU
|
|
27
|
+
// would be the upgrade if a workspace ever holds more agents than this.
|
|
28
|
+
const KEY_CACHE_MAX = 512;
|
|
29
|
+
const keyObjects = new Map();
|
|
30
|
+
|
|
31
|
+
function cachedKey(material, build) {
|
|
32
|
+
const cached = keyObjects.get(material);
|
|
33
|
+
if (cached) return cached;
|
|
34
|
+
|
|
35
|
+
if (keyObjects.size >= KEY_CACHE_MAX) keyObjects.clear();
|
|
36
|
+
const key = build();
|
|
37
|
+
keyObjects.set(material, key);
|
|
38
|
+
return key;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function generateKeypair() {
|
|
42
|
+
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
|
43
|
+
return {
|
|
44
|
+
privateKey: privateKey.export(DER_PRIVATE).toString('base64'),
|
|
45
|
+
publicKey: publicKey.export(DER_PUBLIC).toString('base64'),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Signed over the fields a forger would want to change: who sent it, who it is
|
|
50
|
+
// for, which channel it belongs to, where it sits in a reply chain, and which
|
|
51
|
+
// file it points at. Channel is included so a signed message cannot be replayed
|
|
52
|
+
// into a different channel; file is included so a valid signature cannot be
|
|
53
|
+
// re-attached to somebody else's upload.
|
|
54
|
+
export const signingPayload = ({ channel, from, to, conv, hop, file = '', text }) =>
|
|
55
|
+
Buffer.from(`agent-wire/v2\n${channel}\n${from}\n${to}\n${conv}\n${hop}\n${file}\n${text}`, 'utf8');
|
|
56
|
+
|
|
57
|
+
export function signMessage(privateKeyBase64, fields) {
|
|
58
|
+
const privateKey = cachedKey(privateKeyBase64, () => createPrivateKey({ key: Buffer.from(privateKeyBase64, 'base64'), ...DER_PRIVATE }));
|
|
59
|
+
return sign(null, signingPayload(fields), privateKey).toString('base64');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function verifySignature(publicKeyBase64, signature, fields) {
|
|
63
|
+
try {
|
|
64
|
+
const publicKey = cachedKey(publicKeyBase64, () => createPublicKey({ key: Buffer.from(publicKeyBase64, 'base64'), ...DER_PUBLIC }));
|
|
65
|
+
return verify(null, signingPayload(fields), publicKey, Buffer.from(signature, 'base64'));
|
|
66
|
+
} catch {
|
|
67
|
+
// A malformed key or signature is a failed verification, not a crash: the
|
|
68
|
+
// bytes came from a Slack message and anyone in the channel can shape them.
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const loadPeers = () => readJsonCached(paths.peers, {});
|
|
74
|
+
|
|
75
|
+
// Verdicts, in the order they are decided:
|
|
76
|
+
// "signed" — signature valid and the key matches the one pinned to this name
|
|
77
|
+
// "new" — signature valid, first time this name appears, key now pinned
|
|
78
|
+
// "impostor" — signature valid but the name is pinned to a DIFFERENT key
|
|
79
|
+
// "unsigned" — no signature, or the signature does not verify
|
|
80
|
+
export function checkAuthorship({ from, publicKey, signature, ...fields }) {
|
|
81
|
+
if (!publicKey || !signature) return { verdict: 'unsigned' };
|
|
82
|
+
if (!verifySignature(publicKey, signature, { from, ...fields })) return { verdict: 'unsigned' };
|
|
83
|
+
|
|
84
|
+
const peers = loadPeers();
|
|
85
|
+
const pinned = peers[from];
|
|
86
|
+
if (pinned && pinned.publicKey !== publicKey) return { verdict: 'impostor', pinnedSince: pinned.firstSeen };
|
|
87
|
+
if (pinned) return { verdict: 'signed' };
|
|
88
|
+
|
|
89
|
+
peers[from] = { publicKey, firstSeen: new Date().toISOString() };
|
|
90
|
+
writeJson(paths.peers, peers);
|
|
91
|
+
return { verdict: 'new' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const forgetPeer = (name) => {
|
|
95
|
+
const peers = loadPeers();
|
|
96
|
+
delete peers[name];
|
|
97
|
+
writeJson(paths.peers, peers);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const listPeers = () => Object.entries(loadPeers())
|
|
101
|
+
.map(([name, peer]) => ({ name, firstSeen: peer.firstSeen, fingerprint: peer.publicKey.slice(0, FINGERPRINT_CHARS) }));
|
package/src/inbox.mjs
CHANGED
|
@@ -1,76 +1,99 @@
|
|
|
1
|
-
// The local log is the source of truth, not the Slack channel. Slack history is a
|
|
2
|
-
// cache we can re-read at any time, so a full re-scan is an ordinary idempotent
|
|
3
|
-
// operation rather than a recovery procedure.
|
|
4
|
-
//
|
|
5
|
-
// inbox.jsonl is append-only and keyed by the Slack timestamp, which is unique per
|
|
6
|
-
// channel and survives a re-install. Message state lives in a separate file so the
|
|
7
|
-
// append-only log never has to be rewritten in place.
|
|
8
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
9
|
-
|
|
10
|
-
import { HOME, paths,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
export
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
1
|
+
// The local log is the source of truth, not the Slack channel. Slack history is a
|
|
2
|
+
// cache we can re-read at any time, so a full re-scan is an ordinary idempotent
|
|
3
|
+
// operation rather than a recovery procedure.
|
|
4
|
+
//
|
|
5
|
+
// inbox.jsonl is append-only and keyed by the Slack timestamp, which is unique per
|
|
6
|
+
// channel and survives a re-install. Message state lives in a separate file so the
|
|
7
|
+
// append-only log never has to be rewritten in place.
|
|
8
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
9
|
+
|
|
10
|
+
import { HOME, derivedFromFile, paths, readJsonCached, writeJson } from './config.mjs';
|
|
11
|
+
|
|
12
|
+
// Enough to catch up on a conversation, short enough not to bury the session that
|
|
13
|
+
// asked. A caller that wants the whole log passes its own count.
|
|
14
|
+
const DEFAULT_COUNT = 20;
|
|
15
|
+
|
|
16
|
+
const storageKey = (item) => `${item.channel}:${item.ts}`;
|
|
17
|
+
|
|
18
|
+
// Parsed at most once per write. Four call sites read the whole log — select,
|
|
19
|
+
// append, archive, findByTs — and a poll runs several of them back to back, so
|
|
20
|
+
// without this a 20k-message log is parsed four times to answer one question.
|
|
21
|
+
export function readInbox() {
|
|
22
|
+
if (!existsSync(paths.inbox)) return [];
|
|
23
|
+
return derivedFromFile(paths.inbox, 'parsed', () => readFileSync(paths.inbox, 'utf8')
|
|
24
|
+
.split('\n').filter(Boolean)
|
|
25
|
+
.map((line) => { try { return JSON.parse(line); } catch { return null; } })
|
|
26
|
+
.filter(Boolean));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The dedup check is a lookup, so it is stored as one. Rebuilding a 20k-entry Set
|
|
30
|
+
// per appended message is the whole cost of appending a message.
|
|
31
|
+
const inboxKeys = () => derivedFromFile(paths.inbox, 'keys', () => new Set(readInbox().map(storageKey)));
|
|
32
|
+
|
|
33
|
+
export const stateOf = (states, item) => states[storageKey(item)] ?? 'unread';
|
|
34
|
+
|
|
35
|
+
// The Slack timestamp is the idempotency key: a retried poll, an overlapping
|
|
36
|
+
// window, or a re-installed app all replay the same ts, and a duplicate the human
|
|
37
|
+
// has to clean up by hand is the failure that generates support noise.
|
|
38
|
+
export function appendMessages(items) {
|
|
39
|
+
if (items.length === 0) return 0;
|
|
40
|
+
|
|
41
|
+
const seen = inboxKeys();
|
|
42
|
+
const fresh = items.filter((item) => !seen.has(storageKey(item)));
|
|
43
|
+
if (fresh.length === 0) return 0;
|
|
44
|
+
|
|
45
|
+
mkdirSync(HOME, { recursive: true });
|
|
46
|
+
appendFileSync(paths.inbox, fresh.map((item) => JSON.stringify(item)).join('\n') + '\n');
|
|
47
|
+
return fresh.length;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// `channel` names one channel explicitly and overrides everything. `channels`
|
|
51
|
+
// is the caller's allow-list, which is how switched-off channels stay out of the
|
|
52
|
+
// default view without being deleted from the log.
|
|
53
|
+
export function selectMessages({ state = 'unread', count = DEFAULT_COUNT, channel = null, channels = null } = {}) {
|
|
54
|
+
const states = readJsonCached(paths.states, {});
|
|
55
|
+
const isVisible = (item) => {
|
|
56
|
+
if (channel) return item.channel === channel;
|
|
57
|
+
if (channels) return channels.includes(item.channel);
|
|
58
|
+
return true;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Scanned from the newest end and stopped at `count`. Filtering the whole log
|
|
62
|
+
// to keep the last twenty of it was most of what reading an inbox cost, and
|
|
63
|
+
// the log only grows.
|
|
64
|
+
const log = readInbox();
|
|
65
|
+
const picked = [];
|
|
66
|
+
for (let index = log.length - 1; index >= 0 && picked.length < count; index--) {
|
|
67
|
+
const item = log[index];
|
|
68
|
+
if (!isVisible(item)) continue;
|
|
69
|
+
if (state !== 'all' && stateOf(states, item) !== state) continue;
|
|
70
|
+
picked.push(item);
|
|
71
|
+
}
|
|
72
|
+
return picked.reverse();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function markRead(items) {
|
|
76
|
+
const states = readJsonCached(paths.states, {});
|
|
77
|
+
for (const item of items) states[storageKey(item)] = 'read';
|
|
78
|
+
writeJson(paths.states, states);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function archive(ts) {
|
|
82
|
+
const states = readJsonCached(paths.states, {});
|
|
83
|
+
const targets = ts
|
|
84
|
+
? readInbox().filter((item) => item.ts === ts)
|
|
85
|
+
: readInbox().filter((item) => stateOf(states, item) === 'read');
|
|
86
|
+
for (const item of targets) states[storageKey(item)] = 'archived';
|
|
87
|
+
writeJson(paths.states, states);
|
|
88
|
+
return targets.length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const findByTs = (ts) => readInbox().find((item) => item.ts === ts) ?? null;
|
|
92
|
+
|
|
93
|
+
export const readCursor = (channelId) => readJsonCached(paths.cursors, {})[channelId] ?? null;
|
|
94
|
+
|
|
95
|
+
export function writeCursor(channelId, ts) {
|
|
96
|
+
const cursors = readJsonCached(paths.cursors, {});
|
|
97
|
+
cursors[channelId] = ts;
|
|
98
|
+
writeJson(paths.cursors, cursors);
|
|
99
|
+
}
|