@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/core",
3
- "version": "3.3.0",
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.3.0",
54
- "@opengeni/codex": "3.3.0",
55
- "@opengeni/config": "3.3.0",
56
- "@opengeni/contracts": "3.3.0",
57
- "@opengeni/db": "3.3.0",
58
- "@opengeni/documents": "3.3.0",
59
- "@opengeni/events": "3.3.0",
60
- "@opengeni/network": "3.3.0",
61
- "@opengeni/observability": "3.3.0",
62
- "@opengeni/runtime": "3.3.0",
63
- "@opengeni/storage": "3.3.0",
64
- "@opengeni/vaults": "3.3.0",
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
- ] = await Promise.all([
106
- listCapabilityCatalogItems(input.db, input.workspaceId),
107
- listCapabilityInstallations(input.db, input.workspaceId),
108
- listSocialConnections(input.db, input.workspaceId, 500, input.subjectId),
109
- listConnectionsMetadata(input.db, input.workspaceId, null),
110
- discoverCuratedSkillLibraryItems(),
111
- listInstalledSkills(input.db, input.workspaceId),
112
- input.settings.codexConnectedAppsEnabled
113
- ? resolveCodexAppsDesignationForRun(
114
- input.db,
115
- input.workspaceId,
116
- input.accountId ? { accountId: input.accountId } : {},
117
- )
118
- : Promise.resolve(null),
119
- listEnabledMcpCapabilityServers(input.db, input.workspaceId),
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
- getSessionTurnForAttempt,
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 { actor } = await resolveSessionAuthorizationActor(db, grant, reads);
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
- // Callers can supply a transaction handle. Both reads open nested RLS
295
- // savepoints, which must not race on the same PostgreSQL connection.
296
- const slackAccess = await getSlackInteractionSessionAccessForSession(deps.db, {
297
- accountId: grant.accountId,
298
- workspaceId: grant.workspaceId,
299
- sessionId: input.sessionId,
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 resolved = await resolveSessionAuthorizationActor(deps.db, grant, reads);
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 = await resolveSessionAuthorizationActor(deps.db, grant, reads);
360
- const resolvedTarget = await resolveSessionAuthorizationTarget(
361
- deps.db,
362
- grant,
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 = resolvedTarget.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 { actor, callerAccess } = await resolveSessionAuthorizationActor(deps.db, grant, reads);
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
- async function resolveSessionAuthorizationTarget(
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<ResolvedSessionAuthorizationTarget> {
534
- const session = await reusableAuthorizationRead(
560
+ ): Promise<SessionAuthorizationFacts> {
561
+ return await reusableAuthorizationRead(
535
562
  reads,
536
563
  db,
537
- ["target-identity", grant.workspaceId, sessionId],
538
- () => getSessionAuthorizationTargetProjection(db, grant.workspaceId, sessionId),
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
- async function resolveSessionAuthorizationActor(
548
- db: Database,
583
+
584
+ function resolveSessionAuthorizationActor(
549
585
  grant: AccessGrant,
550
- reads: SessionAuthorizationReads | null,
551
- ): Promise<ResolvedSessionAuthorizationActor> {
552
- const callerSessionId = grant.metadata?.["sessionId"];
553
- const turnId = grant.metadata?.["turnId"];
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
- typeof callerSessionId !== "string" ||
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: