@grknbyk/agent-wire 0.4.0 → 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/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, readJson, writeJson } from './config.mjs';
11
-
12
- const DER_PRIVATE = { type: 'pkcs8', format: 'der' };
13
- const DER_PUBLIC = { type: 'spki', format: 'der' };
14
-
15
- export function generateKeypair() {
16
- const { privateKey, publicKey } = generateKeyPairSync('ed25519');
17
- return {
18
- privateKey: privateKey.export(DER_PRIVATE).toString('base64'),
19
- publicKey: publicKey.export(DER_PUBLIC).toString('base64'),
20
- };
21
- }
22
-
23
- // Signed over the fields a forger would want to change: who sent it, who it is
24
- // for, which channel it belongs to, and where it sits in a reply chain. Channel
25
- // is included so a signed message cannot be replayed into a different channel.
26
- export const signingPayload = ({ channel, from, to, conv, hop, text }) =>
27
- Buffer.from(`agent-wire/v1\n${channel}\n${from}\n${to}\n${conv}\n${hop}\n${text}`, 'utf8');
28
-
29
- export function signMessage(privateKeyBase64, fields) {
30
- const privateKey = createPrivateKey({ key: Buffer.from(privateKeyBase64, 'base64'), ...DER_PRIVATE });
31
- return sign(null, signingPayload(fields), privateKey).toString('base64');
32
- }
33
-
34
- function verifySignature(publicKeyBase64, signature, fields) {
35
- try {
36
- const publicKey = createPublicKey({ key: Buffer.from(publicKeyBase64, 'base64'), ...DER_PUBLIC });
37
- return verify(null, signingPayload(fields), publicKey, Buffer.from(signature, 'base64'));
38
- } catch {
39
- // A malformed key or signature is a failed verification, not a crash: the
40
- // bytes came from a Slack message and anyone in the channel can shape them.
41
- return false;
42
- }
43
- }
44
-
45
- const loadPeers = () => readJson(paths.peers, {});
46
-
47
- // Verdicts, in the order they are decided:
48
- // "signed" — signature valid and the key matches the one pinned to this name
49
- // "new" — signature valid, first time this name appears, key now pinned
50
- // "impostor" — signature valid but the name is pinned to a DIFFERENT key
51
- // "unsigned" — no signature, or the signature does not verify
52
- export function checkAuthorship({ from, publicKey, signature, ...fields }) {
53
- if (!publicKey || !signature) return { verdict: 'unsigned' };
54
- if (!verifySignature(publicKey, signature, { from, ...fields })) return { verdict: 'unsigned' };
55
-
56
- const peers = loadPeers();
57
- const pinned = peers[from];
58
- if (pinned && pinned.publicKey !== publicKey) return { verdict: 'impostor', pinnedSince: pinned.firstSeen };
59
- if (pinned) return { verdict: 'signed' };
60
-
61
- peers[from] = { publicKey, firstSeen: new Date().toISOString() };
62
- writeJson(paths.peers, peers);
63
- return { verdict: 'new' };
64
- }
65
-
66
- export const forgetPeer = (name) => {
67
- const peers = loadPeers();
68
- delete peers[name];
69
- writeJson(paths.peers, peers);
70
- };
71
-
72
- export const listPeers = () => Object.entries(loadPeers())
73
- .map(([name, peer]) => ({ name, firstSeen: peer.firstSeen, fingerprint: peer.publicKey.slice(0, 12) }));
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, readJson, writeJson } from './config.mjs';
11
-
12
- const storageKey = (item) => `${item.channel}:${item.ts}`;
13
-
14
- export function readInbox() {
15
- if (!existsSync(paths.inbox)) return [];
16
- return readFileSync(paths.inbox, 'utf8').split('\n').filter(Boolean)
17
- .map((line) => { try { return JSON.parse(line); } catch { return null; } })
18
- .filter(Boolean);
19
- }
20
-
21
- export const stateOf = (states, item) => states[storageKey(item)] ?? 'unread';
22
-
23
- // The Slack timestamp is the idempotency key: a retried poll, an overlapping
24
- // window, or a re-installed app all replay the same ts, and a duplicate the human
25
- // has to clean up by hand is the failure that generates support noise.
26
- export function appendMessages(items) {
27
- if (items.length === 0) return 0;
28
-
29
- const seen = new Set(readInbox().map(storageKey));
30
- const fresh = items.filter((item) => !seen.has(storageKey(item)));
31
- if (fresh.length === 0) return 0;
32
-
33
- mkdirSync(HOME, { recursive: true });
34
- appendFileSync(paths.inbox, fresh.map((item) => JSON.stringify(item)).join('\n') + '\n');
35
- return fresh.length;
36
- }
37
-
38
- // `channel` names one channel explicitly and overrides everything. `channels`
39
- // is the caller's allow-list, which is how switched-off channels stay out of the
40
- // default view without being deleted from the log.
41
- export function selectMessages({ state = 'unread', count = 20, channel = null, channels = null } = {}) {
42
- const states = readJson(paths.states, {});
43
- const visible = readInbox().filter((item) => {
44
- if (channel) return item.channel === channel;
45
- if (channels) return channels.includes(item.channel);
46
- return true;
47
- });
48
- const wanted = state === 'all' ? visible : visible.filter((item) => stateOf(states, item) === state);
49
- return wanted.slice(-count);
50
- }
51
-
52
- export function markRead(items) {
53
- const states = readJson(paths.states, {});
54
- for (const item of items) states[storageKey(item)] = 'read';
55
- writeJson(paths.states, states);
56
- }
57
-
58
- export function archive(ts) {
59
- const states = readJson(paths.states, {});
60
- const targets = ts
61
- ? readInbox().filter((item) => item.ts === ts)
62
- : readInbox().filter((item) => stateOf(states, item) === 'read');
63
- for (const item of targets) states[storageKey(item)] = 'archived';
64
- writeJson(paths.states, states);
65
- return targets.length;
66
- }
67
-
68
- export const findByTs = (ts) => readInbox().find((item) => item.ts === ts) ?? null;
69
-
70
- export const readCursor = (channelId) => readJson(paths.cursors, {})[channelId] ?? null;
71
-
72
- export function writeCursor(channelId, ts) {
73
- const cursors = readJson(paths.cursors, {});
74
- cursors[channelId] = ts;
75
- writeJson(paths.cursors, cursors);
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
+ }