mandala-computer-mcp 0.7.0 → 0.9.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/README.md +232 -29
- package/dist/api.d.ts +6 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +13 -3
- package/dist/api.js.map +1 -1
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +76 -1
- package/dist/cli.js.map +1 -1
- package/dist/credentials.d.ts +55 -0
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +104 -2
- package/dist/credentials.js.map +1 -1
- package/dist/errors.d.ts +57 -16
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +112 -42
- package/dist/errors.js.map +1 -1
- package/dist/format.d.ts +17 -2
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +27 -4
- package/dist/format.js.map +1 -1
- package/dist/http.d.ts +58 -0
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +713 -57
- package/dist/http.js.map +1 -1
- package/dist/paths.d.ts +18 -11
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +31 -14
- package/dist/paths.js.map +1 -1
- package/dist/secret-store.d.ts +2 -1
- package/dist/secret-store.d.ts.map +1 -1
- package/dist/secret-store.js +6 -1
- package/dist/secret-store.js.map +1 -1
- package/dist/server.d.ts +2 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -1
- package/dist/server.js.map +1 -1
- package/dist/session.d.ts +24 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +6 -0
- package/dist/session.js.map +1 -1
- package/dist/stdio.d.ts.map +1 -1
- package/dist/stdio.js +9 -1
- package/dist/stdio.js.map +1 -1
- package/dist/tool-filters.d.ts +1 -1
- package/dist/tool-filters.d.ts.map +1 -1
- package/dist/tool-filters.js +3 -0
- package/dist/tool-filters.js.map +1 -1
- package/dist/tools/account.d.ts.map +1 -1
- package/dist/tools/account.js +80 -6
- package/dist/tools/account.js.map +1 -1
- package/dist/tools/agent.js +12 -23
- package/dist/tools/agent.js.map +1 -1
- package/dist/tools/chat.d.ts.map +1 -1
- package/dist/tools/chat.js +56 -7
- package/dist/tools/chat.js.map +1 -1
- package/dist/tools/computers.d.ts.map +1 -1
- package/dist/tools/computers.js +93 -8
- package/dist/tools/computers.js.map +1 -1
- package/dist/tools/guest.d.ts.map +1 -1
- package/dist/tools/guest.js +28 -3
- package/dist/tools/guest.js.map +1 -1
- package/dist/tools/input.d.ts.map +1 -1
- package/dist/tools/input.js +98 -48
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/operations.d.ts +7 -2
- package/dist/tools/operations.d.ts.map +1 -1
- package/dist/tools/operations.js +29 -6
- package/dist/tools/operations.js.map +1 -1
- package/dist/tools/secrets.d.ts.map +1 -1
- package/dist/tools/secrets.js +80 -25
- package/dist/tools/secrets.js.map +1 -1
- package/dist/tools/snapshots.d.ts.map +1 -1
- package/dist/tools/snapshots.js +21 -8
- package/dist/tools/snapshots.js.map +1 -1
- package/dist/tools/templates.js +2 -2
- package/dist/tools/templates.js.map +1 -1
- package/dist/tools/webhooks.js +1 -1
- package/dist/tools/webhooks.js.map +1 -1
- package/dist/version.d.ts +8 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/package.json +1 -1
package/dist/http.js
CHANGED
|
@@ -7,7 +7,7 @@ import express, {} from 'express';
|
|
|
7
7
|
import { Api, MODEL_KEY_HEADER } from './api.js';
|
|
8
8
|
import { APIError } from './errors.js';
|
|
9
9
|
import { MeteredBody } from './http-body.js';
|
|
10
|
-
import {
|
|
10
|
+
import { WHOAMI } from './paths.js';
|
|
11
11
|
import { createServer, SERVER_NAME, SERVER_VERSION } from './server.js';
|
|
12
12
|
import { toolFilter } from './tool-filters.js';
|
|
13
13
|
/** The OAuth scope the hosted resource asks for. */
|
|
@@ -66,7 +66,28 @@ const DEFAULT_TTL_MS = 30 * 60 * 1000;
|
|
|
66
66
|
* any string gets that far; without a cap, a loop of initializes is a memory
|
|
67
67
|
* exhaustion that costs the sender nothing.
|
|
68
68
|
*/
|
|
69
|
-
const DEFAULT_MAX_SESSIONS = 256;
|
|
69
|
+
export const DEFAULT_MAX_SESSIONS = 256;
|
|
70
|
+
/**
|
|
71
|
+
* A ceiling on live sessions per bearer, under the one above.
|
|
72
|
+
*
|
|
73
|
+
* The process-wide cap bounds memory, but on its own it is a pool one caller
|
|
74
|
+
* can drain: a single accepted bearer could open every one of the 256 sessions
|
|
75
|
+
* and every other tenant's initialize was 503'd until the sweep, half an hour
|
|
76
|
+
* later. 16 leaves room for one person running several MCP clients on one key
|
|
77
|
+
* and still leaves the pool to everybody else.
|
|
78
|
+
*
|
|
79
|
+
* A suspended account's bearer is admitted (OPL-5437: it is how its holder
|
|
80
|
+
* reaches `whoami` and learns it is suspended), and one session is all that
|
|
81
|
+
* needs. Every other call it could make is refused by the platform. One
|
|
82
|
+
* session for the ACCOUNT, however many keys or tokens it holds (OPL-5452):
|
|
83
|
+
* see `standing` in {@link runHttp}.
|
|
84
|
+
*/
|
|
85
|
+
const DEFAULT_MAX_SESSIONS_PER_BEARER = 16;
|
|
86
|
+
const MAX_SESSIONS_SUSPENDED = 1;
|
|
87
|
+
/** How long a caller whose bearer holds its maximum is asked to wait, in s. */
|
|
88
|
+
const BEARER_FULL_RETRY_AFTER_S = 30;
|
|
89
|
+
/** How long a caller is asked to wait when the whole pool is full, in s. */
|
|
90
|
+
const POOL_FULL_RETRY_AFTER_S = 30;
|
|
70
91
|
const DEFAULT_MAX_LARGE_BODY_PARSES = 4;
|
|
71
92
|
const SMALL_BODY_BYTES = 256 * 1024;
|
|
72
93
|
/**
|
|
@@ -212,64 +233,122 @@ export async function runHttp(cfg) {
|
|
|
212
233
|
const checkTtl = cfg.bearerCheckTtlMs ?? DEFAULT_BEARER_CHECK_TTL_MS;
|
|
213
234
|
const accepted = new Map();
|
|
214
235
|
let checksInFlight = 0;
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
236
|
+
const standing = new Map();
|
|
237
|
+
let probeSeq = 0;
|
|
238
|
+
/** Whether the latest answer about this account says it is suspended. */
|
|
239
|
+
const accountSuspended = (account) => standing.get(account)?.suspended === true;
|
|
240
|
+
/** Take a probe's answer for its account, unless a newer-started one answered already. */
|
|
241
|
+
const noteStanding = (account, suspended, seq) => {
|
|
242
|
+
const known = standing.get(account);
|
|
243
|
+
if (known && known.seq >= seq)
|
|
244
|
+
return;
|
|
245
|
+
// Re-inserted, so Map order is the order answers were taken in.
|
|
246
|
+
standing.delete(account);
|
|
247
|
+
if (standing.size >= MAX_ACCEPTED_BEARERS) {
|
|
248
|
+
// An account holding no session needs no answer kept: a later request
|
|
249
|
+
// or initialize for it finds none and asks again.
|
|
250
|
+
const holding = new Set();
|
|
251
|
+
for (const live of sessions.values())
|
|
252
|
+
holding.add(live.account);
|
|
253
|
+
for (const k of standing.keys())
|
|
254
|
+
if (!holding.has(k))
|
|
255
|
+
standing.delete(k);
|
|
256
|
+
if (standing.size >= MAX_ACCEPTED_BEARERS) {
|
|
257
|
+
standing.delete(standing.keys().next().value);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
standing.set(account, { suspended, seq });
|
|
261
|
+
};
|
|
262
|
+
/** The platform's acceptance of this bearer, while the cache window lasts. */
|
|
263
|
+
const acceptance = (key) => {
|
|
264
|
+
const entry = accepted.get(digest(key).toString('hex'));
|
|
265
|
+
return entry !== undefined && entry.until > Date.now() ? entry : undefined;
|
|
219
266
|
};
|
|
267
|
+
/** Whether the platform accepted this bearer within the cache window. */
|
|
268
|
+
const isAccepted = (key) => acceptance(key) !== undefined;
|
|
220
269
|
/**
|
|
221
270
|
* `ok`: the probe answered 2xx, and only that — the one answer that says the
|
|
222
|
-
* platform authenticated this credential AND let it act. `
|
|
223
|
-
*
|
|
224
|
-
*
|
|
271
|
+
* platform authenticated this credential AND let it act. `suspended`: the
|
|
272
|
+
* same 2xx, for an account whose standing says `status: "suspended"` —
|
|
273
|
+
* admitted as `ok` is, and held to one session in all. `refused`: a 401.
|
|
274
|
+
* `unknown`: anything else — a 403 (a lost membership, say; a suspended
|
|
275
|
+
* account is answered 2xx on whoami and admitted), a 404 (the route moved), a 429, a 5xx, a timeout, or this
|
|
225
276
|
* server already asking about as many bearers as it will at once. Only `ok`
|
|
226
|
-
*
|
|
277
|
+
* and `suspended` are cached: the account key with the acceptance, the
|
|
278
|
+
* standing per account. `account` is the account key the bearer counts against (see
|
|
279
|
+
* {@link accountKey}); it is set whenever the verdict is `ok` or
|
|
280
|
+
* `suspended`, and returned rather than read back from the cache, which a
|
|
281
|
+
* zero TTL or a full cache may already have let go of. `workspace` is the
|
|
282
|
+
* workspace key (see {@link workspaceKey}), returned the same way, when
|
|
283
|
+
* whoami named a workspace for an account it named (OPL-5453).
|
|
227
284
|
*/
|
|
228
285
|
// `onStart` runs only when a probe really starts — never for a cached answer
|
|
229
286
|
// or a full cap — and synchronously, before the first await, so a caller's
|
|
230
287
|
// bookkeeping and its check against it cannot interleave with another's.
|
|
288
|
+
//
|
|
289
|
+
// `ok` and `suspended` are the account's STANDING when this returns, which
|
|
290
|
+
// a newer-started probe of another of its bearers may have set, not what
|
|
291
|
+
// this bearer's own probe (or cached acceptance) said (OPL-5452).
|
|
231
292
|
const checkBearer = async (key, onStart) => {
|
|
232
293
|
const id = digest(key).toString('hex');
|
|
233
|
-
|
|
234
|
-
|
|
294
|
+
const cached = acceptance(key);
|
|
295
|
+
// Believed only while its account has a standing to read (see above).
|
|
296
|
+
if (cached && standing.has(cached.account)) {
|
|
297
|
+
return {
|
|
298
|
+
verdict: accountSuspended(cached.account) ? 'suspended' : 'ok',
|
|
299
|
+
account: cached.account,
|
|
300
|
+
workspace: cached.workspace,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
235
303
|
if (checksInFlight >= MAX_BEARER_CHECKS_IN_FLIGHT)
|
|
236
|
-
return 'unknown';
|
|
304
|
+
return { verdict: 'unknown' };
|
|
237
305
|
checksInFlight++;
|
|
306
|
+
// Numbered as it starts, before the first await: see `standing`.
|
|
307
|
+
const seq = ++probeSeq;
|
|
238
308
|
onStart?.();
|
|
309
|
+
let suspended;
|
|
310
|
+
let confirmed;
|
|
311
|
+
let workspaceId;
|
|
239
312
|
try {
|
|
240
313
|
const api = new Api(key, cfg.baseUrl, AbortSignal.timeout(BEARER_CHECK_TIMEOUT_MS), {
|
|
241
314
|
serviceSecret,
|
|
242
315
|
});
|
|
243
|
-
// `GET
|
|
244
|
-
//
|
|
245
|
-
// gives it the lowest role there is (viewer)
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
// sent.
|
|
253
|
-
await api.json('GET',
|
|
316
|
+
// `GET whoami`, chosen because every valid credential is answered 2xx
|
|
317
|
+
// on it, a suspended account's included. The platform's route table
|
|
318
|
+
// gives it the lowest role there is (viewer) and marks it readable by a
|
|
319
|
+
// suspended account, and the handler answers from the control plane's
|
|
320
|
+
// own tables without asking any host — so no role a grant can carry, no
|
|
321
|
+
// workspace scope, no account standing and no host being down turns it
|
|
322
|
+
// into a refusal. It was `GET ssh-keys`, which a suspended account is
|
|
323
|
+
// refused (403): its holder was answered 503 on every initialize and so
|
|
324
|
+
// could never reach whoami, the one tool that would say it is suspended
|
|
325
|
+
// (OPL-5437). It takes no parameters, so none are sent.
|
|
326
|
+
const who = await api.json('GET', WHOAMI);
|
|
327
|
+
suspended = isSuspended(who);
|
|
328
|
+
confirmed = accountIdOf(who);
|
|
329
|
+
workspaceId = workspaceIdOf(who);
|
|
254
330
|
}
|
|
255
331
|
catch (err) {
|
|
256
|
-
return err instanceof APIError && err.status === 401 ? 'refused' : 'unknown';
|
|
332
|
+
return { verdict: err instanceof APIError && err.status === 401 ? 'refused' : 'unknown' };
|
|
257
333
|
}
|
|
258
334
|
finally {
|
|
259
335
|
checksInFlight--;
|
|
260
336
|
}
|
|
261
337
|
const now = Date.now();
|
|
262
338
|
if (accepted.size >= MAX_ACCEPTED_BEARERS) {
|
|
263
|
-
for (const [k,
|
|
264
|
-
if (
|
|
339
|
+
for (const [k, e] of accepted)
|
|
340
|
+
if (e.until <= now)
|
|
265
341
|
accepted.delete(k);
|
|
266
342
|
// Still full: drop the oldest, which Map iteration yields first.
|
|
267
343
|
if (accepted.size >= MAX_ACCEPTED_BEARERS) {
|
|
268
344
|
accepted.delete(accepted.keys().next().value);
|
|
269
345
|
}
|
|
270
346
|
}
|
|
271
|
-
|
|
272
|
-
|
|
347
|
+
const account = accountKey(id, confirmed);
|
|
348
|
+
const workspace = workspaceKey(account, workspaceId);
|
|
349
|
+
accepted.set(id, { until: now + checkTtl, account, workspace });
|
|
350
|
+
noteStanding(account, suspended, seq);
|
|
351
|
+
return { verdict: accountSuspended(account) ? 'suspended' : 'ok', account, workspace };
|
|
273
352
|
};
|
|
274
353
|
// Refused initializes per source, in fixed one-minute windows. Kept apart
|
|
275
354
|
// from the session cap so a flood of invented tokens is turned away before
|
|
@@ -350,6 +429,50 @@ export async function runHttp(cfg) {
|
|
|
350
429
|
// the same `sessions.size`, all of them pass, and the cap bounds nothing —
|
|
351
430
|
// which is the exact memory exhaustion it was put here to stop.
|
|
352
431
|
let pending = 0;
|
|
432
|
+
// The same reservations again, per bearer digest (hex), for the per-bearer
|
|
433
|
+
// cap and for the same reason: a burst of initializes from one bearer would
|
|
434
|
+
// otherwise all count the same sessions and all pass. An entry is deleted
|
|
435
|
+
// when it reaches zero, so the map holds only bearers mid-initialize.
|
|
436
|
+
const pendingByDigest = new Map();
|
|
437
|
+
// Normalised, because the cap is also arithmetic against the process-wide
|
|
438
|
+
// one: a NaN here (`Number(process.env.X)` with X unset) made every
|
|
439
|
+
// comparison below false and switched off BOTH caps, the 256 backstop
|
|
440
|
+
// included. Anything that is not a number of at least one is the default;
|
|
441
|
+
// Infinity is let through, and leaves only the process-wide cap.
|
|
442
|
+
const perBearer = cfg.maxSessionsPerBearer;
|
|
443
|
+
const maxPerBearer = typeof perBearer === 'number' && perBearer >= 1
|
|
444
|
+
? Math.floor(perBearer)
|
|
445
|
+
: DEFAULT_MAX_SESSIONS_PER_BEARER;
|
|
446
|
+
// The same reservations per account (OPL-5447): an account may hold any
|
|
447
|
+
// number of bearers, so a burst spread across its keys would otherwise all
|
|
448
|
+
// count the same sessions and all pass the account ceiling.
|
|
449
|
+
const pendingByAccount = new Map();
|
|
450
|
+
// The ceiling no account may pass, live plus pending: a quarter of the pool
|
|
451
|
+
// by default (OPL-5454). Half let two accounts take the whole of it between
|
|
452
|
+
// them, however many others were waiting. Normalised as the per-bearer cap
|
|
453
|
+
// is, and clamped to the pool.
|
|
454
|
+
const perAccount = cfg.maxSessionsPerAccount;
|
|
455
|
+
const maxPerAccount = typeof perAccount === 'number' && perAccount >= 1
|
|
456
|
+
? Math.min(Math.floor(perAccount), maxSessions)
|
|
457
|
+
: Math.max(1, Math.floor(maxSessions / 4));
|
|
458
|
+
// The same reservations per workspace (OPL-5453), keyed by workspace key and
|
|
459
|
+
// kept only for workspace-scoped bearers, for the same reason again.
|
|
460
|
+
const pendingByWorkspace = new Map();
|
|
461
|
+
// The sub-ceiling no workspace may pass under its account's: a quarter of
|
|
462
|
+
// the account's ceiling by default, the EFFECTIVE one, so raising only the
|
|
463
|
+
// account ceiling raises this with it. Normalised likewise, and clamped to
|
|
464
|
+
// the account ceiling, which holds a workspace whatever this says.
|
|
465
|
+
const perWorkspace = cfg.maxSessionsPerWorkspace;
|
|
466
|
+
const maxPerWorkspace = typeof perWorkspace === 'number' && perWorkspace >= 1
|
|
467
|
+
? Math.min(Math.floor(perWorkspace), maxPerAccount)
|
|
468
|
+
: Math.max(1, Math.floor(maxPerAccount / 4));
|
|
469
|
+
// The last quarter of the pool, kept for accounts holding less than their
|
|
470
|
+
// share of the rest (OPL-5454; see the initialize's admission). None in a
|
|
471
|
+
// pool under 4. Not configurable. The share is taken of the UNRESERVED part:
|
|
472
|
+
// of the whole pool, a lone account would always hold less than its share
|
|
473
|
+
// and could take the reserve too, which would then protect nobody.
|
|
474
|
+
const poolReserve = Math.floor(maxSessions / 4);
|
|
475
|
+
const unreserved = maxSessions - poolReserve;
|
|
353
476
|
// Two parsers, chosen by whether this server has already checked who is
|
|
354
477
|
// asking.
|
|
355
478
|
//
|
|
@@ -587,21 +710,223 @@ export async function runHttp(cfg) {
|
|
|
587
710
|
* exists for, a laptop that slept and left the socket half-open, is exactly
|
|
588
711
|
* the one where `close` never fires to undo the count. The transport and its
|
|
589
712
|
* fully-registered server would sit on a `maxSessions` slot forever.
|
|
713
|
+
*
|
|
714
|
+
* `heard` says whether this traffic may stamp the session as heard from; it
|
|
715
|
+
* is asked at the start and again at the end. Only `active` is unconditional,
|
|
716
|
+
* so a session with work in flight is never swept whatever `heard` says.
|
|
590
717
|
*/
|
|
591
|
-
const beginActivity = (live) => {
|
|
718
|
+
const beginActivity = (live, heard = always) => {
|
|
592
719
|
live.active++;
|
|
593
|
-
|
|
720
|
+
if (heard())
|
|
721
|
+
live.lastSeen = Date.now();
|
|
594
722
|
let held = true;
|
|
595
723
|
return () => {
|
|
596
724
|
if (!held)
|
|
597
725
|
return;
|
|
598
726
|
held = false;
|
|
599
727
|
live.active--;
|
|
600
|
-
|
|
728
|
+
if (heard())
|
|
729
|
+
live.lastSeen = Date.now();
|
|
601
730
|
};
|
|
602
731
|
};
|
|
603
|
-
|
|
604
|
-
|
|
732
|
+
/**
|
|
733
|
+
* Whether traffic on this bearer that carries no request may keep its
|
|
734
|
+
* session from idling out (OPL-5455). Hosted, only while the platform's
|
|
735
|
+
* acceptance of the bearer is still cached: a notification, a response or
|
|
736
|
+
* the standing stream never asks the platform, so without this a revoked
|
|
737
|
+
* token's holder could keep its sessions (and its account's ceiling) held
|
|
738
|
+
* for ever by sending nothing but those. Once the acceptance lapses and no
|
|
739
|
+
* request renews it, they idle out after the session TTL. A cache peek, never
|
|
740
|
+
* a probe. Self-hosted: always.
|
|
741
|
+
*/
|
|
742
|
+
const heardWhileAccepted = (key) => challenge ? () => isAccepted(key) : always;
|
|
743
|
+
/** The live sessions opened with this bearer, by its digest. */
|
|
744
|
+
const sessionsOf = (keyDigest) => {
|
|
745
|
+
const own = [];
|
|
746
|
+
for (const entry of sessions) {
|
|
747
|
+
const theirs = entry[1].keyDigest;
|
|
748
|
+
if (theirs.length === keyDigest.length && timingSafeEqual(theirs, keyDigest))
|
|
749
|
+
own.push(entry);
|
|
750
|
+
}
|
|
751
|
+
return own;
|
|
752
|
+
};
|
|
753
|
+
/**
|
|
754
|
+
* The live sessions counted against this account, whichever bearer opened
|
|
755
|
+
* them. One whose bearer the platform has refused is included: it holds its
|
|
756
|
+
* transport and its slot in the pool until it is closed, so leaving it out
|
|
757
|
+
* would let an account that revokes its own keys hold more than its
|
|
758
|
+
* ceiling, up to the whole pool.
|
|
759
|
+
*/
|
|
760
|
+
const sessionsOfAccount = (account) => {
|
|
761
|
+
const counted = [];
|
|
762
|
+
for (const entry of sessions)
|
|
763
|
+
if (entry[1].account === account)
|
|
764
|
+
counted.push(entry);
|
|
765
|
+
return counted;
|
|
766
|
+
};
|
|
767
|
+
/**
|
|
768
|
+
* The live sessions counted against this workspace key (OPL-5453; see
|
|
769
|
+
* {@link workspaceKey}), whichever bearer opened them, refused ones
|
|
770
|
+
* included for the reason {@link sessionsOfAccount} gives.
|
|
771
|
+
*/
|
|
772
|
+
const sessionsOfWorkspace = (workspace) => {
|
|
773
|
+
const counted = [];
|
|
774
|
+
for (const entry of sessions)
|
|
775
|
+
if (entry[1].workspace === workspace)
|
|
776
|
+
counted.push(entry);
|
|
777
|
+
return counted;
|
|
778
|
+
};
|
|
779
|
+
/**
|
|
780
|
+
* The sessions to close so that one more fits under this bearer's cap, its
|
|
781
|
+
* account's ceiling (OPL-5447) and, for a workspace-scoped bearer, its
|
|
782
|
+
* workspace's sub-ceiling (OPL-5453), or which of them it cannot fit under:
|
|
783
|
+
* `bearer`, then `workspace`, then `account`, when more than one cannot be
|
|
784
|
+
* met.
|
|
785
|
+
*
|
|
786
|
+
* Only idle sessions (`active === 0`, what the sweeper closes) are
|
|
787
|
+
* candidates, and of two kinds. The bearer's own, which make room under
|
|
788
|
+
* whichever limit binds: its own cap, as before, or its account's ceiling.
|
|
789
|
+
* And the account's sessions of its other bearers that the platform has
|
|
790
|
+
* refused: nothing more is served on those, yet they count against the
|
|
791
|
+
* ceiling until closed, so without this a revoked key would hold its
|
|
792
|
+
* account's other keys out until the sweep. Refused ones go first, then the
|
|
793
|
+
* least recently seen. A live session of another bearer is never a
|
|
794
|
+
* candidate, and neither is anything of another account; an own session
|
|
795
|
+
* counts toward the ceiling's need only if it counts against this account,
|
|
796
|
+
* and any candidate toward the workspace's only if it counts against this
|
|
797
|
+
* workspace. A candidate is taken only if it meets a need that is still
|
|
798
|
+
* unmet.
|
|
799
|
+
*
|
|
800
|
+
* `suspended` (hosted mode, the account's standing, OPL-5452) lowers both
|
|
801
|
+
* limits to one, and makes every idle session of the account a candidate,
|
|
802
|
+
* whichever bearer opened it: a suspended account holds one session in all,
|
|
803
|
+
* and the bearer asking now (a refreshed token, say) may take it over while
|
|
804
|
+
* it is idle. One with a request in flight is still never a candidate. The
|
|
805
|
+
* workspace sub-ceiling is not applied then: the account's one session is
|
|
806
|
+
* below any sub-ceiling, and applying it could close a second session that
|
|
807
|
+
* the account's one-session limit does not need closed.
|
|
808
|
+
*
|
|
809
|
+
* `workspace` is the requester's workspace key, undefined when its bearer
|
|
810
|
+
* is not workspace-scoped. `pendingBearer`/`pendingWorkspace`/
|
|
811
|
+
* `pendingAccount` are initializes admitted and not yet landed; at landing
|
|
812
|
+
* all are 0 (see `onsessioninitialized`). The result names sessions, so
|
|
813
|
+
* nothing that outlives the call may keep it.
|
|
814
|
+
*/
|
|
815
|
+
const planRoom = (keyDigest, account, workspace, suspended, pendingBearer, pendingWorkspace, pendingAccount) => {
|
|
816
|
+
const bearerCap = suspended ? MAX_SESSIONS_SUSPENDED : maxPerBearer;
|
|
817
|
+
const accountCap = suspended ? MAX_SESSIONS_SUSPENDED : maxPerAccount;
|
|
818
|
+
const own = sessionsOf(keyDigest);
|
|
819
|
+
const counted = sessionsOfAccount(account);
|
|
820
|
+
let bearerNeed = own.length + pendingBearer - bearerCap + 1;
|
|
821
|
+
let accountNeed = counted.length + pendingAccount - accountCap + 1;
|
|
822
|
+
let workspaceNeed = workspace !== undefined && !suspended
|
|
823
|
+
? sessionsOfWorkspace(workspace).length + pendingWorkspace - maxPerWorkspace + 1
|
|
824
|
+
: 0;
|
|
825
|
+
const victims = [];
|
|
826
|
+
if (bearerNeed <= 0 && accountNeed <= 0 && workspaceNeed <= 0)
|
|
827
|
+
return { victims };
|
|
828
|
+
const mine = new Set(own.map(([id]) => id));
|
|
829
|
+
const candidates = [
|
|
830
|
+
...own,
|
|
831
|
+
...counted.filter(([id, live]) => !mine.has(id) && (live.refused || suspended)),
|
|
832
|
+
]
|
|
833
|
+
.filter(([, live]) => live.active === 0)
|
|
834
|
+
.sort(closingOrder);
|
|
835
|
+
for (const entry of candidates) {
|
|
836
|
+
if (bearerNeed <= 0 && accountNeed <= 0 && workspaceNeed <= 0)
|
|
837
|
+
break;
|
|
838
|
+
const ofBearer = mine.has(entry[0]);
|
|
839
|
+
const ofAccount = entry[1].account === account;
|
|
840
|
+
const ofWorkspace = workspace !== undefined && entry[1].workspace === workspace;
|
|
841
|
+
if (!(ofBearer && bearerNeed > 0) &&
|
|
842
|
+
!(ofAccount && accountNeed > 0) &&
|
|
843
|
+
!(ofWorkspace && workspaceNeed > 0)) {
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
victims.push(entry);
|
|
847
|
+
if (ofBearer)
|
|
848
|
+
bearerNeed--;
|
|
849
|
+
if (ofAccount)
|
|
850
|
+
accountNeed--;
|
|
851
|
+
if (ofWorkspace)
|
|
852
|
+
workspaceNeed--;
|
|
853
|
+
}
|
|
854
|
+
if (bearerNeed > 0)
|
|
855
|
+
return { full: 'bearer', holds: own.length };
|
|
856
|
+
if (workspaceNeed > 0)
|
|
857
|
+
return { full: 'workspace' };
|
|
858
|
+
if (accountNeed > 0)
|
|
859
|
+
return { full: 'account' };
|
|
860
|
+
return { victims };
|
|
861
|
+
};
|
|
862
|
+
/**
|
|
863
|
+
* Hold a suspended account to one session in all (OPL-5452): close its idle
|
|
864
|
+
* sessions, whichever of its bearers opened them, in {@link closingOrder},
|
|
865
|
+
* until it holds one or none idle is left. Never `keep` (the session being
|
|
866
|
+
* served), and never one with a request in flight, which goes once it is
|
|
867
|
+
* idle, at the next of these. Does nothing unless the account's standing
|
|
868
|
+
* says suspended, so a self-hosted server, which has none, is untouched.
|
|
869
|
+
*
|
|
870
|
+
* A request whose body is still arriving has not reached its session yet,
|
|
871
|
+
* so its session can look idle here; for a suspended account that costs
|
|
872
|
+
* nothing, since the platform refuses every call it could make but whoami.
|
|
873
|
+
*/
|
|
874
|
+
const enforceAccountSuspension = (account, keep) => {
|
|
875
|
+
if (!accountSuspended(account))
|
|
876
|
+
return;
|
|
877
|
+
const counted = sessionsOfAccount(account);
|
|
878
|
+
const excess = counted.length - MAX_SESSIONS_SUSPENDED;
|
|
879
|
+
if (excess <= 0)
|
|
880
|
+
return;
|
|
881
|
+
const idle = counted.filter(([id, live]) => id !== keep && live.active === 0);
|
|
882
|
+
idle.sort(closingOrder);
|
|
883
|
+
for (const [id, gone] of idle.slice(0, excess)) {
|
|
884
|
+
sessions.delete(id);
|
|
885
|
+
void gone.transport.close().catch(() => { });
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
/**
|
|
889
|
+
* {@link enforceAccountSuspension} for a session served under its own
|
|
890
|
+
* bearer, whose account the platform may have named only since the session
|
|
891
|
+
* was admitted: `bearerAccount` is what the bearer counts against NOW (its
|
|
892
|
+
* probe's or cached acceptance's account). A session is counted against the
|
|
893
|
+
* account key it was admitted under and is never re-keyed, so one admitted
|
|
894
|
+
* while whoami named no account sits in a `bearer:` bucket that holds no
|
|
895
|
+
* standing. When the bearer's account now differs from that and is
|
|
896
|
+
* suspended, the bearer's own idle sessions go down to one as well, on the
|
|
897
|
+
* account's standing as it is now: a newer-started probe that found the
|
|
898
|
+
* account active leaves them be.
|
|
899
|
+
*/
|
|
900
|
+
const enforceSuspensionFor = (live, bearerAccount, keep) => {
|
|
901
|
+
if (bearerAccount !== undefined &&
|
|
902
|
+
bearerAccount !== live.account &&
|
|
903
|
+
accountSuspended(bearerAccount)) {
|
|
904
|
+
trimIdle(live.keyDigest, MAX_SESSIONS_SUSPENDED, keep);
|
|
905
|
+
}
|
|
906
|
+
enforceAccountSuspension(live.account, keep);
|
|
907
|
+
};
|
|
908
|
+
/**
|
|
909
|
+
* Close this bearer's idle sessions, least recently seen first, until it
|
|
910
|
+
* holds no more than `cap` or none idle is left. Never `keep`, and never one
|
|
911
|
+
* with a request in flight: the same test the sweeper and an initialize's
|
|
912
|
+
* room-making apply. Only a cap that has fallen needs this — an account
|
|
913
|
+
* suspended after its bearer opened sessions (OPL-5448) — since an
|
|
914
|
+
* initialize never admits a session over the cap it is checked against.
|
|
915
|
+
*/
|
|
916
|
+
const trimIdle = (keyDigest, cap, keep) => {
|
|
917
|
+
const own = sessionsOf(keyDigest);
|
|
918
|
+
const excess = own.length - cap;
|
|
919
|
+
if (excess <= 0)
|
|
920
|
+
return;
|
|
921
|
+
const idle = own.filter(([id, live]) => id !== keep && live.active === 0);
|
|
922
|
+
idle.sort((a, b) => a[1].lastSeen - b[1].lastSeen);
|
|
923
|
+
for (const [id, gone] of idle.slice(0, excess)) {
|
|
924
|
+
sessions.delete(id);
|
|
925
|
+
void gone.transport.close().catch(() => { });
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
const serving = async (live, handle, heard = always) => {
|
|
929
|
+
const release = beginActivity(live, heard);
|
|
605
930
|
try {
|
|
606
931
|
return await handle();
|
|
607
932
|
}
|
|
@@ -613,11 +938,16 @@ export async function runHttp(cfg) {
|
|
|
613
938
|
release();
|
|
614
939
|
}
|
|
615
940
|
};
|
|
616
|
-
/**
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
941
|
+
/**
|
|
942
|
+
* Stamp a session as heard from, without claiming anything is in flight, on
|
|
943
|
+
* open and on close, each only when `heard` allows it (see `beginActivity`).
|
|
944
|
+
*/
|
|
945
|
+
const touch = (live, res, heard) => {
|
|
946
|
+
if (heard())
|
|
620
947
|
live.lastSeen = Date.now();
|
|
948
|
+
res.on('close', () => {
|
|
949
|
+
if (heard())
|
|
950
|
+
live.lastSeen = Date.now();
|
|
621
951
|
});
|
|
622
952
|
};
|
|
623
953
|
/**
|
|
@@ -736,6 +1066,7 @@ export async function runHttp(cfg) {
|
|
|
736
1066
|
return challenged(res, NO_TOKEN, rpcId(req));
|
|
737
1067
|
}
|
|
738
1068
|
if (sessionId) {
|
|
1069
|
+
let unpin = () => { };
|
|
739
1070
|
try {
|
|
740
1071
|
const live = sessions.get(sessionId);
|
|
741
1072
|
if (!live)
|
|
@@ -758,24 +1089,67 @@ export async function runHttp(cfg) {
|
|
|
758
1089
|
}
|
|
759
1090
|
return unauthorized(res, 'This session belongs to a different API key.', rpcId(req));
|
|
760
1091
|
}
|
|
1092
|
+
// In flight from here, not only once it is dispatched: the bearer
|
|
1093
|
+
// check below awaits the platform, for up to BEARER_CHECK_TIMEOUT_MS on
|
|
1094
|
+
// a cache miss, and an initialize on the same bearer at its cap closes
|
|
1095
|
+
// an idle session to make room. Counted as busy through that wait, this
|
|
1096
|
+
// one is never the session closed under a request already routed to it
|
|
1097
|
+
// (OPL-5443). Only `active` is raised: a request refused below has not
|
|
1098
|
+
// been served, and does not stamp the session as heard from.
|
|
1099
|
+
live.active++;
|
|
1100
|
+
let pinned = true;
|
|
1101
|
+
unpin = () => {
|
|
1102
|
+
if (!pinned)
|
|
1103
|
+
return;
|
|
1104
|
+
pinned = false;
|
|
1105
|
+
live.active--;
|
|
1106
|
+
};
|
|
761
1107
|
if (challenge && live.refused)
|
|
762
|
-
return challenged(res,
|
|
1108
|
+
return challenged(res, refusedMessage(key), rpcId(req));
|
|
763
1109
|
// Checked before a request is dispatched, while the answer can still
|
|
764
1110
|
// be a 401 whatever the call goes on to do. A platform that cannot say
|
|
765
1111
|
// is not a refusal: the call goes ahead, and a real refusal during it
|
|
766
1112
|
// still reaches the client through the held answer or the mark.
|
|
767
|
-
|
|
1113
|
+
const request = carriesRequest(req.body);
|
|
1114
|
+
const checked = challenge && request ? await checkBearer(key) : undefined;
|
|
1115
|
+
const verdict = checked?.verdict;
|
|
1116
|
+
if (verdict === 'refused') {
|
|
768
1117
|
live.refused = true;
|
|
769
|
-
return challenged(res,
|
|
1118
|
+
return challenged(res, refusedMessage(key), rpcId(req));
|
|
1119
|
+
}
|
|
1120
|
+
// An account suspended after its bearers opened sessions kept every
|
|
1121
|
+
// one of them, while an initialize on it was held to one (OPL-5448).
|
|
1122
|
+
// Its other idle sessions go now, whichever of its bearers opened
|
|
1123
|
+
// them (OPL-5452), down to this one: it is pinned above, and one busy
|
|
1124
|
+
// elsewhere is skipped and closed at a later one of these once idle.
|
|
1125
|
+
// What decides is the account's standing as it is now, not the
|
|
1126
|
+
// verdict above: a newer-started probe of another bearer may have
|
|
1127
|
+
// found the account reinstated while this one awaited its own.
|
|
1128
|
+
// A request has just had its bearer's standing confirmed (or found it
|
|
1129
|
+
// cached). Anything else — a notification, a response — asks the
|
|
1130
|
+
// platform nothing, so it acts on the account's standing only while
|
|
1131
|
+
// its bearer's acceptance is cached: the standing is then no older
|
|
1132
|
+
// than that, and never an answer from long before a reinstatement.
|
|
1133
|
+
const confirmed = request ? verdict === 'ok' || verdict === 'suspended' : isAccepted(key);
|
|
1134
|
+
if (challenge && confirmed) {
|
|
1135
|
+
enforceSuspensionFor(live, request ? checked?.account : acceptance(key)?.account, sessionId);
|
|
770
1136
|
}
|
|
771
1137
|
const lease = res.locals.largeBodyLease;
|
|
772
1138
|
const id = rpcId(req);
|
|
773
1139
|
const held = challenge
|
|
774
|
-
? holdAnswer(res, () => challenged(res,
|
|
1140
|
+
? holdAnswer(res, () => challenged(res, refusedMessage(key), id))
|
|
775
1141
|
: undefined;
|
|
776
|
-
return await heldAnswer.run(held, () => requestBodyLease.run(lease, () => serving(live, () => live.transport.handleRequest(req, res, req.body)
|
|
1142
|
+
return await heldAnswer.run(held, () => requestBodyLease.run(lease, () => serving(live, () => live.transport.handleRequest(req, res, req.body),
|
|
1143
|
+
// A request keeps the session as before: it was just put to
|
|
1144
|
+
// the platform, or found its acceptance cached. Anything else
|
|
1145
|
+
// keeps it only while that acceptance lasts.
|
|
1146
|
+
request ? always : heardWhileAccepted(key))));
|
|
777
1147
|
}
|
|
778
1148
|
finally {
|
|
1149
|
+
// serving() held its own lease across handleRequest, and a tool
|
|
1150
|
+
// callback that outlives it holds another through activity(), so the
|
|
1151
|
+
// pin can go once the route is done with the request.
|
|
1152
|
+
unpin();
|
|
779
1153
|
// A verified session is the only request allowed through the large
|
|
780
1154
|
// parser. Release the route's ownership here; a tool callback that
|
|
781
1155
|
// outlives handleRequest retains its own reference through activity().
|
|
@@ -807,6 +1181,8 @@ export async function runHttp(cfg) {
|
|
|
807
1181
|
return rpcError(res, 403, -32000, refused, rpcId(req));
|
|
808
1182
|
// Hosted: no session for a bearer the platform does not accept. Before the
|
|
809
1183
|
// cap and the reservation, so a refused bearer never holds a slot.
|
|
1184
|
+
let checkedAccount;
|
|
1185
|
+
let checkedWorkspace;
|
|
810
1186
|
if (challenge) {
|
|
811
1187
|
// A spent budget does not close the address. Many clients can share one
|
|
812
1188
|
// (a NAT, an office), and one bad neighbour must not lock out the rest:
|
|
@@ -827,7 +1203,7 @@ export async function runHttp(cfg) {
|
|
|
827
1203
|
// The interval is spent only by a probe that starts: a request turned
|
|
828
1204
|
// away because the process-wide cap is full reached nobody, and must not
|
|
829
1205
|
// make the next client at this address wait out an interval for it.
|
|
830
|
-
const verdict = await checkBearer(key, spent
|
|
1206
|
+
const { verdict, account, workspace } = await checkBearer(key, spent
|
|
831
1207
|
? () => {
|
|
832
1208
|
const entry = failures.get(sourceOf(req));
|
|
833
1209
|
if (entry)
|
|
@@ -836,33 +1212,155 @@ export async function runHttp(cfg) {
|
|
|
836
1212
|
: undefined);
|
|
837
1213
|
if (verdict === 'refused') {
|
|
838
1214
|
noteFailure(req);
|
|
839
|
-
return challenged(res,
|
|
1215
|
+
return challenged(res, refusedMessage(key), rpcId(req));
|
|
840
1216
|
}
|
|
841
1217
|
// Not an initialize, it is a 400 whatever the platform could say about
|
|
842
1218
|
// the key short of refusing it.
|
|
843
1219
|
if (verdict === 'unknown' && initialize) {
|
|
844
1220
|
return unavailable(res, 'The platform could not confirm this token just now. Retry shortly.', rpcId(req));
|
|
845
1221
|
}
|
|
1222
|
+
checkedAccount = account;
|
|
1223
|
+
checkedWorkspace = workspace;
|
|
846
1224
|
}
|
|
847
1225
|
if (!initialize) {
|
|
848
1226
|
return badRequest(res, 'No session id, and this is not an initialize request.', rpcId(req));
|
|
849
1227
|
}
|
|
1228
|
+
// One bearer may not hold the whole pool (OPL-5443), nor one account more
|
|
1229
|
+
// than its ceiling however many bearers it holds (OPL-5447). Both counted
|
|
1230
|
+
// before the process-wide cap, so a bearer that can make room by closing
|
|
1231
|
+
// one of its idle sessions frees a process-wide slot as well. From the
|
|
1232
|
+
// count to the reservation below is synchronous, so a burst cannot all
|
|
1233
|
+
// read the same count.
|
|
1234
|
+
const keyDigest = digest(key);
|
|
1235
|
+
const keyId = keyDigest.toString('hex');
|
|
1236
|
+
// Hosted, the account the platform confirmed for this bearer (or the
|
|
1237
|
+
// bearer itself, when whoami named none); self-hosted, the bearer.
|
|
1238
|
+
const account = checkedAccount ?? accountKey(keyId, undefined);
|
|
1239
|
+
// Hosted, the workspace key when the platform named a workspace for the
|
|
1240
|
+
// bearer's account (OPL-5453); otherwise none, and no sub-ceiling.
|
|
1241
|
+
const workspace = checkedAccount !== undefined ? checkedWorkspace : undefined;
|
|
1242
|
+
// The account's standing, read after the check above (OPL-5452): what the
|
|
1243
|
+
// newest-started probe of ANY of its bearers said, not only this one's.
|
|
1244
|
+
// Self-hosted there is none, and nothing is suspended.
|
|
1245
|
+
const suspended = accountSuspended(account);
|
|
1246
|
+
const bearerCap = suspended ? MAX_SESSIONS_SUSPENDED : maxPerBearer;
|
|
1247
|
+
// A bearer, or a suspended account, can hold more than its limit only when
|
|
1248
|
+
// the limit fell under it: an account suspended after it opened sessions
|
|
1249
|
+
// (OPL-5448, OPL-5452). Those over it are closed first if idle, whatever
|
|
1250
|
+
// becomes of this initialize, so what is left to count, and to name in a
|
|
1251
|
+
// 429, is what it really holds.
|
|
1252
|
+
enforceAccountSuspension(account);
|
|
1253
|
+
trimIdle(keyDigest, bearerCap);
|
|
1254
|
+
const tooMany = (message) => {
|
|
1255
|
+
res.set('Retry-After', String(BEARER_FULL_RETRY_AFTER_S));
|
|
1256
|
+
return rpcError(res, 429, -32002, message, rpcId(req));
|
|
1257
|
+
};
|
|
1258
|
+
// Room is made from this bearer's own IDLE sessions, least recently seen
|
|
1259
|
+
// first, exactly as the sweeper would close them, under whichever limit
|
|
1260
|
+
// binds: its own cap or its account's ceiling. Never another bearer's live
|
|
1261
|
+
// session, and never one with a request in flight: past the ceiling an
|
|
1262
|
+
// initialize is refused, since the account's holder is the one who knows
|
|
1263
|
+
// which of its clients can spare a session. Two exceptions (see
|
|
1264
|
+
// `planRoom`): an idle session of the account's whose bearer the platform
|
|
1265
|
+
// refused, and, while the account is suspended, any idle session of the
|
|
1266
|
+
// account's, since it holds one in all (OPL-5452). A client whose old
|
|
1267
|
+
// session is gone gets 404 for it and
|
|
1268
|
+
// initializes again, as MCP clients do. Decided here, so a caller with
|
|
1269
|
+
// nothing idle to give back is refused at once; carried out only once the
|
|
1270
|
+
// new session exists (in `onsessioninitialized`), so an initialize the SDK
|
|
1271
|
+
// then refuses (406, 415, 400) or that throws on the way does not also
|
|
1272
|
+
// cost the caller a working session. Only the number is kept past here,
|
|
1273
|
+
// and, for the pool reserve's count below, the ids, which do not leave
|
|
1274
|
+
// this synchronous block.
|
|
1275
|
+
let planned = 0;
|
|
1276
|
+
let plannedIds;
|
|
1277
|
+
{
|
|
1278
|
+
const room = planRoom(keyDigest, account, workspace, suspended, pendingByDigest.get(keyId) ?? 0, workspace !== undefined ? (pendingByWorkspace.get(workspace) ?? 0) : 0, pendingByAccount.get(account) ?? 0);
|
|
1279
|
+
if ('full' in room) {
|
|
1280
|
+
if (room.full === 'account') {
|
|
1281
|
+
return tooMany(suspended ? SUSPENDED_ACCOUNT_FULL : accountFullMessage(account, maxPerAccount));
|
|
1282
|
+
}
|
|
1283
|
+
if (room.full === 'workspace')
|
|
1284
|
+
return tooMany(workspaceFullMessage(maxPerWorkspace));
|
|
1285
|
+
// Over the cap after the trim means every session over it is busy,
|
|
1286
|
+
// and closing one would not be enough: the client can only wait.
|
|
1287
|
+
return tooMany(room.holds > bearerCap
|
|
1288
|
+
? `This token holds ${room.holds} sessions on this server, over its maximum of ${bearerCap}, and the ones over it are all serving a request. Retry once they finish; idle ones are closed to make room.`
|
|
1289
|
+
: `This token already holds its maximum of ${bearerCap} ${bearerCap === 1 ? 'session' : 'sessions'} on this server. Close one (DELETE /mcp) or retry shortly.`);
|
|
1290
|
+
}
|
|
1291
|
+
planned = room.victims.length;
|
|
1292
|
+
plannedIds = new Set(room.victims.map(([id]) => id));
|
|
1293
|
+
}
|
|
850
1294
|
// Swept sessions free their slot on the timer; this is the backstop for the
|
|
851
1295
|
// case the timer cannot help with, which is arrivals faster than the TTL.
|
|
852
|
-
|
|
1296
|
+
// The sessions this initialize will close are still in the map, and are not
|
|
1297
|
+
// counted against it: a bearer making room in its own share is not newly
|
|
1298
|
+
// refused for the room it is about to give back. Nothing is closed to make
|
|
1299
|
+
// room here, whoever holds the pool.
|
|
1300
|
+
if (sessions.size - planned + pending >= maxSessions) {
|
|
1301
|
+
res.set('Retry-After', String(POOL_FULL_RETRY_AFTER_S));
|
|
853
1302
|
return unavailable(res, `This server is holding its maximum of ${maxSessions} sessions. Retry shortly.`, rpcId(req));
|
|
854
1303
|
}
|
|
1304
|
+
// The last quarter of the pool is kept for accounts holding less than
|
|
1305
|
+
// their share (OPL-5454): without it the ceilings alone let four accounts
|
|
1306
|
+
// take every session between them, and the fifth was refused until one
|
|
1307
|
+
// gave some back. Counted as the check above counts, pending included, so
|
|
1308
|
+
// a burst cannot all read the same count. The share is of the unreserved
|
|
1309
|
+
// part, divided among the accounts holding or opening sessions, this one
|
|
1310
|
+
// included. Checked at admission only, as the pool is; nothing is closed
|
|
1311
|
+
// for it.
|
|
1312
|
+
const inUse = sessions.size - planned + pending;
|
|
1313
|
+
if (poolReserve > 0 && inUse >= unreserved) {
|
|
1314
|
+
const holders = new Set([account, ...pendingByAccount.keys()]);
|
|
1315
|
+
let held = pendingByAccount.get(account) ?? 0;
|
|
1316
|
+
for (const [id, live] of sessions) {
|
|
1317
|
+
if (plannedIds.has(id))
|
|
1318
|
+
continue;
|
|
1319
|
+
holders.add(live.account);
|
|
1320
|
+
if (live.account === account)
|
|
1321
|
+
held++;
|
|
1322
|
+
}
|
|
1323
|
+
const fairShare = Math.max(1, Math.floor(unreserved / holders.size));
|
|
1324
|
+
if (held >= fairShare) {
|
|
1325
|
+
res.set('Retry-After', String(POOL_FULL_RETRY_AFTER_S));
|
|
1326
|
+
return unavailable(res, `This server keeps its last ${poolReserve} sessions for accounts holding less than their share of it, and this account holds its share. Retry shortly.`, rpcId(req));
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
855
1329
|
pending++;
|
|
1330
|
+
pendingByDigest.set(keyId, (pendingByDigest.get(keyId) ?? 0) + 1);
|
|
1331
|
+
pendingByAccount.set(account, (pendingByAccount.get(account) ?? 0) + 1);
|
|
1332
|
+
if (workspace !== undefined) {
|
|
1333
|
+
pendingByWorkspace.set(workspace, (pendingByWorkspace.get(workspace) ?? 0) + 1);
|
|
1334
|
+
}
|
|
856
1335
|
// Released exactly once, whether the initialize lands in the map or throws
|
|
857
1336
|
// on the way there. A reservation that leaked on the failure path would
|
|
858
1337
|
// ratchet the cap down until the process restarted — every later initialize
|
|
859
1338
|
// refused with 503 for the life of the process, which is the denial of
|
|
860
|
-
// service the counter was added to prevent, self-inflicted.
|
|
1339
|
+
// service the counter was added to prevent, self-inflicted. The per-bearer,
|
|
1340
|
+
// per-account and per-workspace counts leaking would do the same to that
|
|
1341
|
+
// bearer, account and workspace.
|
|
861
1342
|
let reserved = true;
|
|
862
1343
|
const release = () => {
|
|
863
1344
|
if (reserved) {
|
|
864
1345
|
reserved = false;
|
|
865
1346
|
pending--;
|
|
1347
|
+
const left = (pendingByDigest.get(keyId) ?? 1) - 1;
|
|
1348
|
+
if (left > 0)
|
|
1349
|
+
pendingByDigest.set(keyId, left);
|
|
1350
|
+
else
|
|
1351
|
+
pendingByDigest.delete(keyId);
|
|
1352
|
+
const accountLeft = (pendingByAccount.get(account) ?? 1) - 1;
|
|
1353
|
+
if (accountLeft > 0)
|
|
1354
|
+
pendingByAccount.set(account, accountLeft);
|
|
1355
|
+
else
|
|
1356
|
+
pendingByAccount.delete(account);
|
|
1357
|
+
if (workspace !== undefined) {
|
|
1358
|
+
const workspaceLeft = (pendingByWorkspace.get(workspace) ?? 1) - 1;
|
|
1359
|
+
if (workspaceLeft > 0)
|
|
1360
|
+
pendingByWorkspace.set(workspace, workspaceLeft);
|
|
1361
|
+
else
|
|
1362
|
+
pendingByWorkspace.delete(workspace);
|
|
1363
|
+
}
|
|
866
1364
|
}
|
|
867
1365
|
};
|
|
868
1366
|
// Everything from here to the map write is inside the reservation,
|
|
@@ -875,6 +1373,9 @@ export async function runHttp(cfg) {
|
|
|
875
1373
|
let transport;
|
|
876
1374
|
let mcp;
|
|
877
1375
|
let finishInitialize = () => { };
|
|
1376
|
+
// Set when the session was dropped at birth for want of room (see
|
|
1377
|
+
// `onsessioninitialized`): it has an id but no map slot.
|
|
1378
|
+
let dropped = false;
|
|
878
1379
|
try {
|
|
879
1380
|
const t = new StreamableHTTPServerTransport({
|
|
880
1381
|
sessionIdGenerator: () => randomUUID(),
|
|
@@ -889,9 +1390,43 @@ export async function runHttp(cfg) {
|
|
|
889
1390
|
allowedHosts: hosts,
|
|
890
1391
|
allowedOrigins,
|
|
891
1392
|
onsessioninitialized: (id) => {
|
|
1393
|
+
// The room the check above planned, made now that there is a session
|
|
1394
|
+
// to make it for. Chosen again from what is here NOW: a session
|
|
1395
|
+
// closed or put to work since then is not a candidate. Should too
|
|
1396
|
+
// few still be idle, nothing is closed and this session is dropped
|
|
1397
|
+
// instead of admitted over the cap. The transport closes before the
|
|
1398
|
+
// SDK answers, so the SDK answers this initialize itself, with its
|
|
1399
|
+
// 404 -32001 'Session not found' and no Retry-After — not the 429
|
|
1400
|
+
// the refusal above gives, which cannot be written from here. An
|
|
1401
|
+
// SDK client surfaces that as a failed connect; it does not retry
|
|
1402
|
+
// on its own. Only a race reaches this: a request landing on a
|
|
1403
|
+
// planned session between the check above and this callback.
|
|
1404
|
+
//
|
|
1405
|
+
// The account's ceiling is re-checked here too (OPL-5447), and the
|
|
1406
|
+
// workspace's sub-ceiling (OPL-5453), against
|
|
1407
|
+
// the map alone. Initializes still pending were each admitted
|
|
1408
|
+
// counting this one's reservation, and the room they will make is
|
|
1409
|
+
// still in the map, so counting them again here would drop a session
|
|
1410
|
+
// that fits; what keeps a burst under the ceiling is the pending
|
|
1411
|
+
// count at admission. Every landing leaves the map within both.
|
|
1412
|
+
// The account's standing is read again: a probe that answered in
|
|
1413
|
+
// between is the newer word on whether it is suspended.
|
|
1414
|
+
const room = planRoom(keyDigest, account, workspace, accountSuspended(account), 0, 0, 0);
|
|
1415
|
+
if ('full' in room) {
|
|
1416
|
+
dropped = true;
|
|
1417
|
+
release();
|
|
1418
|
+
void t.close().catch(() => { });
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
for (const [other, gone] of room.victims) {
|
|
1422
|
+
sessions.delete(other);
|
|
1423
|
+
void gone.transport.close().catch(() => { });
|
|
1424
|
+
}
|
|
892
1425
|
const live = {
|
|
893
1426
|
transport: t,
|
|
894
|
-
keyDigest
|
|
1427
|
+
keyDigest,
|
|
1428
|
+
account,
|
|
1429
|
+
workspace,
|
|
895
1430
|
lastSeen: Date.now(),
|
|
896
1431
|
// Initialize is already in flight when the session first becomes
|
|
897
1432
|
// visible to the sweeper. Count it until handleRequest settles.
|
|
@@ -961,10 +1496,11 @@ export async function runHttp(cfg) {
|
|
|
961
1496
|
mcp = server;
|
|
962
1497
|
await server.connect(t);
|
|
963
1498
|
const handled = await t.handleRequest(req, res, req.body);
|
|
964
|
-
// Any initialize that never reached `onsessioninitialized
|
|
965
|
-
// slot and nothing the sweeper will reap. The
|
|
966
|
-
// this path without throwing; other early returns
|
|
967
|
-
|
|
1499
|
+
// Any initialize that never reached `onsessioninitialized`, or was
|
|
1500
|
+
// dropped there, has no map slot and nothing the sweeper will reap. The
|
|
1501
|
+
// SDK's 403 used to take this path without throwing; other early returns
|
|
1502
|
+
// still can.
|
|
1503
|
+
if (!t.sessionId || dropped) {
|
|
968
1504
|
void server.close().catch(() => { });
|
|
969
1505
|
void t.close().catch(() => { });
|
|
970
1506
|
}
|
|
@@ -1009,14 +1545,28 @@ export async function runHttp(cfg) {
|
|
|
1009
1545
|
// The stream is refused on a token the platform has already refused; a
|
|
1010
1546
|
// DELETE is still honoured, since the digest shows it is the holder.
|
|
1011
1547
|
if (challenge && live.refused && req.method === 'GET')
|
|
1012
|
-
return challenged(res,
|
|
1013
|
-
//
|
|
1014
|
-
// stream
|
|
1548
|
+
return challenged(res, refusedMessage(key));
|
|
1549
|
+
// A suspended account is held to one session on its stream too, as on a
|
|
1550
|
+
// notification: the stream asks the platform nothing, so only while this
|
|
1551
|
+
// bearer's acceptance is cached (OPL-5452). This session is the one kept.
|
|
1552
|
+
if (challenge && req.method !== 'DELETE' && isAccepted(key)) {
|
|
1553
|
+
enforceSuspensionFor(live, acceptance(key)?.account, sessionId);
|
|
1554
|
+
}
|
|
1555
|
+
// The GET is the notification stream and is only noted; anything else
|
|
1556
|
+
// here (a DELETE, or a HEAD, which Express routes to the GET handler with
|
|
1557
|
+
// the method unchanged) is held for while the SDK handles it. See
|
|
1558
|
+
// `serving`. None of it is put to the platform, so none of it may keep the
|
|
1559
|
+
// session from idling out once the token's acceptance lapses: a HEAD is
|
|
1560
|
+
// answered 405 and a DELETE the SDK refuses (an unsupported protocol
|
|
1561
|
+
// version, say) 400, both leaving the session open, and a revoked token's
|
|
1562
|
+
// holder could otherwise send either for ever to keep its sessions, and
|
|
1563
|
+
// its account's ceiling, held (OPL-5455). A DELETE that succeeds closes
|
|
1564
|
+
// the session anyway, and `active` is counted whatever `heard` says.
|
|
1015
1565
|
if (req.method === 'GET') {
|
|
1016
|
-
touch(live, res);
|
|
1566
|
+
touch(live, res, heardWhileAccepted(key));
|
|
1017
1567
|
return live.transport.handleRequest(req, res);
|
|
1018
1568
|
}
|
|
1019
|
-
return serving(live, () => live.transport.handleRequest(req, res));
|
|
1569
|
+
return serving(live, () => live.transport.handleRequest(req, res), heardWhileAccepted(key));
|
|
1020
1570
|
};
|
|
1021
1571
|
app.get('/mcp', bySession);
|
|
1022
1572
|
app.delete('/mcp', bySession);
|
|
@@ -1161,6 +1711,101 @@ const BEARER_CHECK_TIMEOUT_MS = 10_000;
|
|
|
1161
1711
|
const MAX_BEARER_CHECKS_IN_FLIGHT = 32;
|
|
1162
1712
|
const MAX_ACCEPTED_BEARERS = 4096;
|
|
1163
1713
|
const MAX_FAILURE_SOURCES = 10_000;
|
|
1714
|
+
/**
|
|
1715
|
+
* Whether a whoami record says its account is suspended.
|
|
1716
|
+
*
|
|
1717
|
+
* Only an explicit `account.status` of `"suspended"` counts. A record this
|
|
1718
|
+
* cannot read is not evidence of suspension, and treating it as such would hold
|
|
1719
|
+
* a working account to one session on the strength of a shape change.
|
|
1720
|
+
*/
|
|
1721
|
+
function isSuspended(who) {
|
|
1722
|
+
if (typeof who !== 'object' || who === null)
|
|
1723
|
+
return false;
|
|
1724
|
+
const account = who.account;
|
|
1725
|
+
if (typeof account !== 'object' || account === null)
|
|
1726
|
+
return false;
|
|
1727
|
+
return account.status === 'suspended';
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1730
|
+
* The account id a whoami record names: a non-empty string of at most 256
|
|
1731
|
+
* characters. Anything else is no account, and the bearer then counts as an
|
|
1732
|
+
* account of its own rather than as one this server could not confirm.
|
|
1733
|
+
*/
|
|
1734
|
+
function accountIdOf(who) {
|
|
1735
|
+
if (typeof who !== 'object' || who === null)
|
|
1736
|
+
return undefined;
|
|
1737
|
+
const account = who.account;
|
|
1738
|
+
if (typeof account !== 'object' || account === null)
|
|
1739
|
+
return undefined;
|
|
1740
|
+
const id = account.id;
|
|
1741
|
+
return typeof id === 'string' && id.length > 0 && id.length <= 256 ? id : undefined;
|
|
1742
|
+
}
|
|
1743
|
+
/**
|
|
1744
|
+
* The workspace id a whoami record names for a workspace-scoped bearer
|
|
1745
|
+
* (OPL-5453): `workspace.id`, a non-empty string of at most 256 characters.
|
|
1746
|
+
* Anything else, `workspace: null` (a key of the account as a whole)
|
|
1747
|
+
* included, is no workspace, and the bearer is held by no sub-ceiling.
|
|
1748
|
+
*/
|
|
1749
|
+
function workspaceIdOf(who) {
|
|
1750
|
+
if (typeof who !== 'object' || who === null)
|
|
1751
|
+
return undefined;
|
|
1752
|
+
const workspace = who.workspace;
|
|
1753
|
+
if (typeof workspace !== 'object' || workspace === null)
|
|
1754
|
+
return undefined;
|
|
1755
|
+
const id = workspace.id;
|
|
1756
|
+
return typeof id === 'string' && id.length > 0 && id.length <= 256 ? id : undefined;
|
|
1757
|
+
}
|
|
1758
|
+
/**
|
|
1759
|
+
* What a session counts against for the account ceiling (OPL-5447).
|
|
1760
|
+
*
|
|
1761
|
+
* The account id the platform confirmed for the bearer when there is one
|
|
1762
|
+
* (hosted mode), and otherwise the bearer's own digest: a hosted whoami that
|
|
1763
|
+
* names no account, and every bearer on a self-hosted server, which verifies
|
|
1764
|
+
* none and so puts no trust in a string it cannot check. Prefixed so the two
|
|
1765
|
+
* can never name the same thing.
|
|
1766
|
+
*/
|
|
1767
|
+
function accountKey(keyId, confirmed) {
|
|
1768
|
+
return confirmed !== undefined ? `account:${confirmed}` : `bearer:${keyId}`;
|
|
1769
|
+
}
|
|
1770
|
+
/**
|
|
1771
|
+
* The 429 for a suspended account with nothing idle to give up (OPL-5452):
|
|
1772
|
+
* its session is serving a request, or another initialize of its is in
|
|
1773
|
+
* flight. An idle one would have been closed to make room.
|
|
1774
|
+
*/
|
|
1775
|
+
const SUSPENDED_ACCOUNT_FULL = 'This account is suspended, so it may hold only 1 session on this server across all its tokens, and none it holds is idle. Retry once its session finishes its request, or close it (DELETE /mcp).';
|
|
1776
|
+
/**
|
|
1777
|
+
* The workspace key a workspace-scoped bearer counts against for the workspace
|
|
1778
|
+
* sub-ceiling (OPL-5453), or undefined when there is none: its account key and
|
|
1779
|
+
* the workspace id together, so the same workspace id under two accounts can
|
|
1780
|
+
* never share a count. Only an account the platform confirmed (`account:`)
|
|
1781
|
+
* has workspaces; a `bearer:` account never does.
|
|
1782
|
+
*/
|
|
1783
|
+
function workspaceKey(account, workspaceId) {
|
|
1784
|
+
return account.startsWith('account:') && workspaceId !== undefined
|
|
1785
|
+
? `${account}/workspace:${workspaceId}`
|
|
1786
|
+
: undefined;
|
|
1787
|
+
}
|
|
1788
|
+
/** The 429 for a workspace at its sub-ceiling. It names the limit and nothing else. */
|
|
1789
|
+
function workspaceFullMessage(cap) {
|
|
1790
|
+
const noun = cap === 1 ? 'session' : 'sessions';
|
|
1791
|
+
return `This workspace has reached its maximum of ${cap} ${noun} on this server, across all its tokens. Close one (DELETE /mcp) or retry shortly.`;
|
|
1792
|
+
}
|
|
1793
|
+
/** The 429 for an account at its ceiling. It names the limit and nothing else. */
|
|
1794
|
+
function accountFullMessage(account, cap) {
|
|
1795
|
+
const noun = cap === 1 ? 'session' : 'sessions';
|
|
1796
|
+
return account.startsWith('account:')
|
|
1797
|
+
? `This account has reached its maximum of ${cap} ${noun} on this server, across all its tokens. Close one (DELETE /mcp) or retry shortly.`
|
|
1798
|
+
: `This token has reached the per-account maximum of ${cap} ${noun} on this server, as an account of its own. Close one (DELETE /mcp) or retry shortly.`;
|
|
1799
|
+
}
|
|
1800
|
+
const always = () => true;
|
|
1801
|
+
/**
|
|
1802
|
+
* The order idle sessions are closed in to make room: those whose bearer the
|
|
1803
|
+
* platform has refused first, since nothing more is served on them, then the
|
|
1804
|
+
* least recently seen.
|
|
1805
|
+
*/
|
|
1806
|
+
function closingOrder(a, b) {
|
|
1807
|
+
return Number(b[1].refused) - Number(a[1].refused) || a[1].lastSeen - b[1].lastSeen;
|
|
1808
|
+
}
|
|
1164
1809
|
/** Whether a POST body holds a JSON-RPC request, which is what opens a stream. */
|
|
1165
1810
|
function carriesRequest(body) {
|
|
1166
1811
|
const isRequest = (m) => typeof m === 'object' && m !== null && 'method' in m && 'id' in m;
|
|
@@ -1168,6 +1813,17 @@ function carriesRequest(body) {
|
|
|
1168
1813
|
}
|
|
1169
1814
|
const NO_TOKEN = 'Authorization required. Authorize this client with Mandala (OAuth), or send a Mandala API key: Authorization: Bearer …';
|
|
1170
1815
|
const TOKEN_REFUSED = 'The platform no longer accepts this access token. Refresh it, or authorize again.';
|
|
1816
|
+
const API_KEY_REFUSED = 'The platform does not accept this API key. Check it, or create a new one in Settings → Credentials.';
|
|
1817
|
+
/**
|
|
1818
|
+
* What a refused bearer is told. A `com_…` API key was never an OAuth access
|
|
1819
|
+
* token and cannot be refreshed, so it is not told to refresh (OPL-5464); any
|
|
1820
|
+
* other bearer is an access token and keeps the OAuth wording. Only the message
|
|
1821
|
+
* text differs — the 401 and its challenge are the same — and the bearer itself
|
|
1822
|
+
* is never part of it.
|
|
1823
|
+
*/
|
|
1824
|
+
function refusedMessage(key) {
|
|
1825
|
+
return key?.startsWith('com_') ? API_KEY_REFUSED : TOKEN_REFUSED;
|
|
1826
|
+
}
|
|
1171
1827
|
/**
|
|
1172
1828
|
* Hold a response's status line until its first byte of body (hosted mode).
|
|
1173
1829
|
*
|