omni-notify-mcp 1.3.289 → 1.3.291
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/dist/agentGateway.js +2 -0
- package/dist/gateway/conversations.js +8 -3
- package/dist/gateway/mcpDoor.js +3 -1
- package/dist/gateway/mcpEvents.js +390 -0
- package/dist/gateway/nativeTerminal.js +1 -1
- package/dist/gateway/restDoor.js +1 -1
- package/dist/gateway/terminalPage.js +40 -1
- package/package.json +3 -2
- package/dist/googlehome/foyer-client.js +0 -210
- package/dist/googlehome/foyer-codec.js +0 -318
- package/dist/googlehome/gpsoauth.js +0 -119
- package/dist/googlehome/index.js +0 -267
package/dist/agentGateway.js
CHANGED
|
@@ -15,6 +15,7 @@ import { masterMuted } from "./notify.js";
|
|
|
15
15
|
import { appendEvent, cliOwns, CONVERSATION_MUTED_MESSAGE, conversationRefusal, editOwed, GatewayError, listConversations, monotonicMs, openConversationOf, openConversationsOf, queueNoticeOf, readEvents, resolveProject, routeAsk, stopTargetOf, sendToConversation, startConversation, updateConversation, } from "./gateway/conversations.js";
|
|
16
16
|
import { SessionDriver } from "./gateway/sessionDriver.js";
|
|
17
17
|
import { DEFAULT_PROVIDER } from "./gateway/provider.js";
|
|
18
|
+
import { turnEnded } from "./gateway/mcpEvents.js";
|
|
18
19
|
export const AGENT_PREFIX = "!";
|
|
19
20
|
/** A chat door's own words when a message points at a browser CLI console session: the chat does not drive a live CLI, so it says so plainly and names where that session IS reachable. */
|
|
20
21
|
const CONSOLE_NOT_IN_CHAT = "That is a live CLI console session opened in the browser — this chat does not type into it. Use /terminal in BN, or reach the SAME session from MCP/REST by its id (agent_send types into it, agent_stream streams its console).";
|
|
@@ -77,6 +78,7 @@ export class AgentGateway {
|
|
|
77
78
|
constructor(driver) {
|
|
78
79
|
this.driver = driver;
|
|
79
80
|
driver.onEvent((event) => this.deliver(event));
|
|
81
|
+
driver.onEvent(turnEnded);
|
|
80
82
|
}
|
|
81
83
|
registerDoor(platform, door) {
|
|
82
84
|
this.doors.set(platform, door);
|
|
@@ -42,9 +42,12 @@ export function monotonicMs() {
|
|
|
42
42
|
/** Thrown for a request the caller got wrong; the door turns it into the status code. */
|
|
43
43
|
export class GatewayError extends Error {
|
|
44
44
|
status;
|
|
45
|
-
|
|
45
|
+
facts;
|
|
46
|
+
/** `facts` ride beside the words in the refusal's body, so a caller acts on what was refused without parsing the message. */
|
|
47
|
+
constructor(status, message, facts = {}) {
|
|
46
48
|
super(message);
|
|
47
49
|
this.status = status;
|
|
50
|
+
this.facts = facts;
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
53
|
/** The service's own store for the agent-conversation On/Off switch, beside config.json in the config dir: BN's page flips it, this process reads it, and BullseyeSync carries it to every machine. */
|
|
@@ -113,9 +116,11 @@ function dropStaged(staged) {
|
|
|
113
116
|
* its caller told, and the staged file of a write that is given up is removed.
|
|
114
117
|
*/
|
|
115
118
|
function writeMeta(meta) {
|
|
116
|
-
|
|
119
|
+
writeRecordFile(metaPath(meta.id), JSON.stringify(meta, null, 2));
|
|
120
|
+
}
|
|
121
|
+
/** Writes one store file of the agent dir the way a record is written: staged beside itself and renamed over the old one, a refused rename tried again. */
|
|
122
|
+
export function writeRecordFile(final, text) {
|
|
117
123
|
const staged = `${final}.${process.pid}.tmp`;
|
|
118
|
-
const text = JSON.stringify(meta, null, 2);
|
|
119
124
|
for (let attempt = 0;; attempt++) {
|
|
120
125
|
try {
|
|
121
126
|
writeFileSync(staged, text);
|
package/dist/gateway/mcpDoor.js
CHANGED
|
@@ -24,6 +24,7 @@ import { grantClient, publicBase } from "./oauthDoor.js";
|
|
|
24
24
|
import { bearer, personId, sessionSignIn, tokenProves } from "./passkeyDoor.js";
|
|
25
25
|
import { consoleSession } from "./nativeTerminal.js";
|
|
26
26
|
import { agentStreamFor } from "./agentStream.js";
|
|
27
|
+
import { EVENTS_CAPABILITY, serveEvents } from "./mcpEvents.js";
|
|
27
28
|
import { PACKAGE_VERSION, readGatewaySettings } from "../notify.js";
|
|
28
29
|
const answer = (value) => ({ content: [{ type: "text", text: JSON.stringify(value) }] });
|
|
29
30
|
/** A refused act is the tool's own error result, never a protocol failure, so the calling agent can read why. */
|
|
@@ -128,7 +129,8 @@ async function consoleRead(live, after, wait) {
|
|
|
128
129
|
}
|
|
129
130
|
function buildServer(caller, base) {
|
|
130
131
|
built += 1;
|
|
131
|
-
const server = new McpServer({ name: "notify-agent-gateway", version: PACKAGE_VERSION, icons: iconsFor(base) }, { instructions: INSTRUCTIONS });
|
|
132
|
+
const server = new McpServer({ name: "notify-agent-gateway", version: PACKAGE_VERSION, icons: iconsFor(base) }, { instructions: INSTRUCTIONS, capabilities: EVENTS_CAPABILITY });
|
|
133
|
+
serveEvents(server.server, caller);
|
|
132
134
|
server.tool("agent_prompt", `Runs a request on this machine's own Claude Code agent — with the user's files, git repositories, shell and MCP servers — and returns the agent's answer. Each call goes on with the same agent as this connection's previous call, so the agent keeps what it already knows (a stopped one resumes); with no conversation named, new=true starts a fresh agent, and so does an ask that does not point at the work already under way while that agent is busy and is not waiting on an answer. Waits up to ${MAX_WAIT_SECONDS} seconds: the answer comes back in this call, or working=true with the id and position to read on with agent_wait. It is the convenience send-and-wait for a request expected to finish within about a minute; for a long or autonomous task use agent_start and then agent_wait, which do not hold a call open while the agent works. A call that returned working=true or timed out was received and is still running: read on with agent_wait, and do not call agent_prompt again with the same text, which would submit the task a second time. The result carries the ending event's timing, in which a reply's time is three separate figures rather than one number: bnMs, the end-to-end elapsed time from the request arriving at BN's door to the answer being stored; queuedMs (beside queued, whether it stood in line at all — false is the store's own statement that it never stood in line, absent is unmeasured), the part of it the message waited for a free slot or the turn ahead; and agentMs, the part the agent itself worked. totalMs is BN's own total from the message being stored to the answer being stored, never the agent's time, and rows carries the turn layer by layer. It also carries waitedMs, the milliseconds this call itself held on BN's own clock. When the id names a CLI console session (a full CLI opened in the browser and mirrored at /terminal), the text is typed into that SAME live CLI and the call returns at once with {console:true, sent:true, stream, after} — there is no turn to wait on, so read the CLI's answer with agent_wait or agent_stream_read at that after, or attach to stream.`, {
|
|
133
135
|
text: z.string().min(1).describe("The user's request, as the user wrote it"),
|
|
134
136
|
conversation: z.string().min(1).optional().describe("A 'conversation' id an earlier agent_prompt returned, to address that agent; without it the call goes on with this connection's current agent"),
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Events on the MCP door (#3666): the host path OpenAI documents for waking a ChatGPT chat when work it dispatched
|
|
3
|
+
* ends (developers.openai.com/plugins/build/mcp-events). The door advertises `capabilities.events` and answers
|
|
4
|
+
* `events/list`, `events/subscribe` and `events/unsubscribe`; a subscription to `agent.turn_completed` names one
|
|
5
|
+
* conversation the subscribing connection itself started, and when a turn of it answers or fails BN POSTs one
|
|
6
|
+
* Standard-Webhooks-signed event to the callback, which ChatGPT acts on in the subscribed chat.
|
|
7
|
+
*
|
|
8
|
+
* Subscriptions are kept in one store file beside the conversations (`<config dir>/agent/event-subscriptions.json`), so
|
|
9
|
+
* a BN restart keeps them. A callback is HTTPS only, every address its host resolves to must be public, the connection
|
|
10
|
+
* is made to the address that was checked, and a redirect is never followed — the verification challenge included.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "crypto";
|
|
13
|
+
import { lookup } from "dns/promises";
|
|
14
|
+
import { existsSync, readFileSync } from "fs";
|
|
15
|
+
import { request } from "https";
|
|
16
|
+
import { BlockList, isIP } from "net";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
20
|
+
import { agentDir, GatewayError, readConversation, requireConversationEnabled, writeRecordFile } from "./conversations.js";
|
|
21
|
+
/** What the door adds to the capabilities its initialize answers: `events`, a key the SDK's capability type does not name. */
|
|
22
|
+
export const EVENTS_CAPABILITY = { events: {} };
|
|
23
|
+
const TURN_COMPLETED = "agent.turn_completed";
|
|
24
|
+
const EVENT_DEFINITION = {
|
|
25
|
+
name: TURN_COMPLETED,
|
|
26
|
+
description: "A turn of a BN agent conversation ended: the agent answered it or it failed. conversation_id is a conversation this connection opened with agent_start, agent_open or agent_prompt.",
|
|
27
|
+
delivery: ["webhook"],
|
|
28
|
+
inputSchema: {
|
|
29
|
+
type: "object",
|
|
30
|
+
properties: { conversation_id: { type: "string" } },
|
|
31
|
+
required: ["conversation_id"],
|
|
32
|
+
additionalProperties: false,
|
|
33
|
+
},
|
|
34
|
+
payloadSchema: {
|
|
35
|
+
type: "object",
|
|
36
|
+
properties: {
|
|
37
|
+
conversation_id: { type: "string" },
|
|
38
|
+
turn: { type: "integer" },
|
|
39
|
+
outcome: { type: "string", enum: ["answered", "failed"] },
|
|
40
|
+
text: { type: "string" },
|
|
41
|
+
text_truncated: { type: "boolean", description: "true when text was cut to fit one event; agent_wait reads it whole" },
|
|
42
|
+
ended_at: { type: "string", format: "date-time" },
|
|
43
|
+
},
|
|
44
|
+
required: ["conversation_id", "turn", "outcome", "text", "text_truncated", "ended_at"],
|
|
45
|
+
additionalProperties: false,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
/** The documented JSON-RPC error for a callback that could not be verified or reached. */
|
|
49
|
+
const CALLBACK_ENDPOINT_ERROR = -32015;
|
|
50
|
+
// GATE: one event request is at most 256 KiB, the documented limit; a longer answer is cut so the body fits and is marked text_truncated.
|
|
51
|
+
const MAX_EVENT_BYTES = 262_144;
|
|
52
|
+
// GATE: a callback request is over within 10 s of its start, the documented sample client's timeout; one still open then is cut and is a timeout.
|
|
53
|
+
const CALLBACK_TIMEOUT_MS = 10_000;
|
|
54
|
+
// GATE: an event is tried at most 5 times, waiting 1, 2, 4 and 8 s between tries, when the callback's failure is transient (network, timeout, 408, 429, 5xx).
|
|
55
|
+
const DELIVERY_TRIES = 5;
|
|
56
|
+
const DELIVERY_FIRST_WAIT_MS = 1_000;
|
|
57
|
+
// GATE: a callback's answer is read up to 64 KiB; the challenge echo is all that is ever read from it.
|
|
58
|
+
const MAX_REPLY_BYTES = 65_536;
|
|
59
|
+
/** A callback that cannot be used, with the documented reason category. */
|
|
60
|
+
class CallbackFailure extends Error {
|
|
61
|
+
reason;
|
|
62
|
+
constructor(reason, detail) {
|
|
63
|
+
super(detail);
|
|
64
|
+
this.reason = reason;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const storePath = () => join(agentDir(), "event-subscriptions.json");
|
|
68
|
+
function storedSubscriptions() {
|
|
69
|
+
if (!existsSync(storePath()))
|
|
70
|
+
return [];
|
|
71
|
+
return JSON.parse(readFileSync(storePath(), "utf8")).subscriptions;
|
|
72
|
+
}
|
|
73
|
+
/** The subscriptions whose granted lifetime has not passed. */
|
|
74
|
+
function liveSubscriptions(now = Date.now()) {
|
|
75
|
+
return storedSubscriptions().filter((s) => s.refreshBefore === null || Date.parse(s.refreshBefore) > now);
|
|
76
|
+
}
|
|
77
|
+
/** Rewrites the store from the live subscriptions with `change` applied, so an expired one leaves it on the next write. */
|
|
78
|
+
function editStore(change) {
|
|
79
|
+
writeRecordFile(storePath(), JSON.stringify({ subscriptions: change(liveSubscriptions()) }, null, 2));
|
|
80
|
+
}
|
|
81
|
+
const invalid = (message) => new McpError(ErrorCode.InvalidParams, message);
|
|
82
|
+
/** Arguments compared as canonical JSON, so key order never makes two subscriptions of one identity. */
|
|
83
|
+
function canonical(value) {
|
|
84
|
+
if (Array.isArray(value))
|
|
85
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
86
|
+
if (value !== null && typeof value === "object")
|
|
87
|
+
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(",")}}`;
|
|
88
|
+
return JSON.stringify(value);
|
|
89
|
+
}
|
|
90
|
+
/** The deterministic subscription id: the principal, the callback URL, the event name and the canonical arguments. */
|
|
91
|
+
function subscriptionId(principal, url, name, args) {
|
|
92
|
+
return `sub_${createHash("sha256").update([principal, url, name, canonical(args)].join("\n")).digest("hex").slice(0, 32)}`;
|
|
93
|
+
}
|
|
94
|
+
function eventNamed(name) {
|
|
95
|
+
if (name !== TURN_COMPLETED)
|
|
96
|
+
throw invalid(`no event named ${JSON.stringify(name)}: this server offers ${TURN_COMPLETED}`);
|
|
97
|
+
}
|
|
98
|
+
const ArgumentsSchema = z.object({ conversation_id: z.string().min(1) }).strict();
|
|
99
|
+
function argumentsOf(value) {
|
|
100
|
+
const parsed = ArgumentsSchema.safeParse(value);
|
|
101
|
+
if (!parsed.success)
|
|
102
|
+
throw invalid(`arguments must be exactly {conversation_id: string}: ${parsed.error.issues.map((i) => i.message).join("; ")}`);
|
|
103
|
+
return parsed.data;
|
|
104
|
+
}
|
|
105
|
+
/** The conversation is one this connection itself started; any other, or one that does not exist, is refused alike. */
|
|
106
|
+
function requireOwned(principal, conversationId) {
|
|
107
|
+
let owner;
|
|
108
|
+
try {
|
|
109
|
+
const meta = readConversation(conversationId);
|
|
110
|
+
owner = meta.shelf === "deleted" ? undefined : meta.chat;
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
if (!(err instanceof GatewayError) || err.status !== 404)
|
|
114
|
+
throw err;
|
|
115
|
+
owner = undefined;
|
|
116
|
+
}
|
|
117
|
+
if (owner !== principal)
|
|
118
|
+
throw invalid(`no conversation ${conversationId} was started by this connection`);
|
|
119
|
+
}
|
|
120
|
+
const DeliverySchema = z.object({ mode: z.literal("webhook"), url: z.string().min(1) }).passthrough();
|
|
121
|
+
function deliveryOf(value) {
|
|
122
|
+
const parsed = DeliverySchema.safeParse(value);
|
|
123
|
+
if (!parsed.success)
|
|
124
|
+
throw invalid("delivery must be {mode: \"webhook\", url, secret}");
|
|
125
|
+
return { url: parsed.data.url, secret: parsed.data.secret };
|
|
126
|
+
}
|
|
127
|
+
/** The signing key a `whsec_` secret carries: base64 decoding to 24 to 64 bytes. */
|
|
128
|
+
function signingKey(secret) {
|
|
129
|
+
if (typeof secret !== "string" || !secret.startsWith("whsec_"))
|
|
130
|
+
throw invalid("delivery.secret must start with whsec_");
|
|
131
|
+
const encoded = secret.slice("whsec_".length);
|
|
132
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || encoded.length % 4 !== 0)
|
|
133
|
+
throw invalid("delivery.secret must be whsec_ followed by base64");
|
|
134
|
+
const key = Buffer.from(encoded, "base64");
|
|
135
|
+
if (key.length < 24 || key.length > 64)
|
|
136
|
+
throw invalid(`delivery.secret decodes to ${key.length} bytes; 24 to 64 are accepted`);
|
|
137
|
+
return key;
|
|
138
|
+
}
|
|
139
|
+
/** Standard Webhooks headers for one attempt: the id, the signing time, and v1 HMAC-SHA256 over `id.timestamp.body`. */
|
|
140
|
+
function signedHeaders(key, id, body, subscription) {
|
|
141
|
+
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
142
|
+
const signature = createHmac("sha256", key).update(`${id}.${timestamp}.`).update(body).digest("base64");
|
|
143
|
+
return { "content-type": "application/json", "webhook-id": id, "webhook-timestamp": timestamp, "webhook-signature": `v1,${signature}`, "x-mcp-subscription-id": subscription };
|
|
144
|
+
}
|
|
145
|
+
// GATE: loopback callbacks and one extra trusted CA are honoured ONLY under NOTIFY_MCP_TEST_ENDPOINTS=1 with NOTIFY_EVENTS_TEST_LOOPBACK=1 and NOTIFY_EVENTS_TEST_CA; without them every loopback address is refused and only the system's CAs are trusted.
|
|
146
|
+
const TEST_LOOPBACK = process.env.NOTIFY_MCP_TEST_ENDPOINTS === "1" && process.env.NOTIFY_EVENTS_TEST_LOOPBACK === "1";
|
|
147
|
+
const TEST_CA = process.env.NOTIFY_MCP_TEST_ENDPOINTS === "1" && process.env.NOTIFY_EVENTS_TEST_CA ? readFileSync(process.env.NOTIFY_EVENTS_TEST_CA, "utf8") : undefined;
|
|
148
|
+
/** Every address that is not on the public internet: private, loopback, link-local, shared, documentation, benchmark, multicast, reserved, and the IPv6 translation forms. No ::ffff:0:0/96 rule: a BlockList judges an IPv4-mapped address by the IPv4 rules itself, and that rule would match every IPv4 address. */
|
|
149
|
+
const NON_PUBLIC = (() => {
|
|
150
|
+
const list = new BlockList();
|
|
151
|
+
const v4 = [["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24], ["192.88.99.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4]];
|
|
152
|
+
const v6 = [["::", 128], ["64:ff9b::", 96], ["64:ff9b:1::", 48], ["100::", 64], ["2001::", 23], ["2001:db8::", 32], ["2002::", 16], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8]];
|
|
153
|
+
if (!TEST_LOOPBACK) {
|
|
154
|
+
v4.push(["127.0.0.0", 8]);
|
|
155
|
+
v6.push(["::1", 128]);
|
|
156
|
+
}
|
|
157
|
+
for (const [net, prefix] of v4)
|
|
158
|
+
list.addSubnet(net, prefix, "ipv4");
|
|
159
|
+
for (const [net, prefix] of v6)
|
|
160
|
+
list.addSubnet(net, prefix, "ipv6");
|
|
161
|
+
return list;
|
|
162
|
+
})();
|
|
163
|
+
/** Checks a callback URL at the moment of use: HTTPS, no credentials, and every address its host resolves to public. */
|
|
164
|
+
async function callbackTarget(raw) {
|
|
165
|
+
if (!URL.canParse(raw))
|
|
166
|
+
throw new CallbackFailure("invalid_url", "the callback is not a URL");
|
|
167
|
+
const url = new URL(raw);
|
|
168
|
+
if (url.protocol !== "https:")
|
|
169
|
+
throw new CallbackFailure("not_https", "the callback must be an https URL");
|
|
170
|
+
if (url.username !== "" || url.password !== "")
|
|
171
|
+
throw new CallbackFailure("invalid_url", "the callback URL must carry no credentials");
|
|
172
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
173
|
+
const literal = isIP(host);
|
|
174
|
+
let addresses;
|
|
175
|
+
if (literal !== 0)
|
|
176
|
+
addresses = [{ address: host, family: literal }];
|
|
177
|
+
else {
|
|
178
|
+
try {
|
|
179
|
+
addresses = await lookup(host, { all: true, verbatim: true });
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
const code = err.code;
|
|
183
|
+
if (code !== "ENOTFOUND" && code !== "ENODATA" && code !== "EAI_AGAIN" && code !== "ESERVFAIL")
|
|
184
|
+
throw err;
|
|
185
|
+
throw new CallbackFailure("unresolvable", `the callback host ${host} does not resolve (${code})`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const refused = addresses.find((a) => NON_PUBLIC.check(a.address, a.family === 6 ? "ipv6" : "ipv4"));
|
|
189
|
+
// NULL-OK: field refused is OPTIONAL and therefore it is ok to check for NULL
|
|
190
|
+
if (refused !== undefined)
|
|
191
|
+
throw new CallbackFailure("non_public_address", `the callback host ${host} resolves to ${refused.address}, which is not a public address`);
|
|
192
|
+
const [first] = addresses;
|
|
193
|
+
return { url, host, address: first.address, family: first.family === 6 ? 6 : 4 };
|
|
194
|
+
}
|
|
195
|
+
/** One POST to the checked address with no redirect followed: the status and up to MAX_REPLY_BYTES of the answer. */
|
|
196
|
+
function post(target, headers, body) {
|
|
197
|
+
const pinned = (_host, options, done) => {
|
|
198
|
+
if (options.all === true)
|
|
199
|
+
done(null, [{ address: target.address, family: target.family }]);
|
|
200
|
+
else
|
|
201
|
+
done(null, target.address, target.family);
|
|
202
|
+
};
|
|
203
|
+
return new Promise((resolve, reject) => {
|
|
204
|
+
const req = request({
|
|
205
|
+
host: target.host,
|
|
206
|
+
port: target.url.port === "" ? 443 : Number(target.url.port),
|
|
207
|
+
path: `${target.url.pathname}${target.url.search}`,
|
|
208
|
+
method: "POST",
|
|
209
|
+
agent: false,
|
|
210
|
+
lookup: pinned,
|
|
211
|
+
servername: isIP(target.host) === 0 ? target.host : undefined,
|
|
212
|
+
ca: TEST_CA,
|
|
213
|
+
headers: { ...headers, "content-length": String(body.length) },
|
|
214
|
+
}, (res) => {
|
|
215
|
+
const chunks = [];
|
|
216
|
+
let size = 0;
|
|
217
|
+
res.on("data", (chunk) => {
|
|
218
|
+
if (size < MAX_REPLY_BYTES)
|
|
219
|
+
chunks.push(chunk);
|
|
220
|
+
size += chunk.length;
|
|
221
|
+
});
|
|
222
|
+
res.on("end", () => resolve({ status: res.statusCode ?? 0, text: Buffer.concat(chunks).subarray(0, MAX_REPLY_BYTES).toString("utf8") }));
|
|
223
|
+
res.on("error", (err) => reject(err instanceof CallbackFailure ? err : new CallbackFailure("unreachable", `the callback's answer broke off: ${err.code ?? err.message}`)));
|
|
224
|
+
});
|
|
225
|
+
const deadline = setTimeout(() => req.destroy(new CallbackFailure("timeout", `the callback did not finish answering within ${CALLBACK_TIMEOUT_MS / 1000} s`)), CALLBACK_TIMEOUT_MS);
|
|
226
|
+
req.on("close", () => clearTimeout(deadline));
|
|
227
|
+
req.on("error", (err) => reject(err instanceof CallbackFailure ? err : new CallbackFailure("unreachable", `the callback could not be reached: ${err.code ?? err.message}`)));
|
|
228
|
+
req.end(body);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
/** The challenge the callback echoed: a JSON body's `challenge`, else the body's text. */
|
|
232
|
+
function echoOf(text) {
|
|
233
|
+
try {
|
|
234
|
+
const parsed = JSON.parse(text);
|
|
235
|
+
if (parsed !== null && typeof parsed === "object" && typeof parsed.challenge === "string")
|
|
236
|
+
return parsed.challenge;
|
|
237
|
+
if (typeof parsed === "string")
|
|
238
|
+
return parsed;
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
if (!(err instanceof SyntaxError))
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
return text.trim();
|
|
245
|
+
}
|
|
246
|
+
function sameText(a, b) {
|
|
247
|
+
const x = Buffer.from(a);
|
|
248
|
+
const y = Buffer.from(b);
|
|
249
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
250
|
+
}
|
|
251
|
+
/** Sends the signed single-use challenge and accepts the callback only when a 2xx answer echoes it. */
|
|
252
|
+
async function verifyCallback(target, key, subscription) {
|
|
253
|
+
const challenge = randomBytes(32).toString("base64url");
|
|
254
|
+
const body = Buffer.from(JSON.stringify({ type: "verification", challenge }));
|
|
255
|
+
const reply = await post(target, signedHeaders(key, `msg_verification_${randomUUID()}`, body, subscription), body);
|
|
256
|
+
if (reply.status >= 300 && reply.status < 400)
|
|
257
|
+
throw new CallbackFailure("redirect", `the callback answered the challenge with a redirect (HTTP ${reply.status}), which is never followed`);
|
|
258
|
+
if (reply.status < 200 || reply.status >= 300)
|
|
259
|
+
throw new CallbackFailure("challenge_failed", `the callback answered the challenge with HTTP ${reply.status}`);
|
|
260
|
+
if (!sameText(echoOf(reply.text), challenge))
|
|
261
|
+
throw new CallbackFailure("challenge_failed", "the callback's answer did not echo the challenge");
|
|
262
|
+
}
|
|
263
|
+
/** The lifetime granted: exactly the ttlMs asked for, and no expiry when ttlMs is null or not sent. */
|
|
264
|
+
function grantedRefreshBefore(ttlMs) {
|
|
265
|
+
// NULL-OK: field ttlMs is OPTIONAL and therefore it is ok to check for NULL
|
|
266
|
+
if (ttlMs === undefined || ttlMs === null)
|
|
267
|
+
return null;
|
|
268
|
+
if (typeof ttlMs !== "number" || !Number.isFinite(ttlMs) || ttlMs < 0)
|
|
269
|
+
throw invalid("ttlMs must be a non-negative number, or null for no expiry");
|
|
270
|
+
const expiry = new Date(Date.now() + ttlMs);
|
|
271
|
+
if (Number.isNaN(expiry.getTime()))
|
|
272
|
+
throw invalid(`ttlMs ${ttlMs} is past the last date a timestamp can hold; send null for no expiry`);
|
|
273
|
+
return expiry.toISOString();
|
|
274
|
+
}
|
|
275
|
+
async function subscribe(principal, params) {
|
|
276
|
+
requireConversationEnabledAsMcp();
|
|
277
|
+
eventNamed(params.name);
|
|
278
|
+
const args = argumentsOf(params.arguments);
|
|
279
|
+
requireOwned(principal, args.conversation_id);
|
|
280
|
+
const delivery = deliveryOf(params.delivery);
|
|
281
|
+
const key = signingKey(delivery.secret);
|
|
282
|
+
const id = subscriptionId(principal, delivery.url, TURN_COMPLETED, args);
|
|
283
|
+
const refreshBefore = grantedRefreshBefore(params.ttlMs);
|
|
284
|
+
try {
|
|
285
|
+
await verifyCallback(await callbackTarget(delivery.url), key, id);
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
if (!(err instanceof CallbackFailure))
|
|
289
|
+
throw err;
|
|
290
|
+
throw new McpError(CALLBACK_ENDPOINT_ERROR, err.message, { reason: err.reason });
|
|
291
|
+
}
|
|
292
|
+
const subscription = { id, principal, name: TURN_COMPLETED, arguments: args, url: delivery.url, secret: delivery.secret, refreshBefore };
|
|
293
|
+
editStore((live) => [...live.filter((s) => s.id !== id), subscription]);
|
|
294
|
+
process.stderr.write(`[agent-gateway] mcp events subscribed ${id} conv=${args.conversation_id} refreshBefore=${refreshBefore ?? "none"}\n`);
|
|
295
|
+
return { id, refreshBefore, cursor: null, truncated: false };
|
|
296
|
+
}
|
|
297
|
+
function unsubscribe(principal, params) {
|
|
298
|
+
eventNamed(params.name);
|
|
299
|
+
const args = argumentsOf(params.arguments);
|
|
300
|
+
const { url } = deliveryOf(params.delivery);
|
|
301
|
+
const id = subscriptionId(principal, url, TURN_COMPLETED, args);
|
|
302
|
+
editStore((live) => live.filter((s) => s.id !== id));
|
|
303
|
+
return {};
|
|
304
|
+
}
|
|
305
|
+
/** The conversation switch refuses a new subscription as it refuses a tool call, in the switch's own words. */
|
|
306
|
+
function requireConversationEnabledAsMcp() {
|
|
307
|
+
try {
|
|
308
|
+
requireConversationEnabled();
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
if (!(err instanceof GatewayError))
|
|
312
|
+
throw err;
|
|
313
|
+
throw new McpError(ErrorCode.InvalidRequest, err.message);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const ListRequest = z.object({ method: z.literal("events/list"), params: z.object({}).passthrough().optional() });
|
|
317
|
+
const SubscribeRequest = z.object({ method: z.literal("events/subscribe"), params: z.object({}).passthrough() });
|
|
318
|
+
const UnsubscribeRequest = z.object({ method: z.literal("events/unsubscribe"), params: z.object({}).passthrough() });
|
|
319
|
+
/** Puts the three event methods on a door server, for the connection `principal` names. */
|
|
320
|
+
export function serveEvents(server, principal) {
|
|
321
|
+
server.setRequestHandler(ListRequest, () => ({ events: [EVENT_DEFINITION] }));
|
|
322
|
+
server.setRequestHandler(SubscribeRequest, (req) => subscribe(principal, req.params));
|
|
323
|
+
server.setRequestHandler(UnsubscribeRequest, (req) => unsubscribe(principal, req.params));
|
|
324
|
+
}
|
|
325
|
+
/** The event body, serialized once: the answer is cut, and marked, only as far as the 256 KiB limit needs. */
|
|
326
|
+
function eventBody(eventId, event) {
|
|
327
|
+
let text = event.text;
|
|
328
|
+
for (;;) {
|
|
329
|
+
const data = { conversation_id: event.id, turn: event.turn, outcome: event.kind === "answer" ? "answered" : "failed", text, text_truncated: text.length < event.text.length, ended_at: event.ts };
|
|
330
|
+
const body = Buffer.from(JSON.stringify({ eventId, name: TURN_COMPLETED, timestamp: event.ts, data, cursor: null }));
|
|
331
|
+
if (body.length <= MAX_EVENT_BYTES)
|
|
332
|
+
return body;
|
|
333
|
+
text = text.slice(0, Math.max(0, text.length - (body.length - MAX_EVENT_BYTES) - 64));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const logDelivery = (eventId, subscription, what) => {
|
|
337
|
+
const line = `[agent-gateway] mcp event ${eventId} sub=${subscription.id} conv=${subscription.arguments.conversation_id} ${what}`;
|
|
338
|
+
process.stderr.write(`${line.length > 200 ? `${line.slice(0, 197)}...` : line}\n`);
|
|
339
|
+
};
|
|
340
|
+
/** Retriable: a host that did not resolve, a callback not reached or not answering in time, 408, 429 and 5xx. A refused address and every other status are the last word on this event. */
|
|
341
|
+
const TRANSIENT_FAILURES = new Set(["unresolvable", "unreachable", "timeout"]);
|
|
342
|
+
const transient = (outcome) => (typeof outcome === "number" ? outcome === 408 || outcome === 429 || outcome >= 500 : TRANSIENT_FAILURES.has(outcome.reason));
|
|
343
|
+
/** One try: the callback's HTTP status, or why no status came back. */
|
|
344
|
+
async function attemptDelivery(subscription, key, eventId, body) {
|
|
345
|
+
try {
|
|
346
|
+
return (await post(await callbackTarget(subscription.url), signedHeaders(key, eventId, body, subscription.id), body)).status;
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
if (!(err instanceof CallbackFailure))
|
|
350
|
+
throw err;
|
|
351
|
+
return err;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/** Posts one event to one subscription, the same body and id on every try with a fresh signature, until it lands or a final answer comes. */
|
|
355
|
+
async function deliver(subscription, event) {
|
|
356
|
+
const eventId = `evt_${createHash("sha256").update(`${subscription.id}\n${event.id}\n${event.turn}`).digest("hex").slice(0, 32)}`;
|
|
357
|
+
const body = eventBody(eventId, event);
|
|
358
|
+
const key = signingKey(subscription.secret);
|
|
359
|
+
for (let attempt = 1;; attempt++) {
|
|
360
|
+
const outcome = await attemptDelivery(subscription, key, eventId, body);
|
|
361
|
+
if (typeof outcome === "number" && outcome >= 200 && outcome < 300)
|
|
362
|
+
return logDelivery(eventId, subscription, `delivered HTTP ${outcome} on try ${attempt}`);
|
|
363
|
+
if (outcome === 410) {
|
|
364
|
+
editStore((live) => live.filter((s) => s.id !== subscription.id));
|
|
365
|
+
return logDelivery(eventId, subscription, "the callback answered 410 Gone: the subscription is removed");
|
|
366
|
+
}
|
|
367
|
+
const answered = typeof outcome === "number" ? `HTTP ${outcome}` : `${outcome.reason}: ${outcome.message}`;
|
|
368
|
+
if (!transient(outcome))
|
|
369
|
+
return logDelivery(eventId, subscription, `refused by the callback (${answered}) on try ${attempt}; not retried`);
|
|
370
|
+
if (attempt === DELIVERY_TRIES)
|
|
371
|
+
return logDelivery(eventId, subscription, `not delivered after ${attempt} tries, last ${answered}`);
|
|
372
|
+
await new Promise((r) => setTimeout(r, DELIVERY_FIRST_WAIT_MS << (attempt - 1)));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/** Reads the live subscriptions of the turn's conversation and delivers to each, after the session driver's own emit has returned. */
|
|
376
|
+
async function reportTurn(event) {
|
|
377
|
+
await Promise.resolve();
|
|
378
|
+
for (const subscription of liveSubscriptions().filter((s) => s.arguments.conversation_id === event.id)) {
|
|
379
|
+
// OWNER: each delivery runs on its own and nothing awaits it — a failure no retry clears is written to the console with its event's conversation, turn and subscription.
|
|
380
|
+
void deliver(subscription, event).catch((err) => logDelivery(`turn=${event.turn}`, subscription, `not delivered: ${err instanceof Error ? err.message : String(err)}`));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
/** The session driver's listener: a turn that answered or failed is reported to every live subscription of its conversation. */
|
|
384
|
+
export function turnEnded(event) {
|
|
385
|
+
// NULL-OK: field turn is OPTIONAL and therefore it is ok to check for NULL
|
|
386
|
+
if (event.turn === undefined || (event.kind !== "answer" && event.kind !== "failed"))
|
|
387
|
+
return;
|
|
388
|
+
// OWNER: the report runs on its own after the turn is stored and nothing awaits it — a store that cannot be read is written to the console with the conversation and turn.
|
|
389
|
+
void reportTurn(event).catch((err) => process.stderr.write(`[agent-gateway] mcp events for conv=${event.id} turn=${event.turn} not reported: ${err instanceof Error ? err.message : String(err)}\n`));
|
|
390
|
+
}
|
|
@@ -372,7 +372,7 @@ export function attachNativeTerminal(owner, spec) {
|
|
|
372
372
|
const turn = turnInFlight(readEvents(meta.id), meta);
|
|
373
373
|
// NULL-OK: field turn is OPTIONAL and therefore it is ok to check for NULL
|
|
374
374
|
if (turn !== undefined)
|
|
375
|
-
throw new GatewayError(409, `Turn ${turn.turn} is ${turn.started ? "running" : "waiting to start"} since ${clockOf(turn.startedAt)} on this conversation, so a CLI cannot attach: an agent is already working its session, and a second one on the same session would be two agents on one job. Wait for that turn to end
|
|
375
|
+
throw new GatewayError(409, `Turn ${turn.turn} is ${turn.started ? "running" : "waiting to start"} since ${clockOf(turn.startedAt)} on this conversation, so a CLI cannot attach: an agent is already working its session, and a second one on the same session would be two agents on one job. Wait for that turn to end.`, { turn: turn.turn });
|
|
376
376
|
const native = spec.provider.nativeSession;
|
|
377
377
|
if (native === undefined)
|
|
378
378
|
throw new GatewayError(409, `${spec.provider.label} keeps no resumable session, so a browser CLI cannot run this conversation's own session — BN's turn driver answers its messages.`);
|
package/dist/gateway/restDoor.js
CHANGED
|
@@ -285,7 +285,7 @@ export function startRestDoor(settings) {
|
|
|
285
285
|
return;
|
|
286
286
|
}
|
|
287
287
|
if (err instanceof GatewayError) {
|
|
288
|
-
reply(res, err.status, { error: err.message });
|
|
288
|
+
reply(res, err.status, { ...err.facts, error: err.message });
|
|
289
289
|
return;
|
|
290
290
|
}
|
|
291
291
|
process.stderr.write(`[agent-gateway] ${req.method} ${req.url} failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
@@ -96,7 +96,7 @@ function show(name,heading){
|
|
|
96
96
|
return view;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
async function api(method,path,body,signal){var response=await fetch(path,{method:method,headers:{authorization:"Bearer "+token,"content-type":"application/json"},body:body===undefined?undefined:JSON.stringify(body),signal:signal});var data=await response.json();if(!response.ok){if(response.status===401)signIn("Sign in again to reach your CLI.");var error=Error(data.error||"Request failed");error.status=response.status;throw error;}return data;}
|
|
99
|
+
async function api(method,path,body,signal){var response=await fetch(path,{method:method,headers:{authorization:"Bearer "+token,"content-type":"application/json"},body:body===undefined?undefined:JSON.stringify(body),signal:signal});var data=await response.json();if(!response.ok){if(response.status===401)signIn("Sign in again to reach your CLI.");var error=Error(data.error||"Request failed");error.status=response.status;error.turn=data.turn;throw error;}return data;}
|
|
100
100
|
function fail(error){note.textContent=error.message;}
|
|
101
101
|
function ordered(id,act,body){if(!socket||socket.readyState!==WebSocket.OPEN)return;socket.send(JSON.stringify(Object.assign({kind:act},body)));}
|
|
102
102
|
/** The last folder of a path, so a row names its project without printing the whole path. */
|
|
@@ -156,9 +156,48 @@ async function openCli(meta){
|
|
|
156
156
|
}catch(error){
|
|
157
157
|
if(mine!==view)return;
|
|
158
158
|
if(error.status===404)return goneView(meta);
|
|
159
|
+
if(error.status===409&&error.turn!==undefined)return turnMirror(meta,error.turn);
|
|
159
160
|
goneView(meta,error.message);
|
|
160
161
|
}
|
|
161
162
|
}
|
|
163
|
+
/** A conversation whose turn is still running opens on that turn's own live output, drawn in the terminal pane (#3662):
|
|
164
|
+
* a turn started over MCP runs its CLI headless, so there is no terminal to attach to until it ends, and a second CLI on
|
|
165
|
+
* the same session would be two agents on one job. The moment that turn ends, openCli runs again and attaches the
|
|
166
|
+
* native CLI of this SAME session — its prompt, its history, its slash commands — with no second agent and nothing stopped. */
|
|
167
|
+
function turnMirror(meta,turn){
|
|
168
|
+
var mine=show("terminal","Native CLI · "+meta.id),toolbar=el("div","setup"),back=el("button","","↩ Back"),host=el("div");host.id="terminal";
|
|
169
|
+
main.style.display="flex";main.style.flexDirection="column";host.style.flex="1";host.style.minHeight="0";
|
|
170
|
+
toolbar.append(back,el("span","origin",originOf(meta)));main.append(toolbar,host);
|
|
171
|
+
var term=new Terminal({disableStdin:true,theme:{background:"#101217"},allowProposedApi:true}),fit=new FitAddon.FitAddon();
|
|
172
|
+
term.loadAddon(fit);term.loadAddon(new Unicode11Addon.Unicode11Addon());term.unicode.activeVersion="11";term.open(host);fit.fit();
|
|
173
|
+
var resized=new ResizeObserver(function(){fit.fit();});resized.observe(host);
|
|
174
|
+
var reading=new AbortController();
|
|
175
|
+
stopStream=function(){reading.abort();resized.disconnect();term.dispose();};
|
|
176
|
+
back.onclick=goBack;
|
|
177
|
+
note.textContent="Turn "+turn+" is running — its live output is shown here, and this session's native CLI opens here the moment it ends.";
|
|
178
|
+
var STYLE={thinking:"\\x1b[2;3m",doing:"\\x1b[36m⏺ ",turn:"\\x1b[1m",output:""};
|
|
179
|
+
function draw(frame){term.write((STYLE[frame.kind]||"")+String(frame.text).replace(/\\r?\\n/g,"\\r\\n")+"\\x1b[0m\\r\\n");}
|
|
180
|
+
function ends(frame){return frame.kind==="turn"&&frame.turn===turn&&(frame.event==="answer"||frame.event==="failed"||frame.event==="stopped");}
|
|
181
|
+
(async function(){
|
|
182
|
+
var response=await fetch("/v1/conversations/"+meta.id+"/stream",{headers:{authorization:"Bearer "+token},signal:reading.signal});
|
|
183
|
+
if(!response.ok)throw Error((await response.json()).error||"The live output could not be read.");
|
|
184
|
+
var reader=response.body.getReader(),decoder=new TextDecoder(),pending="";
|
|
185
|
+
for(;;){
|
|
186
|
+
var chunk=await reader.read();
|
|
187
|
+
if(chunk.done||mine!==view)return;
|
|
188
|
+
pending+=decoder.decode(chunk.value,{stream:true});
|
|
189
|
+
var blocks=pending.split("\\n\\n");pending=blocks.pop();
|
|
190
|
+
for(var block of blocks){
|
|
191
|
+
if(block.indexOf("data: ")!==0)continue;
|
|
192
|
+
var frame=JSON.parse(block.slice(6));draw(frame);
|
|
193
|
+
if(!ends(frame))continue;
|
|
194
|
+
var fresh=await api("GET","/v1/conversations/"+meta.id);
|
|
195
|
+
if(mine===view)openCli(fresh);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
})().catch(function(error){if(mine===view&&error.name!=="AbortError")note.textContent="The live output stopped: "+error.message;});
|
|
200
|
+
}
|
|
162
201
|
/** A conversation whose CLI is not there and cannot be opened right now, in plain words: the reason is the refusal when
|
|
163
202
|
* the attach was tried and said no, and absent when the CLI this machine held for it is simply gone — it ended, or BN
|
|
164
203
|
* was restarted past it (terminalDoor closes every console session as the gateway comes up). Its screen was never
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omni-notify-mcp",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.291",
|
|
4
4
|
"description": "Reach your AI agents from anywhere — and let them reach you. One stdio process serves the whole tool surface to Claude Code, GitHub Copilot Chat and Codex: desktop, Telegram, Slack, ntfy, Discord, Teams and SMS are configured in one file, and agents notify you when long work finishes or a decision is needed, ask you a question and wait for your answer, and receive and reply to the messages you send them — with Do Not Disturb and idle detection deciding what actually gets through.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -42,13 +42,14 @@
|
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/mailparser": "^3.4.6",
|
|
45
|
-
"esbuild": "^0.24.0",
|
|
46
45
|
"@types/node": "^20.0.0",
|
|
47
46
|
"@types/node-notifier": "^8.0.5",
|
|
48
47
|
"@types/nodemailer": "^7.0.2",
|
|
49
48
|
"@types/qrcode": "^1.5.6",
|
|
50
49
|
"@types/ws": "^8.18.1",
|
|
50
|
+
"esbuild": "^0.24.0",
|
|
51
51
|
"playwright": "^1.59.1",
|
|
52
|
+
"standardwebhooks": "^1.0.0",
|
|
52
53
|
"ts-node": "^10.9.2",
|
|
53
54
|
"typescript": "^5.4.0"
|
|
54
55
|
},
|