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/cli.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
6
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
7
|
+
import { dirname as dirname2 } from "node:path";
|
|
7
8
|
|
|
8
9
|
// src/herdr-ctl.ts
|
|
9
10
|
import { execFile } from "node:child_process";
|
|
@@ -420,6 +421,7 @@ function socketSend(socketPath, method, params) {
|
|
|
420
421
|
import { randomUUID } from "node:crypto";
|
|
421
422
|
|
|
422
423
|
// src/protocol.ts
|
|
424
|
+
var KINDS = ["chat", "task", "notice", "ack"];
|
|
423
425
|
var PEER_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;
|
|
424
426
|
var BROADCAST = "all";
|
|
425
427
|
function decodeContent(kind, content) {
|
|
@@ -450,6 +452,8 @@ var AgentHub = class {
|
|
|
450
452
|
lastSeen = /* @__PURE__ */ new Map();
|
|
451
453
|
/** Identity metadata keyed by peer id; outlives registration (see {@link PeerProfile}). */
|
|
452
454
|
profiles = /* @__PURE__ */ new Map();
|
|
455
|
+
/** Named channels (group channel), keyed by group id. */
|
|
456
|
+
groups = /* @__PURE__ */ new Map();
|
|
453
457
|
gcTimer;
|
|
454
458
|
/** Stop the idle GC (call when the hub shuts down). */
|
|
455
459
|
dispose() {
|
|
@@ -489,6 +493,7 @@ var AgentHub = class {
|
|
|
489
493
|
/** Remove a peer and its queued messages; pending waiters resolve as
|
|
490
494
|
* timeouts. The profile is kept so a reconnecting peer keeps its alias. */
|
|
491
495
|
unregister(peerId) {
|
|
496
|
+
if ((this.queues.get(peerId) ?? []).length > 0) this.options.onMailboxesChanged?.();
|
|
492
497
|
this.queues.delete(peerId);
|
|
493
498
|
for (const waiter of this.waiters.get(peerId) ?? []) {
|
|
494
499
|
clearTimeout(waiter.timer);
|
|
@@ -629,9 +634,20 @@ var AgentHub = class {
|
|
|
629
634
|
if (!original) throw new Error(`cannot ack unknown message: ${ref}`);
|
|
630
635
|
return this.route(from, original.from, "ack", JSON.stringify(ack), ref);
|
|
631
636
|
}
|
|
632
|
-
/** Recent messages involving `peer` (inbound and outbound
|
|
637
|
+
/** Recent messages involving `peer` (inbound and outbound, plus group
|
|
638
|
+
* channels the peer belongs to), newest first. */
|
|
633
639
|
history(peerId, limit) {
|
|
634
|
-
const
|
|
640
|
+
const memberOf = new Set(
|
|
641
|
+
[...this.groups.values()].filter((group) => group.members.includes(peerId)).map((group) => group.id)
|
|
642
|
+
);
|
|
643
|
+
const filtered = this.historyRing.filter(
|
|
644
|
+
(message) => message.from === peerId || message.to === peerId || message.to === BROADCAST || message.channel !== void 0 && memberOf.has(message.channel)
|
|
645
|
+
);
|
|
646
|
+
return filtered.slice(-Math.max(0, limit)).reverse();
|
|
647
|
+
}
|
|
648
|
+
/** Recent messages of one group channel, newest first. */
|
|
649
|
+
historyChannel(groupId, limit) {
|
|
650
|
+
const filtered = this.historyRing.filter((message) => message.channel === groupId || message.to === groupId && message.channel === void 0 && this.groups.has(groupId));
|
|
635
651
|
return filtered.slice(-Math.max(0, limit)).reverse();
|
|
636
652
|
}
|
|
637
653
|
/** Most recent messages across every peer, unfiltered (newest first).
|
|
@@ -640,10 +656,175 @@ var AgentHub = class {
|
|
|
640
656
|
historyAll(limit) {
|
|
641
657
|
return this.historyRing.slice(-Math.max(0, limit)).reverse();
|
|
642
658
|
}
|
|
659
|
+
// ---- groups (group channel) ------------------------------------------------
|
|
660
|
+
/** Create a channel; throws when the id is taken or a member is unknown. */
|
|
661
|
+
createGroup(id, members, createdBy, name) {
|
|
662
|
+
if (this.groups.has(id)) throw new Error(`group already exists: ${id}`);
|
|
663
|
+
if (!PEER_ID_PATTERN.test(id)) throw new Error(`invalid group id: ${id} (expected [A-Za-z0-9._:-]{1,64})`);
|
|
664
|
+
const unique = [...new Set(members)];
|
|
665
|
+
if (unique.length === 0) throw new Error("a group needs at least one member");
|
|
666
|
+
for (const member of unique) {
|
|
667
|
+
if (!this.lastSeen.has(member)) throw new Error(`unknown member peer: ${member}`);
|
|
668
|
+
}
|
|
669
|
+
if (!unique.includes(createdBy)) unique.unshift(createdBy);
|
|
670
|
+
const group = { id, members: unique, createdBy, createdAt: Date.now(), ...name !== void 0 && name !== "" ? { name } : {} };
|
|
671
|
+
this.groups.set(id, group);
|
|
672
|
+
this.options.onGroupsChanged?.();
|
|
673
|
+
return group;
|
|
674
|
+
}
|
|
675
|
+
/** Remove a channel (its history rows stay — attribution is unchanged). */
|
|
676
|
+
deleteGroup(id) {
|
|
677
|
+
if (!this.groups.delete(id)) return false;
|
|
678
|
+
this.options.onGroupsChanged?.();
|
|
679
|
+
return true;
|
|
680
|
+
}
|
|
681
|
+
groupOf(id) {
|
|
682
|
+
return this.groups.get(id);
|
|
683
|
+
}
|
|
684
|
+
/** Add a registered peer to an existing channel, so they can send and read
|
|
685
|
+
* it like any other member. Throws when the group is unknown, the peer is
|
|
686
|
+
* not registered, or it is already inside. */
|
|
687
|
+
addGroupMember(groupId, member) {
|
|
688
|
+
const group = this.groups.get(groupId);
|
|
689
|
+
if (group === void 0) throw new Error(`unknown group: ${groupId}`);
|
|
690
|
+
if (group.members.includes(member)) throw new Error(`peer '${member}' is already a member of group '${groupId}'`);
|
|
691
|
+
if (!this.lastSeen.has(member)) throw new Error(`unknown member peer: ${member}`);
|
|
692
|
+
group.members.push(member);
|
|
693
|
+
this.options.onGroupsChanged?.();
|
|
694
|
+
return group;
|
|
695
|
+
}
|
|
696
|
+
/** Remove a member from a channel. The creator cannot be removed (it would
|
|
697
|
+
* orphan the channel's management). Throws when the group or the membership
|
|
698
|
+
* is missing. */
|
|
699
|
+
removeGroupMember(groupId, member) {
|
|
700
|
+
const group = this.groups.get(groupId);
|
|
701
|
+
if (group === void 0) throw new Error(`unknown group: ${groupId}`);
|
|
702
|
+
if (member === group.createdBy) throw new Error(`cannot remove the group creator '${member}'`);
|
|
703
|
+
const index = group.members.indexOf(member);
|
|
704
|
+
if (index === -1) throw new Error(`peer '${member}' is not a member of group '${groupId}'`);
|
|
705
|
+
group.members.splice(index, 1);
|
|
706
|
+
this.options.onGroupsChanged?.();
|
|
707
|
+
return group;
|
|
708
|
+
}
|
|
709
|
+
/** All groups (snapshot copy), insertion order. */
|
|
710
|
+
allGroups() {
|
|
711
|
+
return [...this.groups.values()].map((group) => ({ ...group, members: [...group.members] }));
|
|
712
|
+
}
|
|
713
|
+
/** Send a chat message to every member of a group except the sender;
|
|
714
|
+
* members that went offline are skipped (they read the group history).
|
|
715
|
+
* The message carries `to` and `channel` = the group id. */
|
|
716
|
+
sendToGroup(from, groupId, content) {
|
|
717
|
+
const group = this.groups.get(groupId);
|
|
718
|
+
if (group === void 0) throw new Error(`unknown group: ${groupId}`);
|
|
719
|
+
if (!group.members.includes(from)) throw new Error(`peer '${from}' is not a member of group '${groupId}'`);
|
|
720
|
+
const message = {
|
|
721
|
+
id: randomUUID(),
|
|
722
|
+
from,
|
|
723
|
+
to: groupId,
|
|
724
|
+
kind: "chat",
|
|
725
|
+
content,
|
|
726
|
+
channel: groupId,
|
|
727
|
+
ts: Date.now()
|
|
728
|
+
};
|
|
729
|
+
this.lastSeen.set(from, message.ts);
|
|
730
|
+
for (const member of group.members) {
|
|
731
|
+
if (member !== from && this.lastSeen.has(member)) this.deliver(member, message);
|
|
732
|
+
}
|
|
733
|
+
return message;
|
|
734
|
+
}
|
|
735
|
+
/** Snapshot of every group for persistence. */
|
|
736
|
+
exportGroups() {
|
|
737
|
+
return this.allGroups();
|
|
738
|
+
}
|
|
739
|
+
/** Load persisted groups at boot (same validation as createGroup, minus
|
|
740
|
+
* the "members must be online" rule — members reconnect later). */
|
|
741
|
+
importGroups(groups) {
|
|
742
|
+
if (!Array.isArray(groups)) return 0;
|
|
743
|
+
let count = 0;
|
|
744
|
+
for (const raw of groups) {
|
|
745
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
746
|
+
const candidate = raw;
|
|
747
|
+
if (typeof candidate.id !== "string" || !PEER_ID_PATTERN.test(candidate.id) || this.groups.has(candidate.id)) continue;
|
|
748
|
+
if (!Array.isArray(candidate.members) || candidate.members.length === 0) continue;
|
|
749
|
+
const members = [...new Set(candidate.members.filter((m) => typeof m === "string" && PEER_ID_PATTERN.test(m)))];
|
|
750
|
+
if (members.length === 0) continue;
|
|
751
|
+
this.groups.set(candidate.id, {
|
|
752
|
+
id: candidate.id,
|
|
753
|
+
members,
|
|
754
|
+
createdBy: typeof candidate.createdBy === "string" && PEER_ID_PATTERN.test(candidate.createdBy) ? candidate.createdBy : members[0],
|
|
755
|
+
createdAt: typeof candidate.createdAt === "number" && Number.isFinite(candidate.createdAt) ? candidate.createdAt : Date.now(),
|
|
756
|
+
...typeof candidate.name === "string" && candidate.name !== "" ? { name: candidate.name } : {}
|
|
757
|
+
});
|
|
758
|
+
count++;
|
|
759
|
+
}
|
|
760
|
+
return count;
|
|
761
|
+
}
|
|
762
|
+
// ---- persistence import/export (remote-mode SQLite store) -----------
|
|
763
|
+
/** Restore the history ring at boot. Shape-invalid rows are dropped; only
|
|
764
|
+
* the newest {@link HubOptions.historyLimit} survive. */
|
|
765
|
+
importHistory(messages) {
|
|
766
|
+
if (!Array.isArray(messages)) return 0;
|
|
767
|
+
const valid = [];
|
|
768
|
+
for (const raw of messages) {
|
|
769
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
770
|
+
const m = raw;
|
|
771
|
+
if (typeof m.id !== "string" || typeof m.from !== "string" || typeof m.to !== "string" || typeof m.content !== "string" || typeof m.ts !== "number") continue;
|
|
772
|
+
if (KINDS.includes(m.kind) === false) continue;
|
|
773
|
+
valid.push({
|
|
774
|
+
id: m.id,
|
|
775
|
+
from: m.from,
|
|
776
|
+
to: m.to,
|
|
777
|
+
kind: m.kind,
|
|
778
|
+
content: m.content,
|
|
779
|
+
...typeof m.ref === "string" ? { ref: m.ref } : {},
|
|
780
|
+
...typeof m.channel === "string" ? { channel: m.channel } : {},
|
|
781
|
+
ts: m.ts
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
for (const message of valid.slice(-this.options.historyLimit)) this.historyRing.push(message);
|
|
785
|
+
return Math.min(valid.length, this.options.historyLimit);
|
|
786
|
+
}
|
|
787
|
+
/** Snapshot of every offline mailbox, keyed by peer (persistence). */
|
|
788
|
+
exportMailboxes() {
|
|
789
|
+
const out = {};
|
|
790
|
+
for (const [peer, queue] of this.queues) {
|
|
791
|
+
if (queue.length > 0) out[peer] = queue.map((message) => ({ ...message }));
|
|
792
|
+
}
|
|
793
|
+
return out;
|
|
794
|
+
}
|
|
795
|
+
/** Restore offline mailboxes at boot (peer reconnects and drains later). */
|
|
796
|
+
importMailboxes(mailboxes) {
|
|
797
|
+
if (typeof mailboxes !== "object" || mailboxes === null) return 0;
|
|
798
|
+
let count = 0;
|
|
799
|
+
for (const [peer, queue] of Object.entries(mailboxes)) {
|
|
800
|
+
if (!Array.isArray(queue) || queue.length === 0) continue;
|
|
801
|
+
const restored = [];
|
|
802
|
+
for (const raw of queue) {
|
|
803
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
804
|
+
const m = raw;
|
|
805
|
+
if (typeof m.id !== "string" || typeof m.from !== "string" || typeof m.content !== "string" || typeof m.ts !== "number") continue;
|
|
806
|
+
restored.push({
|
|
807
|
+
id: m.id,
|
|
808
|
+
from: m.from,
|
|
809
|
+
to: typeof m.to === "string" ? m.to : peer,
|
|
810
|
+
kind: KINDS.includes(m.kind) ? m.kind : "chat",
|
|
811
|
+
content: m.content,
|
|
812
|
+
...typeof m.ref === "string" ? { ref: m.ref } : {},
|
|
813
|
+
...typeof m.channel === "string" ? { channel: m.channel } : {},
|
|
814
|
+
ts: m.ts
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
if (restored.length > 0) {
|
|
818
|
+
this.queues.set(peer, restored.slice(-this.options.maxQueue));
|
|
819
|
+
count += restored.length;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
return count;
|
|
823
|
+
}
|
|
643
824
|
/**
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
825
|
+
* Live summary for the status tool. `livePeers` (sessions with a live SSE
|
|
826
|
+
* stream) count as connected even without recent tool activity.
|
|
827
|
+
*/
|
|
647
828
|
status(livePeers) {
|
|
648
829
|
const now = Date.now();
|
|
649
830
|
return {
|
|
@@ -667,7 +848,7 @@ var AgentHub = class {
|
|
|
667
848
|
poll(peerId, from) {
|
|
668
849
|
const queue = this.queues.get(peerId) ?? [];
|
|
669
850
|
const drained = from === void 0 ? queue.splice(0, queue.length) : drainFrom(queue, from);
|
|
670
|
-
|
|
851
|
+
if (drained.length > 0) this.options.onMailboxesChanged?.();
|
|
671
852
|
return drained;
|
|
672
853
|
}
|
|
673
854
|
/**
|
|
@@ -750,12 +931,15 @@ var AgentHub = class {
|
|
|
750
931
|
if (queue.length >= this.options.maxQueue) queue.shift();
|
|
751
932
|
queue.push(message);
|
|
752
933
|
this.queues.set(target, queue);
|
|
934
|
+
this.remember(message);
|
|
753
935
|
this.options.onQueued?.(message, target);
|
|
754
936
|
}
|
|
755
|
-
/** Append a delivered message to the history ring
|
|
937
|
+
/** Append a delivered message to the history ring (and fire the
|
|
938
|
+
* persistence hook — one call per message, delivered or queued). */
|
|
756
939
|
remember(message) {
|
|
757
940
|
this.historyRing.push(message);
|
|
758
941
|
if (this.historyRing.length > this.options.historyLimit) this.historyRing.splice(0, this.historyRing.length - this.options.historyLimit);
|
|
942
|
+
this.options.onMessage?.(message);
|
|
759
943
|
}
|
|
760
944
|
};
|
|
761
945
|
function drainFrom(queue, from) {
|
|
@@ -773,13 +957,154 @@ function omit(value, key) {
|
|
|
773
957
|
return rest;
|
|
774
958
|
}
|
|
775
959
|
|
|
960
|
+
// src/auth.ts
|
|
961
|
+
import { randomBytes } from "node:crypto";
|
|
962
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
963
|
+
import { dirname } from "node:path";
|
|
964
|
+
var ANON_JOIN_PEER = "__join__";
|
|
965
|
+
var TOKEN_PATTERN = /^[A-Za-z0-9._:-]{16,128}$/;
|
|
966
|
+
function loadTokenFile(file) {
|
|
967
|
+
let parsed;
|
|
968
|
+
try {
|
|
969
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
970
|
+
} catch (error) {
|
|
971
|
+
throw new Error(`auth token file unreadable: ${file} (${error.message})`);
|
|
972
|
+
}
|
|
973
|
+
const rows = parsed?.tokens;
|
|
974
|
+
if (!Array.isArray(rows)) throw new Error(`auth token file ${file}: expected {"tokens":[...]}`);
|
|
975
|
+
const table = /* @__PURE__ */ new Map();
|
|
976
|
+
const peers = /* @__PURE__ */ new Set();
|
|
977
|
+
rows.forEach((row, index) => {
|
|
978
|
+
const msg = (why) => `auth token file ${file}: entry #${index + 1} ${why}`;
|
|
979
|
+
if (typeof row !== "object" || row === null) throw new Error(msg("is not an object"));
|
|
980
|
+
const { token, peer, role, owner } = row;
|
|
981
|
+
if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) {
|
|
982
|
+
throw new Error(msg("has an invalid token (expect 16-128 chars of [A-Za-z0-9._:-])"));
|
|
983
|
+
}
|
|
984
|
+
if (typeof peer !== "string" || !PEER_ID_PATTERN.test(peer) || peer === "all") {
|
|
985
|
+
throw new Error(msg(`has an invalid peer id '${String(peer)}'`));
|
|
986
|
+
}
|
|
987
|
+
if (role !== "agent" && role !== "manager") {
|
|
988
|
+
throw new Error(msg(`has an invalid role '${String(role)}' (expected 'agent' | 'manager')`));
|
|
989
|
+
}
|
|
990
|
+
if (owner !== void 0 && typeof owner !== "string") throw new Error(msg("has a non-string owner"));
|
|
991
|
+
if (table.has(token)) throw new Error(msg("duplicates a token"));
|
|
992
|
+
if (peers.has(peer)) {
|
|
993
|
+
throw new Error(msg(`maps a second token to peer '${peer}' (two tokens sharing a peer share its mailbox)`));
|
|
994
|
+
}
|
|
995
|
+
table.set(token, { peer, role, ...typeof owner === "string" && owner !== "" ? { owner } : {} });
|
|
996
|
+
peers.add(peer);
|
|
997
|
+
});
|
|
998
|
+
if (table.size === 0) throw new Error(`auth token file ${file}: no tokens defined`);
|
|
999
|
+
return table;
|
|
1000
|
+
}
|
|
1001
|
+
function watchTokenFile(file, log2) {
|
|
1002
|
+
let table = loadTokenFile(file);
|
|
1003
|
+
const reload = () => {
|
|
1004
|
+
try {
|
|
1005
|
+
const next = loadTokenFile(file);
|
|
1006
|
+
table = next;
|
|
1007
|
+
log2.info(`auth tokens reloaded: ${table.size} entr(ies)`);
|
|
1008
|
+
} catch (error) {
|
|
1009
|
+
log2.warn(`auth token reload ignored, keeping previous table: ${error.message}`);
|
|
1010
|
+
}
|
|
1011
|
+
};
|
|
1012
|
+
log2.info(`auth tokens loaded: ${table.size} entr(ies) from ${file}`);
|
|
1013
|
+
let mtime = statMtime(file);
|
|
1014
|
+
const timer = setInterval(() => {
|
|
1015
|
+
const current = statMtime(file);
|
|
1016
|
+
if (current === mtime) return;
|
|
1017
|
+
mtime = current;
|
|
1018
|
+
reload();
|
|
1019
|
+
}, 2e3);
|
|
1020
|
+
timer.unref?.();
|
|
1021
|
+
return { authenticate: (token) => token === void 0 ? void 0 : table.get(token), reload };
|
|
1022
|
+
}
|
|
1023
|
+
function statMtime(file) {
|
|
1024
|
+
try {
|
|
1025
|
+
return statSync(file).mtimeMs;
|
|
1026
|
+
} catch {
|
|
1027
|
+
return 0;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
function bearerFromHeaders(headers) {
|
|
1031
|
+
const raw = Array.isArray(headers.authorization) ? headers.authorization[0] : headers.authorization;
|
|
1032
|
+
if (raw === void 0) return void 0;
|
|
1033
|
+
const match = /^Bearer\s+(.+)$/.exec(raw.trim());
|
|
1034
|
+
return match === null ? void 0 : match[1];
|
|
1035
|
+
}
|
|
1036
|
+
function generateToken() {
|
|
1037
|
+
return randomBytes(24).toString("base64url");
|
|
1038
|
+
}
|
|
1039
|
+
function readTokens(file) {
|
|
1040
|
+
if (!existsSync(file)) return [];
|
|
1041
|
+
try {
|
|
1042
|
+
return [...loadTokenFile(file).entries()].map(([token, id]) => ({
|
|
1043
|
+
token,
|
|
1044
|
+
peer: id.peer,
|
|
1045
|
+
role: id.role,
|
|
1046
|
+
...id.owner !== void 0 ? { owner: id.owner } : {}
|
|
1047
|
+
}));
|
|
1048
|
+
} catch (error) {
|
|
1049
|
+
try {
|
|
1050
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
1051
|
+
if (Array.isArray(parsed.tokens) && parsed.tokens.length === 0) return [];
|
|
1052
|
+
} catch {
|
|
1053
|
+
}
|
|
1054
|
+
throw error;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
function writeTokenFile(file, rows) {
|
|
1058
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
1059
|
+
const tmp = `${file}.tmp`;
|
|
1060
|
+
writeFileSync(tmp, JSON.stringify({ tokens: rows }, null, 2) + "\n", "utf8");
|
|
1061
|
+
renameSync(tmp, file);
|
|
1062
|
+
}
|
|
1063
|
+
function addToken(file, row) {
|
|
1064
|
+
const token = row.token ?? generateToken();
|
|
1065
|
+
if (!TOKEN_PATTERN.test(token)) {
|
|
1066
|
+
throw new Error(`invalid token provided (expect 16-128 chars of [A-Za-z0-9._:-])`);
|
|
1067
|
+
}
|
|
1068
|
+
const peers = new Set(readTokens(file).map((t) => t.peer));
|
|
1069
|
+
if (peers.has(row.peer)) {
|
|
1070
|
+
throw new Error(`peer '${row.peer}' already has a token \u2014 remove it first to rotate (see \`auth remove\`)`);
|
|
1071
|
+
}
|
|
1072
|
+
const owner = row.owner !== void 0 && row.owner !== "" ? row.owner : void 0;
|
|
1073
|
+
const identity = { token, peer: row.peer, role: row.role, ...owner !== void 0 ? { owner } : {} };
|
|
1074
|
+
const next = [...readTokens(file), identity];
|
|
1075
|
+
loadTokenFileVoid(next);
|
|
1076
|
+
writeTokenFile(file, next);
|
|
1077
|
+
return identity;
|
|
1078
|
+
}
|
|
1079
|
+
function removeToken(file, peer) {
|
|
1080
|
+
const tokens = readTokens(file);
|
|
1081
|
+
const kept = tokens.filter((t) => t.peer !== peer);
|
|
1082
|
+
if (kept.length === tokens.length) return false;
|
|
1083
|
+
writeTokenFile(file, kept);
|
|
1084
|
+
return true;
|
|
1085
|
+
}
|
|
1086
|
+
function loadTokenFileVoid(rows) {
|
|
1087
|
+
const table = /* @__PURE__ */ new Map();
|
|
1088
|
+
const peers = /* @__PURE__ */ new Set();
|
|
1089
|
+
for (const { token, peer, role, owner } of rows) {
|
|
1090
|
+
if (!TOKEN_PATTERN.test(token)) throw new Error(`invalid token for peer '${peer}'`);
|
|
1091
|
+
if (!PEER_ID_PATTERN.test(peer) || peer === "all") throw new Error(`invalid peer id '${peer}'`);
|
|
1092
|
+
if (role !== "agent" && role !== "manager") throw new Error(`invalid role '${role}'`);
|
|
1093
|
+
if (table.has(token)) throw new Error("duplicate token");
|
|
1094
|
+
if (peers.has(peer)) throw new Error(`duplicate peer '${peer}'`);
|
|
1095
|
+
table.set(token, { peer, role, ...owner !== void 0 && owner !== "" ? { owner } : {} });
|
|
1096
|
+
peers.add(peer);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
776
1100
|
// src/hub-tools.ts
|
|
777
1101
|
var DEFAULT_WAIT_MS = 3e4;
|
|
778
1102
|
function present(message) {
|
|
779
|
-
const { ref, ...rest } = message;
|
|
1103
|
+
const { ref, channel, ...rest } = message;
|
|
780
1104
|
return {
|
|
781
1105
|
...rest,
|
|
782
1106
|
...ref !== void 0 ? { ref } : {},
|
|
1107
|
+
...channel !== void 0 ? { channel } : {},
|
|
783
1108
|
content: decodeContent(message.kind, message.content)
|
|
784
1109
|
};
|
|
785
1110
|
}
|
|
@@ -799,7 +1124,8 @@ function autoRegisterPeer(hub, registry, sessionId, clientName, clientVersion) {
|
|
|
799
1124
|
const bound = registry.peerFor(sessionId);
|
|
800
1125
|
if (bound !== void 0) return bound;
|
|
801
1126
|
if (registry.isSuppressed(sessionId)) return void 0;
|
|
802
|
-
const
|
|
1127
|
+
const tokenPeer = registry.tokenFor(sessionId)?.peer;
|
|
1128
|
+
const peerId = tokenPeer === ANON_JOIN_PEER ? `join-${sanitizePeerId(clientName ?? "agent")}-${Math.random().toString(36).slice(2, 6)}` : tokenPeer ?? sanitizePeerId(clientName ?? "agent");
|
|
803
1129
|
if (!hub.has(peerId)) {
|
|
804
1130
|
hub.register(peerId, {
|
|
805
1131
|
...clientName !== void 0 ? { name: clientName } : {},
|
|
@@ -820,16 +1146,31 @@ function livePeersFor(registry) {
|
|
|
820
1146
|
}
|
|
821
1147
|
function rosterFor(hub, registry) {
|
|
822
1148
|
const status = hub.status(livePeersFor(registry));
|
|
1149
|
+
const peerIps = /* @__PURE__ */ new Map();
|
|
1150
|
+
const peerAnon = /* @__PURE__ */ new Map();
|
|
1151
|
+
for (const [session, peer] of registry.peerBindings) {
|
|
1152
|
+
const ip = registry.sessionIps.get(session);
|
|
1153
|
+
if (ip !== void 0) {
|
|
1154
|
+
const list = peerIps.get(peer) ?? [];
|
|
1155
|
+
if (!list.includes(ip)) list.push(ip);
|
|
1156
|
+
peerIps.set(peer, list);
|
|
1157
|
+
}
|
|
1158
|
+
const identity = registry.tokenFor(session);
|
|
1159
|
+
if (identity?.anonymous === true) peerAnon.set(peer, true);
|
|
1160
|
+
}
|
|
823
1161
|
return status.peers.map((peer) => {
|
|
824
1162
|
const profile = hub.profileOf(peer.id);
|
|
825
1163
|
const alias = profile?.alias;
|
|
826
1164
|
const clientName = profile?.clientName;
|
|
827
1165
|
const clientVersion = profile?.clientVersion;
|
|
1166
|
+
const ips = peerIps.get(peer.id);
|
|
828
1167
|
return {
|
|
829
1168
|
id: peer.id,
|
|
830
1169
|
...alias !== void 0 ? { alias } : {},
|
|
831
1170
|
...clientName !== void 0 ? { clientName } : {},
|
|
832
1171
|
...clientVersion !== void 0 ? { clientVersion } : {},
|
|
1172
|
+
...peerAnon.get(peer.id) === true ? { anonymous: true } : {},
|
|
1173
|
+
...ips !== void 0 && ips.length > 0 ? { clientIps: ips } : {},
|
|
833
1174
|
connected: peer.connected,
|
|
834
1175
|
lastSeenMs: peer.lastSeenMs
|
|
835
1176
|
};
|
|
@@ -864,6 +1205,7 @@ function hubTools(hub, registry, options) {
|
|
|
864
1205
|
from: message.from,
|
|
865
1206
|
to: message.to,
|
|
866
1207
|
kind: message.kind,
|
|
1208
|
+
...message.channel !== void 0 ? { channel: message.channel } : {},
|
|
867
1209
|
ts: message.ts
|
|
868
1210
|
});
|
|
869
1211
|
const presentWait = (result) => result.type === "timeout" ? result : { type: "message", message: present(result.message) };
|
|
@@ -878,7 +1220,8 @@ function hubTools(hub, registry, options) {
|
|
|
878
1220
|
}
|
|
879
1221
|
return herdr;
|
|
880
1222
|
};
|
|
881
|
-
const requireManager = (peer, action) => {
|
|
1223
|
+
const requireManager = (peer, action, sessionId) => {
|
|
1224
|
+
if (registry.tokenFor(sessionId)?.role === "manager") return;
|
|
882
1225
|
const managers = options.managerPeers ?? "all";
|
|
883
1226
|
if (managers !== "all" && !managers.has(peer)) {
|
|
884
1227
|
throw new Error(`peer '${peer}' is not a hub manager \u2014 ${action} requires manager rights`);
|
|
@@ -910,6 +1253,10 @@ function hubTools(hub, registry, options) {
|
|
|
910
1253
|
throw new Error(`reserved peer id: ${peerId} (it is the broadcast address)`);
|
|
911
1254
|
}
|
|
912
1255
|
const alias = args.alias === void 0 ? void 0 : sanitizeAlias(String(args.alias));
|
|
1256
|
+
const tokenPeer = registry.tokenFor(sessionId)?.peer;
|
|
1257
|
+
if (tokenPeer !== void 0 && peerId !== tokenPeer) {
|
|
1258
|
+
throw new Error(`your identity is bound to your access token ('${tokenPeer}') \u2014 ask a hub manager to rename you`);
|
|
1259
|
+
}
|
|
913
1260
|
if (hub.has(peerId) && registry.peerFor(sessionId) !== peerId) {
|
|
914
1261
|
throw new Error(`peer already registered by another connection: ${peerId}`);
|
|
915
1262
|
}
|
|
@@ -951,7 +1298,7 @@ function hubTools(hub, registry, options) {
|
|
|
951
1298
|
const caller = registry.peerFor(sessionId);
|
|
952
1299
|
if (caller === void 0) throw new Error("not registered \u2014 call bridge_register(peerId) first");
|
|
953
1300
|
const target = String(args.peer);
|
|
954
|
-
requireManager(caller, `kicking '${target}'
|
|
1301
|
+
requireManager(caller, `kicking '${target}'`, sessionId);
|
|
955
1302
|
if (!hub.has(target)) {
|
|
956
1303
|
return { ok: true, peerId: null, kicked: false };
|
|
957
1304
|
}
|
|
@@ -968,14 +1315,14 @@ function hubTools(hub, registry, options) {
|
|
|
968
1315
|
peerId: optStr("New routing id for `peer` \u2014 a true rename that moves all state (manager only). Target must be a registered peer."),
|
|
969
1316
|
peer: optStr("Whose name/id to change; default yourself. Changing another peer requires manager rights.")
|
|
970
1317
|
}),
|
|
971
|
-
handler: wrap(true, async (args, peer) => {
|
|
1318
|
+
handler: wrap(true, async (args, peer, sessionId) => {
|
|
972
1319
|
const hasAlias = args.alias !== void 0;
|
|
973
1320
|
const hasPeerId = args.peerId !== void 0;
|
|
974
1321
|
if (!hasAlias && !hasPeerId) {
|
|
975
1322
|
throw new Error("nothing to rename: pass alias and/or peerId");
|
|
976
1323
|
}
|
|
977
1324
|
let target = args.peer === void 0 ? peer : String(args.peer);
|
|
978
|
-
if (target !== peer) requireManager(peer, `renaming '${target}'
|
|
1325
|
+
if (target !== peer) requireManager(peer, `renaming '${target}'`, sessionId);
|
|
979
1326
|
let previousId;
|
|
980
1327
|
if (hasPeerId) {
|
|
981
1328
|
if (args.peer === void 0) {
|
|
@@ -1076,16 +1423,98 @@ function hubTools(hub, registry, options) {
|
|
|
1076
1423
|
name: "bridge_history",
|
|
1077
1424
|
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).',
|
|
1078
1425
|
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).") }),
|
|
1079
|
-
handler: wrap(true, async (args, peer) => {
|
|
1426
|
+
handler: wrap(true, async (args, peer, sessionId) => {
|
|
1080
1427
|
const limit = Math.min(args.limit === void 0 ? 20 : Number(args.limit), 1e3);
|
|
1081
1428
|
const target = args.peer === void 0 ? peer : String(args.peer);
|
|
1082
1429
|
if (target !== peer) {
|
|
1083
|
-
requireManager(peer, `reading ${target === BROADCAST ? "the full history" : `peer '${target}' history`}
|
|
1430
|
+
requireManager(peer, `reading ${target === BROADCAST ? "the full history" : `peer '${target}' history`}`, sessionId);
|
|
1431
|
+
}
|
|
1432
|
+
if (args.channel !== void 0) {
|
|
1433
|
+
const groupId = String(args.channel);
|
|
1434
|
+
const group = hub.groupOf(groupId);
|
|
1435
|
+
if (group === void 0) throw new Error(`unknown group: ${groupId}`);
|
|
1436
|
+
if (!group.members.includes(peer)) requireManager(peer, `reading group '${groupId}' history`, sessionId);
|
|
1437
|
+
return { messages: hub.historyChannel(groupId, limit).map(present) };
|
|
1084
1438
|
}
|
|
1085
1439
|
const messages = target === BROADCAST ? hub.historyAll(limit) : hub.history(target, limit);
|
|
1086
1440
|
return { messages: messages.map(present) };
|
|
1087
1441
|
})
|
|
1088
1442
|
},
|
|
1443
|
+
// ---- group tools (group channel) ---------------------------------------------
|
|
1444
|
+
// A group routes one message to several members; the message carries
|
|
1445
|
+
// channel = group id. Offline members are skipped (they read history) —
|
|
1446
|
+
// unlike direct sends, which fail on unknown recipients.
|
|
1447
|
+
{
|
|
1448
|
+
name: "bridge_group_create",
|
|
1449
|
+
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).",
|
|
1450
|
+
inputSchema: schema(
|
|
1451
|
+
{
|
|
1452
|
+
group: str("Unique group id: letters/digits/._:- , 1-64 chars."),
|
|
1453
|
+
name: optStr("Optional display name."),
|
|
1454
|
+
members: { type: "array", items: { type: "string" }, description: "Member peerIds (you are added automatically; duplicates ignored)." }
|
|
1455
|
+
},
|
|
1456
|
+
["group"]
|
|
1457
|
+
),
|
|
1458
|
+
handler: wrap(true, async (args, peer) => {
|
|
1459
|
+
const groupId = String(args.group);
|
|
1460
|
+
const members = Array.isArray(args.members) ? args.members.map(String) : [];
|
|
1461
|
+
const group = hub.createGroup(groupId, members, peer, args.name === void 0 ? void 0 : String(args.name));
|
|
1462
|
+
return { ok: true, group: { id: group.id, ...group.name !== void 0 ? { name: group.name } : {}, members: group.members, createdBy: group.createdBy } };
|
|
1463
|
+
})
|
|
1464
|
+
},
|
|
1465
|
+
{
|
|
1466
|
+
name: "bridge_group_send",
|
|
1467
|
+
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.",
|
|
1468
|
+
inputSchema: schema({ group: str("Group id from bridge_group_create / bridge_group_list."), message: str("The message text.") }, ["group", "message"]),
|
|
1469
|
+
handler: wrap(true, async (args, peer) => receipt(hub.sendToGroup(peer, String(args.group), String(args.message))))
|
|
1470
|
+
},
|
|
1471
|
+
{
|
|
1472
|
+
name: "bridge_group_list",
|
|
1473
|
+
description: "List every group channel with its members and creator.",
|
|
1474
|
+
inputSchema: schema({}),
|
|
1475
|
+
handler: wrap(true, async () => ({ groups: hub.allGroups() }))
|
|
1476
|
+
},
|
|
1477
|
+
{
|
|
1478
|
+
name: "bridge_group_delete",
|
|
1479
|
+
description: "Delete a group channel (history rows are kept). The creator or a hub manager may delete.",
|
|
1480
|
+
inputSchema: schema({ group: str("Group id to delete.") }, ["group"]),
|
|
1481
|
+
handler: wrap(true, async (args, peer, sessionId) => {
|
|
1482
|
+
const groupId = String(args.group);
|
|
1483
|
+
const group = hub.groupOf(groupId);
|
|
1484
|
+
if (group === void 0) throw new Error(`unknown group: ${groupId}`);
|
|
1485
|
+
if (group.createdBy !== peer) requireManager(peer, `deleting group '${groupId}'`, sessionId);
|
|
1486
|
+
hub.deleteGroup(groupId);
|
|
1487
|
+
return { ok: true, group: groupId };
|
|
1488
|
+
})
|
|
1489
|
+
},
|
|
1490
|
+
{
|
|
1491
|
+
name: "bridge_group_add_member",
|
|
1492
|
+
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.",
|
|
1493
|
+
inputSchema: schema({ group: str("Group id from bridge_group_create / bridge_group_list."), member: str("peerId of the new member.") }, ["group", "member"]),
|
|
1494
|
+
handler: wrap(true, async (args, peer, sessionId) => {
|
|
1495
|
+
const groupId = String(args.group);
|
|
1496
|
+
const member = String(args.member);
|
|
1497
|
+
const group = hub.groupOf(groupId);
|
|
1498
|
+
if (group === void 0) throw new Error(`unknown group: ${groupId}`);
|
|
1499
|
+
if (group.createdBy !== peer) requireManager(peer, `adding a member to group '${groupId}'`, sessionId);
|
|
1500
|
+
const updated = hub.addGroupMember(groupId, member);
|
|
1501
|
+
return { ok: true, group: { id: updated.id, members: updated.members } };
|
|
1502
|
+
})
|
|
1503
|
+
},
|
|
1504
|
+
{
|
|
1505
|
+
name: "bridge_group_remove_member",
|
|
1506
|
+
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.",
|
|
1507
|
+
inputSchema: schema({ group: str("Group id from bridge_group_create / bridge_group_list."), member: str("peerId to remove.") }, ["group", "member"]),
|
|
1508
|
+
handler: wrap(true, async (args, peer, sessionId) => {
|
|
1509
|
+
const groupId = String(args.group);
|
|
1510
|
+
const member = String(args.member);
|
|
1511
|
+
const group = hub.groupOf(groupId);
|
|
1512
|
+
if (group === void 0) throw new Error(`unknown group: ${groupId}`);
|
|
1513
|
+
if (group.createdBy !== peer) requireManager(peer, `removing a member from group '${groupId}'`, sessionId);
|
|
1514
|
+
const updated = hub.removeGroupMember(groupId, member);
|
|
1515
|
+
return { ok: true, group: { id: updated.id, members: updated.members } };
|
|
1516
|
+
})
|
|
1517
|
+
},
|
|
1089
1518
|
// ---- herdr control tools ------------------------------------------
|
|
1090
1519
|
// These type into real agent terminals via the herdr runtime. They are
|
|
1091
1520
|
// gated by checkControl and documented as physical input: unlike
|
|
@@ -1288,6 +1717,16 @@ function hubTools(hub, registry, options) {
|
|
|
1288
1717
|
|
|
1289
1718
|
// src/mcp-server.ts
|
|
1290
1719
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1720
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
1721
|
+
var ADMIN_PATH = "/admin";
|
|
1722
|
+
var ADMIN_API_PATH = "/admin/api";
|
|
1723
|
+
var adminPageCache;
|
|
1724
|
+
function adminPage() {
|
|
1725
|
+
if (adminPageCache === void 0) {
|
|
1726
|
+
adminPageCache = readFileSync2(new URL("../assets/admin.html", import.meta.url), "utf8");
|
|
1727
|
+
}
|
|
1728
|
+
return adminPageCache;
|
|
1729
|
+
}
|
|
1291
1730
|
var SUPPORTED_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
1292
1731
|
var LATEST_VERSION = SUPPORTED_VERSIONS[0];
|
|
1293
1732
|
var MAX_BODY_BYTES = 1048576;
|
|
@@ -1360,8 +1799,24 @@ var SessionRegistry = class {
|
|
|
1360
1799
|
isSuppressed(sessionId) {
|
|
1361
1800
|
return sessionId !== void 0 && this.suppressedAuto.has(sessionId);
|
|
1362
1801
|
}
|
|
1802
|
+
/** Session → client IP (remote mode: the admin roster shows where a peer
|
|
1803
|
+
* connects from, so the operator can tell Zhang San's kimi-code from Li Si's). */
|
|
1804
|
+
sessionIps = /* @__PURE__ */ new Map();
|
|
1363
1805
|
/** Sessions with a live SSE stream (server→client channel). */
|
|
1364
1806
|
liveStreams = /* @__PURE__ */ new Set();
|
|
1807
|
+
/** Session → token identity (remote auth mode only; see src/auth.ts).
|
|
1808
|
+
* The TOKEN, not the client-reported name, decides the peer id, so two
|
|
1809
|
+
* users running the same client get distinct peers. */
|
|
1810
|
+
tokenBindings = /* @__PURE__ */ new Map();
|
|
1811
|
+
/** Bind a session to its token identity (at initialize / SSE open). */
|
|
1812
|
+
bindToken(sessionId, identity) {
|
|
1813
|
+
this.tokenBindings.set(sessionId, identity);
|
|
1814
|
+
}
|
|
1815
|
+
/** Token identity bound to a session, if remote auth is in use. */
|
|
1816
|
+
tokenFor(sessionId) {
|
|
1817
|
+
if (sessionId === void 0) return void 0;
|
|
1818
|
+
return this.tokenBindings.get(sessionId);
|
|
1819
|
+
}
|
|
1365
1820
|
/** Mark a session's SSE stream open (server→client channel alive). */
|
|
1366
1821
|
markSseOpen(sessionId) {
|
|
1367
1822
|
this.liveStreams.add(sessionId);
|
|
@@ -1420,18 +1875,29 @@ var SessionRegistry = class {
|
|
|
1420
1875
|
var McpStreamableHttpServer = class {
|
|
1421
1876
|
constructor(tools, info, registry, log2 = () => {
|
|
1422
1877
|
}, onInitialize = () => {
|
|
1423
|
-
}) {
|
|
1878
|
+
}, authenticate, adminApi) {
|
|
1424
1879
|
this.tools = tools;
|
|
1425
1880
|
this.info = info;
|
|
1426
1881
|
this.registry = registry;
|
|
1427
1882
|
this.log = log2;
|
|
1428
1883
|
this.onInitialize = onInitialize;
|
|
1884
|
+
this.authenticate = authenticate;
|
|
1885
|
+
this.adminApi = adminApi;
|
|
1429
1886
|
}
|
|
1430
1887
|
sseStreams = /* @__PURE__ */ new Map();
|
|
1431
1888
|
/** Attach request handling for `path` (e.g. `/mcp`) to an http server. */
|
|
1432
1889
|
attach(server, path2) {
|
|
1433
1890
|
server.on("request", (req, res) => {
|
|
1434
1891
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1892
|
+
if (url.pathname === ADMIN_PATH && req.method === "GET") {
|
|
1893
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
|
|
1894
|
+
res.end(adminPage());
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1897
|
+
if (url.pathname.startsWith(ADMIN_API_PATH) && this.adminApi !== void 0) {
|
|
1898
|
+
void this.handleAdminApi(req, res, url);
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1435
1901
|
if (url.pathname !== path2) {
|
|
1436
1902
|
res.writeHead(404, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "not found" }));
|
|
1437
1903
|
return;
|
|
@@ -1481,7 +1947,14 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
|
|
|
1481
1947
|
}
|
|
1482
1948
|
}
|
|
1483
1949
|
handleGet(req, res) {
|
|
1950
|
+
if (this.authenticate !== void 0 && this.authenticate(req) === void 0) {
|
|
1951
|
+
res.writeHead(401, { ...corsHeaders(), "Content-Type": "application/json; charset=utf-8" });
|
|
1952
|
+
res.end(JSON.stringify({ error: "unauthorized: missing or invalid bearer token" }));
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1484
1955
|
const sessionId = this.registry.ensureSession(this.registry.sessionIdFor(req));
|
|
1956
|
+
const identity = this.authenticate?.(req);
|
|
1957
|
+
if (identity !== void 0) this.registry.bindToken(sessionId, identity);
|
|
1485
1958
|
res.writeHead(200, {
|
|
1486
1959
|
...corsHeaders(),
|
|
1487
1960
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
@@ -1497,7 +1970,79 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
|
|
|
1497
1970
|
this.registry.markSseClosed(sessionId);
|
|
1498
1971
|
});
|
|
1499
1972
|
}
|
|
1973
|
+
/** Reject a request without a valid token (remote auth mode). */
|
|
1974
|
+
unauthorized(res, id, why) {
|
|
1975
|
+
res.writeHead(401, { ...corsHeaders(), "Content-Type": "application/json; charset=utf-8" });
|
|
1976
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32001, message: `unauthorized: ${why}` } }));
|
|
1977
|
+
}
|
|
1978
|
+
/** Management API for the bundled admin page: issue/revoke tokens and list
|
|
1979
|
+
* them. Like everything else, each call is authenticated by `authenticate`
|
|
1980
|
+
* and must resolve to a MANAGER role — an agent token gets 403. */
|
|
1981
|
+
async handleAdminApi(req, res, url) {
|
|
1982
|
+
const respond = (status, body) => {
|
|
1983
|
+
res.writeHead(status, { ...corsHeaders(), "Content-Type": "application/json; charset=utf-8" });
|
|
1984
|
+
res.end(JSON.stringify(body));
|
|
1985
|
+
};
|
|
1986
|
+
const identity = this.authenticate?.(req);
|
|
1987
|
+
if (identity === void 0) {
|
|
1988
|
+
respond(401, { error: "unauthorized: manager token required" });
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
if (identity.role !== "manager") {
|
|
1992
|
+
respond(403, { error: "forbidden: this API needs a manager-role token" });
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
const api = this.adminApi;
|
|
1996
|
+
const seg = url.pathname.slice(ADMIN_API_PATH.length).replace(/^\/+|\/+$/g, "").split("/");
|
|
1997
|
+
if (req.method === "GET" && seg[0] === "tokens") {
|
|
1998
|
+
respond(200, { tokens: api.listTokens(url.searchParams.get("reveal") === "1") });
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
if (req.method === "POST" && seg[0] === "tokens" && seg[1] === "add") {
|
|
2002
|
+
let input;
|
|
2003
|
+
try {
|
|
2004
|
+
input = JSON.parse((await readBody(req)).toString("utf8"));
|
|
2005
|
+
} catch (error) {
|
|
2006
|
+
respond(400, { error: `bad json: ${error.message}` });
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
try {
|
|
2010
|
+
const created = api.addToken({
|
|
2011
|
+
peer: String(input.peer ?? ""),
|
|
2012
|
+
role: String(input.role ?? "agent"),
|
|
2013
|
+
owner: input.owner === void 0 ? void 0 : String(input.owner)
|
|
2014
|
+
});
|
|
2015
|
+
respond(200, { created });
|
|
2016
|
+
} catch (error) {
|
|
2017
|
+
respond(400, { error: error.message });
|
|
2018
|
+
}
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
if (req.method === "DELETE" && seg[0] === "tokens" && seg[1] === "remove" && seg[2] !== void 0) {
|
|
2022
|
+
respond(200, { removed: api.removeToken(decodeURIComponent(seg[2])) });
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
2025
|
+
respond(404, { error: "not found" });
|
|
2026
|
+
}
|
|
1500
2027
|
async handlePost(req, res) {
|
|
2028
|
+
let authIdentity;
|
|
2029
|
+
if (this.authenticate !== void 0) {
|
|
2030
|
+
authIdentity = this.authenticate(req);
|
|
2031
|
+
if (authIdentity === void 0) {
|
|
2032
|
+
this.unauthorized(res, null, "missing or invalid bearer token");
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
const existingSession = this.registry.sessionIdFor(req);
|
|
2036
|
+
const bound = existingSession === void 0 ? void 0 : this.registry.tokenFor(existingSession);
|
|
2037
|
+
const bothAnonymous = bound?.anonymous === true && authIdentity.anonymous === true;
|
|
2038
|
+
if (bound !== void 0 && !bothAnonymous && (bound.peer !== authIdentity.peer || bound.role !== authIdentity.role)) {
|
|
2039
|
+
this.unauthorized(res, null, "token does not match this session");
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
if (existingSession !== void 0 && bound === void 0 && !authIdentity.anonymous) {
|
|
2043
|
+
this.registry.bindToken(existingSession, authIdentity);
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
1501
2046
|
let body;
|
|
1502
2047
|
try {
|
|
1503
2048
|
body = await readBody(req);
|
|
@@ -1518,12 +2063,13 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
|
|
|
1518
2063
|
}
|
|
1519
2064
|
const id = message.id ?? null;
|
|
1520
2065
|
const sessionId = this.registry.sessionIdFor(req);
|
|
2066
|
+
if (sessionId !== void 0) this.registry.sessionIps.set(sessionId, clientIp(req));
|
|
1521
2067
|
if (id === null) {
|
|
1522
2068
|
res.writeHead(202, { ...corsHeaders(), "Content-Length": "0" }).end();
|
|
1523
2069
|
return;
|
|
1524
2070
|
}
|
|
1525
2071
|
try {
|
|
1526
|
-
const { result, extraHeaders } = await this.dispatch(message, sessionId);
|
|
2072
|
+
const { result, extraHeaders } = await this.dispatch(message, sessionId, authIdentity);
|
|
1527
2073
|
res.writeHead(200, {
|
|
1528
2074
|
...corsHeaders(),
|
|
1529
2075
|
"Content-Type": "application/json; charset=utf-8",
|
|
@@ -1536,7 +2082,7 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
|
|
|
1536
2082
|
this.jsonRpcError(res, id, code, error.message, void 0, messageProtocolVersion(req));
|
|
1537
2083
|
}
|
|
1538
2084
|
}
|
|
1539
|
-
async dispatch(message, sessionId) {
|
|
2085
|
+
async dispatch(message, sessionId, authIdentity) {
|
|
1540
2086
|
const method = message.method ?? "";
|
|
1541
2087
|
switch (method) {
|
|
1542
2088
|
case "initialize": {
|
|
@@ -1547,6 +2093,9 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
|
|
|
1547
2093
|
if (clientName !== void 0) {
|
|
1548
2094
|
this.registry.noteClient(newSessionId, clientName, clientVersion);
|
|
1549
2095
|
}
|
|
2096
|
+
if (authIdentity !== void 0) {
|
|
2097
|
+
this.registry.bindToken(newSessionId, authIdentity);
|
|
2098
|
+
}
|
|
1550
2099
|
this.onInitialize(newSessionId, clientName, clientVersion);
|
|
1551
2100
|
const requested = message.params?.protocolVersion;
|
|
1552
2101
|
const protocolVersion = typeof requested === "string" && SUPPORTED_VERSIONS.includes(requested) ? requested : LATEST_VERSION;
|
|
@@ -1610,6 +2159,14 @@ function rpcError(code, message) {
|
|
|
1610
2159
|
function messageProtocolVersion(req) {
|
|
1611
2160
|
return req.headers["mcp-protocol-version"] ?? LATEST_VERSION;
|
|
1612
2161
|
}
|
|
2162
|
+
function clientIp(req) {
|
|
2163
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
2164
|
+
const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded;
|
|
2165
|
+
if (typeof raw === "string" && raw.trim() !== "") {
|
|
2166
|
+
return raw.split(",")[0].trim();
|
|
2167
|
+
}
|
|
2168
|
+
return (req.socket.remoteAddress ?? "?").replace(/^::ffff:/, "");
|
|
2169
|
+
}
|
|
1613
2170
|
function corsHeaders() {
|
|
1614
2171
|
return {
|
|
1615
2172
|
"Access-Control-Allow-Origin": "*",
|
|
@@ -1635,9 +2192,149 @@ function readBody(req) {
|
|
|
1635
2192
|
});
|
|
1636
2193
|
}
|
|
1637
2194
|
|
|
2195
|
+
// src/persist.ts
|
|
2196
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2197
|
+
var KIND_NAMES = ["chat", "task", "notice", "ack"];
|
|
2198
|
+
function messageToRow(message) {
|
|
2199
|
+
return [message.id, message.from, message.to, message.kind, message.content, message.ref ?? null, message.channel ?? null, message.ts];
|
|
2200
|
+
}
|
|
2201
|
+
function rowToMessage(row) {
|
|
2202
|
+
const id = row.id;
|
|
2203
|
+
const from = row.from_peer;
|
|
2204
|
+
const to = row.to_peer;
|
|
2205
|
+
const kind = row.kind;
|
|
2206
|
+
const content = row.content;
|
|
2207
|
+
const ts = row.ts;
|
|
2208
|
+
if (typeof id !== "string" || typeof from !== "string" || typeof to !== "string" || typeof content !== "string" || typeof ts !== "number") return void 0;
|
|
2209
|
+
if (!KIND_NAMES.includes(kind)) return void 0;
|
|
2210
|
+
return {
|
|
2211
|
+
id,
|
|
2212
|
+
from,
|
|
2213
|
+
to,
|
|
2214
|
+
kind,
|
|
2215
|
+
content,
|
|
2216
|
+
...typeof row.ref === "string" && row.ref !== "" ? { ref: row.ref } : {},
|
|
2217
|
+
...typeof row.channel === "string" && row.channel !== "" ? { channel: row.channel } : {},
|
|
2218
|
+
ts
|
|
2219
|
+
};
|
|
2220
|
+
}
|
|
2221
|
+
var SQLiteStateStore = class {
|
|
2222
|
+
constructor(file, historyLimit, log2) {
|
|
2223
|
+
this.historyLimit = historyLimit;
|
|
2224
|
+
this.db = new DatabaseSync(file);
|
|
2225
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
2226
|
+
this.db.exec(`
|
|
2227
|
+
CREATE TABLE IF NOT EXISTS profiles (
|
|
2228
|
+
id TEXT PRIMARY KEY, alias TEXT, client_name TEXT, client_version TEXT, registered_at INTEGER
|
|
2229
|
+
);
|
|
2230
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
2231
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, from_peer TEXT NOT NULL,
|
|
2232
|
+
to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, ts INTEGER NOT NULL
|
|
2233
|
+
);
|
|
2234
|
+
CREATE TABLE IF NOT EXISTS mailboxes (
|
|
2235
|
+
peer TEXT NOT NULL, seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, from_peer TEXT NOT NULL,
|
|
2236
|
+
to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, ts INTEGER NOT NULL
|
|
2237
|
+
);
|
|
2238
|
+
CREATE TABLE IF NOT EXISTS groups (
|
|
2239
|
+
id TEXT PRIMARY KEY, name TEXT, members TEXT NOT NULL, created_by TEXT NOT NULL, created_at INTEGER NOT NULL
|
|
2240
|
+
);
|
|
2241
|
+
`);
|
|
2242
|
+
log2.info(`sqlite state open: ${file}`);
|
|
2243
|
+
}
|
|
2244
|
+
db;
|
|
2245
|
+
buffer = [];
|
|
2246
|
+
/** Buffer one history message (called per message via the hub hook). */
|
|
2247
|
+
appendMessage(message) {
|
|
2248
|
+
this.buffer.push(message);
|
|
2249
|
+
}
|
|
2250
|
+
/** Insert buffered messages and re-snapshot mailboxes/profiles/groups. */
|
|
2251
|
+
flush(snapshot) {
|
|
2252
|
+
const insert = this.db.prepare(
|
|
2253
|
+
"INSERT INTO messages (id, from_peer, to_peer, kind, content, ref, channel, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
|
2254
|
+
);
|
|
2255
|
+
for (const message of this.buffer.splice(0, this.buffer.length)) {
|
|
2256
|
+
insert.run(...messageToRow(message));
|
|
2257
|
+
}
|
|
2258
|
+
this.db.exec("DELETE FROM mailboxes");
|
|
2259
|
+
const insertMail = this.db.prepare(
|
|
2260
|
+
"INSERT INTO mailboxes (peer, id, from_peer, to_peer, kind, content, ref, channel, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
2261
|
+
);
|
|
2262
|
+
for (const [peer, queue] of Object.entries(snapshot.mailboxes)) {
|
|
2263
|
+
for (const message of queue) insertMail.run(peer, ...messageToRow(message));
|
|
2264
|
+
}
|
|
2265
|
+
this.db.exec("DELETE FROM profiles");
|
|
2266
|
+
const insertProfile = this.db.prepare(
|
|
2267
|
+
"INSERT INTO profiles (id, alias, client_name, client_version, registered_at) VALUES (?, ?, ?, ?, ?)"
|
|
2268
|
+
);
|
|
2269
|
+
for (const profile of snapshot.profiles) {
|
|
2270
|
+
insertProfile.run(profile.id, profile.alias ?? null, profile.clientName ?? null, profile.clientVersion ?? null, profile.registeredAt);
|
|
2271
|
+
}
|
|
2272
|
+
this.db.exec("DELETE FROM groups");
|
|
2273
|
+
const insertGroup = this.db.prepare(
|
|
2274
|
+
"INSERT INTO groups (id, name, members, created_by, created_at) VALUES (?, ?, ?, ?, ?)"
|
|
2275
|
+
);
|
|
2276
|
+
for (const group of snapshot.groups) {
|
|
2277
|
+
insertGroup.run(group.id, group.name ?? null, JSON.stringify(group.members), group.createdBy, group.createdAt);
|
|
2278
|
+
}
|
|
2279
|
+
const row = this.db.prepare("SELECT COUNT(*) AS n FROM messages").get();
|
|
2280
|
+
const total = Number(row.n);
|
|
2281
|
+
if (total > this.historyLimit * 2) {
|
|
2282
|
+
this.db.exec(`DELETE FROM messages WHERE seq <= (SELECT MAX(seq) FROM messages) - ${this.historyLimit}`);
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
/** Read everything persisted so far (boot restore). */
|
|
2286
|
+
load() {
|
|
2287
|
+
const profiles = [];
|
|
2288
|
+
for (const row of this.db.prepare("SELECT * FROM profiles").all()) {
|
|
2289
|
+
if (typeof row.id !== "string") continue;
|
|
2290
|
+
profiles.push({
|
|
2291
|
+
id: row.id,
|
|
2292
|
+
...typeof row.alias === "string" && row.alias !== "" ? { alias: row.alias } : {},
|
|
2293
|
+
...typeof row.client_name === "string" && row.client_name !== "" ? { clientName: row.client_name } : {},
|
|
2294
|
+
...typeof row.client_version === "string" && row.client_version !== "" ? { clientVersion: row.client_version } : {},
|
|
2295
|
+
registeredAt: typeof row.registered_at === "number" ? row.registered_at : Date.now()
|
|
2296
|
+
});
|
|
2297
|
+
}
|
|
2298
|
+
const messages = [];
|
|
2299
|
+
for (const row of this.db.prepare("SELECT * FROM messages ORDER BY seq").all()) {
|
|
2300
|
+
const message = rowToMessage(row);
|
|
2301
|
+
if (message !== void 0) messages.push(message);
|
|
2302
|
+
}
|
|
2303
|
+
const mailboxes = {};
|
|
2304
|
+
for (const row of this.db.prepare("SELECT * FROM mailboxes ORDER BY seq").all()) {
|
|
2305
|
+
if (typeof row.peer !== "string") continue;
|
|
2306
|
+
const message = rowToMessage(row);
|
|
2307
|
+
if (message === void 0) continue;
|
|
2308
|
+
(mailboxes[row.peer] ??= []).push(message);
|
|
2309
|
+
}
|
|
2310
|
+
const groups = [];
|
|
2311
|
+
for (const row of this.db.prepare("SELECT * FROM groups").all()) {
|
|
2312
|
+
if (typeof row.id !== "string" || typeof row.members !== "string") continue;
|
|
2313
|
+
try {
|
|
2314
|
+
const members = JSON.parse(row.members);
|
|
2315
|
+
if (!Array.isArray(members) || members.length === 0) continue;
|
|
2316
|
+
groups.push({
|
|
2317
|
+
id: row.id,
|
|
2318
|
+
members: members.filter((m) => typeof m === "string"),
|
|
2319
|
+
createdBy: typeof row.created_by === "string" ? row.created_by : "",
|
|
2320
|
+
createdAt: typeof row.created_at === "number" ? row.created_at : Date.now(),
|
|
2321
|
+
...typeof row.name === "string" && row.name !== "" ? { name: row.name } : {}
|
|
2322
|
+
});
|
|
2323
|
+
} catch {
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
return { profiles, messages, mailboxes, groups };
|
|
2327
|
+
}
|
|
2328
|
+
/** Final flush + close (hub shutdown). */
|
|
2329
|
+
close(snapshot) {
|
|
2330
|
+
this.flush(snapshot);
|
|
2331
|
+
this.db.close();
|
|
2332
|
+
}
|
|
2333
|
+
};
|
|
2334
|
+
|
|
1638
2335
|
// src/index.ts
|
|
1639
2336
|
var SERVER_NAME = "agent-comm-hub";
|
|
1640
|
-
var SERVER_VERSION = "0.
|
|
2337
|
+
var SERVER_VERSION = "0.7.1";
|
|
1641
2338
|
var DEFAULT_HOST = "127.0.0.1";
|
|
1642
2339
|
var DEFAULT_PORT = 18764;
|
|
1643
2340
|
var DEFAULT_PATH = "/mcp";
|
|
@@ -1659,16 +2356,39 @@ var DEFAULT_CONFIG = {
|
|
|
1659
2356
|
// are NOT managers — they keep chatting, a GUI manages.
|
|
1660
2357
|
managerPeers: ["agent-hub-cli"]
|
|
1661
2358
|
};
|
|
2359
|
+
function authTokensAdmin(file, reloadAuth) {
|
|
2360
|
+
if (file === void 0) return void 0;
|
|
2361
|
+
return {
|
|
2362
|
+
listTokens: (reveal) => readTokens(file).map((r) => ({
|
|
2363
|
+
token: reveal ? r.token : `${r.token.slice(0, 6)}\u2026${r.token.slice(-4)}`,
|
|
2364
|
+
peer: r.peer,
|
|
2365
|
+
role: r.role,
|
|
2366
|
+
...r.owner !== void 0 ? { owner: r.owner } : {}
|
|
2367
|
+
})),
|
|
2368
|
+
addToken: (input) => {
|
|
2369
|
+
const row = addToken(file, { peer: input.peer, role: input.role, owner: input.owner });
|
|
2370
|
+
reloadAuth?.();
|
|
2371
|
+
return { token: row.token, peer: row.peer, role: row.role, ...row.owner !== void 0 ? { owner: row.owner } : {} };
|
|
2372
|
+
},
|
|
2373
|
+
removeToken: (peer) => {
|
|
2374
|
+
const ok = removeToken(file, peer);
|
|
2375
|
+
if (ok) reloadAuth?.();
|
|
2376
|
+
return ok;
|
|
2377
|
+
},
|
|
2378
|
+
reloadAuth: () => reloadAuth?.()
|
|
2379
|
+
};
|
|
2380
|
+
}
|
|
1662
2381
|
function writeRosterFile(file, profiles) {
|
|
1663
|
-
|
|
2382
|
+
mkdirSync2(dirname2(file), { recursive: true });
|
|
1664
2383
|
const tmp = `${file}.tmp`;
|
|
1665
|
-
|
|
1666
|
-
|
|
2384
|
+
writeFileSync2(tmp, JSON.stringify({ version: 1, savedAt: Date.now(), profiles }), "utf8");
|
|
2385
|
+
renameSync2(tmp, file);
|
|
1667
2386
|
}
|
|
1668
2387
|
function startHub(config = {}, log2 = console) {
|
|
1669
2388
|
const overrides = Object.fromEntries(Object.entries(config).filter(([, value]) => value !== void 0));
|
|
1670
2389
|
const resolved = { ...DEFAULT_CONFIG, ...overrides };
|
|
1671
2390
|
let mcp;
|
|
2391
|
+
let dbStore;
|
|
1672
2392
|
const pushRoster = () => {
|
|
1673
2393
|
mcp.notifyAll("notifications/message", {
|
|
1674
2394
|
level: "info",
|
|
@@ -1683,24 +2403,51 @@ function startHub(config = {}, log2 = console) {
|
|
|
1683
2403
|
data: { event: "message", message: present(message) }
|
|
1684
2404
|
});
|
|
1685
2405
|
};
|
|
2406
|
+
const managerSet = resolved.managerPeers === "all" ? void 0 : new Set(resolved.managerPeers);
|
|
2407
|
+
const pushRelay = (message) => {
|
|
2408
|
+
const managers = [];
|
|
2409
|
+
for (const sessionId of registry.liveSessions()) {
|
|
2410
|
+
const peer = registry.peerFor(sessionId);
|
|
2411
|
+
if (peer === void 0) continue;
|
|
2412
|
+
if (registry.tokenFor(sessionId)?.role === "manager" || managerSet === void 0 || managerSet.has(peer)) {
|
|
2413
|
+
managers.push(sessionId);
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
if (managers.length > 0) {
|
|
2417
|
+
mcp.notify(managers, "notifications/message", {
|
|
2418
|
+
level: "info",
|
|
2419
|
+
logger: "bridge",
|
|
2420
|
+
data: { event: "message", message: present(message) }
|
|
2421
|
+
});
|
|
2422
|
+
}
|
|
2423
|
+
};
|
|
1686
2424
|
let saveTimer;
|
|
1687
2425
|
let rosterDirty = false;
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
rosterDirty
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
2426
|
+
let dbDirty = false;
|
|
2427
|
+
const flushSaves = () => {
|
|
2428
|
+
if (rosterDirty && resolved.stateFile !== void 0) {
|
|
2429
|
+
rosterDirty = false;
|
|
2430
|
+
try {
|
|
2431
|
+
writeRosterFile(resolved.stateFile, hub.exportProfiles());
|
|
2432
|
+
} catch (error) {
|
|
2433
|
+
log2.warn(`roster save failed: ${error.message}`);
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
if (dbDirty && dbStore !== void 0) {
|
|
2437
|
+
dbDirty = false;
|
|
2438
|
+
try {
|
|
2439
|
+
dbStore.flush({ mailboxes: hub.exportMailboxes(), profiles: hub.exportProfiles(), groups: hub.exportGroups() });
|
|
2440
|
+
} catch (error) {
|
|
2441
|
+
log2.warn(`sqlite flush failed: ${error.message}`);
|
|
2442
|
+
}
|
|
1695
2443
|
}
|
|
1696
2444
|
};
|
|
1697
|
-
const
|
|
1698
|
-
if (resolved.stateFile === void 0) return;
|
|
1699
|
-
rosterDirty = true;
|
|
2445
|
+
const scheduleSave = () => {
|
|
2446
|
+
if (resolved.stateFile === void 0 && dbStore === void 0) return;
|
|
1700
2447
|
if (saveTimer !== void 0) return;
|
|
1701
2448
|
saveTimer = setTimeout(() => {
|
|
1702
2449
|
saveTimer = void 0;
|
|
1703
|
-
|
|
2450
|
+
flushSaves();
|
|
1704
2451
|
}, 500);
|
|
1705
2452
|
saveTimer.unref?.();
|
|
1706
2453
|
};
|
|
@@ -1711,6 +2458,30 @@ function startHub(config = {}, log2 = console) {
|
|
|
1711
2458
|
connectedWindowMs: resolved.connectedWindowMs,
|
|
1712
2459
|
peerIdleTimeoutMs: resolved.peerIdleTimeoutMs,
|
|
1713
2460
|
onPeerGc: (peerId) => registry.unbindPeerId(peerId),
|
|
2461
|
+
// Remote persistence: buffer every history append; re-snapshot when a
|
|
2462
|
+
// mailbox is consumed so drained messages cannot resurrect on restart.
|
|
2463
|
+
// Also relay every message to manager sessions (the relay stream view) —
|
|
2464
|
+
// best-effort; a notification failure must never break routing.
|
|
2465
|
+
onMessage: (message) => {
|
|
2466
|
+
if (dbStore !== void 0) {
|
|
2467
|
+
dbStore.appendMessage(message);
|
|
2468
|
+
dbDirty = true;
|
|
2469
|
+
}
|
|
2470
|
+
try {
|
|
2471
|
+
pushRelay(message);
|
|
2472
|
+
} catch (error) {
|
|
2473
|
+
log2.warn(`relay notification failed: ${error.message}`);
|
|
2474
|
+
}
|
|
2475
|
+
},
|
|
2476
|
+
onMailboxesChanged: () => {
|
|
2477
|
+
if (dbStore === void 0) return;
|
|
2478
|
+
dbDirty = true;
|
|
2479
|
+
},
|
|
2480
|
+
onGroupsChanged: () => {
|
|
2481
|
+
if (dbStore === void 0) return;
|
|
2482
|
+
dbDirty = true;
|
|
2483
|
+
scheduleSave();
|
|
2484
|
+
},
|
|
1714
2485
|
// The idle GC must never evict a peer whose session has a live SSE channel.
|
|
1715
2486
|
isPeerLive: (peerId) => livePeersFor(registry).has(peerId),
|
|
1716
2487
|
// SSE event push: roster changes and queued-mail hints. Best-effort — a
|
|
@@ -1721,7 +2492,9 @@ function startHub(config = {}, log2 = console) {
|
|
|
1721
2492
|
} catch (error) {
|
|
1722
2493
|
log2.warn(`peers_changed notification failed: ${error.message}`);
|
|
1723
2494
|
}
|
|
1724
|
-
|
|
2495
|
+
rosterDirty = true;
|
|
2496
|
+
dbDirty = true;
|
|
2497
|
+
scheduleSave();
|
|
1725
2498
|
},
|
|
1726
2499
|
onQueued: (message, target) => {
|
|
1727
2500
|
try {
|
|
@@ -1732,9 +2505,39 @@ function startHub(config = {}, log2 = console) {
|
|
|
1732
2505
|
}
|
|
1733
2506
|
});
|
|
1734
2507
|
const registry = new SessionRegistry();
|
|
1735
|
-
if (resolved.
|
|
2508
|
+
if (resolved.db !== void 0) {
|
|
2509
|
+
try {
|
|
2510
|
+
dbStore = new SQLiteStateStore(resolved.db, resolved.historyLimit, log2);
|
|
2511
|
+
const state = dbStore.load();
|
|
2512
|
+
const importedProfiles = hub.importProfiles(state.profiles);
|
|
2513
|
+
if (state.profiles.length === 0 && resolved.stateFile !== void 0) {
|
|
2514
|
+
try {
|
|
2515
|
+
const legacy = JSON.parse(readFileSync3(resolved.stateFile, "utf8"));
|
|
2516
|
+
const migrated = hub.importProfiles(legacy.profiles);
|
|
2517
|
+
if (migrated > 0) log2.info(`roster migrated: ${migrated} profile(s) from ${resolved.stateFile} into ${resolved.db}`);
|
|
2518
|
+
} catch (error) {
|
|
2519
|
+
if (error.code !== "ENOENT") {
|
|
2520
|
+
log2.warn(`legacy roster import failed: ${error.message}`);
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
} else if (importedProfiles > 0) {
|
|
2524
|
+
log2.info(`roster restored: ${importedProfiles} profile(s) from ${resolved.db}`);
|
|
2525
|
+
}
|
|
2526
|
+
const restoredMessages = hub.importHistory(state.messages);
|
|
2527
|
+
const restoredQueued = hub.importMailboxes(state.mailboxes);
|
|
2528
|
+
const restoredGroups = hub.importGroups(state.groups);
|
|
2529
|
+
if (restoredMessages + restoredQueued + restoredGroups > 0) {
|
|
2530
|
+
log2.info(`state restored from ${resolved.db}: ${restoredMessages} message(s), ${restoredQueued} queued, ${restoredGroups} group(s)`);
|
|
2531
|
+
dbDirty = true;
|
|
2532
|
+
}
|
|
2533
|
+
} catch (error) {
|
|
2534
|
+
dbStore = void 0;
|
|
2535
|
+
log2.warn(`sqlite state unavailable (${error.message}); running memory-only`);
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
if (dbStore === void 0 && resolved.stateFile !== void 0) {
|
|
1736
2539
|
try {
|
|
1737
|
-
const parsed = JSON.parse(
|
|
2540
|
+
const parsed = JSON.parse(readFileSync3(resolved.stateFile, "utf8"));
|
|
1738
2541
|
const imported = hub.importProfiles(parsed.profiles);
|
|
1739
2542
|
if (imported > 0) log2.info(`roster restored: ${imported} profile(s) from ${resolved.stateFile}`);
|
|
1740
2543
|
} catch (error) {
|
|
@@ -1750,6 +2553,25 @@ function startHub(config = {}, log2 = console) {
|
|
|
1750
2553
|
socketPath: resolved.herdrSocketPath,
|
|
1751
2554
|
sendRequest: resolved.herdrSendRequest
|
|
1752
2555
|
});
|
|
2556
|
+
let auth;
|
|
2557
|
+
let authReload;
|
|
2558
|
+
if (resolved.authTokens !== void 0) {
|
|
2559
|
+
const table = watchTokenFile(resolved.authTokens, {
|
|
2560
|
+
info: (message) => log2.info(message),
|
|
2561
|
+
warn: (message) => log2.warn(message)
|
|
2562
|
+
});
|
|
2563
|
+
authReload = table.reload;
|
|
2564
|
+
auth = (req) => {
|
|
2565
|
+
const presented = bearerFromHeaders(req.headers);
|
|
2566
|
+
if (presented !== void 0) return table.authenticate(presented);
|
|
2567
|
+
if (resolved.allowJoin === true) {
|
|
2568
|
+
return { peer: ANON_JOIN_PEER, role: "agent", anonymous: true };
|
|
2569
|
+
}
|
|
2570
|
+
return void 0;
|
|
2571
|
+
};
|
|
2572
|
+
} else if (resolved.allowJoin === true) {
|
|
2573
|
+
auth = (req) => ({ peer: ANON_JOIN_PEER, role: "agent", anonymous: true });
|
|
2574
|
+
}
|
|
1753
2575
|
mcp = new McpStreamableHttpServer(
|
|
1754
2576
|
hubTools(hub, registry, {
|
|
1755
2577
|
defaultWaitMs: resolved.defaultWaitMs,
|
|
@@ -1763,12 +2585,26 @@ function startHub(config = {}, log2 = console) {
|
|
|
1763
2585
|
(message) => log2.warn(message),
|
|
1764
2586
|
(sessionId, clientName, clientVersion) => {
|
|
1765
2587
|
try {
|
|
2588
|
+
const identity = registry.tokenFor(sessionId);
|
|
2589
|
+
if (identity?.anonymous === true && identity.peer === ANON_JOIN_PEER) {
|
|
2590
|
+
let candidate = `join-${sanitizePeerId(clientName ?? "agent")}-${randomBytes2(2).toString("hex")}`;
|
|
2591
|
+
while (hub.has(candidate)) {
|
|
2592
|
+
candidate = `join-${sanitizePeerId(clientName ?? "agent")}-${randomBytes2(2).toString("hex")}`;
|
|
2593
|
+
}
|
|
2594
|
+
registry.bindToken(sessionId, { ...identity, peer: candidate });
|
|
2595
|
+
}
|
|
1766
2596
|
const peer = autoRegisterPeer(hub, registry, sessionId, clientName, clientVersion);
|
|
1767
|
-
if (peer !== void 0)
|
|
2597
|
+
if (peer !== void 0) {
|
|
2598
|
+
const owner = registry.tokenFor(sessionId)?.owner;
|
|
2599
|
+
const anon = registry.tokenFor(sessionId)?.anonymous === true ? " (anonymous join)" : "";
|
|
2600
|
+
log2.info(`peer joined: ${peer}${owner !== void 0 ? ` (owner: ${owner})` : ""}${anon}`);
|
|
2601
|
+
}
|
|
1768
2602
|
} catch (error) {
|
|
1769
2603
|
log2.warn(`auto-register failed: ${error.message}`);
|
|
1770
2604
|
}
|
|
1771
|
-
}
|
|
2605
|
+
},
|
|
2606
|
+
auth,
|
|
2607
|
+
authTokensAdmin(resolved.authTokens, authReload)
|
|
1772
2608
|
);
|
|
1773
2609
|
const server = createServer();
|
|
1774
2610
|
mcp.attach(server, resolved.path);
|
|
@@ -1788,7 +2624,15 @@ function startHub(config = {}, log2 = console) {
|
|
|
1788
2624
|
clearTimeout(saveTimer);
|
|
1789
2625
|
saveTimer = void 0;
|
|
1790
2626
|
}
|
|
1791
|
-
|
|
2627
|
+
flushSaves();
|
|
2628
|
+
if (dbStore !== void 0) {
|
|
2629
|
+
try {
|
|
2630
|
+
dbStore.close({ mailboxes: hub.exportMailboxes(), profiles: hub.exportProfiles(), groups: hub.exportGroups() });
|
|
2631
|
+
} catch (error) {
|
|
2632
|
+
log2.warn(`sqlite close failed: ${error.message}`);
|
|
2633
|
+
}
|
|
2634
|
+
dbStore = void 0;
|
|
2635
|
+
}
|
|
1792
2636
|
server.closeAllConnections?.();
|
|
1793
2637
|
server.close();
|
|
1794
2638
|
}
|
|
@@ -1797,19 +2641,19 @@ function startHub(config = {}, log2 = console) {
|
|
|
1797
2641
|
|
|
1798
2642
|
// src/setup.ts
|
|
1799
2643
|
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1800
|
-
import { existsSync as
|
|
2644
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
1801
2645
|
import { homedir as homedir2 } from "node:os";
|
|
1802
|
-
import { dirname as
|
|
2646
|
+
import { dirname as dirname4, join as join2 } from "node:path";
|
|
1803
2647
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1804
2648
|
|
|
1805
2649
|
// src/discover.ts
|
|
1806
2650
|
import { execFileSync } from "node:child_process";
|
|
1807
|
-
import { existsSync, readdirSync, accessSync, readFileSync as
|
|
2651
|
+
import { existsSync as existsSync2, readdirSync, accessSync, readFileSync as readFileSync4, constants as fsConstants } from "node:fs";
|
|
1808
2652
|
import { homedir } from "node:os";
|
|
1809
|
-
import { dirname as
|
|
2653
|
+
import { dirname as dirname3, join, sep } from "node:path";
|
|
1810
2654
|
import { fileURLToPath } from "node:url";
|
|
1811
2655
|
function registryFile() {
|
|
1812
|
-
return join(
|
|
2656
|
+
return join(dirname3(fileURLToPath(import.meta.url)), "..", "agents", "registry.json");
|
|
1813
2657
|
}
|
|
1814
2658
|
function expandHome(file, home) {
|
|
1815
2659
|
return file.startsWith("~/") ? join(home, file.slice(2)) : file;
|
|
@@ -1821,7 +2665,7 @@ function expandConfigFile(file, home) {
|
|
|
1821
2665
|
const prefix = expanded.slice(0, star);
|
|
1822
2666
|
const suffix = expanded.slice(star + 1);
|
|
1823
2667
|
const base = prefix.slice(0, prefix.lastIndexOf(sep));
|
|
1824
|
-
if (!
|
|
2668
|
+
if (!existsSync2(base)) return [];
|
|
1825
2669
|
return readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(base, entry.name, suffix));
|
|
1826
2670
|
}
|
|
1827
2671
|
function validateRegistry(registry) {
|
|
@@ -1865,7 +2709,7 @@ function validateRegistry(registry) {
|
|
|
1865
2709
|
}
|
|
1866
2710
|
}
|
|
1867
2711
|
function loadRegistry(file = registryFile()) {
|
|
1868
|
-
const registry = JSON.parse(
|
|
2712
|
+
const registry = JSON.parse(readFileSync4(file, "utf8"));
|
|
1869
2713
|
validateRegistry(registry);
|
|
1870
2714
|
return registry;
|
|
1871
2715
|
}
|
|
@@ -1916,7 +2760,7 @@ function npmFallbackRoots(home, platform) {
|
|
|
1916
2760
|
return [
|
|
1917
2761
|
"/usr/local/lib/node_modules",
|
|
1918
2762
|
"/usr/lib/node_modules",
|
|
1919
|
-
...
|
|
2763
|
+
...existsSync2(join(home, ".nvm", "versions", "node")) ? readdirSync(join(home, ".nvm", "versions", "node")).map((dir) => join(home, ".nvm", "versions", "node", dir, "lib", "node_modules")) : []
|
|
1920
2764
|
];
|
|
1921
2765
|
}
|
|
1922
2766
|
function discover(registry, options = {}) {
|
|
@@ -1927,15 +2771,15 @@ function discover(registry, options = {}) {
|
|
|
1927
2771
|
let npmNames = null;
|
|
1928
2772
|
if (!options.noNpm) {
|
|
1929
2773
|
if (options.npmRoot !== void 0 && options.npmRoot !== null) {
|
|
1930
|
-
npmNames =
|
|
2774
|
+
npmNames = existsSync2(options.npmRoot) ? readNpmNames(options.npmRoot) : [];
|
|
1931
2775
|
} else if (options.npmRoot !== null) {
|
|
1932
2776
|
const root = npmGlobalRoot(platform);
|
|
1933
|
-
if (root &&
|
|
2777
|
+
if (root && existsSync2(root)) {
|
|
1934
2778
|
npmNames = readNpmNames(root);
|
|
1935
2779
|
} else {
|
|
1936
2780
|
npmNames = [];
|
|
1937
2781
|
for (const fallback of npmFallbackRoots(home, platform)) {
|
|
1938
|
-
if (
|
|
2782
|
+
if (existsSync2(fallback)) {
|
|
1939
2783
|
npmNames = readNpmNames(fallback);
|
|
1940
2784
|
break;
|
|
1941
2785
|
}
|
|
@@ -1948,7 +2792,7 @@ function discover(registry, options = {}) {
|
|
|
1948
2792
|
const configFiles = [];
|
|
1949
2793
|
for (const config of agent.configs) {
|
|
1950
2794
|
for (const file of expandConfigFile(config.file, home)) {
|
|
1951
|
-
if (
|
|
2795
|
+
if (existsSync2(file)) {
|
|
1952
2796
|
configFiles.push(file);
|
|
1953
2797
|
if (source === "none") source = "config";
|
|
1954
2798
|
}
|
|
@@ -1979,7 +2823,7 @@ function runDiscover(options = {}) {
|
|
|
1979
2823
|
var DEFAULT_URL = "http://127.0.0.1:18764/mcp";
|
|
1980
2824
|
var DEFAULT_SERVER = "agent-hub";
|
|
1981
2825
|
function defaultSkillSrc() {
|
|
1982
|
-
return join2(
|
|
2826
|
+
return join2(dirname4(fileURLToPath2(import.meta.url)), "..", "agents", "SKILL.md");
|
|
1983
2827
|
}
|
|
1984
2828
|
function stamp() {
|
|
1985
2829
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -1987,7 +2831,7 @@ function stamp() {
|
|
|
1987
2831
|
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
1988
2832
|
}
|
|
1989
2833
|
async function readJson(file) {
|
|
1990
|
-
if (!
|
|
2834
|
+
if (!existsSync3(file)) return null;
|
|
1991
2835
|
try {
|
|
1992
2836
|
return JSON.parse(await readFile(file, "utf8"));
|
|
1993
2837
|
} catch (error) {
|
|
@@ -1995,7 +2839,7 @@ async function readJson(file) {
|
|
|
1995
2839
|
}
|
|
1996
2840
|
}
|
|
1997
2841
|
async function writeJsonNoBom(file, doc) {
|
|
1998
|
-
await mkdir(
|
|
2842
|
+
await mkdir(dirname4(file), { recursive: true });
|
|
1999
2843
|
await writeFile(file, JSON.stringify(doc, null, 2) + "\n", "utf8");
|
|
2000
2844
|
}
|
|
2001
2845
|
async function backup(file) {
|
|
@@ -2016,7 +2860,7 @@ function resolveSection(doc, section) {
|
|
|
2016
2860
|
return node;
|
|
2017
2861
|
}
|
|
2018
2862
|
async function mergeJsonServer(file, section, entry, opts) {
|
|
2019
|
-
if (!
|
|
2863
|
+
if (!existsSync3(file)) return "skipped";
|
|
2020
2864
|
const doc = await readJson(file);
|
|
2021
2865
|
if (doc === null) return "skipped";
|
|
2022
2866
|
const servers = resolveSection(doc, section);
|
|
@@ -2038,7 +2882,7 @@ async function mergeJsonServer(file, section, entry, opts) {
|
|
|
2038
2882
|
return "changed";
|
|
2039
2883
|
}
|
|
2040
2884
|
async function mergeTomlSection(file, opts) {
|
|
2041
|
-
if (!
|
|
2885
|
+
if (!existsSync3(file)) return "skipped";
|
|
2042
2886
|
const text = await readFile(file, "utf8");
|
|
2043
2887
|
const marker = `[mcp_servers.${opts.serverName}]`;
|
|
2044
2888
|
const markerRe = new RegExp(`^\\[mcp_servers\\.${escapeRegExp(opts.serverName)}\\]`, "m");
|
|
@@ -2077,7 +2921,7 @@ ${DSH_PATCH_MARKER} (installed by \`agent-comm-hub setup\`; undo with \`setup --
|
|
|
2077
2921
|
`;
|
|
2078
2922
|
}
|
|
2079
2923
|
async function mergeDshPatch(file, opts) {
|
|
2080
|
-
if (!
|
|
2924
|
+
if (!existsSync3(file)) return "skipped";
|
|
2081
2925
|
const text = await readFile(file, "utf8");
|
|
2082
2926
|
const lines = text.split("\n");
|
|
2083
2927
|
const hubEntryRanges = () => {
|
|
@@ -2119,14 +2963,14 @@ async function mergeDshPatch(file, opts) {
|
|
|
2119
2963
|
}
|
|
2120
2964
|
async function syncSkill(skillDir, skillSrc, remove, log2) {
|
|
2121
2965
|
if (remove) {
|
|
2122
|
-
if (
|
|
2123
|
-
await mkdir(
|
|
2966
|
+
if (existsSync3(skillDir)) {
|
|
2967
|
+
await mkdir(dirname4(skillDir), { recursive: true });
|
|
2124
2968
|
await rmRecursive(skillDir);
|
|
2125
2969
|
log2(` skill removed: ${skillDir}`);
|
|
2126
2970
|
}
|
|
2127
2971
|
return;
|
|
2128
2972
|
}
|
|
2129
|
-
if (!
|
|
2973
|
+
if (!existsSync3(skillSrc)) {
|
|
2130
2974
|
log2(` SKILL.md source missing: ${skillSrc} (skipped)`);
|
|
2131
2975
|
return;
|
|
2132
2976
|
}
|
|
@@ -2212,9 +3056,9 @@ async function runSetup(options = {}) {
|
|
|
2212
3056
|
// src/ops.ts
|
|
2213
3057
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2214
3058
|
import { request } from "node:http";
|
|
2215
|
-
import { mkdirSync as
|
|
3059
|
+
import { mkdirSync as mkdirSync3, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
2216
3060
|
import { homedir as homedir3 } from "node:os";
|
|
2217
|
-
import { dirname as
|
|
3061
|
+
import { dirname as dirname5, join as join3 } from "node:path";
|
|
2218
3062
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
2219
3063
|
async function runStatus(options = {}) {
|
|
2220
3064
|
const host = options.host ?? "127.0.0.1";
|
|
@@ -2267,8 +3111,8 @@ async function runStatus(options = {}) {
|
|
|
2267
3111
|
function runUpdate() {
|
|
2268
3112
|
const messages = [];
|
|
2269
3113
|
try {
|
|
2270
|
-
const pkgFile = join3(
|
|
2271
|
-
const before = JSON.parse(
|
|
3114
|
+
const pkgFile = join3(dirname5(fileURLToPath3(import.meta.url)), "..", "package.json");
|
|
3115
|
+
const before = JSON.parse(readFileSync5(pkgFile, "utf8")).version ?? "?";
|
|
2272
3116
|
messages.push(`current version: ${before}`);
|
|
2273
3117
|
const script = [
|
|
2274
3118
|
"import { execFileSync } from 'node:child_process'",
|
|
@@ -2324,8 +3168,8 @@ function runService(options) {
|
|
|
2324
3168
|
messages.push(`[dry-run] write ${vbs}`);
|
|
2325
3169
|
messages.push(`[dry-run] reg add "${runKey}" /v ${valueName} /t REG_SZ /d "wscript.exe \\"${vbs}\\"" /f`);
|
|
2326
3170
|
} else {
|
|
2327
|
-
|
|
2328
|
-
|
|
3171
|
+
mkdirSync3(launcherDir, { recursive: true });
|
|
3172
|
+
writeFileSync3(vbs, vbsContent);
|
|
2329
3173
|
execFileSync2("reg", ["add", runKey, "/v", valueName, "/t", "REG_SZ", "/d", `wscript.exe "${vbs}"`, "/f"], { encoding: "utf8", windowsHide: true });
|
|
2330
3174
|
messages.push(`auto-start registered: HKCU Run '${valueName}' -> hidden wscript launcher "${vbs}"`);
|
|
2331
3175
|
messages.push(`start it now with: wscript.exe "${vbs}"`);
|
|
@@ -2364,8 +3208,8 @@ WantedBy=default.target
|
|
|
2364
3208
|
messages.push(`[dry-run] would write ${unitFile}`);
|
|
2365
3209
|
messages.push(`[dry-run] systemctl --user daemon-reload && systemctl --user enable --now agent-comm-hub`);
|
|
2366
3210
|
} else {
|
|
2367
|
-
|
|
2368
|
-
|
|
3211
|
+
mkdirSync3(unitDir, { recursive: true });
|
|
3212
|
+
writeFileSync3(unitFile, unit);
|
|
2369
3213
|
run("systemctl", ["--user", "daemon-reload"], false);
|
|
2370
3214
|
const out = run("systemctl", ["--user", "enable", "--now", "agent-comm-hub"], false);
|
|
2371
3215
|
messages.push(out || `systemd user unit installed and enabled: ${unitFile}`);
|
|
@@ -2418,8 +3262,8 @@ WantedBy=default.target
|
|
|
2418
3262
|
messages.push(`[dry-run] write ${plist}`);
|
|
2419
3263
|
messages.push(`[dry-run] launchctl bootstrap gui/${uid} ${plist}`);
|
|
2420
3264
|
} else {
|
|
2421
|
-
|
|
2422
|
-
|
|
3265
|
+
mkdirSync3(launchAgentsDir, { recursive: true });
|
|
3266
|
+
writeFileSync3(plist, plistContent);
|
|
2423
3267
|
try {
|
|
2424
3268
|
execFileSync2("launchctl", ["bootstrap", `gui/${uid}`, plist], { encoding: "utf8", windowsHide: true });
|
|
2425
3269
|
} catch {
|
|
@@ -2456,18 +3300,21 @@ WantedBy=default.target
|
|
|
2456
3300
|
// src/cli.ts
|
|
2457
3301
|
import { homedir as homedir4 } from "node:os";
|
|
2458
3302
|
import { join as join4 } from "node:path";
|
|
3303
|
+
var STRING_FLAGS = /* @__PURE__ */ new Set(["--host", "--path", "--url", "--server-name", "--agent", "--herdr-bin", "--manager-peers", "--state-file", "--auth-tokens", "--db", "--file", "--role", "--owner", "--token"]);
|
|
2459
3304
|
var DEFAULT_STATE_FILE = join4(homedir4(), ".agent-comm-hub", "roster.json");
|
|
3305
|
+
var DEFAULT_DB = join4(homedir4(), ".agent-comm-hub", "state.db");
|
|
3306
|
+
var DEFAULT_AUTH_FILE = join4(homedir4(), ".agent-comm-hub", "tokens.json");
|
|
2460
3307
|
function parseArgs(argv) {
|
|
2461
3308
|
const args = {};
|
|
2462
3309
|
const numeric = /* @__PURE__ */ new Set(["--port", "--max-queue", "--history-limit", "--wait-timeout-ms", "--default-wait-ms", "--connected-window-ms", "--peer-idle-timeout-ms", "--herdr-timeout-ms"]);
|
|
2463
|
-
const string = /* @__PURE__ */ new Set(["--host", "--path", "--url", "--server-name", "--agent", "--herdr-bin", "--manager-peers", "--state-file"]);
|
|
3310
|
+
const string = /* @__PURE__ */ new Set(["--host", "--path", "--url", "--server-name", "--agent", "--herdr-bin", "--manager-peers", "--state-file", "--auth-tokens", "--db", "--file", "--role", "--owner", "--token"]);
|
|
2464
3311
|
for (let i = 0; i < argv.length; i++) {
|
|
2465
3312
|
const flag = argv[i];
|
|
2466
3313
|
if (flag === "--help" || flag === "-h" || flag === "--version" || flag === "-V") {
|
|
2467
3314
|
args[flag] = true;
|
|
2468
3315
|
continue;
|
|
2469
3316
|
}
|
|
2470
|
-
if (flag === "--remove" || flag === "--dry-run") {
|
|
3317
|
+
if (flag === "--remove" || flag === "--dry-run" || flag === "--reveal" || flag === "--allow-join") {
|
|
2471
3318
|
args[flag] = true;
|
|
2472
3319
|
continue;
|
|
2473
3320
|
}
|
|
@@ -2506,6 +3353,10 @@ Usage:
|
|
|
2506
3353
|
agent-comm-hub update self-update from the npm registry
|
|
2507
3354
|
(files updated in place; restart
|
|
2508
3355
|
the hub afterwards)
|
|
3356
|
+
agent-comm-hub auth add <peer> [flags] manage the remote-mode token table
|
|
3357
|
+
agent-comm-hub auth remove <peer> (add prints the generated token ONCE)
|
|
3358
|
+
agent-comm-hub auth list [--reveal]
|
|
3359
|
+
agent-comm-hub auth gen print a fresh random token
|
|
2509
3360
|
|
|
2510
3361
|
Hub options:
|
|
2511
3362
|
--host <addr> Bind address (default 127.0.0.1)
|
|
@@ -2527,6 +3378,27 @@ Hub options:
|
|
|
2527
3378
|
this JSON file so identities survive restarts
|
|
2528
3379
|
(default ~/.agent-comm-hub/roster.json; "off"
|
|
2529
3380
|
keeps everything in memory)
|
|
3381
|
+
--auth-tokens <file> REMOTE MODE: require "Authorization: Bearer <token>"
|
|
3382
|
+
on every MCP request; each token maps to a fixed
|
|
3383
|
+
peer id + role (agent|manager). See server/README.md
|
|
3384
|
+
for the token table format and deployment recipes.
|
|
3385
|
+
--db <path> REMOTE MODE: SQLite state DB persisting history,
|
|
3386
|
+
mailboxes, profiles and groups across restarts
|
|
3387
|
+
(default ~/.agent-comm-hub/state.db; "off" = memory)
|
|
3388
|
+
|
|
3389
|
+
Auth options (agent-comm-hub auth <action>):
|
|
3390
|
+
--file <path> Token table to manage
|
|
3391
|
+
(default ~/.agent-comm-hub/tokens.json)
|
|
3392
|
+
--role <r> auth add: agent | manager (default agent)
|
|
3393
|
+
--owner <name> auth add: optional owner label
|
|
3394
|
+
--token <t> auth add: supply your own token instead of
|
|
3395
|
+
generating one (16-128 chars [A-Za-z0-9._:-])
|
|
3396
|
+
--allow-join Start the hub in allow-join mode: agents without
|
|
3397
|
+
a token may connect (LAN/VPN only; keep OFF on
|
|
3398
|
+
the public internet). Each gets a unique peer id
|
|
3399
|
+
+ source IP shown in the admin roster for you to
|
|
3400
|
+
claim (rename) or issue a token to.
|
|
3401
|
+
--reveal auth list: include the plaintext tokens
|
|
2530
3402
|
|
|
2531
3403
|
Setup options:
|
|
2532
3404
|
--url <url> Hub endpoint to register (default http://127.0.0.1:18764/mcp)
|
|
@@ -2611,6 +3483,77 @@ try {
|
|
|
2611
3483
|
}
|
|
2612
3484
|
process.exit(0);
|
|
2613
3485
|
}
|
|
3486
|
+
if (command === "auth") {
|
|
3487
|
+
const [action, ...authRest] = rest;
|
|
3488
|
+
const splitArgs = (args2) => {
|
|
3489
|
+
const positional = [];
|
|
3490
|
+
const flags = [];
|
|
3491
|
+
for (let i = 0; i < args2.length; i++) {
|
|
3492
|
+
if (args2[i].startsWith("--")) {
|
|
3493
|
+
flags.push(args2[i]);
|
|
3494
|
+
if (STRING_FLAGS.has(args2[i]) && args2[i + 1] !== void 0 && !args2[i + 1].startsWith("--")) flags.push(args2[++i]);
|
|
3495
|
+
} else positional.push(args2[i]);
|
|
3496
|
+
}
|
|
3497
|
+
return { positional, flags };
|
|
3498
|
+
};
|
|
3499
|
+
const file = () => {
|
|
3500
|
+
const { flags } = splitArgs(authRest);
|
|
3501
|
+
return parseArgs(flags)["--file"] ?? DEFAULT_AUTH_FILE;
|
|
3502
|
+
};
|
|
3503
|
+
if (action === "add") {
|
|
3504
|
+
const { positional, flags } = splitArgs(authRest);
|
|
3505
|
+
const args2 = parseArgs(flags);
|
|
3506
|
+
const peerId = positional[0];
|
|
3507
|
+
if (peerId === void 0) {
|
|
3508
|
+
console.error("auth add: missing <peer> \u2014 usage: agent-comm-hub auth add <peer> [--role agent|manager] [--owner name]");
|
|
3509
|
+
process.exit(1);
|
|
3510
|
+
}
|
|
3511
|
+
const role = args2["--role"] ?? "agent";
|
|
3512
|
+
if (role !== "agent" && role !== "manager") {
|
|
3513
|
+
console.error(`auth add: invalid role '${role}' (expected agent | manager)`);
|
|
3514
|
+
process.exit(1);
|
|
3515
|
+
}
|
|
3516
|
+
const row = addToken(file(), {
|
|
3517
|
+
peer: peerId,
|
|
3518
|
+
role,
|
|
3519
|
+
owner: args2["--owner"],
|
|
3520
|
+
token: args2["--token"]
|
|
3521
|
+
});
|
|
3522
|
+
console.log(`token created for ${peerId}${row.owner ? ` (${row.owner})` : ""} [${row.role}]`);
|
|
3523
|
+
console.log(` ${row.token}`);
|
|
3524
|
+
console.log("The MCP config to pass to that agent (Authorization header) \u2014 not echoed here;");
|
|
3525
|
+
console.log(`Table written to ${file()} (hub hot-reloads; takes effect in ~2s)`);
|
|
3526
|
+
process.exit(0);
|
|
3527
|
+
}
|
|
3528
|
+
if (action === "remove") {
|
|
3529
|
+
const peerId = splitArgs(authRest).positional[0];
|
|
3530
|
+
if (peerId === void 0) {
|
|
3531
|
+
console.error("auth remove: missing <peer>");
|
|
3532
|
+
process.exit(1);
|
|
3533
|
+
}
|
|
3534
|
+
console.log(removeToken(file(), peerId) ? `removed peer ${peerId}` : `peer ${peerId} not found`);
|
|
3535
|
+
process.exit(0);
|
|
3536
|
+
}
|
|
3537
|
+
if (action === "list") {
|
|
3538
|
+
const args2 = parseArgs(splitArgs(authRest).flags);
|
|
3539
|
+
const reveal = args2["--reveal"] === true;
|
|
3540
|
+
const rows = readTokens(file());
|
|
3541
|
+
if (rows.length === 0) {
|
|
3542
|
+
console.log("token table is empty \u2014 add one with: agent-comm-hub auth add <peer>");
|
|
3543
|
+
}
|
|
3544
|
+
for (const row of rows) {
|
|
3545
|
+
const t = reveal ? row.token : row.token.slice(0, 6) + "\u2026" + row.token.slice(-4);
|
|
3546
|
+
console.log(`${row.peer.padEnd(24)} ${row.role.padEnd(8)} ${row.owner ?? ""} ${t}`);
|
|
3547
|
+
}
|
|
3548
|
+
process.exit(0);
|
|
3549
|
+
}
|
|
3550
|
+
if (action === "gen") {
|
|
3551
|
+
console.log(generateToken());
|
|
3552
|
+
process.exit(0);
|
|
3553
|
+
}
|
|
3554
|
+
console.error(`auth: unknown action '${action ?? ""}' (expected add | remove | list | gen)`);
|
|
3555
|
+
process.exit(1);
|
|
3556
|
+
}
|
|
2614
3557
|
if (command === "update") {
|
|
2615
3558
|
const args2 = parseArgs(rest);
|
|
2616
3559
|
if (args2["--help"] || args2["-h"]) {
|
|
@@ -2647,7 +3590,10 @@ try {
|
|
|
2647
3590
|
herdrBin: args["--herdr-bin"],
|
|
2648
3591
|
herdrTimeoutMs: args["--herdr-timeout-ms"],
|
|
2649
3592
|
managerPeers: args["--manager-peers"] === void 0 ? void 0 : args["--manager-peers"] === "all" ? "all" : String(args["--manager-peers"]).split(",").map((id) => id.trim()).filter((id) => id !== ""),
|
|
2650
|
-
stateFile: args["--state-file"] === void 0 ? DEFAULT_STATE_FILE : args["--state-file"] === "off" ? void 0 : String(args["--state-file"])
|
|
3593
|
+
stateFile: args["--state-file"] === void 0 ? DEFAULT_STATE_FILE : args["--state-file"] === "off" ? void 0 : String(args["--state-file"]),
|
|
3594
|
+
authTokens: args["--auth-tokens"] === void 0 ? void 0 : String(args["--auth-tokens"]),
|
|
3595
|
+
allowJoin: args["--allow-join"] === true,
|
|
3596
|
+
db: args["--db"] === void 0 ? DEFAULT_DB : args["--db"] === "off" ? void 0 : String(args["--db"])
|
|
2651
3597
|
}, log);
|
|
2652
3598
|
const shutdown = () => {
|
|
2653
3599
|
log.info("agent-comm-hub shutting down");
|