@cirvix_ai/agent-control 0.1.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/LICENSE +202 -0
- package/NOTICE +42 -0
- package/README.md +341 -0
- package/action/README.md +100 -0
- package/action/action.yml +134 -0
- package/action/report.mjs +144 -0
- package/bin/cirvix.mjs +1073 -0
- package/package.json +60 -0
- package/src/commands/demo.mjs +315 -0
- package/src/commands/init.mjs +558 -0
- package/src/commands/policy.mjs +345 -0
- package/src/commands/sarif.mjs +176 -0
- package/src/commands/scan.mjs +210 -0
- package/src/commands/status.mjs +208 -0
- package/src/commands/upgrade.mjs +162 -0
- package/src/core/approvals.mjs +388 -0
- package/src/core/audit.mjs +181 -0
- package/src/core/canonical.mjs +316 -0
- package/src/core/daemon.mjs +352 -0
- package/src/core/decisions.mjs +253 -0
- package/src/core/delegation.mjs +658 -0
- package/src/core/detect.mjs +337 -0
- package/src/core/entitlement-gate.mjs +100 -0
- package/src/core/entitlements.mjs +285 -0
- package/src/core/format.mjs +33 -0
- package/src/core/gateway.mjs +959 -0
- package/src/core/guard.mjs +568 -0
- package/src/core/http-transport.mjs +505 -0
- package/src/core/journal.mjs +419 -0
- package/src/core/jsonrpc.mjs +152 -0
- package/src/core/meter.mjs +225 -0
- package/src/core/normalize.mjs +516 -0
- package/src/core/notices.mjs +80 -0
- package/src/core/pipeline.mjs +629 -0
- package/src/core/policy-dsl.mjs +611 -0
- package/src/core/policy.mjs +710 -0
- package/src/core/prompts.mjs +146 -0
- package/src/core/risk.mjs +509 -0
- package/src/core/sanitize.mjs +279 -0
- package/src/core/secret-detect.mjs +533 -0
- package/src/core/secrets.mjs +312 -0
- package/src/core/uds.mjs +383 -0
- package/src/core/vault.mjs +530 -0
- package/src/index.mjs +143 -0
- package/src/testing.mjs +145 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP over HTTP — the second transport.
|
|
3
|
+
*
|
|
4
|
+
* The gateway already governs stdio, which is what editors use for local
|
|
5
|
+
* servers. Hosted MCP servers speak HTTP instead: Streamable HTTP (the current
|
|
6
|
+
* spec) and HTTP+SSE (the earlier one, still widely deployed). A control plane
|
|
7
|
+
* that only sees stdio is blind to exactly the servers a company did not write
|
|
8
|
+
* and cannot audit.
|
|
9
|
+
*
|
|
10
|
+
* ONE INTERFACE, TWO WIRES
|
|
11
|
+
*
|
|
12
|
+
* `HttpUpstream` presents the same surface as the stdio `Upstream` in
|
|
13
|
+
* `gateway.mjs` — `start`, `send`, `request`, `settle`, `stop`, `alive`,
|
|
14
|
+
* `tools` — so the gateway routes, namespaces, pins, and evaluates without
|
|
15
|
+
* knowing which transport a server is on. The decision path must not fork by
|
|
16
|
+
* transport, because two decision paths is two policies.
|
|
17
|
+
*
|
|
18
|
+
* WHAT THIS DELIBERATELY DOES NOT DO
|
|
19
|
+
*
|
|
20
|
+
* It does not follow redirects to a different origin, and it does not accept a
|
|
21
|
+
* server-supplied endpoint that points somewhere else. Both are how a proxy
|
|
22
|
+
* gets turned into an SSRF primitive: the agent asks to call a tool on
|
|
23
|
+
* `mcp.vendor.com`, the vendor answers `302 → http://169.254.169.254/…`, and
|
|
24
|
+
* the request now carries whatever ambient credentials the gateway's network
|
|
25
|
+
* position grants. The endpoint an operator configured is the only endpoint
|
|
26
|
+
* this talks to.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { MessageFramer } from "./jsonrpc.mjs";
|
|
30
|
+
|
|
31
|
+
/** How long a single JSON-RPC request may take before it is abandoned. */
|
|
32
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
33
|
+
|
|
34
|
+
/** Cap on a single response body. A hostile server must not exhaust memory. */
|
|
35
|
+
const MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Hosts a gateway will never connect to, whatever the configuration says.
|
|
39
|
+
*
|
|
40
|
+
* Link-local and metadata addresses are not a legitimate MCP endpoint under any
|
|
41
|
+
* deployment, and blocking them here means a typo, a copied config, or a
|
|
42
|
+
* compromised registry entry cannot turn the gateway into a credential thief.
|
|
43
|
+
*/
|
|
44
|
+
const FORBIDDEN_HOSTS = [
|
|
45
|
+
/^169\.254\./,
|
|
46
|
+
/^metadata\.google\.internal$/i,
|
|
47
|
+
/^metadata\.goog$/i,
|
|
48
|
+
/^100\.100\.100\.200$/,
|
|
49
|
+
/^\[?fd00:ec2::254\]?$/i,
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
export function assertAllowedEndpoint(url) {
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = new URL(url);
|
|
56
|
+
} catch {
|
|
57
|
+
throw new Error(`"${url}" is not a valid MCP endpoint URL.`);
|
|
58
|
+
}
|
|
59
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
60
|
+
throw new Error(`MCP over HTTP needs an http(s) URL; got ${parsed.protocol}`);
|
|
61
|
+
}
|
|
62
|
+
const host = parsed.hostname.toLowerCase();
|
|
63
|
+
if (FORBIDDEN_HOSTS.some((re) => re.test(host))) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Refusing to connect to ${host}: link-local and cloud-metadata addresses are never a legitimate MCP endpoint.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return parsed;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* -------------------------------------------------------------------------- */
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* An upstream MCP server reached over HTTP.
|
|
75
|
+
*
|
|
76
|
+
* Supports both shapes without the caller choosing:
|
|
77
|
+
*
|
|
78
|
+
* Streamable HTTP — POST the request, read the answer from the response body,
|
|
79
|
+
* which may be `application/json` (one message) or `text/event-stream` (a
|
|
80
|
+
* stream of them). This is the current spec.
|
|
81
|
+
*
|
|
82
|
+
* HTTP+SSE — GET the endpoint to open an event stream, receive an `endpoint`
|
|
83
|
+
* event naming where to POST, then POST requests there and read the answers
|
|
84
|
+
* off the stream. This is the 2024 spec, still deployed.
|
|
85
|
+
*
|
|
86
|
+
* Which one a server speaks is discovered on first contact rather than
|
|
87
|
+
* configured, because operators do not know and should not have to.
|
|
88
|
+
*/
|
|
89
|
+
export class HttpUpstream {
|
|
90
|
+
#pending = new Map();
|
|
91
|
+
#nextId = 1;
|
|
92
|
+
#abort = null;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {string} name
|
|
96
|
+
* @param {{url:string, headers?:object, timeoutMs?:number}} spec
|
|
97
|
+
* @param {{onMessage:Function, onExit:Function, log:Function}} hooks
|
|
98
|
+
*/
|
|
99
|
+
constructor(name, spec, { onMessage, onExit, log = () => {} } = {}) {
|
|
100
|
+
this.name = name;
|
|
101
|
+
this.spec = spec;
|
|
102
|
+
this.url = assertAllowedEndpoint(spec.url).toString();
|
|
103
|
+
this.headers = spec.headers ?? {};
|
|
104
|
+
this.timeoutMs = spec.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
105
|
+
this.onMessage = onMessage;
|
|
106
|
+
this.onExit = onExit;
|
|
107
|
+
this.log = log;
|
|
108
|
+
|
|
109
|
+
this.alive = false;
|
|
110
|
+
this.tools = new Map();
|
|
111
|
+
/** Set once the server tells us where to POST (HTTP+SSE mode). */
|
|
112
|
+
this.postUrl = null;
|
|
113
|
+
/** Streamable HTTP sessions are carried in this header. */
|
|
114
|
+
this.sessionId = null;
|
|
115
|
+
this.mode = "unknown";
|
|
116
|
+
this.fetchImpl = spec.fetchImpl ?? globalThis.fetch;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Opens the connection.
|
|
121
|
+
*
|
|
122
|
+
* Streamable HTTP needs no handshake — the first POST is the connection — so
|
|
123
|
+
* `start` only opens a stream when the server turns out to want one. It is
|
|
124
|
+
* marked alive optimistically and demoted on the first failed request, which
|
|
125
|
+
* matches the stdio upstream's behaviour: a dead server's tools disappear
|
|
126
|
+
* from `tools/list` rather than taking down the session.
|
|
127
|
+
*/
|
|
128
|
+
async start() {
|
|
129
|
+
this.alive = true;
|
|
130
|
+
this.#abort = new AbortController();
|
|
131
|
+
|
|
132
|
+
// Probe for the older HTTP+SSE shape. A server that does not implement GET
|
|
133
|
+
// answers 405 or 404, which is the signal that it is Streamable HTTP.
|
|
134
|
+
try {
|
|
135
|
+
const res = await this.fetchImpl(this.url, {
|
|
136
|
+
method: "GET",
|
|
137
|
+
headers: { accept: "text/event-stream", ...this.headers },
|
|
138
|
+
signal: this.#abort.signal,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (res.ok && (res.headers.get("content-type") ?? "").includes("text/event-stream")) {
|
|
142
|
+
this.mode = "sse";
|
|
143
|
+
void this.#pumpEventStream(res);
|
|
144
|
+
this.log(`upstream ${this.name}: HTTP+SSE`);
|
|
145
|
+
return this;
|
|
146
|
+
}
|
|
147
|
+
// Anything else means Streamable HTTP. Drain so the socket is released.
|
|
148
|
+
await res.body?.cancel().catch(() => {});
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (err?.name === "AbortError") return this;
|
|
151
|
+
this.log(`upstream ${this.name}: SSE probe failed (${err.message}); assuming Streamable HTTP`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
this.mode = "streamable";
|
|
155
|
+
this.log(`upstream ${this.name}: Streamable HTTP`);
|
|
156
|
+
return this;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Reads an SSE stream, dispatching each `data:` payload as a JSON-RPC
|
|
161
|
+
* message.
|
|
162
|
+
*
|
|
163
|
+
* SSE frames are separated by a blank line and a single event may span
|
|
164
|
+
* several `data:` lines. Treating each chunk as a frame is the bug that makes
|
|
165
|
+
* a proxy corrupt large payloads under load — the same class of bug the stdio
|
|
166
|
+
* framer exists to avoid.
|
|
167
|
+
*/
|
|
168
|
+
async #pumpEventStream(response) {
|
|
169
|
+
const reader = response.body?.getReader();
|
|
170
|
+
if (!reader) return;
|
|
171
|
+
|
|
172
|
+
const decoder = new TextDecoder();
|
|
173
|
+
let buffer = "";
|
|
174
|
+
let bytes = 0;
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
for (;;) {
|
|
178
|
+
const { done, value } = await reader.read();
|
|
179
|
+
if (done) break;
|
|
180
|
+
|
|
181
|
+
bytes += value.byteLength;
|
|
182
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
183
|
+
this.log(`upstream ${this.name}: event stream exceeded ${MAX_BODY_BYTES} bytes; closing`);
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
buffer += decoder.decode(value, { stream: true });
|
|
188
|
+
|
|
189
|
+
let split;
|
|
190
|
+
while ((split = buffer.search(/\r?\n\r?\n/)) !== -1) {
|
|
191
|
+
const rawEvent = buffer.slice(0, split);
|
|
192
|
+
buffer = buffer.slice(split).replace(/^\r?\n\r?\n/, "");
|
|
193
|
+
this.#handleEvent(rawEvent);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} catch (err) {
|
|
197
|
+
if (err?.name !== "AbortError") this.log(`upstream ${this.name}: stream error ${err.message}`);
|
|
198
|
+
} finally {
|
|
199
|
+
this.alive = false;
|
|
200
|
+
for (const [, entry] of this.#pending) entry.reject(new Error(`upstream ${this.name} stream closed`));
|
|
201
|
+
this.#pending.clear();
|
|
202
|
+
this.onExit?.(this);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#handleEvent(raw) {
|
|
207
|
+
let event = "message";
|
|
208
|
+
const data = [];
|
|
209
|
+
|
|
210
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
211
|
+
if (line.startsWith(":")) continue; // comment / keep-alive
|
|
212
|
+
const colon = line.indexOf(":");
|
|
213
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
214
|
+
const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
|
|
215
|
+
if (field === "event") event = value;
|
|
216
|
+
else if (field === "data") data.push(value);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const payload = data.join("\n");
|
|
220
|
+
if (!payload) return;
|
|
221
|
+
|
|
222
|
+
if (event === "endpoint") {
|
|
223
|
+
// The server names where to POST. Resolved against the configured URL and
|
|
224
|
+
// re-checked, so a server cannot redirect us to another origin.
|
|
225
|
+
try {
|
|
226
|
+
const resolved = new URL(payload, this.url);
|
|
227
|
+
const configured = new URL(this.url);
|
|
228
|
+
if (resolved.origin !== configured.origin) {
|
|
229
|
+
this.log(
|
|
230
|
+
`upstream ${this.name}: refused an endpoint on a different origin (${resolved.origin}); keeping ${configured.origin}`,
|
|
231
|
+
);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
assertAllowedEndpoint(resolved.toString());
|
|
235
|
+
this.postUrl = resolved.toString();
|
|
236
|
+
} catch (err) {
|
|
237
|
+
this.log(`upstream ${this.name}: bad endpoint event — ${err.message}`);
|
|
238
|
+
}
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let message;
|
|
243
|
+
try {
|
|
244
|
+
message = JSON.parse(payload);
|
|
245
|
+
} catch {
|
|
246
|
+
this.log(`upstream ${this.name}: unparseable SSE payload`);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
this.onMessage?.(this, message);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Fire-and-forget. Mirrors the stdio upstream's `send`. */
|
|
253
|
+
send(message) {
|
|
254
|
+
if (!this.alive) return false;
|
|
255
|
+
void this.#post(message).catch((err) => this.log(`upstream ${this.name}: ${err.message}`));
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async #post(message) {
|
|
260
|
+
const target = this.postUrl ?? this.url;
|
|
261
|
+
const timeout = AbortSignal.timeout(this.timeoutMs);
|
|
262
|
+
|
|
263
|
+
const res = await this.fetchImpl(target, {
|
|
264
|
+
method: "POST",
|
|
265
|
+
headers: {
|
|
266
|
+
"content-type": "application/json",
|
|
267
|
+
accept: "application/json, text/event-stream",
|
|
268
|
+
...(this.sessionId ? { "mcp-session-id": this.sessionId } : {}),
|
|
269
|
+
...this.headers,
|
|
270
|
+
},
|
|
271
|
+
body: JSON.stringify(message),
|
|
272
|
+
// Same-origin only. A cross-origin redirect is how this becomes an SSRF.
|
|
273
|
+
redirect: "error",
|
|
274
|
+
signal: timeout,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const session = res.headers.get("mcp-session-id");
|
|
278
|
+
if (session) this.sessionId = session;
|
|
279
|
+
|
|
280
|
+
if (!res.ok) {
|
|
281
|
+
throw new Error(`${this.name} answered ${res.status} to ${message.method ?? "a message"}`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
285
|
+
|
|
286
|
+
// 202 with no body: the answer will arrive on the event stream.
|
|
287
|
+
if (res.status === 202 || contentType === "") {
|
|
288
|
+
await res.body?.cancel().catch(() => {});
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (contentType.includes("text/event-stream")) {
|
|
293
|
+
await this.#pumpResponseStream(res);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const text = await res.text();
|
|
298
|
+
if (!text) return;
|
|
299
|
+
try {
|
|
300
|
+
const payload = JSON.parse(text);
|
|
301
|
+
for (const m of Array.isArray(payload) ? payload : [payload]) this.onMessage?.(this, m);
|
|
302
|
+
} catch {
|
|
303
|
+
this.log(`upstream ${this.name}: response body was not JSON`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** A response-scoped SSE stream (Streamable HTTP). Ends with the response. */
|
|
308
|
+
async #pumpResponseStream(response) {
|
|
309
|
+
const reader = response.body?.getReader();
|
|
310
|
+
if (!reader) return;
|
|
311
|
+
const decoder = new TextDecoder();
|
|
312
|
+
let buffer = "";
|
|
313
|
+
|
|
314
|
+
for (;;) {
|
|
315
|
+
const { done, value } = await reader.read();
|
|
316
|
+
if (done) break;
|
|
317
|
+
buffer += decoder.decode(value, { stream: true });
|
|
318
|
+
let split;
|
|
319
|
+
while ((split = buffer.search(/\r?\n\r?\n/)) !== -1) {
|
|
320
|
+
const rawEvent = buffer.slice(0, split);
|
|
321
|
+
buffer = buffer.slice(split).replace(/^\r?\n\r?\n/, "");
|
|
322
|
+
this.#handleEvent(rawEvent);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (buffer.trim()) this.#handleEvent(buffer);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Sends a request and resolves with the matching response. */
|
|
329
|
+
request(method, params, timeoutMs = this.timeoutMs) {
|
|
330
|
+
return new Promise((resolve, reject) => {
|
|
331
|
+
if (!this.alive) return reject(new Error(`upstream ${this.name} is not running`));
|
|
332
|
+
|
|
333
|
+
const id = `cx-${this.name}-${this.#nextId++}`;
|
|
334
|
+
const timer = setTimeout(() => {
|
|
335
|
+
this.#pending.delete(id);
|
|
336
|
+
reject(new Error(`upstream ${this.name} timed out on ${method}`));
|
|
337
|
+
}, timeoutMs);
|
|
338
|
+
|
|
339
|
+
this.#pending.set(id, {
|
|
340
|
+
resolve: (v) => {
|
|
341
|
+
clearTimeout(timer);
|
|
342
|
+
resolve(v);
|
|
343
|
+
},
|
|
344
|
+
reject: (e) => {
|
|
345
|
+
clearTimeout(timer);
|
|
346
|
+
reject(e);
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
void this.#post({ jsonrpc: "2.0", id, method, params }).catch((err) => {
|
|
351
|
+
const entry = this.#pending.get(id);
|
|
352
|
+
this.#pending.delete(id);
|
|
353
|
+
clearTimeout(timer);
|
|
354
|
+
// A failed request demotes the upstream rather than throwing into the
|
|
355
|
+
// gateway: one dead server must not take down the session.
|
|
356
|
+
this.alive = false;
|
|
357
|
+
entry?.reject(err) ?? reject(err);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
settle(message) {
|
|
363
|
+
const entry = this.#pending.get(message.id);
|
|
364
|
+
if (!entry) return false;
|
|
365
|
+
this.#pending.delete(message.id);
|
|
366
|
+
if (message.error) {
|
|
367
|
+
entry.reject(Object.assign(new Error(message.error.message), { rpc: message.error }));
|
|
368
|
+
} else entry.resolve(message.result);
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
stop() {
|
|
373
|
+
this.alive = false;
|
|
374
|
+
try {
|
|
375
|
+
this.#abort?.abort();
|
|
376
|
+
} catch {
|
|
377
|
+
/* already closed */
|
|
378
|
+
}
|
|
379
|
+
for (const [, entry] of this.#pending) entry.reject(new Error(`upstream ${this.name} stopped`));
|
|
380
|
+
this.#pending.clear();
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/* -------------------------------------------------------------------------- */
|
|
385
|
+
/* Serving */
|
|
386
|
+
/* -------------------------------------------------------------------------- */
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* The gateway's own HTTP face, for agents that connect over HTTP rather than
|
|
390
|
+
* spawning it over stdio.
|
|
391
|
+
*
|
|
392
|
+
* Streamable HTTP only. Implementing the deprecated HTTP+SSE shape on the
|
|
393
|
+
* serving side would mean maintaining a second session model for clients that
|
|
394
|
+
* can all speak the current one.
|
|
395
|
+
*
|
|
396
|
+
* BINDS TO LOOPBACK BY DEFAULT, AND SAYS SO IF YOU CHANGE IT. A policy engine
|
|
397
|
+
* listening on 0.0.0.0 with no authentication is a remote tool-execution
|
|
398
|
+
* service, and the person who set `--host 0.0.0.0` to reach it from a container
|
|
399
|
+
* did not mean to build one.
|
|
400
|
+
*/
|
|
401
|
+
export class HttpGatewayServer {
|
|
402
|
+
#server = null;
|
|
403
|
+
#sessions = new Map();
|
|
404
|
+
|
|
405
|
+
constructor({ gateway, host = "127.0.0.1", port = 8787, token = null, log = () => {} }) {
|
|
406
|
+
this.gateway = gateway;
|
|
407
|
+
this.host = host;
|
|
408
|
+
this.port = port;
|
|
409
|
+
this.token = token;
|
|
410
|
+
this.log = log;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async start() {
|
|
414
|
+
const { createServer } = await import("node:http");
|
|
415
|
+
|
|
416
|
+
if (this.host !== "127.0.0.1" && this.host !== "localhost" && !this.token) {
|
|
417
|
+
throw new Error(
|
|
418
|
+
`Refusing to listen on ${this.host} without a token. A gateway reachable off-host with no authentication is a remote tool-execution service. Pass --token, or bind to 127.0.0.1.`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
this.#server = createServer((req, res) => void this.#handle(req, res));
|
|
423
|
+
await new Promise((resolve, reject) => {
|
|
424
|
+
this.#server.once("error", reject);
|
|
425
|
+
this.#server.listen(this.port, this.host, () => {
|
|
426
|
+
this.#server.removeListener("error", reject);
|
|
427
|
+
resolve();
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
this.log(`http gateway listening on http://${this.host}:${this.port}`);
|
|
432
|
+
return this;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async #handle(req, res) {
|
|
436
|
+
const send = (status, body, headers = {}) => {
|
|
437
|
+
const payload = typeof body === "string" ? body : JSON.stringify(body);
|
|
438
|
+
res.writeHead(status, {
|
|
439
|
+
"content-type": "application/json",
|
|
440
|
+
"cache-control": "no-store",
|
|
441
|
+
...headers,
|
|
442
|
+
});
|
|
443
|
+
res.end(payload);
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
if (this.token) {
|
|
447
|
+
const auth = req.headers.authorization ?? "";
|
|
448
|
+
if (auth !== `Bearer ${this.token}`) {
|
|
449
|
+
return send(401, { error: "unauthorized" });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (req.method === "GET" && req.url === "/health") {
|
|
454
|
+
return send(200, { ok: true, rules: this.gateway.rules.length, stats: this.gateway.stats });
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (req.method !== "POST") {
|
|
458
|
+
return send(405, { error: "This endpoint speaks Streamable HTTP: POST a JSON-RPC message." });
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
let raw = "";
|
|
462
|
+
let bytes = 0;
|
|
463
|
+
for await (const chunk of req) {
|
|
464
|
+
bytes += chunk.length;
|
|
465
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
466
|
+
req.destroy();
|
|
467
|
+
return send(413, { error: "request too large" });
|
|
468
|
+
}
|
|
469
|
+
raw += chunk;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
let message;
|
|
473
|
+
try {
|
|
474
|
+
message = JSON.parse(raw);
|
|
475
|
+
} catch {
|
|
476
|
+
return send(400, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// The gateway writes answers through a callback; collect them for this
|
|
480
|
+
// request and answer in one body.
|
|
481
|
+
const outbound = [];
|
|
482
|
+
const previousWrite = this.gateway.write;
|
|
483
|
+
this.gateway.write = (m) => outbound.push(m);
|
|
484
|
+
|
|
485
|
+
try {
|
|
486
|
+
await this.gateway.handleClientMessage(message);
|
|
487
|
+
} finally {
|
|
488
|
+
this.gateway.write = previousWrite;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (!outbound.length) return send(202, "");
|
|
492
|
+
return send(200, outbound.length === 1 ? outbound[0] : outbound);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async stop() {
|
|
496
|
+
for (const s of this.#sessions.values()) s.destroy?.();
|
|
497
|
+
this.#sessions.clear();
|
|
498
|
+
await new Promise((resolve) => (this.#server ? this.#server.close(resolve) : resolve()));
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Reads an SSE stream out of a `Response`, for callers that want the frames. */
|
|
503
|
+
export function sseFramer(onMessage, onInvalid = () => {}) {
|
|
504
|
+
return new MessageFramer({ onMessage, onInvalid });
|
|
505
|
+
}
|