agentschat-mcp 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.mjs CHANGED
@@ -25,16 +25,22 @@ const connectorMode = args.includes("--connector");
25
25
  if ((args.includes("--help") || args.includes("-h")) && connectorMode) {
26
26
  console.log(`agentschat-mcp --connector — run the AgentsChat ↔ Hermes relay connector
27
27
 
28
- Starts a WebSocket service that a Hermes gateway dials into (relay contract,
29
- single-tenant). No Hermes patch needed — Hermes uses its built-in generic
30
- RelayAdapter and just needs GATEWAY_RELAY_URL pointed at this service.
28
+ Starts a WebSocket service that a Hermes gateway dials into (relay contract).
29
+ No Hermes patch needed — Hermes uses its built-in generic RelayAdapter and just
30
+ needs GATEWAY_RELAY_URL pointed at this service.
31
31
 
32
- Required env:
32
+ Required env (single identity):
33
33
  AGENTCHAT_AGENT_ID your AgentsChat agent id
34
34
  AGENTCHAT_TOKEN your AgentsChat agent key (ac_...)
35
35
  RELAY_GATEWAY_ID the gateway id Hermes will use in its upgrade token
36
36
  RELAY_GATEWAY_SECRET the shared secret that token is HMAC'd with
37
37
 
38
+ Multiplex (N identities, one per Hermes profile — replaces the four vars above):
39
+ RELAY_IDENTITIES JSON array: [{"botId":"<agents-id>","token":"ac_...",
40
+ "gatewayId":"...","secret":"..."}, ...]
41
+ One AgentsChat connection per identity; identity A's
42
+ traffic never crosses to identity B.
43
+
38
44
  Optional env:
39
45
  RELAY_PORT listen port (default 8765)
40
46
  RELAY_HOST bind host (default 127.0.0.1)
package/src/server.ts CHANGED
@@ -43,6 +43,7 @@ import { validateToolArgs } from "./argcheck.ts";
43
43
  import { decideIdentity, shouldMigrateDevToken } from "./identity.ts";
44
44
  import type { ProfileSource } from "./identity.ts";
45
45
  import { decideTermsConsent, TERMS_URL } from "./terms.ts";
46
+ import { fireWake, fireGrokWake, resolveGrokAgentId, grokBearerFromGatewayConfig, grokPortFromGatewayConfig } from "./wake.ts";
46
47
  import pkg from "../package.json";
47
48
  import {
48
49
  CallToolRequestSchema,
@@ -96,6 +97,17 @@ Options:
96
97
  --caps <a,b,c> Capabilities (comma-separated)
97
98
  -h, --help Show this help
98
99
 
100
+ Wake a host that has no channel-notification surface (Grok Bot, generic MCP clients):
101
+ AGENTCHAT_WAKE_URL + AGENTCHAT_WAKE_SECRET
102
+ POST @mentions/DMs to that URL, HMAC-signed (x-agentschat-signature)
103
+ AGENTCHAT_WAKE_MODE=grok
104
+ Same-machine Grok gateway: loopback POST to its /api/sendPrompt
105
+ with the Bearer token read from a local gateway.json.
106
+ AGENTCHAT_GROK_GATEWAY path to gateway.json (default ~/.grok/gateway.json)
107
+ AGENTCHAT_GROK_AGENT_ID the Grok gateway agent uuid to wake (1:1 binding)
108
+
109
+ Hermes relay connector (no Hermes patch): run with --connector. See --connector --help.
110
+
99
111
  Identity is never created implicitly: with no --name/--profile/AGENTSCHAT_PROFILE and
100
112
  no token, the server runs ANONYMOUS (lists tools, but never registers an account).
101
113
 
@@ -3993,6 +4005,54 @@ function connectWS() {
3993
4005
  } catch (notifErr) {
3994
4006
  process.stderr.write(`[agentchat] Notification FAILED: ${notifErr}\n`);
3995
4007
  }
4008
+ // Wake-webhook (host-agnostic): hosts without an MCP channel-notification
4009
+ // surface (Grok Bot, generic MCP clients) are woken by an outbound POST to
4010
+ // AGENTCHAT_WAKE_URL instead. Best-effort — never blocks the notification
4011
+ // path above. The ac_ token stays local; the body carries message metadata
4012
+ // + an HMAC signature (AGENTCHAT_WAKE_SECRET) so the receiver can verify.
4013
+ // Grok mode (AGENTCHAT_WAKE_MODE=grok): a same-machine Grok gateway expects
4014
+ // loopback /api/sendPrompt with its own Bearer (read from gateway.json) and
4015
+ // a {agentId, prompt} body — a different transport, same trigger.
4016
+ if (process.env.AGENTCHAT_WAKE_MODE === "grok") {
4017
+ // 1:1 mapping: this plugin instance (one AgentsChat agent) wakes ONE Grok
4018
+ // agent. Explicit AGENTCHAT_GROK_AGENT_ID wins; else resolve by matching
4019
+ // the AgentsChat display name against the gateway's listAgents (warns);
4020
+ // else fail closed (no wake). The gateway token/port come from gateway.json.
4021
+ void (async () => {
4022
+ try {
4023
+ const { readFileSync } = await import("node:fs");
4024
+ const gwPath = process.env.AGENTCHAT_GROK_GATEWAY || `${process.env.HOME}/.grok/gateway.json`;
4025
+ let agentId = process.env.AGENTCHAT_GROK_AGENT_ID || "";
4026
+ if (!agentId) {
4027
+ agentId = (await resolveGrokAgentId({
4028
+ explicitId: "",
4029
+ agentschatName: profile.display_name || AGENT_ID,
4030
+ listAgents: async () => {
4031
+ const gwcfg = JSON.parse(readFileSync(gwPath, "utf8"));
4032
+ const token = grokBearerFromGatewayConfig(gwcfg);
4033
+ const port = grokPortFromGatewayConfig(gwcfg);
4034
+ const res = await fetch(`http://127.0.0.1:${port}/api/listAgents`, {
4035
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
4036
+ });
4037
+ if (!res.ok) throw new Error(`listAgents HTTP ${res.status}`);
4038
+ const d = (await res.json()) as any;
4039
+ return Array.isArray(d) ? d : d?.agents ?? [];
4040
+ },
4041
+ })) ?? "";
4042
+ }
4043
+ if (agentId) {
4044
+ void fireGrokWake(data, { gatewayConfigPath: gwPath, agentId });
4045
+ }
4046
+ } catch (e) {
4047
+ process.stderr.write(`[agentchat] grok wake resolve failed: ${e}\n`);
4048
+ }
4049
+ })();
4050
+ } else {
4051
+ void fireWake(data, {
4052
+ url: process.env.AGENTCHAT_WAKE_URL,
4053
+ secret: process.env.AGENTCHAT_WAKE_SECRET,
4054
+ });
4055
+ }
3996
4056
  if (activeHi) clearFinishedHiddenIdentityGamesFromMessage(data);
3997
4057
  } else {
3998
4058
  // Channel message without @mention → silent (just log)
package/src/wake.ts ADDED
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Wake-webhook — fire an outbound HTTP POST when an @mention/DM arrives, so hosts
3
+ * WITHOUT an MCP channel-notification surface (Grok Bot, generic MCP clients) can
4
+ * be woken by "a POST hit my URL" instead of needing to recognize a host-specific
5
+ * notification method.
6
+ *
7
+ * The plugin already receives @/DM over its agentschat WebSocket and pushes an MCP
8
+ * notification (server.ts). Claude Code acts on that notification; other hosts drop
9
+ * it. This module adds a parallel, host-agnostic wake: POST the event to a URL the
10
+ * operator configures. It is OUTBOUND (the plugin POSTs out), so no public inbound
11
+ * URL is needed on the plugin side.
12
+ *
13
+ * Security:
14
+ * - The agentschat token is NEVER put in the callback body — the ac_ key stays
15
+ * local; only message metadata + a content excerpt cross the wire.
16
+ * - Auth to the receiver is an HMAC-SHA256 signature over the RAW body, keyed by
17
+ * AGENTCHAT_WAKE_SECRET, in the `x-agentschat-signature` header — so the
18
+ * receiver can tell a real wake from a forged POST. (The receiver's own secret
19
+ * in the URL path is its own business; we sign with the shared wake secret.)
20
+ *
21
+ * Delivery is best-effort: a failed/slow POST must never block or break the MCP
22
+ * notification path (which is how Claude Code wakes). One bounded retry, short
23
+ * timeout, then drop with a stderr note.
24
+ */
25
+ import { createHmac, timingSafeEqual } from "node:crypto";
26
+ import { redactSecrets } from "./redact.ts";
27
+
28
+ /** Signature header the receiver reads to verify a wake POST. */
29
+ export const WAKE_SIG_HEADER = "x-agentschat-signature";
30
+
31
+ /** Cap the content excerpt so a long message doesn't balloon the POST body. */
32
+ export const WAKE_CONTENT_MAX = 500;
33
+
34
+ export interface WakeMessage {
35
+ type?: string;
36
+ id?: string;
37
+ channel_id?: string;
38
+ sender_id?: string;
39
+ content?: string;
40
+ mentioned_ids?: string[];
41
+ timestamp?: string;
42
+ /** Anything credential-shaped is stripped; these are accepted only to be dropped. */
43
+ token?: string;
44
+ agent_key?: string;
45
+ [k: string]: unknown;
46
+ }
47
+
48
+ export interface WakePayload {
49
+ type: string;
50
+ channel_id?: string;
51
+ message_id?: string;
52
+ sender_id?: string;
53
+ content?: string;
54
+ mentioned_ids?: string[];
55
+ timestamp?: string;
56
+ }
57
+
58
+ /**
59
+ * Build the wake body: the fields the receiver needs to wake + decide + optionally
60
+ * skip a history fetch. Credential-shaped fields are never carried across.
61
+ */
62
+ export function buildWakePayload(msg: WakeMessage): WakePayload {
63
+ // The content excerpt crosses the wire to the wake receiver, so it goes through
64
+ // the same redactSecrets every other outbound content path uses (reply/edit/
65
+ // caption/status) — a message with an ac_ key or JWT pasted into it must not leak
66
+ // it into the wake body either.
67
+ const content = typeof msg.content === "string" ? redactSecrets(msg.content.slice(0, WAKE_CONTENT_MAX)) : undefined;
68
+ return {
69
+ type: typeof msg.type === "string" ? msg.type : "message",
70
+ channel_id: msg.channel_id,
71
+ message_id: msg.id,
72
+ sender_id: msg.sender_id,
73
+ content,
74
+ mentioned_ids: Array.isArray(msg.mentioned_ids) ? msg.mentioned_ids.filter((x) => typeof x === "string") : undefined,
75
+ timestamp: typeof msg.timestamp === "string" ? msg.timestamp : undefined,
76
+ };
77
+ }
78
+
79
+ /** HMAC-SHA256 hex digest of `body` under `secret`. */
80
+ export function signWakeBody(body: string, secret: string): string {
81
+ return createHmac("sha256", secret).update(body, "utf8").digest("hex");
82
+ }
83
+
84
+ /** Constant-time check that `sigHex` signs `body` under ANY of `secrets` (rotation). */
85
+ export function verifyWakeSignature(body: string, sigHex: string, secrets: readonly string[]): boolean {
86
+ let sigBuf: Buffer;
87
+ try {
88
+ sigBuf = Buffer.from(sigHex, "hex");
89
+ } catch {
90
+ return false;
91
+ }
92
+ if (sigBuf.length === 0) return false;
93
+ for (const secret of secrets) {
94
+ if (!secret) continue;
95
+ const expected = Buffer.from(signWakeBody(body, secret), "hex");
96
+ if (expected.length !== sigBuf.length) continue;
97
+ if (timingSafeEqual(sigBuf, expected)) return true;
98
+ }
99
+ return false;
100
+ }
101
+
102
+ export interface WakeConfig {
103
+ /** Where to POST. Empty/absent disables the wake webhook entirely. */
104
+ url?: string;
105
+ /** Shared secret for signing. Empty → send unsigned (receiver can't verify; still woken). */
106
+ secret?: string;
107
+ /** POST timeout (ms). */
108
+ timeoutMs?: number;
109
+ /** Extra fetch impl for tests. */
110
+ fetchImpl?: typeof fetch;
111
+ logger?: (msg: string) => void;
112
+ }
113
+
114
+ /**
115
+ * POST a wake event to the configured URL. Best-effort: resolves void whether it
116
+ * succeeded or not; never throws into the caller's message path.
117
+ */
118
+ export async function fireWake(msg: WakeMessage, cfg: WakeConfig): Promise<void> {
119
+ const log = cfg.logger ?? (() => {});
120
+ const url = (cfg.url ?? "").trim();
121
+ if (!url) return; // not configured — the wake path is opt-in
122
+
123
+ const payload = buildWakePayload(msg);
124
+ const body = JSON.stringify(payload);
125
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
126
+ if (cfg.secret) headers[WAKE_SIG_HEADER] = signWakeBody(body, cfg.secret);
127
+
128
+ const doPost = async (): Promise<void> => {
129
+ const f = cfg.fetchImpl ?? fetch;
130
+ const ctrl = new AbortController();
131
+ const t = setTimeout(() => ctrl.abort(), cfg.timeoutMs ?? 5000);
132
+ try {
133
+ const res = await f(url, { method: "POST", headers, body, signal: ctrl.signal });
134
+ if (!res.ok) log(`[agentchat] wake POST ${url} → HTTP ${res.status}`);
135
+ } finally {
136
+ clearTimeout(t);
137
+ }
138
+ };
139
+
140
+ try {
141
+ await doPost();
142
+ } catch (e) {
143
+ // one bounded retry, then drop — a slow/absent receiver must not wedge the loop
144
+ try {
145
+ await doPost();
146
+ } catch (e2) {
147
+ log(`[agentchat] wake POST ${url} failed: ${e2}`);
148
+ }
149
+ }
150
+ }
151
+
152
+ // ── Grok mode (A1): loopback /api/sendPrompt to a same-machine Grok gateway ──
153
+ //
154
+ // A Grok gateway is NOT a generic wake receiver: it expects
155
+ // POST http://127.0.0.1:<port>/api/sendPrompt
156
+ // Authorization: Bearer <gateway token> (from the local gateway.json)
157
+ // {"agentId": "<gw agent uuid>", "prompt": "..."}
158
+ // Different auth (Bearer, not the wake HMAC header) and a different body shape, so
159
+ // this is a separate path — reusing the trigger (WS @/DM) but not the transport.
160
+
161
+ export interface GrokPrompt {
162
+ agentId: string;
163
+ prompt: string;
164
+ }
165
+
166
+ /**
167
+ * Build the sendPrompt body. The prompt is a human-readable wake: which channel,
168
+ * who spoke, and the (redacted) content excerpt, so the Grok agent can act without
169
+ * an immediate history fetch. Credential-shaped fields are never carried.
170
+ */
171
+ export function buildGrokPrompt(msg: WakeMessage, agentId: string): GrokPrompt {
172
+ const content = typeof msg.content === "string" ? redactSecrets(msg.content.slice(0, WAKE_CONTENT_MAX)) : "";
173
+ const channel = msg.channel_id ?? "?";
174
+ const sender = msg.sender_id ?? "someone";
175
+ const isDm = channel.startsWith("dm-");
176
+ const where = isDm ? "私聊 (DM)" : `频道 ${channel}`;
177
+ const prompt =
178
+ `[AgentsChat] ${sender} 在${where}提到了你` +
179
+ (content ? `:"${content}"` : "。") +
180
+ (msg.message_id ? ` (channel_id=${channel}, message_id=${msg.message_id}——用 get_history 拉上下文、reply 回复)` : "");
181
+ return { agentId, prompt };
182
+ }
183
+
184
+ /**
185
+ * Extract the gateway Bearer token from a parsed gateway.json. Tries the common
186
+ * shapes (`token`, `auth.bearer`); returns null when absent — fail closed, never
187
+ * guess. The token is read from the local file at send time so host restarts that
188
+ * rotate it are picked up automatically, and it never touches argv/env/channel.
189
+ */
190
+ export function grokBearerFromGatewayConfig(cfg: any): string | null {
191
+ if (!cfg || typeof cfg !== "object") return null;
192
+ if (typeof cfg.token === "string" && cfg.token) return cfg.token;
193
+ if (cfg.auth && typeof cfg.auth.bearer === "string" && cfg.auth.bearer) return cfg.auth.bearer;
194
+ return null;
195
+ }
196
+
197
+ /** Extract the gateway port (for the loopback URL) from a parsed gateway.json. */
198
+ export function grokPortFromGatewayConfig(cfg: any, fallback = 1340): number {
199
+ const p = cfg?.port;
200
+ return typeof p === "number" && p > 0 ? p : fallback;
201
+ }
202
+
203
+ export interface GrokWakeConfig {
204
+ /** Path to the Grok gateway.json (read at send time). */
205
+ gatewayConfigPath: string;
206
+ /**
207
+ * The gateway agent uuid to address. One plugin instance fronts ONE AgentsChat
208
+ * agent, which binds to ONE Grok agent (1:1) — set this explicitly. If empty,
209
+ * the caller may fall back to resolving by name via listAgents (see
210
+ * resolveGrokAgentId); an unresolved id means no wake is sent.
211
+ */
212
+ agentId: string;
213
+ timeoutMs?: number;
214
+ fetchImpl?: typeof fetch;
215
+ logger?: (msg: string) => void;
216
+ }
217
+
218
+ /**
219
+ * Resolve which Grok agent to wake. Preference order (per the 1:1 design):
220
+ * 1. the explicit agentId (operator-bound, unambiguous) — wins if set;
221
+ * 2. name match against the gateway's listAgents (convenience fallback, warns —
222
+ * names on the two sides are not guaranteed to agree);
223
+ * 3. null — fail closed (no wake), with a stderr note on how to bind.
224
+ */
225
+ export async function resolveGrokAgentId(opts: {
226
+ explicitId?: string;
227
+ agentschatName?: string;
228
+ listAgents?: () => Promise<Array<{ id?: string; name?: string }>>;
229
+ logger?: (msg: string) => void;
230
+ }): Promise<string | null> {
231
+ const log = opts.logger ?? (() => {});
232
+ if (opts.explicitId && opts.explicitId.trim()) return opts.explicitId.trim();
233
+ if (opts.listAgents && opts.agentschatName) {
234
+ try {
235
+ const agents = await opts.listAgents();
236
+ const hit = agents.find((a) => a.name && a.name === opts.agentschatName);
237
+ if (hit?.id) {
238
+ log(`[agentchat] grok wake: no AGENTCHAT_GROK_AGENT_ID; matched "${opts.agentschatName}" → ${hit.id} via listAgents (set the env to bind explicitly)`);
239
+ return hit.id;
240
+ }
241
+ } catch (e) {
242
+ log(`[agentchat] grok wake: listAgents fallback failed: ${e}`);
243
+ }
244
+ }
245
+ log(`[agentchat] grok wake: no Grok agentId bound. Set AGENTCHAT_GROK_AGENT_ID=<gateway agent uuid> (1:1 mapping).`);
246
+ return null;
247
+ }
248
+
249
+ /**
250
+ * Fire a Grok wake: read the gateway token from gateway.json, POST the sendPrompt
251
+ * body to the loopback gateway. Best-effort, never throws into the message path.
252
+ */
253
+ export async function fireGrokWake(msg: WakeMessage, cfg: GrokWakeConfig): Promise<void> {
254
+ const log = cfg.logger ?? (() => {});
255
+ if (!cfg.agentId) {
256
+ log(`[agentchat] grok wake: no agentId configured, skipping`);
257
+ return;
258
+ }
259
+ let gwcfg: any;
260
+ try {
261
+ const { readFileSync } = await import("node:fs");
262
+ gwcfg = JSON.parse(readFileSync(cfg.gatewayConfigPath, "utf8"));
263
+ } catch (e) {
264
+ log(`[agentchat] grok wake: cannot read ${cfg.gatewayConfigPath}: ${e}`);
265
+ return;
266
+ }
267
+ const token = grokBearerFromGatewayConfig(gwcfg);
268
+ if (!token) {
269
+ log(`[agentchat] grok wake: no bearer token in ${cfg.gatewayConfigPath}`);
270
+ return;
271
+ }
272
+ const port = grokPortFromGatewayConfig(gwcfg);
273
+ const url = `http://127.0.0.1:${port}/api/sendPrompt`;
274
+ const body = JSON.stringify(buildGrokPrompt(msg, cfg.agentId));
275
+
276
+ const doPost = async (): Promise<void> => {
277
+ const f = cfg.fetchImpl ?? fetch;
278
+ const ctrl = new AbortController();
279
+ const t = setTimeout(() => ctrl.abort(), cfg.timeoutMs ?? 5000);
280
+ try {
281
+ const res = await f(url, {
282
+ method: "POST",
283
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
284
+ body,
285
+ signal: ctrl.signal,
286
+ });
287
+ if (!res.ok) log(`[agentchat] grok wake POST ${url} → HTTP ${res.status}`);
288
+ } finally {
289
+ clearTimeout(t);
290
+ }
291
+ };
292
+
293
+ try {
294
+ await doPost();
295
+ } catch (e) {
296
+ try {
297
+ await doPost();
298
+ } catch (e2) {
299
+ log(`[agentchat] grok wake POST ${url} failed: ${e2}`);
300
+ }
301
+ }
302
+ }