drupal-mcp-connector 2.10.1 → 2.12.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/CHANGELOG.md +66 -0
- package/bin/drupal-mcp-edge.js +17 -3
- package/config/config.example.json +12 -0
- package/package.json +1 -1
- package/src/lib/backends/jsonapi.js +32 -3
- package/src/lib/config.js +64 -0
- package/src/lib/http-auth.js +5 -1
- package/src/lib/policy-enforcement.js +268 -0
- package/src/lib/policy-promotion.js +130 -0
- package/src/lib/principal.js +159 -4
- package/src/lib/relay/agent.js +44 -0
- package/src/lib/relay/edge.js +426 -37
- package/src/lib/relay/frames.js +37 -9
- package/src/tools/site.js +1 -1
package/src/lib/relay/edge.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Relay northbound edge (#232) —
|
|
2
|
+
* Relay northbound edge (#232, #242, #244, #247, #250, #253) — DEV-294 AC4,
|
|
3
|
+
* DEV-122 isolation, DEV-124 tenant routing, DEV-123 actor mapping, DEV-125
|
|
4
|
+
* policy digest and W&L-operated bundle promotion.
|
|
3
5
|
*
|
|
4
6
|
* Terminates northbound MCP over the OAuth resource server and fans requests
|
|
5
|
-
* down
|
|
7
|
+
* down outbound tenant-agent channels. The edge proposes; the tenant-side
|
|
6
8
|
* connector disposes. Policy at this seam, fail-closed from birth:
|
|
7
9
|
*
|
|
8
10
|
* - `createInboundHttpsAuth` is the ONLY authentication arm. There is no
|
|
@@ -16,6 +18,11 @@
|
|
|
16
18
|
* before framing. The frame carries the validated identity object only.
|
|
17
19
|
* - The edge holds no site credentials: a catalog entry carrying credential
|
|
18
20
|
* material refuses startup. It cannot leak what it does not hold.
|
|
21
|
+
* - Tunnel identity is the tenant boundary. Agent sessions are keyed by
|
|
22
|
+
* `agentId`; channel records may bind `sites`. A second unscoped agent
|
|
23
|
+
* or overlapping site claim is denied at hello. Fan-down selects the
|
|
24
|
+
* unique bound agent from server-owned grants. Single unscoped agent
|
|
25
|
+
* remains the DEV-294 compatibility path.
|
|
19
26
|
* - Stateless MCP 2026-07-28 northbound: sessionful traffic is refused and
|
|
20
27
|
* no `Mcp-Session-Id` crosses in either direction.
|
|
21
28
|
* - Revocation is per-request with no grace window, for both credential
|
|
@@ -31,7 +38,14 @@ import { createServer as createTlsServer } from "node:tls";
|
|
|
31
38
|
import { createLocalRelay } from "../contracts/relay.js";
|
|
32
39
|
import { createInboundHttpsAuth, SPOOFABLE_IDENTITY_HEADERS } from "../http-auth.js";
|
|
33
40
|
import { createLegacySessionHandler, createMcpRequestHandler } from "../http-handler.js";
|
|
34
|
-
import {
|
|
41
|
+
import { isWriteLikeCall } from "../operations.js";
|
|
42
|
+
import {
|
|
43
|
+
eligiblePromotions,
|
|
44
|
+
POLICY_DIGEST,
|
|
45
|
+
promotionsRequired,
|
|
46
|
+
resolveEligiblePromotion,
|
|
47
|
+
} from "../policy-promotion.js";
|
|
48
|
+
import { DIAGNOSTIC_TOOLS, resolveActor, resolveGrantedSites, resolvePolicy } from "../principal.js";
|
|
35
49
|
import {
|
|
36
50
|
attachFramer,
|
|
37
51
|
createRequestBroker,
|
|
@@ -79,6 +93,230 @@ const SITE_CREDENTIAL_KEYS = Object.freeze([
|
|
|
79
93
|
|
|
80
94
|
const DEFAULT_FAN_DOWN_TIMEOUT_MS = 10_000;
|
|
81
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Normalize a channel-record `sites` list. Empty / missing means unscoped
|
|
98
|
+
* (legal only as the sole connected agent — the DEV-294 compatibility path).
|
|
99
|
+
*
|
|
100
|
+
* @param {unknown} sites
|
|
101
|
+
* @returns {string[]}
|
|
102
|
+
*/
|
|
103
|
+
export function boundSiteNames(sites) {
|
|
104
|
+
if (!Array.isArray(sites)) return [];
|
|
105
|
+
return sites.map(String).filter((name) => name && !name.startsWith("_"));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Decide whether a hello may join the agent-channel session table.
|
|
110
|
+
*
|
|
111
|
+
* @param {object} params
|
|
112
|
+
* @param {{agentId: string, revoked?: boolean, sites?: string[]|null}|null} params.record
|
|
113
|
+
* @param {Array<{agentId: string, sites: string[]|null}>} [params.sessions]
|
|
114
|
+
* Currently connected agents, excluding a reconnect of the same agentId.
|
|
115
|
+
* @param {Iterable<string>} [params.catalogNames]
|
|
116
|
+
* @returns {{ok: true, sites: string[]|null}|{ok: false, reason: string}}
|
|
117
|
+
*/
|
|
118
|
+
export function acceptAgentHello({ record, sessions = [], catalogNames = [] } = {}) {
|
|
119
|
+
if (!record || typeof record.agentId !== "string" || !record.agentId) {
|
|
120
|
+
return { ok: false, reason: "unauthenticated" };
|
|
121
|
+
}
|
|
122
|
+
if (record.revoked) return { ok: false, reason: "revoked" };
|
|
123
|
+
|
|
124
|
+
const known = new Set([...catalogNames].map(String));
|
|
125
|
+
let incoming = boundSiteNames(record.sites);
|
|
126
|
+
if (incoming.length && known.size) {
|
|
127
|
+
incoming = incoming.filter((name) => known.has(name));
|
|
128
|
+
if (!incoming.length) return { ok: false, reason: "unbound_tenant" };
|
|
129
|
+
}
|
|
130
|
+
const incomingScoped = incoming.length > 0;
|
|
131
|
+
const others = sessions.filter((session) => session.agentId !== record.agentId);
|
|
132
|
+
|
|
133
|
+
if (others.length === 0) {
|
|
134
|
+
return { ok: true, sites: incomingScoped ? incoming : null };
|
|
135
|
+
}
|
|
136
|
+
if (!incomingScoped) return { ok: false, reason: "unbound_tenant" };
|
|
137
|
+
if (others.some((session) => !boundSiteNames(session.sites).length)) {
|
|
138
|
+
return { ok: false, reason: "unbound_tenant" };
|
|
139
|
+
}
|
|
140
|
+
const claimed = new Set(others.flatMap((session) => boundSiteNames(session.sites)));
|
|
141
|
+
if (incoming.some((name) => claimed.has(name))) {
|
|
142
|
+
return { ok: false, reason: "overlapping_tenant" };
|
|
143
|
+
}
|
|
144
|
+
return { ok: true, sites: incoming };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Pick the unique tenant agent this principal may use.
|
|
149
|
+
*
|
|
150
|
+
* @param {object} params
|
|
151
|
+
* @param {string[]} params.grantedSiteNames
|
|
152
|
+
* @param {string|null} [params.targetName]
|
|
153
|
+
* @param {Array<{agentId: string, sites: string[]|null}>} params.sessions
|
|
154
|
+
* @returns {{session: object|null, reason: "not_entitled"|"no_agent"|null}}
|
|
155
|
+
*/
|
|
156
|
+
export function selectTenantSession({
|
|
157
|
+
grantedSiteNames = [],
|
|
158
|
+
targetName = null,
|
|
159
|
+
sessions = [],
|
|
160
|
+
} = {}) {
|
|
161
|
+
if (!grantedSiteNames.length) return { session: null, reason: "not_entitled" };
|
|
162
|
+
if (targetName && !grantedSiteNames.includes(targetName)) {
|
|
163
|
+
return { session: null, reason: "not_entitled" };
|
|
164
|
+
}
|
|
165
|
+
const needed = targetName ? [targetName] : [...grantedSiteNames];
|
|
166
|
+
if (!sessions.length) return { session: null, reason: "no_agent" };
|
|
167
|
+
|
|
168
|
+
const scoped = [];
|
|
169
|
+
const unscoped = [];
|
|
170
|
+
for (const session of sessions) {
|
|
171
|
+
if (boundSiteNames(session.sites).length) scoped.push(session);
|
|
172
|
+
else unscoped.push(session);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (unscoped.length && scoped.length) {
|
|
176
|
+
return { session: null, reason: "no_agent" };
|
|
177
|
+
}
|
|
178
|
+
if (unscoped.length === 1 && sessions.length === 1) {
|
|
179
|
+
return { session: unscoped[0], reason: null };
|
|
180
|
+
}
|
|
181
|
+
if (unscoped.length > 1) return { session: null, reason: "no_agent" };
|
|
182
|
+
|
|
183
|
+
const matches = scoped.filter((session) => {
|
|
184
|
+
const names = new Set(boundSiteNames(session.sites));
|
|
185
|
+
return needed.every((name) => names.has(name));
|
|
186
|
+
});
|
|
187
|
+
if (matches.length === 1) return { session: matches[0], reason: null };
|
|
188
|
+
if (matches.length > 1) return { session: null, reason: "not_entitled" };
|
|
189
|
+
|
|
190
|
+
const partial = scoped.filter((session) => {
|
|
191
|
+
const names = new Set(boundSiteNames(session.sites));
|
|
192
|
+
return needed.some((name) => names.has(name));
|
|
193
|
+
});
|
|
194
|
+
if (partial.length > 0 && needed.length > 1) {
|
|
195
|
+
return { session: null, reason: "not_entitled" };
|
|
196
|
+
}
|
|
197
|
+
return { session: null, reason: "no_agent" };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Resolve tenant and target from server-owned grants. Caller `tenant` is a
|
|
202
|
+
* confirming hint inside the grant, never authority. When `tenantGrants` is
|
|
203
|
+
* omitted, tenant is derived from the unique agent covering the site grant
|
|
204
|
+
* (DEV-122 / DEV-294 compatibility).
|
|
205
|
+
*
|
|
206
|
+
* @param {object} params
|
|
207
|
+
* @param {object|null} params.identity
|
|
208
|
+
* @param {string|null} [params.callerTenant]
|
|
209
|
+
* @param {object|null} [params.tenantGrants]
|
|
210
|
+
* @param {string[]} params.grantedSiteNames
|
|
211
|
+
* @param {string|null} [params.targetName]
|
|
212
|
+
* @param {Array<{agentId: string, sites: string[]|null}>} params.sessions
|
|
213
|
+
* @returns {{session: object|null, tenant: string|null, target: string|null, source: string, reason: "not_entitled"|"no_agent"|null}}
|
|
214
|
+
*/
|
|
215
|
+
export function resolveTenantRoute({
|
|
216
|
+
identity,
|
|
217
|
+
callerTenant = null,
|
|
218
|
+
tenantGrants = null,
|
|
219
|
+
grantedSiteNames = [],
|
|
220
|
+
targetName = null,
|
|
221
|
+
sessions = [],
|
|
222
|
+
} = {}) {
|
|
223
|
+
if (tenantGrants) {
|
|
224
|
+
const listed = identity?.clientId
|
|
225
|
+
? new Map(Object.entries(tenantGrants)).get(identity.clientId)
|
|
226
|
+
: undefined;
|
|
227
|
+
const grantedTenants = grantIds(listed);
|
|
228
|
+
if (!grantedTenants.length) {
|
|
229
|
+
return {
|
|
230
|
+
session: null, tenant: null, target: targetName, source: "grant", reason: "not_entitled",
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
if (callerTenant && !grantedTenants.includes(callerTenant)) {
|
|
234
|
+
return {
|
|
235
|
+
session: null, tenant: null, target: targetName, source: "grant", reason: "not_entitled",
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
if (!callerTenant && grantedTenants.length > 1) {
|
|
239
|
+
return {
|
|
240
|
+
session: null, tenant: null, target: targetName, source: "grant", reason: "not_entitled",
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const tenant = callerTenant || grantedTenants[0];
|
|
244
|
+
const session = sessions.find((entry) => entry.agentId === tenant) ?? null;
|
|
245
|
+
if (!session) {
|
|
246
|
+
return {
|
|
247
|
+
session: null, tenant, target: targetName, source: "grant", reason: "no_agent",
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const tenantSites = boundSiteNames(session.sites);
|
|
251
|
+
const allowed = tenantSites.length
|
|
252
|
+
? grantedSiteNames.filter((name) => tenantSites.includes(name))
|
|
253
|
+
: grantedSiteNames;
|
|
254
|
+
if (targetName && !allowed.includes(targetName)) {
|
|
255
|
+
return {
|
|
256
|
+
session: null, tenant, target: targetName, source: "grant", reason: "not_entitled",
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
if (!targetName && grantedSiteNames.length && allowed.length === 0) {
|
|
260
|
+
return {
|
|
261
|
+
session: null, tenant, target: targetName, source: "grant", reason: "not_entitled",
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
return { session, tenant, target: targetName, source: "grant", reason: null };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const selected = selectTenantSession({ grantedSiteNames, targetName, sessions });
|
|
268
|
+
return {
|
|
269
|
+
session: selected.session,
|
|
270
|
+
tenant: selected.session?.agentId ?? null,
|
|
271
|
+
target: targetName,
|
|
272
|
+
source: "grant",
|
|
273
|
+
reason: selected.reason,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function callerTenantHint(args = {}) {
|
|
278
|
+
const value = new Map(Object.entries(args ?? {})).get("tenant");
|
|
279
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const CALLER_HINT_KEYS = Object.freeze(["tenant", "actor", "delegator", "policy", "digest"]);
|
|
283
|
+
|
|
284
|
+
function stripCallerHints(body) {
|
|
285
|
+
const args = body?.params?.arguments;
|
|
286
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) return body;
|
|
287
|
+
const bag = new Map(Object.entries(args));
|
|
288
|
+
let changed = false;
|
|
289
|
+
for (const key of CALLER_HINT_KEYS) {
|
|
290
|
+
if (bag.has(key)) {
|
|
291
|
+
bag.delete(key);
|
|
292
|
+
changed = true;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (!changed) return body;
|
|
296
|
+
return { ...body, params: { ...body.params, arguments: Object.fromEntries(bag) } };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function identityWithGrant(identity, { tenant, actor = null, delegator = null, policy = null }) {
|
|
300
|
+
const bag = new Map(Object.entries(identity ?? {}));
|
|
301
|
+
for (const key of ["actor", "delegator", "policy", "digest"]) bag.delete(key);
|
|
302
|
+
bag.set("tenant", tenant);
|
|
303
|
+
if (actor) bag.set("actor", actor);
|
|
304
|
+
if (delegator) bag.set("delegator", delegator);
|
|
305
|
+
if (policy) bag.set("policy", policy);
|
|
306
|
+
return Object.freeze(Object.fromEntries(bag));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Canonical form of a tenant site bind. Null and empty both mean unscoped.
|
|
311
|
+
*
|
|
312
|
+
* @param {unknown} sites
|
|
313
|
+
* @returns {string}
|
|
314
|
+
*/
|
|
315
|
+
export function siteBindingKey(sites) {
|
|
316
|
+
const names = boundSiteNames(sites);
|
|
317
|
+
return names.length ? names.slice().sort().join("\0") : "";
|
|
318
|
+
}
|
|
319
|
+
|
|
82
320
|
/** Startup refusals: configuration that must not become a listener. */
|
|
83
321
|
export class EdgeStartupError extends Error {
|
|
84
322
|
constructor(message) {
|
|
@@ -110,14 +348,17 @@ export function fanDownHeaders(headers = {}) {
|
|
|
110
348
|
* Hot-reloaded agent channel credential store.
|
|
111
349
|
*
|
|
112
350
|
* File shape: `{ "agents": { "<agentId>": { "tokenSha256": "<hex>",
|
|
113
|
-
* "revoked": false } } }`. The edge stores only
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
351
|
+
* "sites": ["site-name"], "revoked": false } } }`. The edge stores only
|
|
352
|
+
* SHA-256 digests — never a raw channel token. `sites` binds the tunnel to
|
|
353
|
+
* catalog names (the tenant boundary). Omitted `sites` is unscoped and is
|
|
354
|
+
* only accepted as the sole connected agent. A missing or unreadable file
|
|
355
|
+
* denies every lookup (a credential table that cannot be read authorizes
|
|
356
|
+
* nobody), and the file is re-read when its mtime changes so a revoke needs
|
|
357
|
+
* no restart.
|
|
117
358
|
*
|
|
118
359
|
* @param {object} options
|
|
119
360
|
* @param {string} options.filePath
|
|
120
|
-
* @returns {{lookup: (token: string) => {agentId: string, revoked: boolean}|null}}
|
|
361
|
+
* @returns {{lookup: (token: string) => {agentId: string, revoked: boolean, sites: string[]|null}|null}}
|
|
121
362
|
*/
|
|
122
363
|
export function createChannelCredentialStore({
|
|
123
364
|
filePath,
|
|
@@ -146,6 +387,7 @@ export function createChannelCredentialStore({
|
|
|
146
387
|
agentId,
|
|
147
388
|
tokenSha256: entry.tokenSha256.toLowerCase(),
|
|
148
389
|
revoked: entry.revoked === true,
|
|
390
|
+
sites: Array.isArray(entry.sites) ? boundSiteNames(entry.sites) : null,
|
|
149
391
|
}));
|
|
150
392
|
cache = { mtimeMs: info.mtimeMs, agents, denyAll: false };
|
|
151
393
|
} catch {
|
|
@@ -166,7 +408,7 @@ export function createChannelCredentialStore({
|
|
|
166
408
|
for (const agent of agents) {
|
|
167
409
|
const expected = Buffer.from(agent.tokenSha256, "utf8");
|
|
168
410
|
if (digest.length === expected.length && timingSafeEqual(digest, expected)) {
|
|
169
|
-
return { agentId: agent.agentId, revoked: agent.revoked };
|
|
411
|
+
return { agentId: agent.agentId, revoked: agent.revoked, sites: agent.sites };
|
|
170
412
|
}
|
|
171
413
|
}
|
|
172
414
|
return null;
|
|
@@ -178,11 +420,18 @@ function isLoopback(host) {
|
|
|
178
420
|
return host === "127.0.0.1" || host === "::1" || host === "localhost";
|
|
179
421
|
}
|
|
180
422
|
|
|
423
|
+
function grantIds(values) {
|
|
424
|
+
if (!Array.isArray(values)) return [];
|
|
425
|
+
return [...new Set(
|
|
426
|
+
values.map((value) => String(value).trim()).filter((value) => value && !value.startsWith("_")),
|
|
427
|
+
)];
|
|
428
|
+
}
|
|
429
|
+
|
|
181
430
|
function normalizeGrants(grants) {
|
|
182
431
|
if (!grants || typeof grants !== "object" || Array.isArray(grants)) return null;
|
|
183
432
|
const entries = Object.entries(grants)
|
|
184
433
|
.filter(([clientId, sites]) => !clientId.startsWith("_") && Array.isArray(sites))
|
|
185
|
-
.map(([clientId, sites]) => [clientId, sites
|
|
434
|
+
.map(([clientId, sites]) => [clientId, grantIds(sites)]);
|
|
186
435
|
return entries.length ? Object.fromEntries(entries) : null;
|
|
187
436
|
}
|
|
188
437
|
|
|
@@ -222,6 +471,15 @@ function jsonResponse(res, status, body) {
|
|
|
222
471
|
* @param {{issuer: string, audience: string}} options.auth Inbound
|
|
223
472
|
* resource-server config (`resolveInboundAuthConfig` shape). Mandatory.
|
|
224
473
|
* @param {object} options.grants Non-empty client-id → site-name grant table.
|
|
474
|
+
* @param {object|null} [options.tenantGrants] Optional client-id → tenant
|
|
475
|
+
* agent-id table. When present, tenant routing is grant-authoritative.
|
|
476
|
+
* @param {object|null} [options.actors] Optional principal → Drupal actor
|
|
477
|
+
* table (`sub` / `azp` → `{ uuid, delegators? }`). When present, write-like
|
|
478
|
+
* tools/call require a mapping.
|
|
479
|
+
* @param {object|null} [options.policies] Optional principal → SHA-256 digest.
|
|
480
|
+
* @param {object|null} [options.promotions] Optional W&L-operated dual-control
|
|
481
|
+
* ledger (digest → sealed document + two operator ids). When present,
|
|
482
|
+
* non-diagnostic tools/call require a matching agent attestation.
|
|
225
483
|
* @param {Array<{_name: string}>} options.sites Credential-free catalog.
|
|
226
484
|
* @param {string} [options.defaultSite]
|
|
227
485
|
* @param {{lookup: Function}} options.channelCredentials Agent channel store.
|
|
@@ -240,6 +498,10 @@ function jsonResponse(res, status, body) {
|
|
|
240
498
|
export async function startEdge({
|
|
241
499
|
auth,
|
|
242
500
|
grants,
|
|
501
|
+
tenantGrants = null,
|
|
502
|
+
actors = null,
|
|
503
|
+
policies = null,
|
|
504
|
+
promotions = null,
|
|
243
505
|
sites,
|
|
244
506
|
defaultSite,
|
|
245
507
|
channelCredentials,
|
|
@@ -268,6 +530,17 @@ export async function startEdge({
|
|
|
268
530
|
+ "The library's all-sites fallback does not apply to this entry point.",
|
|
269
531
|
);
|
|
270
532
|
}
|
|
533
|
+
const tenantGrantTable = normalizeGrants(tenantGrants);
|
|
534
|
+
const actorTable = actors && typeof actors === "object" && !Array.isArray(actors)
|
|
535
|
+
? actors
|
|
536
|
+
: null;
|
|
537
|
+
const policyTable = policies && typeof policies === "object" && !Array.isArray(policies)
|
|
538
|
+
? policies
|
|
539
|
+
: null;
|
|
540
|
+
const promotionTable = promotions && typeof promotions === "object" && !Array.isArray(promotions)
|
|
541
|
+
? promotions
|
|
542
|
+
: null;
|
|
543
|
+
const promoRequired = promotionsRequired(promotionTable);
|
|
271
544
|
if (typeof channelCredentials?.lookup !== "function") {
|
|
272
545
|
throw new EdgeStartupError(
|
|
273
546
|
"Relay edge requires an agent channel credential store; without one no "
|
|
@@ -293,7 +566,9 @@ export async function startEdge({
|
|
|
293
566
|
const targetRelay = createLocalRelay({ sites: catalog, grants: grantTable, defaultSite });
|
|
294
567
|
const broker = createRequestBroker({ timeoutMs: fanDownTimeoutMs });
|
|
295
568
|
|
|
296
|
-
|
|
569
|
+
/** @type {Map<string, {socket: object, token: string, agentId: string, sites: string[]|null, attestedDigests: Set<string>, offeredDigests: Set<string>}>} */
|
|
570
|
+
const sessions = new Map();
|
|
571
|
+
const catalogNames = catalog.map((site) => site._name);
|
|
297
572
|
|
|
298
573
|
const channelServer = (hasTls ? createTlsServer(tls) : createNetServer())
|
|
299
574
|
.on("connection", handleChannelSocket)
|
|
@@ -303,35 +578,76 @@ export async function startEdge({
|
|
|
303
578
|
// Plain server emits "connection"; the TLS server emits both "connection"
|
|
304
579
|
// (raw) and "secureConnection" (cleartext). Attach once, post-handshake.
|
|
305
580
|
if (hasTls && !socket.encrypted) return;
|
|
581
|
+
let agentId = null;
|
|
306
582
|
attachFramer(socket, (frame) => {
|
|
307
583
|
if (frame.type === "hello") {
|
|
308
584
|
const record = channelCredentials.lookup(frame.token);
|
|
309
|
-
|
|
310
|
-
|
|
585
|
+
const others = [...sessions.values()].filter((entry) => entry.socket !== socket);
|
|
586
|
+
const decision = acceptAgentHello({
|
|
587
|
+
record,
|
|
588
|
+
sessions: others,
|
|
589
|
+
catalogNames,
|
|
590
|
+
});
|
|
591
|
+
if (!decision.ok) {
|
|
592
|
+
writeFrame(socket, { type: "denied", reason: decision.reason });
|
|
311
593
|
socket.end();
|
|
312
594
|
return;
|
|
313
595
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
596
|
+
const existing = sessions.get(record.agentId);
|
|
597
|
+
if (existing && existing.socket !== socket) existing.socket.destroy();
|
|
598
|
+
agentId = record.agentId;
|
|
599
|
+
const offeredDigests = new Set();
|
|
600
|
+
sessions.set(agentId, {
|
|
601
|
+
socket,
|
|
602
|
+
token: frame.token,
|
|
603
|
+
agentId,
|
|
604
|
+
sites: decision.sites,
|
|
605
|
+
attestedDigests: new Set(),
|
|
606
|
+
offeredDigests,
|
|
607
|
+
});
|
|
608
|
+
try {
|
|
609
|
+
for (const row of eligiblePromotions(promotionTable)) {
|
|
610
|
+
const wrote = writeFrame(socket, { type: "policy-bundle", document: row.document });
|
|
611
|
+
if (!wrote) throw new Error("policy-bundle write failed");
|
|
612
|
+
offeredDigests.add(row.digest);
|
|
613
|
+
}
|
|
614
|
+
writeFrame(socket, { type: "hello-ok", agent: { agentId } });
|
|
615
|
+
} catch {
|
|
616
|
+
sessions.delete(agentId);
|
|
617
|
+
agentId = null;
|
|
618
|
+
socket.destroy();
|
|
619
|
+
}
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (!agentId) {
|
|
623
|
+
socket.destroy();
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (frame.type === "policy-bundle-ack") {
|
|
627
|
+
const session = sessions.get(agentId);
|
|
628
|
+
const digest = typeof frame.digest === "string"
|
|
629
|
+
? frame.digest.trim().toLowerCase()
|
|
630
|
+
: "";
|
|
631
|
+
if (
|
|
632
|
+
session && frame.ok === true && POLICY_DIGEST.test(digest)
|
|
633
|
+
&& session.offeredDigests.has(digest)
|
|
634
|
+
) {
|
|
635
|
+
session.attestedDigests.add(digest);
|
|
318
636
|
}
|
|
319
|
-
if (session && session.socket !== socket) session.socket.destroy();
|
|
320
|
-
session = { socket, token: frame.token, agentId: record.agentId };
|
|
321
|
-
writeFrame(socket, { type: "hello-ok", agent: { agentId: record.agentId } });
|
|
322
637
|
return;
|
|
323
638
|
}
|
|
324
639
|
if (frame.type === "mcp-response") {
|
|
325
|
-
broker.settle(frame);
|
|
640
|
+
broker.settle(frame, { owner: agentId });
|
|
326
641
|
return;
|
|
327
642
|
}
|
|
328
643
|
// Any other frame type on the agent channel is a protocol violation.
|
|
329
644
|
socket.destroy();
|
|
330
645
|
});
|
|
331
646
|
socket.on("close", () => {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
647
|
+
const current = agentId ? sessions.get(agentId) : null;
|
|
648
|
+
if (current?.socket === socket) {
|
|
649
|
+
sessions.delete(agentId);
|
|
650
|
+
broker.rejectByOwner(agentId, new Error("Relay agent channel closed."));
|
|
335
651
|
}
|
|
336
652
|
});
|
|
337
653
|
}
|
|
@@ -352,38 +668,107 @@ export async function startEdge({
|
|
|
352
668
|
jsonResponse(res, 403, { error: "not_entitled" });
|
|
353
669
|
return;
|
|
354
670
|
}
|
|
671
|
+
const args = body?.params?.arguments ?? {};
|
|
672
|
+
const callerTenant = callerTenantHint(args);
|
|
673
|
+
const siteArgs = { ...args };
|
|
674
|
+
delete siteArgs.tenant;
|
|
675
|
+
let targetName = null;
|
|
355
676
|
if (body?.method === "tools/call") {
|
|
356
677
|
try {
|
|
357
|
-
targetRelay.resolve(identity,
|
|
678
|
+
targetName = targetRelay.resolve(identity, siteArgs).name;
|
|
358
679
|
} catch {
|
|
359
680
|
jsonResponse(res, 403, { error: "not_entitled" });
|
|
360
681
|
return;
|
|
361
682
|
}
|
|
362
683
|
}
|
|
363
684
|
|
|
364
|
-
|
|
365
|
-
|
|
685
|
+
const mapped = resolveActor({ identity, actors: actorTable });
|
|
686
|
+
const boundPolicy = resolvePolicy({ identity, policies: policyTable });
|
|
687
|
+
const isCall = body?.method === "tools/call";
|
|
688
|
+
const toolName = isCall && typeof body?.params?.name === "string" && body.params.name.trim()
|
|
689
|
+
? body.params.name.trim()
|
|
690
|
+
: null;
|
|
691
|
+
if (mapped.required && mapped.reason && isCall && (!toolName || isWriteLikeCall(toolName, args))) {
|
|
692
|
+
jsonResponse(res, 403, { error: "not_entitled" });
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
const policyCall = isCall && (!toolName || !DIAGNOSTIC_TOOLS.has(toolName));
|
|
696
|
+
if (
|
|
697
|
+
boundPolicy.required && boundPolicy.reason && policyCall
|
|
698
|
+
) {
|
|
699
|
+
jsonResponse(res, 403, { error: "not_entitled" });
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
if (promoRequired && policyCall && !boundPolicy.policy) {
|
|
703
|
+
jsonResponse(res, 403, { error: "not_entitled" });
|
|
366
704
|
return;
|
|
367
705
|
}
|
|
368
|
-
|
|
706
|
+
|
|
707
|
+
const selected = resolveTenantRoute({
|
|
708
|
+
identity,
|
|
709
|
+
callerTenant,
|
|
710
|
+
tenantGrants: tenantGrantTable,
|
|
711
|
+
grantedSiteNames: granted.map((site) => site._name),
|
|
712
|
+
targetName,
|
|
713
|
+
sessions: [...sessions.values()],
|
|
714
|
+
});
|
|
715
|
+
if (promoRequired && policyCall) {
|
|
716
|
+
const promo = resolveEligiblePromotion({
|
|
717
|
+
digest: boundPolicy.policy,
|
|
718
|
+
promotions: promotionTable,
|
|
719
|
+
});
|
|
720
|
+
const attested = selected.session?.attestedDigests;
|
|
721
|
+
if (!promo.eligible || !attested || !attested.has(boundPolicy.policy)) {
|
|
722
|
+
jsonResponse(res, 403, { error: "not_entitled" });
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (!selected.session) {
|
|
727
|
+
const entitled = selected.reason === "not_entitled";
|
|
728
|
+
jsonResponse(res, entitled ? 403 : 503, {
|
|
729
|
+
error: entitled ? "not_entitled" : "no_agent",
|
|
730
|
+
});
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
const record = channelCredentials.lookup(selected.session.token);
|
|
369
734
|
if (!record || record.revoked) {
|
|
370
735
|
jsonResponse(res, 403, { error: "revoked", bound: EDGE_REVOCATION_BOUND.name });
|
|
371
736
|
return;
|
|
372
737
|
}
|
|
738
|
+
if (siteBindingKey(record.sites) !== siteBindingKey(selected.session.sites)) {
|
|
739
|
+
selected.session.socket.destroy();
|
|
740
|
+
jsonResponse(res, 503, { error: "no_agent" });
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
373
743
|
|
|
374
744
|
const id = randomUUID();
|
|
375
|
-
const waited = broker.track(id);
|
|
376
|
-
const
|
|
745
|
+
const waited = broker.track(id, { owner: selected.session.agentId });
|
|
746
|
+
const routedIdentity = identityWithGrant(identity, {
|
|
747
|
+
tenant: selected.tenant,
|
|
748
|
+
actor: mapped.actor,
|
|
749
|
+
delegator: mapped.delegator,
|
|
750
|
+
policy: boundPolicy.policy,
|
|
751
|
+
});
|
|
752
|
+
const wrote = writeFrame(selected.session.socket, {
|
|
377
753
|
type: "mcp-request",
|
|
378
754
|
id,
|
|
379
755
|
method: req.method,
|
|
380
756
|
url: "/mcp",
|
|
381
757
|
headers: fanDownHeaders(req.headers),
|
|
382
|
-
identity,
|
|
383
|
-
body,
|
|
758
|
+
identity: routedIdentity,
|
|
759
|
+
body: stripCallerHints(body),
|
|
760
|
+
correlation: {
|
|
761
|
+
requestId: id,
|
|
762
|
+
tenant: selected.tenant,
|
|
763
|
+
target: selected.target,
|
|
764
|
+
source: selected.source,
|
|
765
|
+
...(mapped.actor ? { actor: mapped.actor } : {}),
|
|
766
|
+
...(mapped.delegator ? { delegator: mapped.delegator } : {}),
|
|
767
|
+
...(boundPolicy.policy ? { policyDigest: boundPolicy.policy } : {}),
|
|
768
|
+
},
|
|
384
769
|
});
|
|
385
770
|
if (!wrote) {
|
|
386
|
-
broker.settle({ id, status: 503 });
|
|
771
|
+
broker.settle({ id, status: 503 }, { owner: selected.session.agentId });
|
|
387
772
|
jsonResponse(res, 503, { error: "no_agent" });
|
|
388
773
|
return;
|
|
389
774
|
}
|
|
@@ -446,16 +831,20 @@ export async function startEdge({
|
|
|
446
831
|
agentPort: channelAddr.port,
|
|
447
832
|
resourceMetadataUrl: inbound.resourceMetadataUrl,
|
|
448
833
|
get hasAgent() {
|
|
449
|
-
return
|
|
834
|
+
return sessions.size > 0;
|
|
450
835
|
},
|
|
451
836
|
get agentId() {
|
|
452
|
-
|
|
837
|
+
if (sessions.size !== 1) return null;
|
|
838
|
+
return sessions.keys().next().value;
|
|
839
|
+
},
|
|
840
|
+
get agentIds() {
|
|
841
|
+
return [...sessions.keys()];
|
|
453
842
|
},
|
|
454
843
|
async close() {
|
|
455
844
|
if (closed) return;
|
|
456
845
|
closed = true;
|
|
457
|
-
|
|
458
|
-
|
|
846
|
+
for (const entry of sessions.values()) entry.socket.destroy();
|
|
847
|
+
sessions.clear();
|
|
459
848
|
broker.rejectAll(new Error("Relay edge closed."));
|
|
460
849
|
await closeServer(channelServer);
|
|
461
850
|
await closeServer(northServer);
|