@opengeni/core 2.8.3 → 2.9.1-canary.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/dist/access/external-actor-authority.d.ts +47 -0
- package/dist/access/index.d.ts +15 -1
- package/dist/application/connect-authority.d.ts +13 -0
- package/dist/application/connect-operation.d.ts +28 -0
- package/dist/application/external-continuation.d.ts +15 -0
- package/dist/application/external-identity-lifecycle.d.ts +20 -0
- package/dist/application/external-link-work-admission.d.ts +12 -0
- package/dist/application/external-workspace-members.d.ts +6 -0
- package/dist/application/host-mcp-owner.d.ts +6 -0
- package/dist/application/new-session-drafts.d.ts +2 -1
- package/dist/application/session-tenancy.d.ts +1 -0
- package/dist/dependencies.d.ts +2 -0
- package/dist/domain/capabilities.d.ts +19 -2
- package/dist/domain/external-creation-attribution.d.ts +6 -0
- package/dist/domain/host-mcp-task-admission.d.ts +18 -0
- package/dist/domain/product-integration-pack.d.ts +3 -9
- package/dist/domain/product-integration-skill.gen.d.ts +5 -0
- package/dist/domain/scheduled-tasks.d.ts +3 -0
- package/dist/domain/sessions.d.ts +15 -4
- package/dist/index.d.ts +7 -0
- package/dist/index.js +1231 -825
- package/dist/index.js.map +1 -1
- package/dist/remote-mcp-credentials.d.ts +8 -0
- package/dist/remote-mcp-credentials.js +219 -0
- package/dist/remote-mcp-credentials.js.map +1 -0
- package/dist/session-authorization.d.ts +5 -6
- package/package.json +17 -13
- package/src/access/external-actor-authority.ts +94 -0
- package/src/access/index.ts +255 -1
- package/src/application/connect-authority.ts +77 -0
- package/src/application/connect-operation.ts +51 -0
- package/src/application/external-continuation.ts +112 -0
- package/src/application/external-identity-lifecycle.ts +48 -0
- package/src/application/external-link-work-admission.ts +87 -0
- package/src/application/external-workspace-members.ts +95 -0
- package/src/application/host-mcp-owner.ts +41 -0
- package/src/application/new-session-drafts.ts +9 -2
- package/src/application/session-tenancy.ts +24 -3
- package/src/application/user-resource-grants.ts +2 -2
- package/src/dependencies.ts +2 -0
- package/src/domain/capabilities.ts +20 -4
- package/src/domain/external-creation-attribution.ts +22 -0
- package/src/domain/host-mcp-task-admission.ts +111 -0
- package/src/domain/product-integration-pack.ts +11 -464
- package/src/domain/product-integration-skill.gen.ts +52 -0
- package/src/domain/scheduled-tasks.ts +66 -5
- package/src/domain/sessions.ts +253 -23
- package/src/index.ts +7 -0
- package/src/remote-mcp-credentials.ts +293 -0
- package/src/session-authorization.ts +22 -17
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import type { Settings } from "@opengeni/config";
|
|
2
|
+
import {
|
|
3
|
+
McpConnectionResourceScope,
|
|
4
|
+
type ConnectionCredentialsPort,
|
|
5
|
+
type McpCredentialsRequest,
|
|
6
|
+
type McpCredentialResolution,
|
|
7
|
+
type McpGatewayCredentialsRequest,
|
|
8
|
+
type McpGatewayCredentialResolution,
|
|
9
|
+
} from "@opengeni/contracts";
|
|
10
|
+
import { pinnedFetch, readResponseJsonBounded, validateHttpUrl } from "@opengeni/network";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
const configuration = z
|
|
14
|
+
.array(
|
|
15
|
+
z
|
|
16
|
+
.object({
|
|
17
|
+
accountId: z.string().uuid(),
|
|
18
|
+
url: z.string().max(2048),
|
|
19
|
+
bearerToken: z
|
|
20
|
+
.string()
|
|
21
|
+
.min(1)
|
|
22
|
+
.max(8192)
|
|
23
|
+
.regex(/^[^\r\n]+$/),
|
|
24
|
+
timeoutMs: z.number().int().min(100).max(30_000).default(10_000),
|
|
25
|
+
})
|
|
26
|
+
.strict(),
|
|
27
|
+
)
|
|
28
|
+
.max(128);
|
|
29
|
+
|
|
30
|
+
const scope = {
|
|
31
|
+
accountId: z.string(),
|
|
32
|
+
workspaceId: z.string(),
|
|
33
|
+
sessionId: z.string(),
|
|
34
|
+
providerDomain: z.string(),
|
|
35
|
+
provider: z.string().optional(),
|
|
36
|
+
scopes: z.array(z.string()).max(256).optional(),
|
|
37
|
+
resource: z.string().optional(),
|
|
38
|
+
selectedResources: z.array(McpConnectionResourceScope).max(256).optional(),
|
|
39
|
+
};
|
|
40
|
+
const resolution = z.discriminatedUnion("status", [
|
|
41
|
+
z
|
|
42
|
+
.object({
|
|
43
|
+
...scope,
|
|
44
|
+
status: z.literal("ok"),
|
|
45
|
+
connectionId: z.string().min(1),
|
|
46
|
+
headers: z.record(z.string(), z.string()),
|
|
47
|
+
placements: z
|
|
48
|
+
.array(
|
|
49
|
+
z
|
|
50
|
+
.object({
|
|
51
|
+
carrier: z.enum(["header", "query", "cookie"]),
|
|
52
|
+
name: z.string(),
|
|
53
|
+
value: z.string(),
|
|
54
|
+
prefix: z.string().optional(),
|
|
55
|
+
})
|
|
56
|
+
.strict(),
|
|
57
|
+
)
|
|
58
|
+
.max(64)
|
|
59
|
+
.optional(),
|
|
60
|
+
expiresAt: z.string().datetime({ offset: true }),
|
|
61
|
+
})
|
|
62
|
+
.strict(),
|
|
63
|
+
z
|
|
64
|
+
.object({
|
|
65
|
+
...scope,
|
|
66
|
+
status: z.literal("auth_needed"),
|
|
67
|
+
connectionId: z.string().optional(),
|
|
68
|
+
reason: z.enum([
|
|
69
|
+
"missing_connection",
|
|
70
|
+
"expired",
|
|
71
|
+
"insufficient_scope",
|
|
72
|
+
"refresh_failed",
|
|
73
|
+
"personal_authority_unavailable",
|
|
74
|
+
"unsupported_auth",
|
|
75
|
+
"resource_scope_unavailable",
|
|
76
|
+
]),
|
|
77
|
+
authorizationUrl: z.string().optional(),
|
|
78
|
+
})
|
|
79
|
+
.strict(),
|
|
80
|
+
]);
|
|
81
|
+
const gatewayResolution = z.discriminatedUnion("status", [
|
|
82
|
+
resolution.options[0].omit({ sessionId: true }).extend({ requestId: z.string().uuid() }),
|
|
83
|
+
resolution.options[1].omit({ sessionId: true }).extend({ requestId: z.string().uuid() }),
|
|
84
|
+
]);
|
|
85
|
+
const responseEnvelope = z
|
|
86
|
+
.object({
|
|
87
|
+
version: z.literal(1),
|
|
88
|
+
requestId: z.string().uuid(),
|
|
89
|
+
destinationUrl: z.string(),
|
|
90
|
+
resolution: z.union([resolution, gatewayResolution]),
|
|
91
|
+
})
|
|
92
|
+
.strict();
|
|
93
|
+
|
|
94
|
+
type RemoteResolution = McpCredentialResolution | McpGatewayCredentialResolution;
|
|
95
|
+
function normalizeResolution(
|
|
96
|
+
value: z.infer<typeof resolution> | z.infer<typeof gatewayResolution>,
|
|
97
|
+
): RemoteResolution {
|
|
98
|
+
const common = {
|
|
99
|
+
accountId: value.accountId,
|
|
100
|
+
workspaceId: value.workspaceId,
|
|
101
|
+
...("sessionId" in value ? { sessionId: value.sessionId } : { requestId: value.requestId }),
|
|
102
|
+
providerDomain: value.providerDomain,
|
|
103
|
+
...(value.provider !== undefined ? { provider: value.provider } : {}),
|
|
104
|
+
...(value.scopes !== undefined ? { scopes: value.scopes } : {}),
|
|
105
|
+
...(value.resource !== undefined ? { resource: value.resource } : {}),
|
|
106
|
+
...(value.selectedResources !== undefined
|
|
107
|
+
? { selectedResources: value.selectedResources }
|
|
108
|
+
: {}),
|
|
109
|
+
};
|
|
110
|
+
if (value.status === "auth_needed")
|
|
111
|
+
return {
|
|
112
|
+
...common,
|
|
113
|
+
status: "auth_needed",
|
|
114
|
+
reason: value.reason,
|
|
115
|
+
...(value.connectionId !== undefined ? { connectionId: value.connectionId } : {}),
|
|
116
|
+
...(value.authorizationUrl !== undefined ? { authorizationUrl: value.authorizationUrl } : {}),
|
|
117
|
+
};
|
|
118
|
+
return {
|
|
119
|
+
...common,
|
|
120
|
+
status: "ok",
|
|
121
|
+
connectionId: value.connectionId,
|
|
122
|
+
headers: value.headers,
|
|
123
|
+
expiresAt: value.expiresAt,
|
|
124
|
+
...(value.placements !== undefined
|
|
125
|
+
? {
|
|
126
|
+
placements: value.placements.map((placement) => ({
|
|
127
|
+
carrier: placement.carrier,
|
|
128
|
+
name: placement.name,
|
|
129
|
+
value: placement.value,
|
|
130
|
+
...(placement.prefix !== undefined ? { prefix: placement.prefix } : {}),
|
|
131
|
+
})),
|
|
132
|
+
}
|
|
133
|
+
: {}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Optional standalone transport for the existing request-time host port.
|
|
138
|
+
* No successful credential is cached or persisted. The host must authorize
|
|
139
|
+
* every immutable request context, including revocation and binding generation.
|
|
140
|
+
*/
|
|
141
|
+
export function createRemoteMcpCredentialsPort(
|
|
142
|
+
settings: Pick<
|
|
143
|
+
Settings,
|
|
144
|
+
"hostMcpCredentialResolversJson" | "environment" | "integrationsAllowPrivateNetworkTargets"
|
|
145
|
+
>,
|
|
146
|
+
transport: typeof pinnedFetch = pinnedFetch,
|
|
147
|
+
): ConnectionCredentialsPort {
|
|
148
|
+
if (!settings.hostMcpCredentialResolversJson) return {};
|
|
149
|
+
let entries: z.infer<typeof configuration>;
|
|
150
|
+
try {
|
|
151
|
+
entries = configuration.parse(JSON.parse(settings.hostMcpCredentialResolversJson));
|
|
152
|
+
for (const entry of entries) validateHttpUrl(entry.url);
|
|
153
|
+
if (new Set(entries.map((entry) => entry.accountId)).size !== entries.length) throw new Error();
|
|
154
|
+
} catch {
|
|
155
|
+
// Configuration includes credentials: never return parser input or causes.
|
|
156
|
+
throw new Error("Invalid host MCP credential resolver configuration");
|
|
157
|
+
}
|
|
158
|
+
const byAccount = new Map(entries.map((entry) => [entry.accountId, entry]));
|
|
159
|
+
const active = new Map<string, Promise<RemoteResolution>>();
|
|
160
|
+
let physicalRequests = 0;
|
|
161
|
+
const resolveRemote = async (
|
|
162
|
+
input: McpCredentialsRequest | McpGatewayCredentialsRequest,
|
|
163
|
+
): Promise<RemoteResolution> => {
|
|
164
|
+
const serialized = JSON.stringify(input);
|
|
165
|
+
const request: McpCredentialsRequest | McpGatewayCredentialsRequest = JSON.parse(serialized);
|
|
166
|
+
const unavailable = (reason: "unsupported_auth" | "refresh_failed"): RemoteResolution => ({
|
|
167
|
+
status: "auth_needed",
|
|
168
|
+
accountId: request.accountId,
|
|
169
|
+
workspaceId: request.workspaceId,
|
|
170
|
+
...("sessionId" in request
|
|
171
|
+
? { sessionId: request.sessionId }
|
|
172
|
+
: { requestId: request.requestId }),
|
|
173
|
+
providerDomain: request.connectionRef.providerDomain,
|
|
174
|
+
...(request.connectionRef.provider !== undefined
|
|
175
|
+
? { provider: request.connectionRef.provider }
|
|
176
|
+
: {}),
|
|
177
|
+
...(request.connectionRef.connectionId !== undefined
|
|
178
|
+
? { connectionId: request.connectionRef.connectionId }
|
|
179
|
+
: {}),
|
|
180
|
+
...(request.connectionRef.scopes !== undefined
|
|
181
|
+
? { scopes: request.connectionRef.scopes }
|
|
182
|
+
: {}),
|
|
183
|
+
...(request.connectionRef.resource !== undefined
|
|
184
|
+
? { resource: request.connectionRef.resource }
|
|
185
|
+
: {}),
|
|
186
|
+
...(request.connectionRef.selectedResources !== undefined
|
|
187
|
+
? { selectedResources: request.connectionRef.selectedResources }
|
|
188
|
+
: {}),
|
|
189
|
+
reason,
|
|
190
|
+
});
|
|
191
|
+
const config = byAccount.get(request.accountId);
|
|
192
|
+
if (!config || request.connectionRef.authoritySource !== "host")
|
|
193
|
+
return unavailable("unsupported_auth");
|
|
194
|
+
// Snapshot before any asynchronous operation; never retain mutable caller
|
|
195
|
+
// objects as the authority for a delayed response.
|
|
196
|
+
if (new TextEncoder().encode(serialized).byteLength > 65_536)
|
|
197
|
+
return unavailable("refresh_failed");
|
|
198
|
+
const pending = active.get(serialized);
|
|
199
|
+
if (pending) return structuredClone(await pending);
|
|
200
|
+
if (physicalRequests >= 128) return unavailable("refresh_failed");
|
|
201
|
+
const run = async (): Promise<RemoteResolution> => {
|
|
202
|
+
const requestId = crypto.randomUUID();
|
|
203
|
+
const controller = new AbortController();
|
|
204
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
205
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
206
|
+
timer = setTimeout(() => {
|
|
207
|
+
controller.abort();
|
|
208
|
+
reject(new Error("Host resolver deadline"));
|
|
209
|
+
}, config.timeoutMs);
|
|
210
|
+
});
|
|
211
|
+
try {
|
|
212
|
+
physicalRequests++;
|
|
213
|
+
return await Promise.race([
|
|
214
|
+
deadline,
|
|
215
|
+
(async () => {
|
|
216
|
+
const response = await transport(
|
|
217
|
+
config.url,
|
|
218
|
+
{
|
|
219
|
+
method: "POST",
|
|
220
|
+
redirect: "manual",
|
|
221
|
+
signal: controller.signal,
|
|
222
|
+
headers: {
|
|
223
|
+
Authorization: `Bearer ${config.bearerToken}`,
|
|
224
|
+
"Content-Type": "application/json",
|
|
225
|
+
},
|
|
226
|
+
body: JSON.stringify({ version: 1, requestId, request }),
|
|
227
|
+
},
|
|
228
|
+
settings,
|
|
229
|
+
{ requireHttpsOutsideLocalTest: true, label: "Host MCP credential resolver" },
|
|
230
|
+
);
|
|
231
|
+
if (!response.ok) {
|
|
232
|
+
await response.body?.cancel();
|
|
233
|
+
return unavailable("refresh_failed");
|
|
234
|
+
}
|
|
235
|
+
const parsed = responseEnvelope.parse(
|
|
236
|
+
await readResponseJsonBounded(response, 65_536, "Host MCP credential resolver", {
|
|
237
|
+
signal: controller.signal,
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
if (
|
|
241
|
+
parsed.requestId !== requestId ||
|
|
242
|
+
parsed.destinationUrl !== request.destinationUrl ||
|
|
243
|
+
parsed.resolution.accountId !== request.accountId ||
|
|
244
|
+
parsed.resolution.workspaceId !== request.workspaceId ||
|
|
245
|
+
("sessionId" in request
|
|
246
|
+
? !("sessionId" in parsed.resolution) ||
|
|
247
|
+
parsed.resolution.sessionId !== request.sessionId
|
|
248
|
+
: !("requestId" in parsed.resolution) ||
|
|
249
|
+
parsed.resolution.requestId !== request.requestId)
|
|
250
|
+
)
|
|
251
|
+
return unavailable("refresh_failed");
|
|
252
|
+
if (
|
|
253
|
+
parsed.resolution.status === "ok" &&
|
|
254
|
+
(Date.parse(parsed.resolution.expiresAt) <= Date.now() + 5_000 ||
|
|
255
|
+
Date.parse(parsed.resolution.expiresAt) > Date.now() + 900_000)
|
|
256
|
+
) {
|
|
257
|
+
return unavailable("refresh_failed");
|
|
258
|
+
}
|
|
259
|
+
// Existing buildHostConnectionTokenResolver validates the precise
|
|
260
|
+
// connection, provider, scopes, resources and credential placements.
|
|
261
|
+
return normalizeResolution(parsed.resolution);
|
|
262
|
+
})().finally(() => {
|
|
263
|
+
physicalRequests--;
|
|
264
|
+
}),
|
|
265
|
+
]);
|
|
266
|
+
} catch {
|
|
267
|
+
return unavailable("refresh_failed");
|
|
268
|
+
} finally {
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
const task = run();
|
|
273
|
+
active.set(serialized, task);
|
|
274
|
+
try {
|
|
275
|
+
return structuredClone(await task);
|
|
276
|
+
} finally {
|
|
277
|
+
if (active.get(serialized) === task) active.delete(serialized);
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
return {
|
|
281
|
+
mcpAuthoritySource: "host",
|
|
282
|
+
async mcpCredentials(input) {
|
|
283
|
+
const result = await resolveRemote(input);
|
|
284
|
+
if (!("sessionId" in result)) throw new Error("Invalid host turn credential scope");
|
|
285
|
+
return result;
|
|
286
|
+
},
|
|
287
|
+
async mcpGatewayCredentials(input) {
|
|
288
|
+
const result = await resolveRemote(input);
|
|
289
|
+
if (!("requestId" in result)) throw new Error("Invalid host gateway credential scope");
|
|
290
|
+
return result;
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
type SessionAuthorizationOperation,
|
|
9
9
|
type SessionAuthorizationSurface,
|
|
10
10
|
type SessionAuthorizationTarget,
|
|
11
|
-
type
|
|
11
|
+
type SessionScopeSubjectId,
|
|
12
12
|
} from "@opengeni/contracts";
|
|
13
13
|
import {
|
|
14
14
|
getSessionAuthorityProjection,
|
|
@@ -54,7 +54,7 @@ export type ResolvedSessionAuthorization = {
|
|
|
54
54
|
/** The frozen agent-access facts of one session, as the pairwise rule sees them. */
|
|
55
55
|
export type SessionAgentAccessFacts = {
|
|
56
56
|
agentAccess: SessionAgentAccess;
|
|
57
|
-
|
|
57
|
+
scopeSubjectId: SessionScopeSubjectId | null;
|
|
58
58
|
};
|
|
59
59
|
|
|
60
60
|
type ResolvedSessionAuthorizationActor = {
|
|
@@ -64,25 +64,27 @@ type ResolvedSessionAuthorizationActor = {
|
|
|
64
64
|
callerAccess: SessionAgentAccessFacts | null;
|
|
65
65
|
};
|
|
66
66
|
|
|
67
|
-
function
|
|
68
|
-
|
|
67
|
+
function sameScopeSubject(
|
|
68
|
+
left: SessionScopeSubjectId | null,
|
|
69
|
+
right: SessionScopeSubjectId | null,
|
|
70
|
+
): boolean {
|
|
71
|
+
return left !== null && right !== null && left === right;
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
/**
|
|
72
75
|
* The agent-to-agent reach rule (migration 0427) for one caller/target pair
|
|
73
76
|
* that live in DIFFERENT root trees. A caller always keeps its own tree, so
|
|
74
|
-
* this is never consulted for same-root access.
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* are today's behaviour. Humans and API keys never pass through here.
|
|
77
|
+
* this is never consulted for same-root access. Only the caller's task scope
|
|
78
|
+
* restricts outgoing reach. The target's visibility and ownership remain
|
|
79
|
+
* independently enforced; its agent scope does not block incoming access.
|
|
78
80
|
*/
|
|
79
81
|
export function agentAccessPermitsCrossTreeAccess(
|
|
80
82
|
caller: SessionAgentAccessFacts,
|
|
81
83
|
target: SessionAgentAccessFacts,
|
|
82
84
|
): boolean {
|
|
83
|
-
if (caller.agentAccess === "session"
|
|
84
|
-
if (caller.agentAccess === "user"
|
|
85
|
-
return
|
|
85
|
+
if (caller.agentAccess === "session") return false;
|
|
86
|
+
if (caller.agentAccess === "user") {
|
|
87
|
+
return sameScopeSubject(caller.scopeSubjectId, target.scopeSubjectId);
|
|
86
88
|
}
|
|
87
89
|
return true;
|
|
88
90
|
}
|
|
@@ -262,16 +264,16 @@ export async function requireSessionAuthorization(
|
|
|
262
264
|
if (!allowed) throw new SessionAuthorizationDeniedError("forbidden");
|
|
263
265
|
}
|
|
264
266
|
// Agent-access scope (migration 0427): an attempt always keeps its own root
|
|
265
|
-
// tree; across trees
|
|
266
|
-
//
|
|
267
|
-
//
|
|
267
|
+
// tree; across trees only its own outgoing task scope restricts reach.
|
|
268
|
+
// User matching reads canonical target identity, not target task scope.
|
|
269
|
+
// Private ownership above and the host authorization below still apply.
|
|
268
270
|
if (actor.kind === "agent_attempt" && target.rootSessionId !== actor.callerRootSessionId) {
|
|
269
271
|
const callerAccess = resolvedActor.callerAccess;
|
|
270
272
|
if (
|
|
271
273
|
!callerAccess ||
|
|
272
274
|
!agentAccessPermitsCrossTreeAccess(callerAccess, {
|
|
273
275
|
agentAccess: authority.agentAccess,
|
|
274
|
-
|
|
276
|
+
scopeSubjectId: authority.scopeSubjectId,
|
|
275
277
|
})
|
|
276
278
|
) {
|
|
277
279
|
throw new SessionAuthorizationDeniedError("forbidden");
|
|
@@ -336,7 +338,7 @@ export async function requireSessionAuthorizationListScope(
|
|
|
336
338
|
? {
|
|
337
339
|
callerRootSessionId: actor.callerRootSessionId,
|
|
338
340
|
agentAccess: callerAccess.agentAccess,
|
|
339
|
-
|
|
341
|
+
scopeSubjectId: callerAccess.scopeSubjectId,
|
|
340
342
|
}
|
|
341
343
|
: null;
|
|
342
344
|
if (!port) return viewer ? agentAccessListScopeForViewer(viewer) : null;
|
|
@@ -442,6 +444,9 @@ async function resolveSessionAuthorizationActor(
|
|
|
442
444
|
(turn.initiator.kind === "subject" ? turn.initiator.subjectId : null),
|
|
443
445
|
}),
|
|
444
446
|
callerParentSessionId: callerSession.parentSessionId,
|
|
445
|
-
callerAccess: {
|
|
447
|
+
callerAccess: {
|
|
448
|
+
agentAccess: callerSession.agentAccess,
|
|
449
|
+
scopeSubjectId: callerSession.scopeSubjectId,
|
|
450
|
+
},
|
|
446
451
|
};
|
|
447
452
|
}
|