@opengeni/core 3.3.0 → 3.4.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/goal-admission.d.ts +2 -0
- package/dist/index.js +110 -93
- package/dist/index.js.map +1 -1
- package/package.json +13 -13
- package/src/domain/capabilities.ts +44 -16
- package/src/domain/session-tool-policy.ts +3 -0
- package/src/session-authorization.ts +94 -83
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/core",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "Opengeni framework-agnostic core: the domain, access, and billing layers (neutral access, off-HTTP V2 surface). Behavior-preserving extraction from apps/api — keeps Hono's HTTPException for error throwing (typed-errors cleanup deferred).",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -50,18 +50,18 @@
|
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
53
|
-
"@opengeni/capabilities": "3.
|
|
54
|
-
"@opengeni/codex": "3.
|
|
55
|
-
"@opengeni/config": "3.
|
|
56
|
-
"@opengeni/contracts": "3.
|
|
57
|
-
"@opengeni/db": "3.
|
|
58
|
-
"@opengeni/documents": "3.
|
|
59
|
-
"@opengeni/events": "3.
|
|
60
|
-
"@opengeni/network": "3.
|
|
61
|
-
"@opengeni/observability": "3.
|
|
62
|
-
"@opengeni/runtime": "3.
|
|
63
|
-
"@opengeni/storage": "3.
|
|
64
|
-
"@opengeni/vaults": "3.
|
|
53
|
+
"@opengeni/capabilities": "3.4.0",
|
|
54
|
+
"@opengeni/codex": "3.4.0",
|
|
55
|
+
"@opengeni/config": "3.4.0",
|
|
56
|
+
"@opengeni/contracts": "3.4.0",
|
|
57
|
+
"@opengeni/db": "3.4.0",
|
|
58
|
+
"@opengeni/documents": "3.4.0",
|
|
59
|
+
"@opengeni/events": "3.4.0",
|
|
60
|
+
"@opengeni/network": "3.4.0",
|
|
61
|
+
"@opengeni/observability": "3.4.0",
|
|
62
|
+
"@opengeni/runtime": "3.4.0",
|
|
63
|
+
"@opengeni/storage": "3.4.0",
|
|
64
|
+
"@opengeni/vaults": "3.4.0",
|
|
65
65
|
"hono": "^4.12.18",
|
|
66
66
|
"zod": "^4.2.1"
|
|
67
67
|
},
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
getCapabilityCatalogItem,
|
|
44
44
|
getCapabilityInstallation,
|
|
45
45
|
getConnectionMetadata,
|
|
46
|
+
isTransactionHandle,
|
|
46
47
|
readCodexCutoverDisposition,
|
|
47
48
|
resolveSubscriptionCoreCodexAppsDesignation,
|
|
48
49
|
rlsContextForWorkspace,
|
|
@@ -93,6 +94,28 @@ export async function buildCapabilityCatalog(input: {
|
|
|
93
94
|
/** The workspace's organization, when the caller knows it (skips a lookup). */
|
|
94
95
|
accountId?: string;
|
|
95
96
|
}): Promise<CapabilityCatalogResponse> {
|
|
97
|
+
// Scoped readers use savepoints and transaction-local actor settings. They
|
|
98
|
+
// must not overlap on a caller's transaction: one reader can otherwise
|
|
99
|
+
// restore another reader's subject instead of the enclosing caller's.
|
|
100
|
+
// Independent pool reads keep their existing parallel execution.
|
|
101
|
+
const serial = isTransactionHandle(input.db);
|
|
102
|
+
const reads = [
|
|
103
|
+
() => listCapabilityCatalogItems(input.db, input.workspaceId),
|
|
104
|
+
() => listCapabilityInstallations(input.db, input.workspaceId),
|
|
105
|
+
() => listSocialConnections(input.db, input.workspaceId, 500, input.subjectId),
|
|
106
|
+
() => listConnectionsMetadata(input.db, input.workspaceId, null),
|
|
107
|
+
() => discoverCuratedSkillLibraryItems(),
|
|
108
|
+
() => listInstalledSkills(input.db, input.workspaceId),
|
|
109
|
+
() =>
|
|
110
|
+
input.settings.codexConnectedAppsEnabled
|
|
111
|
+
? resolveCodexAppsDesignationForRun(
|
|
112
|
+
input.db,
|
|
113
|
+
input.workspaceId,
|
|
114
|
+
input.accountId ? { accountId: input.accountId } : {},
|
|
115
|
+
)
|
|
116
|
+
: Promise.resolve(null),
|
|
117
|
+
() => listEnabledMcpCapabilityServers(input.db, input.workspaceId),
|
|
118
|
+
] as const;
|
|
96
119
|
const [
|
|
97
120
|
persistedItems,
|
|
98
121
|
capabilityInstallations,
|
|
@@ -102,22 +125,27 @@ export async function buildCapabilityCatalog(input: {
|
|
|
102
125
|
installedSkills,
|
|
103
126
|
codexAppsDesignation,
|
|
104
127
|
runnableMcpServers,
|
|
105
|
-
] =
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
128
|
+
] = serial
|
|
129
|
+
? ([
|
|
130
|
+
await reads[0](),
|
|
131
|
+
await reads[1](),
|
|
132
|
+
await reads[2](),
|
|
133
|
+
await reads[3](),
|
|
134
|
+
await reads[4](),
|
|
135
|
+
await reads[5](),
|
|
136
|
+
await reads[6](),
|
|
137
|
+
await reads[7](),
|
|
138
|
+
] as const)
|
|
139
|
+
: await Promise.all([
|
|
140
|
+
reads[0](),
|
|
141
|
+
reads[1](),
|
|
142
|
+
reads[2](),
|
|
143
|
+
reads[3](),
|
|
144
|
+
reads[4](),
|
|
145
|
+
reads[5](),
|
|
146
|
+
reads[6](),
|
|
147
|
+
reads[7](),
|
|
148
|
+
]);
|
|
121
149
|
const runnableCapabilityIds = new Set(runnableMcpServers.map((server) => server.capabilityId));
|
|
122
150
|
const operatorOAuthClientConfigured = operatorOAuthClientResolver(input.settings);
|
|
123
151
|
const catalogInstallations = capabilityInstallations.filter(
|
|
@@ -589,6 +589,9 @@ export function sessionEffectiveToolProjectionInput(
|
|
|
589
589
|
"request_human_input",
|
|
590
590
|
"list_models",
|
|
591
591
|
...webSearchPlan.providerTools,
|
|
592
|
+
// Interaction entry tools stay visible even with progressive disclosure;
|
|
593
|
+
// the remaining authorized family tools are disclosed after opening.
|
|
594
|
+
...firstPartyMcpTools.filter((name) => name === "browser_open" || name === "computer_open"),
|
|
592
595
|
...(toolRefs.some((ref) => ref.id === "opengeni" && ref.eager === true) ||
|
|
593
596
|
!progressiveDisclosure
|
|
594
597
|
? firstPartyMcpTools.filter((name) => !interactionNames.has(name) || !progressiveDisclosure)
|
|
@@ -15,13 +15,11 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
15
15
|
import {
|
|
16
16
|
currentSessionRlsActorIdentityKey,
|
|
17
17
|
currentSiteViewerRlsContext,
|
|
18
|
-
getSessionAuthorityProjection,
|
|
19
|
-
getSessionAuthorizationTargetProjection,
|
|
20
18
|
getSession,
|
|
21
|
-
|
|
22
|
-
getSlackInteractionSessionAccessForSession,
|
|
19
|
+
getSessionAuthorizationFacts,
|
|
23
20
|
withSessionRlsActorContext,
|
|
24
21
|
type Database,
|
|
22
|
+
type SessionAuthorizationFacts,
|
|
25
23
|
type SessionRlsActorContext,
|
|
26
24
|
} from "@opengeni/db";
|
|
27
25
|
import type { AppDependencies } from "./dependencies";
|
|
@@ -200,10 +198,6 @@ export function agentAccessListScopeForViewer(
|
|
|
200
198
|
};
|
|
201
199
|
}
|
|
202
200
|
|
|
203
|
-
type ResolvedSessionAuthorizationTarget = {
|
|
204
|
-
target: SessionAuthorizationTarget;
|
|
205
|
-
};
|
|
206
|
-
|
|
207
201
|
/**
|
|
208
202
|
* Whether a grant acts with live agent-attempt authority: an explicit
|
|
209
203
|
* `agent_attempt` principal kind, or (for legacy tokens without one) any
|
|
@@ -261,7 +255,11 @@ export async function requireLiveAgentAttemptAuthorization(
|
|
|
261
255
|
callerSessionId: string,
|
|
262
256
|
): Promise<Extract<SessionAuthorizationActor, { kind: "agent_attempt" }>> {
|
|
263
257
|
const reads = claimSessionAuthorizationReads();
|
|
264
|
-
const
|
|
258
|
+
const claims = agentAttemptClaims(grant);
|
|
259
|
+
const facts = claims
|
|
260
|
+
? await readSessionAuthorizationFacts(db, grant, claims.callerSessionId, claims, reads)
|
|
261
|
+
: null;
|
|
262
|
+
const { actor } = resolveSessionAuthorizationActor(grant, claims, facts);
|
|
265
263
|
if (actor.kind !== "agent_attempt" || actor.callerSessionId !== callerSessionId) {
|
|
266
264
|
throw new SessionAuthorizationDeniedError("caller_stale");
|
|
267
265
|
}
|
|
@@ -291,18 +289,13 @@ export async function requireSessionAuthorization(
|
|
|
291
289
|
const reads = claimSessionAuthorizationReads();
|
|
292
290
|
const port = deps.sessionAuthorization;
|
|
293
291
|
const isAgentAttempt = grantHasAgentAttemptAuthority(grant);
|
|
294
|
-
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const authority = await getSessionAuthorityProjection(
|
|
302
|
-
deps.db,
|
|
303
|
-
grant.workspaceId,
|
|
304
|
-
input.sessionId,
|
|
305
|
-
);
|
|
292
|
+
const claims = agentAttemptClaims(grant);
|
|
293
|
+
// One scoped read returns the target's authority, its Slack ownership and,
|
|
294
|
+
// for an agent attempt, the caller session and its live turn. Every check
|
|
295
|
+
// below decides from these facts; none of them reads again.
|
|
296
|
+
const facts = await readSessionAuthorizationFacts(deps.db, grant, input.sessionId, claims, reads);
|
|
297
|
+
const authority = facts.target;
|
|
298
|
+
const slackAccess = authority?.slackAccess ?? null;
|
|
306
299
|
|
|
307
300
|
// The projection is read under the caller's own RLS context, so a missing
|
|
308
301
|
// session and one the caller may not see (another member's private session)
|
|
@@ -318,8 +311,7 @@ export async function requireSessionAuthorization(
|
|
|
318
311
|
// databases. The request-local viewer proof is never taken from Site JSON.
|
|
319
312
|
if (authority?.siteAccessRootId) {
|
|
320
313
|
const viewer = currentSiteViewerRlsContext();
|
|
321
|
-
const
|
|
322
|
-
const actor = resolved.actor;
|
|
314
|
+
const actor = resolveSessionAuthorizationActor(grant, claims, facts).actor;
|
|
323
315
|
const allowed =
|
|
324
316
|
actor.kind === "agent_attempt"
|
|
325
317
|
? (authority.siteOwnerSubjectId !== null &&
|
|
@@ -356,15 +348,15 @@ export async function requireSessionAuthorization(
|
|
|
356
348
|
}
|
|
357
349
|
if (!authority) throw new SessionAuthorizationDeniedError("not_found");
|
|
358
350
|
|
|
359
|
-
const resolvedActor =
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
input.sessionId,
|
|
364
|
-
reads,
|
|
365
|
-
);
|
|
351
|
+
const resolvedActor = resolveSessionAuthorizationActor(grant, claims, facts);
|
|
352
|
+
if (authority.accountId !== grant.accountId) {
|
|
353
|
+
throw new SessionAuthorizationDeniedError("not_found");
|
|
354
|
+
}
|
|
366
355
|
const actor = resolvedActor.actor;
|
|
367
|
-
const target =
|
|
356
|
+
const target: SessionAuthorizationTarget = {
|
|
357
|
+
sessionId: authority.sessionId,
|
|
358
|
+
rootSessionId: authority.rootSessionId,
|
|
359
|
+
};
|
|
368
360
|
const agentRelatedSessionAccess =
|
|
369
361
|
actor.kind === "agent_attempt"
|
|
370
362
|
? relatedSessionAccessForAgentAttempt(actor, target.sessionId)
|
|
@@ -483,7 +475,11 @@ export async function requireSessionAuthorizationListScope(
|
|
|
483
475
|
const port = deps.sessionAuthorization;
|
|
484
476
|
const isAgentAttempt = grantHasAgentAttemptAuthority(grant);
|
|
485
477
|
if (!port && !isAgentAttempt) return null;
|
|
486
|
-
const
|
|
478
|
+
const claims = agentAttemptClaims(grant);
|
|
479
|
+
const facts = claims
|
|
480
|
+
? await readSessionAuthorizationFacts(deps.db, grant, claims.callerSessionId, claims, reads)
|
|
481
|
+
: null;
|
|
482
|
+
const { actor, callerAccess } = resolveSessionAuthorizationActor(grant, claims, facts);
|
|
487
483
|
// Standalone agents retain compact workspace discovery only while the signed
|
|
488
484
|
// caller attempt is still the exact live attempt, and only within their own
|
|
489
485
|
// declared agent-access reach (migration 0427). The viewer is derived from
|
|
@@ -525,36 +521,72 @@ export async function requireSessionAuthorizationListScope(
|
|
|
525
521
|
return viewer ? agentAccessListScopeForViewer(viewer, hostScope) : hostScope;
|
|
526
522
|
}
|
|
527
523
|
|
|
528
|
-
|
|
524
|
+
type AgentAttemptClaims = {
|
|
525
|
+
callerSessionId: string;
|
|
526
|
+
turnId: string;
|
|
527
|
+
attemptId: string;
|
|
528
|
+
executionGeneration: number;
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* The signed attempt claims of an agent-attempt grant, or null for any other
|
|
533
|
+
* principal and for malformed claims (which the actor check then refuses).
|
|
534
|
+
*/
|
|
535
|
+
function agentAttemptClaims(grant: AccessGrant): AgentAttemptClaims | null {
|
|
536
|
+
if (!grantHasAgentAttemptAuthority(grant)) return null;
|
|
537
|
+
const callerSessionId = grant.metadata?.["sessionId"];
|
|
538
|
+
const turnId = grant.metadata?.["turnId"];
|
|
539
|
+
const attemptId = grant.metadata?.["attemptId"];
|
|
540
|
+
const executionGeneration = grant.metadata?.["executionGeneration"];
|
|
541
|
+
if (
|
|
542
|
+
typeof callerSessionId !== "string" ||
|
|
543
|
+
typeof turnId !== "string" ||
|
|
544
|
+
typeof attemptId !== "string" ||
|
|
545
|
+
typeof executionGeneration !== "number" ||
|
|
546
|
+
!Number.isSafeInteger(executionGeneration) ||
|
|
547
|
+
executionGeneration < 1
|
|
548
|
+
) {
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
return { callerSessionId, turnId, attemptId, executionGeneration };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function readSessionAuthorizationFacts(
|
|
529
555
|
db: Database,
|
|
530
556
|
grant: AccessGrant,
|
|
531
557
|
sessionId: string,
|
|
558
|
+
claims: AgentAttemptClaims | null,
|
|
532
559
|
reads: SessionAuthorizationReads | null,
|
|
533
|
-
): Promise<
|
|
534
|
-
|
|
560
|
+
): Promise<SessionAuthorizationFacts> {
|
|
561
|
+
return await reusableAuthorizationRead(
|
|
535
562
|
reads,
|
|
536
563
|
db,
|
|
537
|
-
[
|
|
538
|
-
|
|
564
|
+
[
|
|
565
|
+
"authorization-facts",
|
|
566
|
+
grant.accountId,
|
|
567
|
+
grant.workspaceId,
|
|
568
|
+
sessionId,
|
|
569
|
+
claims?.callerSessionId ?? "",
|
|
570
|
+
claims?.attemptId ?? "",
|
|
571
|
+
],
|
|
572
|
+
() =>
|
|
573
|
+
getSessionAuthorizationFacts(db, {
|
|
574
|
+
accountId: grant.accountId,
|
|
575
|
+
workspaceId: grant.workspaceId,
|
|
576
|
+
sessionId,
|
|
577
|
+
caller: claims
|
|
578
|
+
? { sessionId: claims.callerSessionId, attemptId: claims.attemptId }
|
|
579
|
+
: undefined,
|
|
580
|
+
}),
|
|
539
581
|
);
|
|
540
|
-
if (!session || session.accountId !== grant.accountId) {
|
|
541
|
-
throw new SessionAuthorizationDeniedError("not_found");
|
|
542
|
-
}
|
|
543
|
-
return {
|
|
544
|
-
target: { sessionId: session.id, rootSessionId: session.rootSessionId },
|
|
545
|
-
};
|
|
546
582
|
}
|
|
547
|
-
|
|
548
|
-
|
|
583
|
+
|
|
584
|
+
function resolveSessionAuthorizationActor(
|
|
549
585
|
grant: AccessGrant,
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
const attemptId = grant.metadata?.["attemptId"];
|
|
555
|
-
const executionGeneration = grant.metadata?.["executionGeneration"];
|
|
556
|
-
const isAgentAttempt = grantHasAgentAttemptAuthority(grant);
|
|
557
|
-
if (!isAgentAttempt) {
|
|
586
|
+
claims: AgentAttemptClaims | null,
|
|
587
|
+
facts: SessionAuthorizationFacts | null,
|
|
588
|
+
): ResolvedSessionAuthorizationActor {
|
|
589
|
+
if (!grantHasAgentAttemptAuthority(grant)) {
|
|
558
590
|
return {
|
|
559
591
|
actor: SessionAuthorizationActor.parse({
|
|
560
592
|
kind: "subject",
|
|
@@ -565,36 +597,15 @@ async function resolveSessionAuthorizationActor(
|
|
|
565
597
|
callerAccess: null,
|
|
566
598
|
};
|
|
567
599
|
}
|
|
600
|
+
const callerSession = facts?.caller?.session ?? null;
|
|
601
|
+
const turn = facts?.caller?.turn ?? null;
|
|
568
602
|
if (
|
|
569
|
-
|
|
570
|
-
typeof turnId !== "string" ||
|
|
571
|
-
typeof attemptId !== "string" ||
|
|
572
|
-
typeof executionGeneration !== "number" ||
|
|
573
|
-
!Number.isSafeInteger(executionGeneration) ||
|
|
574
|
-
executionGeneration < 1
|
|
575
|
-
) {
|
|
576
|
-
throw new SessionAuthorizationDeniedError("caller_stale");
|
|
577
|
-
}
|
|
578
|
-
// These scoped reads can also open nested savepoints on a transaction handle.
|
|
579
|
-
const callerSession = await reusableAuthorizationRead(
|
|
580
|
-
reads,
|
|
581
|
-
db,
|
|
582
|
-
["session", grant.workspaceId, callerSessionId],
|
|
583
|
-
() => getSession(db, grant.workspaceId, callerSessionId),
|
|
584
|
-
);
|
|
585
|
-
const turn = await reusableAuthorizationRead(
|
|
586
|
-
reads,
|
|
587
|
-
db,
|
|
588
|
-
["turn", grant.workspaceId, callerSessionId, attemptId],
|
|
589
|
-
() => getSessionTurnForAttempt(db, grant.workspaceId, callerSessionId, attemptId),
|
|
590
|
-
);
|
|
591
|
-
if (
|
|
603
|
+
!claims ||
|
|
592
604
|
!callerSession ||
|
|
593
605
|
callerSession.accountId !== grant.accountId ||
|
|
594
606
|
!turn ||
|
|
595
|
-
turn.id !== turnId ||
|
|
596
|
-
turn.executionGeneration !== executionGeneration
|
|
597
|
-
callerSession.activeTurnId !== turn.id
|
|
607
|
+
turn.id !== claims.turnId ||
|
|
608
|
+
turn.executionGeneration !== claims.executionGeneration
|
|
598
609
|
) {
|
|
599
610
|
throw new SessionAuthorizationDeniedError("caller_stale");
|
|
600
611
|
}
|
|
@@ -602,11 +613,11 @@ async function resolveSessionAuthorizationActor(
|
|
|
602
613
|
actor: SessionAuthorizationActor.parse({
|
|
603
614
|
kind: "agent_attempt",
|
|
604
615
|
subjectId: grant.subjectId,
|
|
605
|
-
callerSessionId,
|
|
616
|
+
callerSessionId: claims.callerSessionId,
|
|
606
617
|
callerRootSessionId: callerSession.rootSessionId,
|
|
607
|
-
turnId,
|
|
608
|
-
attemptId,
|
|
609
|
-
executionGeneration,
|
|
618
|
+
turnId: claims.turnId,
|
|
619
|
+
attemptId: claims.attemptId,
|
|
620
|
+
executionGeneration: claims.executionGeneration,
|
|
610
621
|
initiator: turn.initiator,
|
|
611
622
|
initiatorContext: turn.initiatorContext,
|
|
612
623
|
initiatingHumanSubjectId:
|