agent-comm-hub 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/README.zh.md +1 -1
- package/assets/admin.html +1234 -0
- package/lib/cli.js +1021 -75
- package/lib/index.js +898 -42
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
4
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
5
|
+
import { dirname as dirname2 } from "node:path";
|
|
5
6
|
|
|
6
7
|
// src/herdr-ctl.ts
|
|
7
8
|
import { execFile } from "node:child_process";
|
|
@@ -452,6 +453,8 @@ var AgentHub = class {
|
|
|
452
453
|
lastSeen = /* @__PURE__ */ new Map();
|
|
453
454
|
/** Identity metadata keyed by peer id; outlives registration (see {@link PeerProfile}). */
|
|
454
455
|
profiles = /* @__PURE__ */ new Map();
|
|
456
|
+
/** Named channels (group channel), keyed by group id. */
|
|
457
|
+
groups = /* @__PURE__ */ new Map();
|
|
455
458
|
gcTimer;
|
|
456
459
|
/** Stop the idle GC (call when the hub shuts down). */
|
|
457
460
|
dispose() {
|
|
@@ -491,6 +494,7 @@ var AgentHub = class {
|
|
|
491
494
|
/** Remove a peer and its queued messages; pending waiters resolve as
|
|
492
495
|
* timeouts. The profile is kept so a reconnecting peer keeps its alias. */
|
|
493
496
|
unregister(peerId) {
|
|
497
|
+
if ((this.queues.get(peerId) ?? []).length > 0) this.options.onMailboxesChanged?.();
|
|
494
498
|
this.queues.delete(peerId);
|
|
495
499
|
for (const waiter of this.waiters.get(peerId) ?? []) {
|
|
496
500
|
clearTimeout(waiter.timer);
|
|
@@ -631,9 +635,20 @@ var AgentHub = class {
|
|
|
631
635
|
if (!original) throw new Error(`cannot ack unknown message: ${ref}`);
|
|
632
636
|
return this.route(from, original.from, "ack", JSON.stringify(ack), ref);
|
|
633
637
|
}
|
|
634
|
-
/** Recent messages involving `peer` (inbound and outbound
|
|
638
|
+
/** Recent messages involving `peer` (inbound and outbound, plus group
|
|
639
|
+
* channels the peer belongs to), newest first. */
|
|
635
640
|
history(peerId, limit) {
|
|
636
|
-
const
|
|
641
|
+
const memberOf = new Set(
|
|
642
|
+
[...this.groups.values()].filter((group) => group.members.includes(peerId)).map((group) => group.id)
|
|
643
|
+
);
|
|
644
|
+
const filtered = this.historyRing.filter(
|
|
645
|
+
(message) => message.from === peerId || message.to === peerId || message.to === BROADCAST || message.channel !== void 0 && memberOf.has(message.channel)
|
|
646
|
+
);
|
|
647
|
+
return filtered.slice(-Math.max(0, limit)).reverse();
|
|
648
|
+
}
|
|
649
|
+
/** Recent messages of one group channel, newest first. */
|
|
650
|
+
historyChannel(groupId, limit) {
|
|
651
|
+
const filtered = this.historyRing.filter((message) => message.channel === groupId || message.to === groupId && message.channel === void 0 && this.groups.has(groupId));
|
|
637
652
|
return filtered.slice(-Math.max(0, limit)).reverse();
|
|
638
653
|
}
|
|
639
654
|
/** Most recent messages across every peer, unfiltered (newest first).
|
|
@@ -642,10 +657,175 @@ var AgentHub = class {
|
|
|
642
657
|
historyAll(limit) {
|
|
643
658
|
return this.historyRing.slice(-Math.max(0, limit)).reverse();
|
|
644
659
|
}
|
|
660
|
+
// ---- groups (group channel) ------------------------------------------------
|
|
661
|
+
/** Create a channel; throws when the id is taken or a member is unknown. */
|
|
662
|
+
createGroup(id, members, createdBy, name) {
|
|
663
|
+
if (this.groups.has(id)) throw new Error(`group already exists: ${id}`);
|
|
664
|
+
if (!PEER_ID_PATTERN.test(id)) throw new Error(`invalid group id: ${id} (expected [A-Za-z0-9._:-]{1,64})`);
|
|
665
|
+
const unique = [...new Set(members)];
|
|
666
|
+
if (unique.length === 0) throw new Error("a group needs at least one member");
|
|
667
|
+
for (const member of unique) {
|
|
668
|
+
if (!this.lastSeen.has(member)) throw new Error(`unknown member peer: ${member}`);
|
|
669
|
+
}
|
|
670
|
+
if (!unique.includes(createdBy)) unique.unshift(createdBy);
|
|
671
|
+
const group = { id, members: unique, createdBy, createdAt: Date.now(), ...name !== void 0 && name !== "" ? { name } : {} };
|
|
672
|
+
this.groups.set(id, group);
|
|
673
|
+
this.options.onGroupsChanged?.();
|
|
674
|
+
return group;
|
|
675
|
+
}
|
|
676
|
+
/** Remove a channel (its history rows stay — attribution is unchanged). */
|
|
677
|
+
deleteGroup(id) {
|
|
678
|
+
if (!this.groups.delete(id)) return false;
|
|
679
|
+
this.options.onGroupsChanged?.();
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
groupOf(id) {
|
|
683
|
+
return this.groups.get(id);
|
|
684
|
+
}
|
|
685
|
+
/** Add a registered peer to an existing channel, so they can send and read
|
|
686
|
+
* it like any other member. Throws when the group is unknown, the peer is
|
|
687
|
+
* not registered, or it is already inside. */
|
|
688
|
+
addGroupMember(groupId, member) {
|
|
689
|
+
const group = this.groups.get(groupId);
|
|
690
|
+
if (group === void 0) throw new Error(`unknown group: ${groupId}`);
|
|
691
|
+
if (group.members.includes(member)) throw new Error(`peer '${member}' is already a member of group '${groupId}'`);
|
|
692
|
+
if (!this.lastSeen.has(member)) throw new Error(`unknown member peer: ${member}`);
|
|
693
|
+
group.members.push(member);
|
|
694
|
+
this.options.onGroupsChanged?.();
|
|
695
|
+
return group;
|
|
696
|
+
}
|
|
697
|
+
/** Remove a member from a channel. The creator cannot be removed (it would
|
|
698
|
+
* orphan the channel's management). Throws when the group or the membership
|
|
699
|
+
* is missing. */
|
|
700
|
+
removeGroupMember(groupId, member) {
|
|
701
|
+
const group = this.groups.get(groupId);
|
|
702
|
+
if (group === void 0) throw new Error(`unknown group: ${groupId}`);
|
|
703
|
+
if (member === group.createdBy) throw new Error(`cannot remove the group creator '${member}'`);
|
|
704
|
+
const index = group.members.indexOf(member);
|
|
705
|
+
if (index === -1) throw new Error(`peer '${member}' is not a member of group '${groupId}'`);
|
|
706
|
+
group.members.splice(index, 1);
|
|
707
|
+
this.options.onGroupsChanged?.();
|
|
708
|
+
return group;
|
|
709
|
+
}
|
|
710
|
+
/** All groups (snapshot copy), insertion order. */
|
|
711
|
+
allGroups() {
|
|
712
|
+
return [...this.groups.values()].map((group) => ({ ...group, members: [...group.members] }));
|
|
713
|
+
}
|
|
714
|
+
/** Send a chat message to every member of a group except the sender;
|
|
715
|
+
* members that went offline are skipped (they read the group history).
|
|
716
|
+
* The message carries `to` and `channel` = the group id. */
|
|
717
|
+
sendToGroup(from, groupId, content) {
|
|
718
|
+
const group = this.groups.get(groupId);
|
|
719
|
+
if (group === void 0) throw new Error(`unknown group: ${groupId}`);
|
|
720
|
+
if (!group.members.includes(from)) throw new Error(`peer '${from}' is not a member of group '${groupId}'`);
|
|
721
|
+
const message = {
|
|
722
|
+
id: randomUUID(),
|
|
723
|
+
from,
|
|
724
|
+
to: groupId,
|
|
725
|
+
kind: "chat",
|
|
726
|
+
content,
|
|
727
|
+
channel: groupId,
|
|
728
|
+
ts: Date.now()
|
|
729
|
+
};
|
|
730
|
+
this.lastSeen.set(from, message.ts);
|
|
731
|
+
for (const member of group.members) {
|
|
732
|
+
if (member !== from && this.lastSeen.has(member)) this.deliver(member, message);
|
|
733
|
+
}
|
|
734
|
+
return message;
|
|
735
|
+
}
|
|
736
|
+
/** Snapshot of every group for persistence. */
|
|
737
|
+
exportGroups() {
|
|
738
|
+
return this.allGroups();
|
|
739
|
+
}
|
|
740
|
+
/** Load persisted groups at boot (same validation as createGroup, minus
|
|
741
|
+
* the "members must be online" rule — members reconnect later). */
|
|
742
|
+
importGroups(groups) {
|
|
743
|
+
if (!Array.isArray(groups)) return 0;
|
|
744
|
+
let count = 0;
|
|
745
|
+
for (const raw of groups) {
|
|
746
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
747
|
+
const candidate = raw;
|
|
748
|
+
if (typeof candidate.id !== "string" || !PEER_ID_PATTERN.test(candidate.id) || this.groups.has(candidate.id)) continue;
|
|
749
|
+
if (!Array.isArray(candidate.members) || candidate.members.length === 0) continue;
|
|
750
|
+
const members = [...new Set(candidate.members.filter((m) => typeof m === "string" && PEER_ID_PATTERN.test(m)))];
|
|
751
|
+
if (members.length === 0) continue;
|
|
752
|
+
this.groups.set(candidate.id, {
|
|
753
|
+
id: candidate.id,
|
|
754
|
+
members,
|
|
755
|
+
createdBy: typeof candidate.createdBy === "string" && PEER_ID_PATTERN.test(candidate.createdBy) ? candidate.createdBy : members[0],
|
|
756
|
+
createdAt: typeof candidate.createdAt === "number" && Number.isFinite(candidate.createdAt) ? candidate.createdAt : Date.now(),
|
|
757
|
+
...typeof candidate.name === "string" && candidate.name !== "" ? { name: candidate.name } : {}
|
|
758
|
+
});
|
|
759
|
+
count++;
|
|
760
|
+
}
|
|
761
|
+
return count;
|
|
762
|
+
}
|
|
763
|
+
// ---- persistence import/export (remote-mode SQLite store) -----------
|
|
764
|
+
/** Restore the history ring at boot. Shape-invalid rows are dropped; only
|
|
765
|
+
* the newest {@link HubOptions.historyLimit} survive. */
|
|
766
|
+
importHistory(messages) {
|
|
767
|
+
if (!Array.isArray(messages)) return 0;
|
|
768
|
+
const valid = [];
|
|
769
|
+
for (const raw of messages) {
|
|
770
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
771
|
+
const m = raw;
|
|
772
|
+
if (typeof m.id !== "string" || typeof m.from !== "string" || typeof m.to !== "string" || typeof m.content !== "string" || typeof m.ts !== "number") continue;
|
|
773
|
+
if (KINDS.includes(m.kind) === false) continue;
|
|
774
|
+
valid.push({
|
|
775
|
+
id: m.id,
|
|
776
|
+
from: m.from,
|
|
777
|
+
to: m.to,
|
|
778
|
+
kind: m.kind,
|
|
779
|
+
content: m.content,
|
|
780
|
+
...typeof m.ref === "string" ? { ref: m.ref } : {},
|
|
781
|
+
...typeof m.channel === "string" ? { channel: m.channel } : {},
|
|
782
|
+
ts: m.ts
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
for (const message of valid.slice(-this.options.historyLimit)) this.historyRing.push(message);
|
|
786
|
+
return Math.min(valid.length, this.options.historyLimit);
|
|
787
|
+
}
|
|
788
|
+
/** Snapshot of every offline mailbox, keyed by peer (persistence). */
|
|
789
|
+
exportMailboxes() {
|
|
790
|
+
const out = {};
|
|
791
|
+
for (const [peer, queue] of this.queues) {
|
|
792
|
+
if (queue.length > 0) out[peer] = queue.map((message) => ({ ...message }));
|
|
793
|
+
}
|
|
794
|
+
return out;
|
|
795
|
+
}
|
|
796
|
+
/** Restore offline mailboxes at boot (peer reconnects and drains later). */
|
|
797
|
+
importMailboxes(mailboxes) {
|
|
798
|
+
if (typeof mailboxes !== "object" || mailboxes === null) return 0;
|
|
799
|
+
let count = 0;
|
|
800
|
+
for (const [peer, queue] of Object.entries(mailboxes)) {
|
|
801
|
+
if (!Array.isArray(queue) || queue.length === 0) continue;
|
|
802
|
+
const restored = [];
|
|
803
|
+
for (const raw of queue) {
|
|
804
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
805
|
+
const m = raw;
|
|
806
|
+
if (typeof m.id !== "string" || typeof m.from !== "string" || typeof m.content !== "string" || typeof m.ts !== "number") continue;
|
|
807
|
+
restored.push({
|
|
808
|
+
id: m.id,
|
|
809
|
+
from: m.from,
|
|
810
|
+
to: typeof m.to === "string" ? m.to : peer,
|
|
811
|
+
kind: KINDS.includes(m.kind) ? m.kind : "chat",
|
|
812
|
+
content: m.content,
|
|
813
|
+
...typeof m.ref === "string" ? { ref: m.ref } : {},
|
|
814
|
+
...typeof m.channel === "string" ? { channel: m.channel } : {},
|
|
815
|
+
ts: m.ts
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
if (restored.length > 0) {
|
|
819
|
+
this.queues.set(peer, restored.slice(-this.options.maxQueue));
|
|
820
|
+
count += restored.length;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
return count;
|
|
824
|
+
}
|
|
645
825
|
/**
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
826
|
+
* Live summary for the status tool. `livePeers` (sessions with a live SSE
|
|
827
|
+
* stream) count as connected even without recent tool activity.
|
|
828
|
+
*/
|
|
649
829
|
status(livePeers) {
|
|
650
830
|
const now = Date.now();
|
|
651
831
|
return {
|
|
@@ -669,7 +849,7 @@ var AgentHub = class {
|
|
|
669
849
|
poll(peerId, from) {
|
|
670
850
|
const queue = this.queues.get(peerId) ?? [];
|
|
671
851
|
const drained = from === void 0 ? queue.splice(0, queue.length) : drainFrom(queue, from);
|
|
672
|
-
|
|
852
|
+
if (drained.length > 0) this.options.onMailboxesChanged?.();
|
|
673
853
|
return drained;
|
|
674
854
|
}
|
|
675
855
|
/**
|
|
@@ -752,12 +932,15 @@ var AgentHub = class {
|
|
|
752
932
|
if (queue.length >= this.options.maxQueue) queue.shift();
|
|
753
933
|
queue.push(message);
|
|
754
934
|
this.queues.set(target, queue);
|
|
935
|
+
this.remember(message);
|
|
755
936
|
this.options.onQueued?.(message, target);
|
|
756
937
|
}
|
|
757
|
-
/** Append a delivered message to the history ring
|
|
938
|
+
/** Append a delivered message to the history ring (and fire the
|
|
939
|
+
* persistence hook — one call per message, delivered or queued). */
|
|
758
940
|
remember(message) {
|
|
759
941
|
this.historyRing.push(message);
|
|
760
942
|
if (this.historyRing.length > this.options.historyLimit) this.historyRing.splice(0, this.historyRing.length - this.options.historyLimit);
|
|
943
|
+
this.options.onMessage?.(message);
|
|
761
944
|
}
|
|
762
945
|
};
|
|
763
946
|
function drainFrom(queue, from) {
|
|
@@ -775,13 +958,159 @@ function omit(value, key) {
|
|
|
775
958
|
return rest;
|
|
776
959
|
}
|
|
777
960
|
|
|
961
|
+
// src/auth.ts
|
|
962
|
+
import { randomBytes } from "node:crypto";
|
|
963
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
964
|
+
import { dirname } from "node:path";
|
|
965
|
+
var ANON_JOIN_PEER = "__join__";
|
|
966
|
+
var TOKEN_PATTERN = /^[A-Za-z0-9._:-]{16,128}$/;
|
|
967
|
+
function loadTokenFile(file) {
|
|
968
|
+
let parsed;
|
|
969
|
+
try {
|
|
970
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
971
|
+
} catch (error) {
|
|
972
|
+
throw new Error(`auth token file unreadable: ${file} (${error.message})`);
|
|
973
|
+
}
|
|
974
|
+
const rows = parsed?.tokens;
|
|
975
|
+
if (!Array.isArray(rows)) throw new Error(`auth token file ${file}: expected {"tokens":[...]}`);
|
|
976
|
+
const table = /* @__PURE__ */ new Map();
|
|
977
|
+
const peers = /* @__PURE__ */ new Set();
|
|
978
|
+
rows.forEach((row, index) => {
|
|
979
|
+
const msg = (why) => `auth token file ${file}: entry #${index + 1} ${why}`;
|
|
980
|
+
if (typeof row !== "object" || row === null) throw new Error(msg("is not an object"));
|
|
981
|
+
const { token, peer, role, owner } = row;
|
|
982
|
+
if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) {
|
|
983
|
+
throw new Error(msg("has an invalid token (expect 16-128 chars of [A-Za-z0-9._:-])"));
|
|
984
|
+
}
|
|
985
|
+
if (typeof peer !== "string" || !PEER_ID_PATTERN.test(peer) || peer === "all") {
|
|
986
|
+
throw new Error(msg(`has an invalid peer id '${String(peer)}'`));
|
|
987
|
+
}
|
|
988
|
+
if (role !== "agent" && role !== "manager") {
|
|
989
|
+
throw new Error(msg(`has an invalid role '${String(role)}' (expected 'agent' | 'manager')`));
|
|
990
|
+
}
|
|
991
|
+
if (owner !== void 0 && typeof owner !== "string") throw new Error(msg("has a non-string owner"));
|
|
992
|
+
if (table.has(token)) throw new Error(msg("duplicates a token"));
|
|
993
|
+
if (peers.has(peer)) {
|
|
994
|
+
throw new Error(msg(`maps a second token to peer '${peer}' (two tokens sharing a peer share its mailbox)`));
|
|
995
|
+
}
|
|
996
|
+
table.set(token, { peer, role, ...typeof owner === "string" && owner !== "" ? { owner } : {} });
|
|
997
|
+
peers.add(peer);
|
|
998
|
+
});
|
|
999
|
+
if (table.size === 0) throw new Error(`auth token file ${file}: no tokens defined`);
|
|
1000
|
+
return table;
|
|
1001
|
+
}
|
|
1002
|
+
function tokenTable(map) {
|
|
1003
|
+
return {
|
|
1004
|
+
authenticate: (token) => token === void 0 ? void 0 : map.get(token)
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
function watchTokenFile(file, log) {
|
|
1008
|
+
let table = loadTokenFile(file);
|
|
1009
|
+
const reload = () => {
|
|
1010
|
+
try {
|
|
1011
|
+
const next = loadTokenFile(file);
|
|
1012
|
+
table = next;
|
|
1013
|
+
log.info(`auth tokens reloaded: ${table.size} entr(ies)`);
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
log.warn(`auth token reload ignored, keeping previous table: ${error.message}`);
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
log.info(`auth tokens loaded: ${table.size} entr(ies) from ${file}`);
|
|
1019
|
+
let mtime = statMtime(file);
|
|
1020
|
+
const timer = setInterval(() => {
|
|
1021
|
+
const current = statMtime(file);
|
|
1022
|
+
if (current === mtime) return;
|
|
1023
|
+
mtime = current;
|
|
1024
|
+
reload();
|
|
1025
|
+
}, 2e3);
|
|
1026
|
+
timer.unref?.();
|
|
1027
|
+
return { authenticate: (token) => token === void 0 ? void 0 : table.get(token), reload };
|
|
1028
|
+
}
|
|
1029
|
+
function statMtime(file) {
|
|
1030
|
+
try {
|
|
1031
|
+
return statSync(file).mtimeMs;
|
|
1032
|
+
} catch {
|
|
1033
|
+
return 0;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
function bearerFromHeaders(headers) {
|
|
1037
|
+
const raw = Array.isArray(headers.authorization) ? headers.authorization[0] : headers.authorization;
|
|
1038
|
+
if (raw === void 0) return void 0;
|
|
1039
|
+
const match = /^Bearer\s+(.+)$/.exec(raw.trim());
|
|
1040
|
+
return match === null ? void 0 : match[1];
|
|
1041
|
+
}
|
|
1042
|
+
function generateToken() {
|
|
1043
|
+
return randomBytes(24).toString("base64url");
|
|
1044
|
+
}
|
|
1045
|
+
function readTokens(file) {
|
|
1046
|
+
if (!existsSync(file)) return [];
|
|
1047
|
+
try {
|
|
1048
|
+
return [...loadTokenFile(file).entries()].map(([token, id]) => ({
|
|
1049
|
+
token,
|
|
1050
|
+
peer: id.peer,
|
|
1051
|
+
role: id.role,
|
|
1052
|
+
...id.owner !== void 0 ? { owner: id.owner } : {}
|
|
1053
|
+
}));
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
try {
|
|
1056
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
1057
|
+
if (Array.isArray(parsed.tokens) && parsed.tokens.length === 0) return [];
|
|
1058
|
+
} catch {
|
|
1059
|
+
}
|
|
1060
|
+
throw error;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
function writeTokenFile(file, rows) {
|
|
1064
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
1065
|
+
const tmp = `${file}.tmp`;
|
|
1066
|
+
writeFileSync(tmp, JSON.stringify({ tokens: rows }, null, 2) + "\n", "utf8");
|
|
1067
|
+
renameSync(tmp, file);
|
|
1068
|
+
}
|
|
1069
|
+
function addToken(file, row) {
|
|
1070
|
+
const token = row.token ?? generateToken();
|
|
1071
|
+
if (!TOKEN_PATTERN.test(token)) {
|
|
1072
|
+
throw new Error(`invalid token provided (expect 16-128 chars of [A-Za-z0-9._:-])`);
|
|
1073
|
+
}
|
|
1074
|
+
const peers = new Set(readTokens(file).map((t) => t.peer));
|
|
1075
|
+
if (peers.has(row.peer)) {
|
|
1076
|
+
throw new Error(`peer '${row.peer}' already has a token \u2014 remove it first to rotate (see \`auth remove\`)`);
|
|
1077
|
+
}
|
|
1078
|
+
const owner = row.owner !== void 0 && row.owner !== "" ? row.owner : void 0;
|
|
1079
|
+
const identity = { token, peer: row.peer, role: row.role, ...owner !== void 0 ? { owner } : {} };
|
|
1080
|
+
const next = [...readTokens(file), identity];
|
|
1081
|
+
loadTokenFileVoid(next);
|
|
1082
|
+
writeTokenFile(file, next);
|
|
1083
|
+
return identity;
|
|
1084
|
+
}
|
|
1085
|
+
function removeToken(file, peer) {
|
|
1086
|
+
const tokens = readTokens(file);
|
|
1087
|
+
const kept = tokens.filter((t) => t.peer !== peer);
|
|
1088
|
+
if (kept.length === tokens.length) return false;
|
|
1089
|
+
writeTokenFile(file, kept);
|
|
1090
|
+
return true;
|
|
1091
|
+
}
|
|
1092
|
+
function loadTokenFileVoid(rows) {
|
|
1093
|
+
const table = /* @__PURE__ */ new Map();
|
|
1094
|
+
const peers = /* @__PURE__ */ new Set();
|
|
1095
|
+
for (const { token, peer, role, owner } of rows) {
|
|
1096
|
+
if (!TOKEN_PATTERN.test(token)) throw new Error(`invalid token for peer '${peer}'`);
|
|
1097
|
+
if (!PEER_ID_PATTERN.test(peer) || peer === "all") throw new Error(`invalid peer id '${peer}'`);
|
|
1098
|
+
if (role !== "agent" && role !== "manager") throw new Error(`invalid role '${role}'`);
|
|
1099
|
+
if (table.has(token)) throw new Error("duplicate token");
|
|
1100
|
+
if (peers.has(peer)) throw new Error(`duplicate peer '${peer}'`);
|
|
1101
|
+
table.set(token, { peer, role, ...owner !== void 0 && owner !== "" ? { owner } : {} });
|
|
1102
|
+
peers.add(peer);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
778
1106
|
// src/hub-tools.ts
|
|
779
1107
|
var DEFAULT_WAIT_MS = 3e4;
|
|
780
1108
|
function present(message) {
|
|
781
|
-
const { ref, ...rest } = message;
|
|
1109
|
+
const { ref, channel, ...rest } = message;
|
|
782
1110
|
return {
|
|
783
1111
|
...rest,
|
|
784
1112
|
...ref !== void 0 ? { ref } : {},
|
|
1113
|
+
...channel !== void 0 ? { channel } : {},
|
|
785
1114
|
content: decodeContent(message.kind, message.content)
|
|
786
1115
|
};
|
|
787
1116
|
}
|
|
@@ -801,7 +1130,8 @@ function autoRegisterPeer(hub, registry, sessionId, clientName, clientVersion) {
|
|
|
801
1130
|
const bound = registry.peerFor(sessionId);
|
|
802
1131
|
if (bound !== void 0) return bound;
|
|
803
1132
|
if (registry.isSuppressed(sessionId)) return void 0;
|
|
804
|
-
const
|
|
1133
|
+
const tokenPeer = registry.tokenFor(sessionId)?.peer;
|
|
1134
|
+
const peerId = tokenPeer === ANON_JOIN_PEER ? `join-${sanitizePeerId(clientName ?? "agent")}-${Math.random().toString(36).slice(2, 6)}` : tokenPeer ?? sanitizePeerId(clientName ?? "agent");
|
|
805
1135
|
if (!hub.has(peerId)) {
|
|
806
1136
|
hub.register(peerId, {
|
|
807
1137
|
...clientName !== void 0 ? { name: clientName } : {},
|
|
@@ -822,16 +1152,31 @@ function livePeersFor(registry) {
|
|
|
822
1152
|
}
|
|
823
1153
|
function rosterFor(hub, registry) {
|
|
824
1154
|
const status = hub.status(livePeersFor(registry));
|
|
1155
|
+
const peerIps = /* @__PURE__ */ new Map();
|
|
1156
|
+
const peerAnon = /* @__PURE__ */ new Map();
|
|
1157
|
+
for (const [session, peer] of registry.peerBindings) {
|
|
1158
|
+
const ip = registry.sessionIps.get(session);
|
|
1159
|
+
if (ip !== void 0) {
|
|
1160
|
+
const list = peerIps.get(peer) ?? [];
|
|
1161
|
+
if (!list.includes(ip)) list.push(ip);
|
|
1162
|
+
peerIps.set(peer, list);
|
|
1163
|
+
}
|
|
1164
|
+
const identity = registry.tokenFor(session);
|
|
1165
|
+
if (identity?.anonymous === true) peerAnon.set(peer, true);
|
|
1166
|
+
}
|
|
825
1167
|
return status.peers.map((peer) => {
|
|
826
1168
|
const profile = hub.profileOf(peer.id);
|
|
827
1169
|
const alias = profile?.alias;
|
|
828
1170
|
const clientName = profile?.clientName;
|
|
829
1171
|
const clientVersion = profile?.clientVersion;
|
|
1172
|
+
const ips = peerIps.get(peer.id);
|
|
830
1173
|
return {
|
|
831
1174
|
id: peer.id,
|
|
832
1175
|
...alias !== void 0 ? { alias } : {},
|
|
833
1176
|
...clientName !== void 0 ? { clientName } : {},
|
|
834
1177
|
...clientVersion !== void 0 ? { clientVersion } : {},
|
|
1178
|
+
...peerAnon.get(peer.id) === true ? { anonymous: true } : {},
|
|
1179
|
+
...ips !== void 0 && ips.length > 0 ? { clientIps: ips } : {},
|
|
835
1180
|
connected: peer.connected,
|
|
836
1181
|
lastSeenMs: peer.lastSeenMs
|
|
837
1182
|
};
|
|
@@ -866,6 +1211,7 @@ function hubTools(hub, registry, options) {
|
|
|
866
1211
|
from: message.from,
|
|
867
1212
|
to: message.to,
|
|
868
1213
|
kind: message.kind,
|
|
1214
|
+
...message.channel !== void 0 ? { channel: message.channel } : {},
|
|
869
1215
|
ts: message.ts
|
|
870
1216
|
});
|
|
871
1217
|
const presentWait = (result) => result.type === "timeout" ? result : { type: "message", message: present(result.message) };
|
|
@@ -880,7 +1226,8 @@ function hubTools(hub, registry, options) {
|
|
|
880
1226
|
}
|
|
881
1227
|
return herdr;
|
|
882
1228
|
};
|
|
883
|
-
const requireManager = (peer, action) => {
|
|
1229
|
+
const requireManager = (peer, action, sessionId) => {
|
|
1230
|
+
if (registry.tokenFor(sessionId)?.role === "manager") return;
|
|
884
1231
|
const managers = options.managerPeers ?? "all";
|
|
885
1232
|
if (managers !== "all" && !managers.has(peer)) {
|
|
886
1233
|
throw new Error(`peer '${peer}' is not a hub manager \u2014 ${action} requires manager rights`);
|
|
@@ -912,6 +1259,10 @@ function hubTools(hub, registry, options) {
|
|
|
912
1259
|
throw new Error(`reserved peer id: ${peerId} (it is the broadcast address)`);
|
|
913
1260
|
}
|
|
914
1261
|
const alias = args.alias === void 0 ? void 0 : sanitizeAlias(String(args.alias));
|
|
1262
|
+
const tokenPeer = registry.tokenFor(sessionId)?.peer;
|
|
1263
|
+
if (tokenPeer !== void 0 && peerId !== tokenPeer) {
|
|
1264
|
+
throw new Error(`your identity is bound to your access token ('${tokenPeer}') \u2014 ask a hub manager to rename you`);
|
|
1265
|
+
}
|
|
915
1266
|
if (hub.has(peerId) && registry.peerFor(sessionId) !== peerId) {
|
|
916
1267
|
throw new Error(`peer already registered by another connection: ${peerId}`);
|
|
917
1268
|
}
|
|
@@ -953,7 +1304,7 @@ function hubTools(hub, registry, options) {
|
|
|
953
1304
|
const caller = registry.peerFor(sessionId);
|
|
954
1305
|
if (caller === void 0) throw new Error("not registered \u2014 call bridge_register(peerId) first");
|
|
955
1306
|
const target = String(args.peer);
|
|
956
|
-
requireManager(caller, `kicking '${target}'
|
|
1307
|
+
requireManager(caller, `kicking '${target}'`, sessionId);
|
|
957
1308
|
if (!hub.has(target)) {
|
|
958
1309
|
return { ok: true, peerId: null, kicked: false };
|
|
959
1310
|
}
|
|
@@ -970,14 +1321,14 @@ function hubTools(hub, registry, options) {
|
|
|
970
1321
|
peerId: optStr("New routing id for `peer` \u2014 a true rename that moves all state (manager only). Target must be a registered peer."),
|
|
971
1322
|
peer: optStr("Whose name/id to change; default yourself. Changing another peer requires manager rights.")
|
|
972
1323
|
}),
|
|
973
|
-
handler: wrap(true, async (args, peer) => {
|
|
1324
|
+
handler: wrap(true, async (args, peer, sessionId) => {
|
|
974
1325
|
const hasAlias = args.alias !== void 0;
|
|
975
1326
|
const hasPeerId = args.peerId !== void 0;
|
|
976
1327
|
if (!hasAlias && !hasPeerId) {
|
|
977
1328
|
throw new Error("nothing to rename: pass alias and/or peerId");
|
|
978
1329
|
}
|
|
979
1330
|
let target = args.peer === void 0 ? peer : String(args.peer);
|
|
980
|
-
if (target !== peer) requireManager(peer, `renaming '${target}'
|
|
1331
|
+
if (target !== peer) requireManager(peer, `renaming '${target}'`, sessionId);
|
|
981
1332
|
let previousId;
|
|
982
1333
|
if (hasPeerId) {
|
|
983
1334
|
if (args.peer === void 0) {
|
|
@@ -1078,16 +1429,98 @@ function hubTools(hub, registry, options) {
|
|
|
1078
1429
|
name: "bridge_history",
|
|
1079
1430
|
description: 'Recent messages involving you (newest first). Use to refresh context after a reconnect. Reading ANOTHER peer\'s conversation \u2014 or `peer: "all"` for the unfiltered tail \u2014 requires manager rights (hub managerPeers).',
|
|
1080
1431
|
inputSchema: schema({ peer: optStr('PeerId whose conversation to inspect; "all" = every peer; default: yourself. Other peers / "all" require manager rights.'), limit: int("How many messages to return (default 20).") }),
|
|
1081
|
-
handler: wrap(true, async (args, peer) => {
|
|
1432
|
+
handler: wrap(true, async (args, peer, sessionId) => {
|
|
1082
1433
|
const limit = Math.min(args.limit === void 0 ? 20 : Number(args.limit), 1e3);
|
|
1083
1434
|
const target = args.peer === void 0 ? peer : String(args.peer);
|
|
1084
1435
|
if (target !== peer) {
|
|
1085
|
-
requireManager(peer, `reading ${target === BROADCAST ? "the full history" : `peer '${target}' history`}
|
|
1436
|
+
requireManager(peer, `reading ${target === BROADCAST ? "the full history" : `peer '${target}' history`}`, sessionId);
|
|
1437
|
+
}
|
|
1438
|
+
if (args.channel !== void 0) {
|
|
1439
|
+
const groupId = String(args.channel);
|
|
1440
|
+
const group = hub.groupOf(groupId);
|
|
1441
|
+
if (group === void 0) throw new Error(`unknown group: ${groupId}`);
|
|
1442
|
+
if (!group.members.includes(peer)) requireManager(peer, `reading group '${groupId}' history`, sessionId);
|
|
1443
|
+
return { messages: hub.historyChannel(groupId, limit).map(present) };
|
|
1086
1444
|
}
|
|
1087
1445
|
const messages = target === BROADCAST ? hub.historyAll(limit) : hub.history(target, limit);
|
|
1088
1446
|
return { messages: messages.map(present) };
|
|
1089
1447
|
})
|
|
1090
1448
|
},
|
|
1449
|
+
// ---- group tools (group channel) ---------------------------------------------
|
|
1450
|
+
// A group routes one message to several members; the message carries
|
|
1451
|
+
// channel = group id. Offline members are skipped (they read history) —
|
|
1452
|
+
// unlike direct sends, which fail on unknown recipients.
|
|
1453
|
+
{
|
|
1454
|
+
name: "bridge_group_create",
|
|
1455
|
+
description: "Create a group channel. You become a member automatically; `members` lists additional peerIds (each must be registered). Members exchange messages via bridge_group_send; anyone in the group reads its history with bridge_history { channel }. Use bridge_group_delete to remove (creator or manager).",
|
|
1456
|
+
inputSchema: schema(
|
|
1457
|
+
{
|
|
1458
|
+
group: str("Unique group id: letters/digits/._:- , 1-64 chars."),
|
|
1459
|
+
name: optStr("Optional display name."),
|
|
1460
|
+
members: { type: "array", items: { type: "string" }, description: "Member peerIds (you are added automatically; duplicates ignored)." }
|
|
1461
|
+
},
|
|
1462
|
+
["group"]
|
|
1463
|
+
),
|
|
1464
|
+
handler: wrap(true, async (args, peer) => {
|
|
1465
|
+
const groupId = String(args.group);
|
|
1466
|
+
const members = Array.isArray(args.members) ? args.members.map(String) : [];
|
|
1467
|
+
const group = hub.createGroup(groupId, members, peer, args.name === void 0 ? void 0 : String(args.name));
|
|
1468
|
+
return { ok: true, group: { id: group.id, ...group.name !== void 0 ? { name: group.name } : {}, members: group.members, createdBy: group.createdBy } };
|
|
1469
|
+
})
|
|
1470
|
+
},
|
|
1471
|
+
{
|
|
1472
|
+
name: "bridge_group_send",
|
|
1473
|
+
description: "Send a chat message to every member of a group channel except yourself. Offline members are skipped (they catch up via bridge_history { channel }). You must be a member.",
|
|
1474
|
+
inputSchema: schema({ group: str("Group id from bridge_group_create / bridge_group_list."), message: str("The message text.") }, ["group", "message"]),
|
|
1475
|
+
handler: wrap(true, async (args, peer) => receipt(hub.sendToGroup(peer, String(args.group), String(args.message))))
|
|
1476
|
+
},
|
|
1477
|
+
{
|
|
1478
|
+
name: "bridge_group_list",
|
|
1479
|
+
description: "List every group channel with its members and creator.",
|
|
1480
|
+
inputSchema: schema({}),
|
|
1481
|
+
handler: wrap(true, async () => ({ groups: hub.allGroups() }))
|
|
1482
|
+
},
|
|
1483
|
+
{
|
|
1484
|
+
name: "bridge_group_delete",
|
|
1485
|
+
description: "Delete a group channel (history rows are kept). The creator or a hub manager may delete.",
|
|
1486
|
+
inputSchema: schema({ group: str("Group id to delete.") }, ["group"]),
|
|
1487
|
+
handler: wrap(true, async (args, peer, sessionId) => {
|
|
1488
|
+
const groupId = String(args.group);
|
|
1489
|
+
const group = hub.groupOf(groupId);
|
|
1490
|
+
if (group === void 0) throw new Error(`unknown group: ${groupId}`);
|
|
1491
|
+
if (group.createdBy !== peer) requireManager(peer, `deleting group '${groupId}'`, sessionId);
|
|
1492
|
+
hub.deleteGroup(groupId);
|
|
1493
|
+
return { ok: true, group: groupId };
|
|
1494
|
+
})
|
|
1495
|
+
},
|
|
1496
|
+
{
|
|
1497
|
+
name: "bridge_group_add_member",
|
|
1498
|
+
description: "Add a registered peer to a group channel; the new member can then send to it and read its history. The creator or a hub manager may add.",
|
|
1499
|
+
inputSchema: schema({ group: str("Group id from bridge_group_create / bridge_group_list."), member: str("peerId of the new member.") }, ["group", "member"]),
|
|
1500
|
+
handler: wrap(true, async (args, peer, sessionId) => {
|
|
1501
|
+
const groupId = String(args.group);
|
|
1502
|
+
const member = String(args.member);
|
|
1503
|
+
const group = hub.groupOf(groupId);
|
|
1504
|
+
if (group === void 0) throw new Error(`unknown group: ${groupId}`);
|
|
1505
|
+
if (group.createdBy !== peer) requireManager(peer, `adding a member to group '${groupId}'`, sessionId);
|
|
1506
|
+
const updated = hub.addGroupMember(groupId, member);
|
|
1507
|
+
return { ok: true, group: { id: updated.id, members: updated.members } };
|
|
1508
|
+
})
|
|
1509
|
+
},
|
|
1510
|
+
{
|
|
1511
|
+
name: "bridge_group_remove_member",
|
|
1512
|
+
description: "Remove a member from a group channel; they stop receiving its messages (history rows are kept). The creator or a hub manager may remove; the creator itself cannot be removed.",
|
|
1513
|
+
inputSchema: schema({ group: str("Group id from bridge_group_create / bridge_group_list."), member: str("peerId to remove.") }, ["group", "member"]),
|
|
1514
|
+
handler: wrap(true, async (args, peer, sessionId) => {
|
|
1515
|
+
const groupId = String(args.group);
|
|
1516
|
+
const member = String(args.member);
|
|
1517
|
+
const group = hub.groupOf(groupId);
|
|
1518
|
+
if (group === void 0) throw new Error(`unknown group: ${groupId}`);
|
|
1519
|
+
if (group.createdBy !== peer) requireManager(peer, `removing a member from group '${groupId}'`, sessionId);
|
|
1520
|
+
const updated = hub.removeGroupMember(groupId, member);
|
|
1521
|
+
return { ok: true, group: { id: updated.id, members: updated.members } };
|
|
1522
|
+
})
|
|
1523
|
+
},
|
|
1091
1524
|
// ---- herdr control tools ------------------------------------------
|
|
1092
1525
|
// These type into real agent terminals via the herdr runtime. They are
|
|
1093
1526
|
// gated by checkControl and documented as physical input: unlike
|
|
@@ -1290,6 +1723,16 @@ function hubTools(hub, registry, options) {
|
|
|
1290
1723
|
|
|
1291
1724
|
// src/mcp-server.ts
|
|
1292
1725
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1726
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
1727
|
+
var ADMIN_PATH = "/admin";
|
|
1728
|
+
var ADMIN_API_PATH = "/admin/api";
|
|
1729
|
+
var adminPageCache;
|
|
1730
|
+
function adminPage() {
|
|
1731
|
+
if (adminPageCache === void 0) {
|
|
1732
|
+
adminPageCache = readFileSync2(new URL("../assets/admin.html", import.meta.url), "utf8");
|
|
1733
|
+
}
|
|
1734
|
+
return adminPageCache;
|
|
1735
|
+
}
|
|
1293
1736
|
var SUPPORTED_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
1294
1737
|
var LATEST_VERSION = SUPPORTED_VERSIONS[0];
|
|
1295
1738
|
var MAX_BODY_BYTES = 1048576;
|
|
@@ -1362,8 +1805,24 @@ var SessionRegistry = class {
|
|
|
1362
1805
|
isSuppressed(sessionId) {
|
|
1363
1806
|
return sessionId !== void 0 && this.suppressedAuto.has(sessionId);
|
|
1364
1807
|
}
|
|
1808
|
+
/** Session → client IP (remote mode: the admin roster shows where a peer
|
|
1809
|
+
* connects from, so the operator can tell Zhang San's kimi-code from Li Si's). */
|
|
1810
|
+
sessionIps = /* @__PURE__ */ new Map();
|
|
1365
1811
|
/** Sessions with a live SSE stream (server→client channel). */
|
|
1366
1812
|
liveStreams = /* @__PURE__ */ new Set();
|
|
1813
|
+
/** Session → token identity (remote auth mode only; see src/auth.ts).
|
|
1814
|
+
* The TOKEN, not the client-reported name, decides the peer id, so two
|
|
1815
|
+
* users running the same client get distinct peers. */
|
|
1816
|
+
tokenBindings = /* @__PURE__ */ new Map();
|
|
1817
|
+
/** Bind a session to its token identity (at initialize / SSE open). */
|
|
1818
|
+
bindToken(sessionId, identity) {
|
|
1819
|
+
this.tokenBindings.set(sessionId, identity);
|
|
1820
|
+
}
|
|
1821
|
+
/** Token identity bound to a session, if remote auth is in use. */
|
|
1822
|
+
tokenFor(sessionId) {
|
|
1823
|
+
if (sessionId === void 0) return void 0;
|
|
1824
|
+
return this.tokenBindings.get(sessionId);
|
|
1825
|
+
}
|
|
1367
1826
|
/** Mark a session's SSE stream open (server→client channel alive). */
|
|
1368
1827
|
markSseOpen(sessionId) {
|
|
1369
1828
|
this.liveStreams.add(sessionId);
|
|
@@ -1422,18 +1881,29 @@ var SessionRegistry = class {
|
|
|
1422
1881
|
var McpStreamableHttpServer = class {
|
|
1423
1882
|
constructor(tools, info, registry, log = () => {
|
|
1424
1883
|
}, onInitialize = () => {
|
|
1425
|
-
}) {
|
|
1884
|
+
}, authenticate, adminApi) {
|
|
1426
1885
|
this.tools = tools;
|
|
1427
1886
|
this.info = info;
|
|
1428
1887
|
this.registry = registry;
|
|
1429
1888
|
this.log = log;
|
|
1430
1889
|
this.onInitialize = onInitialize;
|
|
1890
|
+
this.authenticate = authenticate;
|
|
1891
|
+
this.adminApi = adminApi;
|
|
1431
1892
|
}
|
|
1432
1893
|
sseStreams = /* @__PURE__ */ new Map();
|
|
1433
1894
|
/** Attach request handling for `path` (e.g. `/mcp`) to an http server. */
|
|
1434
1895
|
attach(server, path2) {
|
|
1435
1896
|
server.on("request", (req, res) => {
|
|
1436
1897
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1898
|
+
if (url.pathname === ADMIN_PATH && req.method === "GET") {
|
|
1899
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
|
|
1900
|
+
res.end(adminPage());
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
if (url.pathname.startsWith(ADMIN_API_PATH) && this.adminApi !== void 0) {
|
|
1904
|
+
void this.handleAdminApi(req, res, url);
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1437
1907
|
if (url.pathname !== path2) {
|
|
1438
1908
|
res.writeHead(404, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "not found" }));
|
|
1439
1909
|
return;
|
|
@@ -1483,7 +1953,14 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
|
|
|
1483
1953
|
}
|
|
1484
1954
|
}
|
|
1485
1955
|
handleGet(req, res) {
|
|
1956
|
+
if (this.authenticate !== void 0 && this.authenticate(req) === void 0) {
|
|
1957
|
+
res.writeHead(401, { ...corsHeaders(), "Content-Type": "application/json; charset=utf-8" });
|
|
1958
|
+
res.end(JSON.stringify({ error: "unauthorized: missing or invalid bearer token" }));
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1486
1961
|
const sessionId = this.registry.ensureSession(this.registry.sessionIdFor(req));
|
|
1962
|
+
const identity = this.authenticate?.(req);
|
|
1963
|
+
if (identity !== void 0) this.registry.bindToken(sessionId, identity);
|
|
1487
1964
|
res.writeHead(200, {
|
|
1488
1965
|
...corsHeaders(),
|
|
1489
1966
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
@@ -1499,7 +1976,79 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
|
|
|
1499
1976
|
this.registry.markSseClosed(sessionId);
|
|
1500
1977
|
});
|
|
1501
1978
|
}
|
|
1979
|
+
/** Reject a request without a valid token (remote auth mode). */
|
|
1980
|
+
unauthorized(res, id, why) {
|
|
1981
|
+
res.writeHead(401, { ...corsHeaders(), "Content-Type": "application/json; charset=utf-8" });
|
|
1982
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32001, message: `unauthorized: ${why}` } }));
|
|
1983
|
+
}
|
|
1984
|
+
/** Management API for the bundled admin page: issue/revoke tokens and list
|
|
1985
|
+
* them. Like everything else, each call is authenticated by `authenticate`
|
|
1986
|
+
* and must resolve to a MANAGER role — an agent token gets 403. */
|
|
1987
|
+
async handleAdminApi(req, res, url) {
|
|
1988
|
+
const respond = (status, body) => {
|
|
1989
|
+
res.writeHead(status, { ...corsHeaders(), "Content-Type": "application/json; charset=utf-8" });
|
|
1990
|
+
res.end(JSON.stringify(body));
|
|
1991
|
+
};
|
|
1992
|
+
const identity = this.authenticate?.(req);
|
|
1993
|
+
if (identity === void 0) {
|
|
1994
|
+
respond(401, { error: "unauthorized: manager token required" });
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
if (identity.role !== "manager") {
|
|
1998
|
+
respond(403, { error: "forbidden: this API needs a manager-role token" });
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
const api = this.adminApi;
|
|
2002
|
+
const seg = url.pathname.slice(ADMIN_API_PATH.length).replace(/^\/+|\/+$/g, "").split("/");
|
|
2003
|
+
if (req.method === "GET" && seg[0] === "tokens") {
|
|
2004
|
+
respond(200, { tokens: api.listTokens(url.searchParams.get("reveal") === "1") });
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
if (req.method === "POST" && seg[0] === "tokens" && seg[1] === "add") {
|
|
2008
|
+
let input;
|
|
2009
|
+
try {
|
|
2010
|
+
input = JSON.parse((await readBody(req)).toString("utf8"));
|
|
2011
|
+
} catch (error) {
|
|
2012
|
+
respond(400, { error: `bad json: ${error.message}` });
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
try {
|
|
2016
|
+
const created = api.addToken({
|
|
2017
|
+
peer: String(input.peer ?? ""),
|
|
2018
|
+
role: String(input.role ?? "agent"),
|
|
2019
|
+
owner: input.owner === void 0 ? void 0 : String(input.owner)
|
|
2020
|
+
});
|
|
2021
|
+
respond(200, { created });
|
|
2022
|
+
} catch (error) {
|
|
2023
|
+
respond(400, { error: error.message });
|
|
2024
|
+
}
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
if (req.method === "DELETE" && seg[0] === "tokens" && seg[1] === "remove" && seg[2] !== void 0) {
|
|
2028
|
+
respond(200, { removed: api.removeToken(decodeURIComponent(seg[2])) });
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
respond(404, { error: "not found" });
|
|
2032
|
+
}
|
|
1502
2033
|
async handlePost(req, res) {
|
|
2034
|
+
let authIdentity;
|
|
2035
|
+
if (this.authenticate !== void 0) {
|
|
2036
|
+
authIdentity = this.authenticate(req);
|
|
2037
|
+
if (authIdentity === void 0) {
|
|
2038
|
+
this.unauthorized(res, null, "missing or invalid bearer token");
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
2041
|
+
const existingSession = this.registry.sessionIdFor(req);
|
|
2042
|
+
const bound = existingSession === void 0 ? void 0 : this.registry.tokenFor(existingSession);
|
|
2043
|
+
const bothAnonymous = bound?.anonymous === true && authIdentity.anonymous === true;
|
|
2044
|
+
if (bound !== void 0 && !bothAnonymous && (bound.peer !== authIdentity.peer || bound.role !== authIdentity.role)) {
|
|
2045
|
+
this.unauthorized(res, null, "token does not match this session");
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
if (existingSession !== void 0 && bound === void 0 && !authIdentity.anonymous) {
|
|
2049
|
+
this.registry.bindToken(existingSession, authIdentity);
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
1503
2052
|
let body;
|
|
1504
2053
|
try {
|
|
1505
2054
|
body = await readBody(req);
|
|
@@ -1520,12 +2069,13 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
|
|
|
1520
2069
|
}
|
|
1521
2070
|
const id = message.id ?? null;
|
|
1522
2071
|
const sessionId = this.registry.sessionIdFor(req);
|
|
2072
|
+
if (sessionId !== void 0) this.registry.sessionIps.set(sessionId, clientIp(req));
|
|
1523
2073
|
if (id === null) {
|
|
1524
2074
|
res.writeHead(202, { ...corsHeaders(), "Content-Length": "0" }).end();
|
|
1525
2075
|
return;
|
|
1526
2076
|
}
|
|
1527
2077
|
try {
|
|
1528
|
-
const { result, extraHeaders } = await this.dispatch(message, sessionId);
|
|
2078
|
+
const { result, extraHeaders } = await this.dispatch(message, sessionId, authIdentity);
|
|
1529
2079
|
res.writeHead(200, {
|
|
1530
2080
|
...corsHeaders(),
|
|
1531
2081
|
"Content-Type": "application/json; charset=utf-8",
|
|
@@ -1538,7 +2088,7 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
|
|
|
1538
2088
|
this.jsonRpcError(res, id, code, error.message, void 0, messageProtocolVersion(req));
|
|
1539
2089
|
}
|
|
1540
2090
|
}
|
|
1541
|
-
async dispatch(message, sessionId) {
|
|
2091
|
+
async dispatch(message, sessionId, authIdentity) {
|
|
1542
2092
|
const method = message.method ?? "";
|
|
1543
2093
|
switch (method) {
|
|
1544
2094
|
case "initialize": {
|
|
@@ -1549,6 +2099,9 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
|
|
|
1549
2099
|
if (clientName !== void 0) {
|
|
1550
2100
|
this.registry.noteClient(newSessionId, clientName, clientVersion);
|
|
1551
2101
|
}
|
|
2102
|
+
if (authIdentity !== void 0) {
|
|
2103
|
+
this.registry.bindToken(newSessionId, authIdentity);
|
|
2104
|
+
}
|
|
1552
2105
|
this.onInitialize(newSessionId, clientName, clientVersion);
|
|
1553
2106
|
const requested = message.params?.protocolVersion;
|
|
1554
2107
|
const protocolVersion = typeof requested === "string" && SUPPORTED_VERSIONS.includes(requested) ? requested : LATEST_VERSION;
|
|
@@ -1612,6 +2165,14 @@ function rpcError(code, message) {
|
|
|
1612
2165
|
function messageProtocolVersion(req) {
|
|
1613
2166
|
return req.headers["mcp-protocol-version"] ?? LATEST_VERSION;
|
|
1614
2167
|
}
|
|
2168
|
+
function clientIp(req) {
|
|
2169
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
2170
|
+
const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded;
|
|
2171
|
+
if (typeof raw === "string" && raw.trim() !== "") {
|
|
2172
|
+
return raw.split(",")[0].trim();
|
|
2173
|
+
}
|
|
2174
|
+
return (req.socket.remoteAddress ?? "?").replace(/^::ffff:/, "");
|
|
2175
|
+
}
|
|
1615
2176
|
function corsHeaders() {
|
|
1616
2177
|
return {
|
|
1617
2178
|
"Access-Control-Allow-Origin": "*",
|
|
@@ -1637,9 +2198,149 @@ function readBody(req) {
|
|
|
1637
2198
|
});
|
|
1638
2199
|
}
|
|
1639
2200
|
|
|
2201
|
+
// src/persist.ts
|
|
2202
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2203
|
+
var KIND_NAMES = ["chat", "task", "notice", "ack"];
|
|
2204
|
+
function messageToRow(message) {
|
|
2205
|
+
return [message.id, message.from, message.to, message.kind, message.content, message.ref ?? null, message.channel ?? null, message.ts];
|
|
2206
|
+
}
|
|
2207
|
+
function rowToMessage(row) {
|
|
2208
|
+
const id = row.id;
|
|
2209
|
+
const from = row.from_peer;
|
|
2210
|
+
const to = row.to_peer;
|
|
2211
|
+
const kind = row.kind;
|
|
2212
|
+
const content = row.content;
|
|
2213
|
+
const ts = row.ts;
|
|
2214
|
+
if (typeof id !== "string" || typeof from !== "string" || typeof to !== "string" || typeof content !== "string" || typeof ts !== "number") return void 0;
|
|
2215
|
+
if (!KIND_NAMES.includes(kind)) return void 0;
|
|
2216
|
+
return {
|
|
2217
|
+
id,
|
|
2218
|
+
from,
|
|
2219
|
+
to,
|
|
2220
|
+
kind,
|
|
2221
|
+
content,
|
|
2222
|
+
...typeof row.ref === "string" && row.ref !== "" ? { ref: row.ref } : {},
|
|
2223
|
+
...typeof row.channel === "string" && row.channel !== "" ? { channel: row.channel } : {},
|
|
2224
|
+
ts
|
|
2225
|
+
};
|
|
2226
|
+
}
|
|
2227
|
+
var SQLiteStateStore = class {
|
|
2228
|
+
constructor(file, historyLimit, log) {
|
|
2229
|
+
this.historyLimit = historyLimit;
|
|
2230
|
+
this.db = new DatabaseSync(file);
|
|
2231
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
2232
|
+
this.db.exec(`
|
|
2233
|
+
CREATE TABLE IF NOT EXISTS profiles (
|
|
2234
|
+
id TEXT PRIMARY KEY, alias TEXT, client_name TEXT, client_version TEXT, registered_at INTEGER
|
|
2235
|
+
);
|
|
2236
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
2237
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, from_peer TEXT NOT NULL,
|
|
2238
|
+
to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, ts INTEGER NOT NULL
|
|
2239
|
+
);
|
|
2240
|
+
CREATE TABLE IF NOT EXISTS mailboxes (
|
|
2241
|
+
peer TEXT NOT NULL, seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, from_peer TEXT NOT NULL,
|
|
2242
|
+
to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, ts INTEGER NOT NULL
|
|
2243
|
+
);
|
|
2244
|
+
CREATE TABLE IF NOT EXISTS groups (
|
|
2245
|
+
id TEXT PRIMARY KEY, name TEXT, members TEXT NOT NULL, created_by TEXT NOT NULL, created_at INTEGER NOT NULL
|
|
2246
|
+
);
|
|
2247
|
+
`);
|
|
2248
|
+
log.info(`sqlite state open: ${file}`);
|
|
2249
|
+
}
|
|
2250
|
+
db;
|
|
2251
|
+
buffer = [];
|
|
2252
|
+
/** Buffer one history message (called per message via the hub hook). */
|
|
2253
|
+
appendMessage(message) {
|
|
2254
|
+
this.buffer.push(message);
|
|
2255
|
+
}
|
|
2256
|
+
/** Insert buffered messages and re-snapshot mailboxes/profiles/groups. */
|
|
2257
|
+
flush(snapshot) {
|
|
2258
|
+
const insert = this.db.prepare(
|
|
2259
|
+
"INSERT INTO messages (id, from_peer, to_peer, kind, content, ref, channel, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
|
2260
|
+
);
|
|
2261
|
+
for (const message of this.buffer.splice(0, this.buffer.length)) {
|
|
2262
|
+
insert.run(...messageToRow(message));
|
|
2263
|
+
}
|
|
2264
|
+
this.db.exec("DELETE FROM mailboxes");
|
|
2265
|
+
const insertMail = this.db.prepare(
|
|
2266
|
+
"INSERT INTO mailboxes (peer, id, from_peer, to_peer, kind, content, ref, channel, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
2267
|
+
);
|
|
2268
|
+
for (const [peer, queue] of Object.entries(snapshot.mailboxes)) {
|
|
2269
|
+
for (const message of queue) insertMail.run(peer, ...messageToRow(message));
|
|
2270
|
+
}
|
|
2271
|
+
this.db.exec("DELETE FROM profiles");
|
|
2272
|
+
const insertProfile = this.db.prepare(
|
|
2273
|
+
"INSERT INTO profiles (id, alias, client_name, client_version, registered_at) VALUES (?, ?, ?, ?, ?)"
|
|
2274
|
+
);
|
|
2275
|
+
for (const profile of snapshot.profiles) {
|
|
2276
|
+
insertProfile.run(profile.id, profile.alias ?? null, profile.clientName ?? null, profile.clientVersion ?? null, profile.registeredAt);
|
|
2277
|
+
}
|
|
2278
|
+
this.db.exec("DELETE FROM groups");
|
|
2279
|
+
const insertGroup = this.db.prepare(
|
|
2280
|
+
"INSERT INTO groups (id, name, members, created_by, created_at) VALUES (?, ?, ?, ?, ?)"
|
|
2281
|
+
);
|
|
2282
|
+
for (const group of snapshot.groups) {
|
|
2283
|
+
insertGroup.run(group.id, group.name ?? null, JSON.stringify(group.members), group.createdBy, group.createdAt);
|
|
2284
|
+
}
|
|
2285
|
+
const row = this.db.prepare("SELECT COUNT(*) AS n FROM messages").get();
|
|
2286
|
+
const total = Number(row.n);
|
|
2287
|
+
if (total > this.historyLimit * 2) {
|
|
2288
|
+
this.db.exec(`DELETE FROM messages WHERE seq <= (SELECT MAX(seq) FROM messages) - ${this.historyLimit}`);
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
/** Read everything persisted so far (boot restore). */
|
|
2292
|
+
load() {
|
|
2293
|
+
const profiles = [];
|
|
2294
|
+
for (const row of this.db.prepare("SELECT * FROM profiles").all()) {
|
|
2295
|
+
if (typeof row.id !== "string") continue;
|
|
2296
|
+
profiles.push({
|
|
2297
|
+
id: row.id,
|
|
2298
|
+
...typeof row.alias === "string" && row.alias !== "" ? { alias: row.alias } : {},
|
|
2299
|
+
...typeof row.client_name === "string" && row.client_name !== "" ? { clientName: row.client_name } : {},
|
|
2300
|
+
...typeof row.client_version === "string" && row.client_version !== "" ? { clientVersion: row.client_version } : {},
|
|
2301
|
+
registeredAt: typeof row.registered_at === "number" ? row.registered_at : Date.now()
|
|
2302
|
+
});
|
|
2303
|
+
}
|
|
2304
|
+
const messages = [];
|
|
2305
|
+
for (const row of this.db.prepare("SELECT * FROM messages ORDER BY seq").all()) {
|
|
2306
|
+
const message = rowToMessage(row);
|
|
2307
|
+
if (message !== void 0) messages.push(message);
|
|
2308
|
+
}
|
|
2309
|
+
const mailboxes = {};
|
|
2310
|
+
for (const row of this.db.prepare("SELECT * FROM mailboxes ORDER BY seq").all()) {
|
|
2311
|
+
if (typeof row.peer !== "string") continue;
|
|
2312
|
+
const message = rowToMessage(row);
|
|
2313
|
+
if (message === void 0) continue;
|
|
2314
|
+
(mailboxes[row.peer] ??= []).push(message);
|
|
2315
|
+
}
|
|
2316
|
+
const groups = [];
|
|
2317
|
+
for (const row of this.db.prepare("SELECT * FROM groups").all()) {
|
|
2318
|
+
if (typeof row.id !== "string" || typeof row.members !== "string") continue;
|
|
2319
|
+
try {
|
|
2320
|
+
const members = JSON.parse(row.members);
|
|
2321
|
+
if (!Array.isArray(members) || members.length === 0) continue;
|
|
2322
|
+
groups.push({
|
|
2323
|
+
id: row.id,
|
|
2324
|
+
members: members.filter((m) => typeof m === "string"),
|
|
2325
|
+
createdBy: typeof row.created_by === "string" ? row.created_by : "",
|
|
2326
|
+
createdAt: typeof row.created_at === "number" ? row.created_at : Date.now(),
|
|
2327
|
+
...typeof row.name === "string" && row.name !== "" ? { name: row.name } : {}
|
|
2328
|
+
});
|
|
2329
|
+
} catch {
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
return { profiles, messages, mailboxes, groups };
|
|
2333
|
+
}
|
|
2334
|
+
/** Final flush + close (hub shutdown). */
|
|
2335
|
+
close(snapshot) {
|
|
2336
|
+
this.flush(snapshot);
|
|
2337
|
+
this.db.close();
|
|
2338
|
+
}
|
|
2339
|
+
};
|
|
2340
|
+
|
|
1640
2341
|
// src/index.ts
|
|
1641
2342
|
var SERVER_NAME = "agent-comm-hub";
|
|
1642
|
-
var SERVER_VERSION = "0.
|
|
2343
|
+
var SERVER_VERSION = "0.7.1";
|
|
1643
2344
|
var DEFAULT_HOST = "127.0.0.1";
|
|
1644
2345
|
var DEFAULT_PORT = 18764;
|
|
1645
2346
|
var DEFAULT_PATH = "/mcp";
|
|
@@ -1661,16 +2362,39 @@ var DEFAULT_CONFIG = {
|
|
|
1661
2362
|
// are NOT managers — they keep chatting, a GUI manages.
|
|
1662
2363
|
managerPeers: ["agent-hub-cli"]
|
|
1663
2364
|
};
|
|
2365
|
+
function authTokensAdmin(file, reloadAuth) {
|
|
2366
|
+
if (file === void 0) return void 0;
|
|
2367
|
+
return {
|
|
2368
|
+
listTokens: (reveal) => readTokens(file).map((r) => ({
|
|
2369
|
+
token: reveal ? r.token : `${r.token.slice(0, 6)}\u2026${r.token.slice(-4)}`,
|
|
2370
|
+
peer: r.peer,
|
|
2371
|
+
role: r.role,
|
|
2372
|
+
...r.owner !== void 0 ? { owner: r.owner } : {}
|
|
2373
|
+
})),
|
|
2374
|
+
addToken: (input) => {
|
|
2375
|
+
const row = addToken(file, { peer: input.peer, role: input.role, owner: input.owner });
|
|
2376
|
+
reloadAuth?.();
|
|
2377
|
+
return { token: row.token, peer: row.peer, role: row.role, ...row.owner !== void 0 ? { owner: row.owner } : {} };
|
|
2378
|
+
},
|
|
2379
|
+
removeToken: (peer) => {
|
|
2380
|
+
const ok = removeToken(file, peer);
|
|
2381
|
+
if (ok) reloadAuth?.();
|
|
2382
|
+
return ok;
|
|
2383
|
+
},
|
|
2384
|
+
reloadAuth: () => reloadAuth?.()
|
|
2385
|
+
};
|
|
2386
|
+
}
|
|
1664
2387
|
function writeRosterFile(file, profiles) {
|
|
1665
|
-
|
|
2388
|
+
mkdirSync2(dirname2(file), { recursive: true });
|
|
1666
2389
|
const tmp = `${file}.tmp`;
|
|
1667
|
-
|
|
1668
|
-
|
|
2390
|
+
writeFileSync2(tmp, JSON.stringify({ version: 1, savedAt: Date.now(), profiles }), "utf8");
|
|
2391
|
+
renameSync2(tmp, file);
|
|
1669
2392
|
}
|
|
1670
2393
|
function startHub(config = {}, log = console) {
|
|
1671
2394
|
const overrides = Object.fromEntries(Object.entries(config).filter(([, value]) => value !== void 0));
|
|
1672
2395
|
const resolved = { ...DEFAULT_CONFIG, ...overrides };
|
|
1673
2396
|
let mcp;
|
|
2397
|
+
let dbStore;
|
|
1674
2398
|
const pushRoster = () => {
|
|
1675
2399
|
mcp.notifyAll("notifications/message", {
|
|
1676
2400
|
level: "info",
|
|
@@ -1685,24 +2409,51 @@ function startHub(config = {}, log = console) {
|
|
|
1685
2409
|
data: { event: "message", message: present(message) }
|
|
1686
2410
|
});
|
|
1687
2411
|
};
|
|
2412
|
+
const managerSet = resolved.managerPeers === "all" ? void 0 : new Set(resolved.managerPeers);
|
|
2413
|
+
const pushRelay = (message) => {
|
|
2414
|
+
const managers = [];
|
|
2415
|
+
for (const sessionId of registry.liveSessions()) {
|
|
2416
|
+
const peer = registry.peerFor(sessionId);
|
|
2417
|
+
if (peer === void 0) continue;
|
|
2418
|
+
if (registry.tokenFor(sessionId)?.role === "manager" || managerSet === void 0 || managerSet.has(peer)) {
|
|
2419
|
+
managers.push(sessionId);
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
if (managers.length > 0) {
|
|
2423
|
+
mcp.notify(managers, "notifications/message", {
|
|
2424
|
+
level: "info",
|
|
2425
|
+
logger: "bridge",
|
|
2426
|
+
data: { event: "message", message: present(message) }
|
|
2427
|
+
});
|
|
2428
|
+
}
|
|
2429
|
+
};
|
|
1688
2430
|
let saveTimer;
|
|
1689
2431
|
let rosterDirty = false;
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
rosterDirty
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
2432
|
+
let dbDirty = false;
|
|
2433
|
+
const flushSaves = () => {
|
|
2434
|
+
if (rosterDirty && resolved.stateFile !== void 0) {
|
|
2435
|
+
rosterDirty = false;
|
|
2436
|
+
try {
|
|
2437
|
+
writeRosterFile(resolved.stateFile, hub.exportProfiles());
|
|
2438
|
+
} catch (error) {
|
|
2439
|
+
log.warn(`roster save failed: ${error.message}`);
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
if (dbDirty && dbStore !== void 0) {
|
|
2443
|
+
dbDirty = false;
|
|
2444
|
+
try {
|
|
2445
|
+
dbStore.flush({ mailboxes: hub.exportMailboxes(), profiles: hub.exportProfiles(), groups: hub.exportGroups() });
|
|
2446
|
+
} catch (error) {
|
|
2447
|
+
log.warn(`sqlite flush failed: ${error.message}`);
|
|
2448
|
+
}
|
|
1697
2449
|
}
|
|
1698
2450
|
};
|
|
1699
|
-
const
|
|
1700
|
-
if (resolved.stateFile === void 0) return;
|
|
1701
|
-
rosterDirty = true;
|
|
2451
|
+
const scheduleSave = () => {
|
|
2452
|
+
if (resolved.stateFile === void 0 && dbStore === void 0) return;
|
|
1702
2453
|
if (saveTimer !== void 0) return;
|
|
1703
2454
|
saveTimer = setTimeout(() => {
|
|
1704
2455
|
saveTimer = void 0;
|
|
1705
|
-
|
|
2456
|
+
flushSaves();
|
|
1706
2457
|
}, 500);
|
|
1707
2458
|
saveTimer.unref?.();
|
|
1708
2459
|
};
|
|
@@ -1713,6 +2464,30 @@ function startHub(config = {}, log = console) {
|
|
|
1713
2464
|
connectedWindowMs: resolved.connectedWindowMs,
|
|
1714
2465
|
peerIdleTimeoutMs: resolved.peerIdleTimeoutMs,
|
|
1715
2466
|
onPeerGc: (peerId) => registry.unbindPeerId(peerId),
|
|
2467
|
+
// Remote persistence: buffer every history append; re-snapshot when a
|
|
2468
|
+
// mailbox is consumed so drained messages cannot resurrect on restart.
|
|
2469
|
+
// Also relay every message to manager sessions (the relay stream view) —
|
|
2470
|
+
// best-effort; a notification failure must never break routing.
|
|
2471
|
+
onMessage: (message) => {
|
|
2472
|
+
if (dbStore !== void 0) {
|
|
2473
|
+
dbStore.appendMessage(message);
|
|
2474
|
+
dbDirty = true;
|
|
2475
|
+
}
|
|
2476
|
+
try {
|
|
2477
|
+
pushRelay(message);
|
|
2478
|
+
} catch (error) {
|
|
2479
|
+
log.warn(`relay notification failed: ${error.message}`);
|
|
2480
|
+
}
|
|
2481
|
+
},
|
|
2482
|
+
onMailboxesChanged: () => {
|
|
2483
|
+
if (dbStore === void 0) return;
|
|
2484
|
+
dbDirty = true;
|
|
2485
|
+
},
|
|
2486
|
+
onGroupsChanged: () => {
|
|
2487
|
+
if (dbStore === void 0) return;
|
|
2488
|
+
dbDirty = true;
|
|
2489
|
+
scheduleSave();
|
|
2490
|
+
},
|
|
1716
2491
|
// The idle GC must never evict a peer whose session has a live SSE channel.
|
|
1717
2492
|
isPeerLive: (peerId) => livePeersFor(registry).has(peerId),
|
|
1718
2493
|
// SSE event push: roster changes and queued-mail hints. Best-effort — a
|
|
@@ -1723,7 +2498,9 @@ function startHub(config = {}, log = console) {
|
|
|
1723
2498
|
} catch (error) {
|
|
1724
2499
|
log.warn(`peers_changed notification failed: ${error.message}`);
|
|
1725
2500
|
}
|
|
1726
|
-
|
|
2501
|
+
rosterDirty = true;
|
|
2502
|
+
dbDirty = true;
|
|
2503
|
+
scheduleSave();
|
|
1727
2504
|
},
|
|
1728
2505
|
onQueued: (message, target) => {
|
|
1729
2506
|
try {
|
|
@@ -1734,9 +2511,39 @@ function startHub(config = {}, log = console) {
|
|
|
1734
2511
|
}
|
|
1735
2512
|
});
|
|
1736
2513
|
const registry = new SessionRegistry();
|
|
1737
|
-
if (resolved.
|
|
2514
|
+
if (resolved.db !== void 0) {
|
|
2515
|
+
try {
|
|
2516
|
+
dbStore = new SQLiteStateStore(resolved.db, resolved.historyLimit, log);
|
|
2517
|
+
const state = dbStore.load();
|
|
2518
|
+
const importedProfiles = hub.importProfiles(state.profiles);
|
|
2519
|
+
if (state.profiles.length === 0 && resolved.stateFile !== void 0) {
|
|
2520
|
+
try {
|
|
2521
|
+
const legacy = JSON.parse(readFileSync3(resolved.stateFile, "utf8"));
|
|
2522
|
+
const migrated = hub.importProfiles(legacy.profiles);
|
|
2523
|
+
if (migrated > 0) log.info(`roster migrated: ${migrated} profile(s) from ${resolved.stateFile} into ${resolved.db}`);
|
|
2524
|
+
} catch (error) {
|
|
2525
|
+
if (error.code !== "ENOENT") {
|
|
2526
|
+
log.warn(`legacy roster import failed: ${error.message}`);
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
} else if (importedProfiles > 0) {
|
|
2530
|
+
log.info(`roster restored: ${importedProfiles} profile(s) from ${resolved.db}`);
|
|
2531
|
+
}
|
|
2532
|
+
const restoredMessages = hub.importHistory(state.messages);
|
|
2533
|
+
const restoredQueued = hub.importMailboxes(state.mailboxes);
|
|
2534
|
+
const restoredGroups = hub.importGroups(state.groups);
|
|
2535
|
+
if (restoredMessages + restoredQueued + restoredGroups > 0) {
|
|
2536
|
+
log.info(`state restored from ${resolved.db}: ${restoredMessages} message(s), ${restoredQueued} queued, ${restoredGroups} group(s)`);
|
|
2537
|
+
dbDirty = true;
|
|
2538
|
+
}
|
|
2539
|
+
} catch (error) {
|
|
2540
|
+
dbStore = void 0;
|
|
2541
|
+
log.warn(`sqlite state unavailable (${error.message}); running memory-only`);
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
if (dbStore === void 0 && resolved.stateFile !== void 0) {
|
|
1738
2545
|
try {
|
|
1739
|
-
const parsed = JSON.parse(
|
|
2546
|
+
const parsed = JSON.parse(readFileSync3(resolved.stateFile, "utf8"));
|
|
1740
2547
|
const imported = hub.importProfiles(parsed.profiles);
|
|
1741
2548
|
if (imported > 0) log.info(`roster restored: ${imported} profile(s) from ${resolved.stateFile}`);
|
|
1742
2549
|
} catch (error) {
|
|
@@ -1752,6 +2559,25 @@ function startHub(config = {}, log = console) {
|
|
|
1752
2559
|
socketPath: resolved.herdrSocketPath,
|
|
1753
2560
|
sendRequest: resolved.herdrSendRequest
|
|
1754
2561
|
});
|
|
2562
|
+
let auth;
|
|
2563
|
+
let authReload;
|
|
2564
|
+
if (resolved.authTokens !== void 0) {
|
|
2565
|
+
const table = watchTokenFile(resolved.authTokens, {
|
|
2566
|
+
info: (message) => log.info(message),
|
|
2567
|
+
warn: (message) => log.warn(message)
|
|
2568
|
+
});
|
|
2569
|
+
authReload = table.reload;
|
|
2570
|
+
auth = (req) => {
|
|
2571
|
+
const presented = bearerFromHeaders(req.headers);
|
|
2572
|
+
if (presented !== void 0) return table.authenticate(presented);
|
|
2573
|
+
if (resolved.allowJoin === true) {
|
|
2574
|
+
return { peer: ANON_JOIN_PEER, role: "agent", anonymous: true };
|
|
2575
|
+
}
|
|
2576
|
+
return void 0;
|
|
2577
|
+
};
|
|
2578
|
+
} else if (resolved.allowJoin === true) {
|
|
2579
|
+
auth = (req) => ({ peer: ANON_JOIN_PEER, role: "agent", anonymous: true });
|
|
2580
|
+
}
|
|
1755
2581
|
mcp = new McpStreamableHttpServer(
|
|
1756
2582
|
hubTools(hub, registry, {
|
|
1757
2583
|
defaultWaitMs: resolved.defaultWaitMs,
|
|
@@ -1765,12 +2591,26 @@ function startHub(config = {}, log = console) {
|
|
|
1765
2591
|
(message) => log.warn(message),
|
|
1766
2592
|
(sessionId, clientName, clientVersion) => {
|
|
1767
2593
|
try {
|
|
2594
|
+
const identity = registry.tokenFor(sessionId);
|
|
2595
|
+
if (identity?.anonymous === true && identity.peer === ANON_JOIN_PEER) {
|
|
2596
|
+
let candidate = `join-${sanitizePeerId(clientName ?? "agent")}-${randomBytes2(2).toString("hex")}`;
|
|
2597
|
+
while (hub.has(candidate)) {
|
|
2598
|
+
candidate = `join-${sanitizePeerId(clientName ?? "agent")}-${randomBytes2(2).toString("hex")}`;
|
|
2599
|
+
}
|
|
2600
|
+
registry.bindToken(sessionId, { ...identity, peer: candidate });
|
|
2601
|
+
}
|
|
1768
2602
|
const peer = autoRegisterPeer(hub, registry, sessionId, clientName, clientVersion);
|
|
1769
|
-
if (peer !== void 0)
|
|
2603
|
+
if (peer !== void 0) {
|
|
2604
|
+
const owner = registry.tokenFor(sessionId)?.owner;
|
|
2605
|
+
const anon = registry.tokenFor(sessionId)?.anonymous === true ? " (anonymous join)" : "";
|
|
2606
|
+
log.info(`peer joined: ${peer}${owner !== void 0 ? ` (owner: ${owner})` : ""}${anon}`);
|
|
2607
|
+
}
|
|
1770
2608
|
} catch (error) {
|
|
1771
2609
|
log.warn(`auto-register failed: ${error.message}`);
|
|
1772
2610
|
}
|
|
1773
|
-
}
|
|
2611
|
+
},
|
|
2612
|
+
auth,
|
|
2613
|
+
authTokensAdmin(resolved.authTokens, authReload)
|
|
1774
2614
|
);
|
|
1775
2615
|
const server = createServer();
|
|
1776
2616
|
mcp.attach(server, resolved.path);
|
|
@@ -1790,7 +2630,15 @@ function startHub(config = {}, log = console) {
|
|
|
1790
2630
|
clearTimeout(saveTimer);
|
|
1791
2631
|
saveTimer = void 0;
|
|
1792
2632
|
}
|
|
1793
|
-
|
|
2633
|
+
flushSaves();
|
|
2634
|
+
if (dbStore !== void 0) {
|
|
2635
|
+
try {
|
|
2636
|
+
dbStore.close({ mailboxes: hub.exportMailboxes(), profiles: hub.exportProfiles(), groups: hub.exportGroups() });
|
|
2637
|
+
} catch (error) {
|
|
2638
|
+
log.warn(`sqlite close failed: ${error.message}`);
|
|
2639
|
+
}
|
|
2640
|
+
dbStore = void 0;
|
|
2641
|
+
}
|
|
1794
2642
|
server.closeAllConnections?.();
|
|
1795
2643
|
server.close();
|
|
1796
2644
|
}
|
|
@@ -1810,13 +2658,21 @@ export {
|
|
|
1810
2658
|
PEER_ID_PATTERN,
|
|
1811
2659
|
SERVER_NAME,
|
|
1812
2660
|
SERVER_VERSION,
|
|
2661
|
+
SQLiteStateStore,
|
|
1813
2662
|
SessionRegistry,
|
|
2663
|
+
addToken,
|
|
1814
2664
|
autoRegisterPeer,
|
|
1815
2665
|
decodeContent,
|
|
1816
2666
|
encodeContent,
|
|
2667
|
+
generateToken,
|
|
1817
2668
|
hubTools,
|
|
2669
|
+
loadTokenFile,
|
|
1818
2670
|
present,
|
|
2671
|
+
readTokens,
|
|
2672
|
+
removeToken,
|
|
1819
2673
|
sanitizeAlias,
|
|
1820
2674
|
sanitizePeerId,
|
|
1821
|
-
startHub
|
|
2675
|
+
startHub,
|
|
2676
|
+
tokenTable,
|
|
2677
|
+
watchTokenFile
|
|
1822
2678
|
};
|