@opengeni/core 0.28.0 → 1.0.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.
Files changed (224) hide show
  1. package/README.md +1 -1
  2. package/dist/access/external-actor-authority.d.ts +47 -0
  3. package/dist/access/index.d.ts +182 -3
  4. package/dist/access/service-initiator.d.ts +3 -0
  5. package/dist/application/api-integration-servers.d.ts +20 -0
  6. package/dist/application/archived-session-imports.d.ts +6 -0
  7. package/dist/application/composer-submit.d.ts +24 -0
  8. package/dist/application/connect-authority.d.ts +13 -0
  9. package/dist/application/connect-operation.d.ts +31 -0
  10. package/dist/application/external-continuation.d.ts +15 -0
  11. package/dist/application/external-identity-lifecycle.d.ts +20 -0
  12. package/dist/application/external-link-work-admission.d.ts +12 -0
  13. package/dist/application/external-workspace-members.d.ts +34 -0
  14. package/dist/application/modal-native-worker-host-transport.d.ts +297 -0
  15. package/dist/application/new-session-drafts.d.ts +39 -2
  16. package/dist/application/sandbox-recovery.d.ts +58 -0
  17. package/dist/application/session-commands.d.ts +26 -13
  18. package/dist/application/session-mcp-credential-rotation.d.ts +29 -0
  19. package/dist/application/session-tenancy.d.ts +19 -0
  20. package/dist/application/user-resource-grants.d.ts +20 -0
  21. package/dist/billing/limits.d.ts +11 -0
  22. package/dist/canonical-human-identities.d.ts +7 -2
  23. package/dist/canonical-human-identities.js +17 -4
  24. package/dist/canonical-human-identities.js.map +1 -1
  25. package/dist/{chunk-6WKPWE5S.js → chunk-2MKMRGSY.js} +108 -24
  26. package/dist/chunk-2MKMRGSY.js.map +1 -0
  27. package/dist/{chunk-NYPMQ7NO.js → chunk-GVO3N37G.js} +205 -37
  28. package/dist/chunk-GVO3N37G.js.map +1 -0
  29. package/dist/{chunk-JJU6I5XD.js → chunk-LCQXNR62.js} +36 -10
  30. package/dist/chunk-LCQXNR62.js.map +1 -0
  31. package/dist/chunk-MM6N7CTO.js +387 -0
  32. package/dist/chunk-MM6N7CTO.js.map +1 -0
  33. package/dist/chunk-QO5GVFFO.js +235 -0
  34. package/dist/chunk-QO5GVFFO.js.map +1 -0
  35. package/dist/codex-model-availability.d.ts +14 -0
  36. package/dist/default-session-model.d.ts +139 -0
  37. package/dist/dependencies.d.ts +87 -4
  38. package/dist/developer-setup-credential-ceiling.d.ts +10 -0
  39. package/dist/domain/agent-config-resolution.d.ts +49 -0
  40. package/dist/domain/automations.d.ts +58 -0
  41. package/dist/domain/capabilities.d.ts +22 -4
  42. package/dist/domain/company-profile-agent-admin.d.ts +25 -0
  43. package/dist/domain/connection-account-selection-error.d.ts +8 -0
  44. package/dist/domain/connector-tool-permissions.d.ts +36 -0
  45. package/dist/domain/direct-model-provider.d.ts +3 -0
  46. package/dist/domain/editable-artifacts/agent-application.d.ts +10 -1
  47. package/dist/domain/editable-artifacts/durable-export.d.ts +22 -1
  48. package/dist/domain/editable-artifacts/ports.d.ts +10 -0
  49. package/dist/domain/editable-artifacts/service.d.ts +4 -3
  50. package/dist/domain/environments.d.ts +5 -9
  51. package/dist/domain/external-creation-attribution.d.ts +6 -0
  52. package/dist/domain/file-owner.d.ts +8 -0
  53. package/dist/domain/github-action-policies.d.ts +36 -0
  54. package/dist/domain/github-repository-bindings.d.ts +117 -0
  55. package/dist/domain/github-skill-source.d.ts +5 -0
  56. package/dist/domain/governed-learning-slack-publication.d.ts +45 -0
  57. package/dist/domain/insights-timing.d.ts +16 -0
  58. package/dist/domain/insights-usage.d.ts +27 -0
  59. package/dist/domain/insights.d.ts +28 -1
  60. package/dist/domain/knowledge-files.d.ts +20 -0
  61. package/dist/domain/knowledge-messages.d.ts +39 -0
  62. package/dist/domain/knowledge-messages.test.d.ts +1 -0
  63. package/dist/domain/knowledge-preparation.d.ts +10 -0
  64. package/dist/domain/knowledge-preparation.test.d.ts +1 -0
  65. package/dist/domain/knowledge-search.d.ts +15 -0
  66. package/dist/domain/knowledge-search.test.d.ts +1 -0
  67. package/dist/domain/knowledge.d.ts +9 -0
  68. package/dist/domain/mcp-account-bindings.d.ts +19 -0
  69. package/dist/domain/mcp-account-routes.d.ts +21 -0
  70. package/dist/domain/native-mcp-connection-admission.d.ts +3 -0
  71. package/dist/domain/organization-integration-catalog.d.ts +15 -0
  72. package/dist/domain/organization-membership-lifecycle.d.ts +1 -0
  73. package/dist/domain/personal-connection-delegations.d.ts +87 -4
  74. package/dist/domain/pr-review.d.ts +249 -0
  75. package/dist/domain/resources.d.ts +24 -3
  76. package/dist/domain/scheduled-task-access.d.ts +247 -0
  77. package/dist/domain/scheduled-tasks.d.ts +103 -6
  78. package/dist/domain/session-connection-accounts.d.ts +16 -0
  79. package/dist/domain/session-file-access.d.ts +4 -0
  80. package/dist/domain/session-native-connection-replacement.d.ts +14 -0
  81. package/dist/domain/session-start-timing.d.ts +7 -0
  82. package/dist/domain/session-tool-policy.d.ts +93 -4
  83. package/dist/domain/sessions.d.ts +275 -28
  84. package/dist/domain/skill-imports.d.ts +1 -0
  85. package/dist/domain/skill-search.d.ts +30 -0
  86. package/dist/domain/skills.d.ts +117 -0
  87. package/dist/domain/slack-bot.d.ts +46 -1
  88. package/dist/domain/workspace-members.d.ts +8 -1
  89. package/dist/editable-artifact-live/application.d.ts +12 -3
  90. package/dist/editable-artifact-live/ports.d.ts +0 -1
  91. package/dist/editable-artifact-live/server.d.ts +5 -2
  92. package/dist/editable-artifact-live/ticket.d.ts +2 -2
  93. package/dist/editable-artifact-live/types.d.ts +14 -2
  94. package/dist/editable-artifact-live.js +2 -2
  95. package/dist/editable-artifacts.js +10 -4
  96. package/dist/index.d.ts +49 -3
  97. package/dist/index.js +18826 -6847
  98. package/dist/index.js.map +1 -1
  99. package/dist/managed-auth-session-sets.d.ts +131 -0
  100. package/dist/managed-auth-session-sets.js +51 -0
  101. package/dist/managed-auth-session-sets.js.map +1 -0
  102. package/dist/managed-session.d.ts +98 -11
  103. package/dist/model-catalog.d.ts +117 -0
  104. package/dist/product-usage-metrics.d.ts +30 -0
  105. package/dist/rigs/index.d.ts +22 -5
  106. package/dist/rigs/provider-images.d.ts +1 -1
  107. package/dist/sandbox/fleet.d.ts +59 -10
  108. package/dist/sandbox/routing.d.ts +22 -2
  109. package/dist/sandbox/runtime-settings.d.ts +56 -0
  110. package/dist/session-authorization.d.ts +38 -1
  111. package/dist/site-session-origin.d.ts +9 -0
  112. package/dist/transcription.d.ts +12 -1
  113. package/dist/turn-surface.d.ts +18 -0
  114. package/dist/user-presence.d.ts +32 -0
  115. package/package.json +18 -11
  116. package/src/access/external-actor-authority.ts +94 -0
  117. package/src/access/index.ts +1386 -44
  118. package/src/access/service-initiator.ts +50 -0
  119. package/src/application/api-integration-servers.ts +215 -0
  120. package/src/application/archived-session-imports.ts +161 -0
  121. package/src/application/composer-submit.ts +77 -0
  122. package/src/application/connect-authority.ts +77 -0
  123. package/src/application/connect-operation.ts +80 -0
  124. package/src/application/external-continuation.ts +129 -0
  125. package/src/application/external-identity-lifecycle.ts +48 -0
  126. package/src/application/external-link-work-admission.ts +90 -0
  127. package/src/application/external-workspace-members.ts +232 -0
  128. package/src/application/modal-native-worker-host-transport.ts +263 -0
  129. package/src/application/new-session-drafts.ts +222 -29
  130. package/src/application/sandbox-recovery.ts +78 -0
  131. package/src/application/session-commands.ts +430 -73
  132. package/src/application/session-mcp-credential-rotation.ts +217 -0
  133. package/src/application/session-tenancy.ts +445 -0
  134. package/src/application/user-resource-grants.ts +154 -0
  135. package/src/billing/limits.ts +103 -24
  136. package/src/canonical-human-identities.ts +22 -3
  137. package/src/codex-model-availability.ts +136 -0
  138. package/src/default-session-model.ts +656 -0
  139. package/src/dependencies.ts +97 -5
  140. package/src/developer-setup-credential-ceiling.ts +60 -0
  141. package/src/domain/agent-config-resolution.ts +148 -0
  142. package/src/domain/automations.ts +270 -0
  143. package/src/domain/capabilities.ts +244 -151
  144. package/src/domain/company-profile-agent-admin.ts +56 -0
  145. package/src/domain/connection-account-selection-error.ts +19 -0
  146. package/src/domain/connector-tool-permissions.ts +371 -0
  147. package/src/domain/direct-model-provider.ts +62 -0
  148. package/src/domain/editable-artifacts/agent-application.ts +61 -15
  149. package/src/domain/editable-artifacts/durable-export.ts +100 -5
  150. package/src/domain/editable-artifacts/in-memory.ts +6 -0
  151. package/src/domain/editable-artifacts/ports.ts +11 -0
  152. package/src/domain/editable-artifacts/service.ts +32 -1
  153. package/src/domain/editable-artifacts/snapshot-compaction.ts +9 -5
  154. package/src/domain/editable-artifacts/snapshot-verifier-genesis.ts +10 -8
  155. package/src/domain/editable-artifacts/snapshot-verifier.ts +10 -3
  156. package/src/domain/environments.ts +25 -45
  157. package/src/domain/external-creation-attribution.ts +22 -0
  158. package/src/domain/file-owner.ts +67 -0
  159. package/src/domain/github-action-policies.ts +169 -0
  160. package/src/domain/github-repository-bindings.ts +295 -0
  161. package/src/domain/github-skill-source.ts +233 -0
  162. package/src/domain/governed-learning-slack-publication.ts +157 -0
  163. package/src/domain/insights-timing.ts +95 -0
  164. package/src/domain/insights-usage.ts +67 -0
  165. package/src/domain/insights.ts +270 -169
  166. package/src/domain/knowledge-files.ts +46 -0
  167. package/src/domain/knowledge-messages.test.ts +91 -0
  168. package/src/domain/knowledge-messages.ts +106 -0
  169. package/src/domain/knowledge-preparation.test.ts +276 -0
  170. package/src/domain/knowledge-preparation.ts +147 -0
  171. package/src/domain/knowledge-search.test.ts +561 -0
  172. package/src/domain/knowledge-search.ts +256 -0
  173. package/src/domain/knowledge.ts +133 -0
  174. package/src/domain/mcp-account-bindings.ts +171 -0
  175. package/src/domain/mcp-account-routes.ts +121 -0
  176. package/src/domain/native-mcp-connection-admission.ts +14 -0
  177. package/src/domain/organization-integration-catalog.ts +88 -0
  178. package/src/domain/organization-membership-lifecycle.ts +10 -0
  179. package/src/domain/personal-connection-delegations.ts +758 -115
  180. package/src/domain/pr-review.ts +687 -0
  181. package/src/domain/resources.ts +182 -25
  182. package/src/domain/scheduled-task-access.ts +1052 -0
  183. package/src/domain/scheduled-tasks.ts +1952 -215
  184. package/src/domain/session-connection-accounts.ts +67 -0
  185. package/src/domain/session-file-access.ts +47 -0
  186. package/src/domain/session-native-connection-replacement.ts +64 -0
  187. package/src/domain/session-start-timing.ts +55 -0
  188. package/src/domain/session-tool-policy.ts +443 -19
  189. package/src/domain/sessions.ts +3417 -619
  190. package/src/domain/skill-imports.ts +137 -17
  191. package/src/domain/skill-search.ts +221 -0
  192. package/src/domain/skills.ts +104 -0
  193. package/src/domain/slack-bot.ts +128 -3
  194. package/src/domain/workspace-members.ts +34 -2
  195. package/src/editable-artifact-live/application.ts +29 -4
  196. package/src/editable-artifact-live/ports.ts +0 -1
  197. package/src/editable-artifact-live/server.ts +167 -13
  198. package/src/editable-artifact-live/ticket.ts +87 -6
  199. package/src/editable-artifact-live/types.ts +16 -2
  200. package/src/editable-artifact-live/wire.ts +0 -10
  201. package/src/index.ts +69 -4
  202. package/src/managed-auth-session-sets.ts +371 -0
  203. package/src/managed-session.ts +520 -2
  204. package/src/model-catalog.ts +873 -0
  205. package/src/product-usage-metrics.ts +101 -0
  206. package/src/rigs/index.ts +179 -63
  207. package/src/rigs/provider-images.ts +2 -3
  208. package/src/sandbox/fleet.ts +471 -59
  209. package/src/sandbox/routing.ts +160 -33
  210. package/src/sandbox/runtime-settings.ts +281 -0
  211. package/src/session-authorization.ts +262 -78
  212. package/src/site-session-origin.ts +22 -0
  213. package/src/transcription.ts +13 -1
  214. package/src/turn-surface.ts +45 -0
  215. package/src/user-presence.ts +134 -0
  216. package/dist/chunk-6WKPWE5S.js.map +0 -1
  217. package/dist/chunk-IBOEYG6N.js +0 -34
  218. package/dist/chunk-IBOEYG6N.js.map +0 -1
  219. package/dist/chunk-JJU6I5XD.js.map +0 -1
  220. package/dist/chunk-NYPMQ7NO.js.map +0 -1
  221. package/dist/domain/durable-learning-slack-publication.d.ts +0 -72
  222. package/dist/domain/packs.d.ts +0 -55
  223. package/src/domain/durable-learning-slack-publication.ts +0 -202
  224. package/src/domain/packs.ts +0 -556
@@ -3,11 +3,16 @@ import {
3
3
  SessionAuthorizationDecision,
4
4
  SessionAuthorizationListScope,
5
5
  type AccessGrant,
6
+ type SessionAgentAccess,
7
+ type SessionAgentAccessViewer,
6
8
  type SessionAuthorizationOperation,
7
9
  type SessionAuthorizationSurface,
8
10
  type SessionAuthorizationTarget,
11
+ type SessionScopeSubjectId,
9
12
  } from "@opengeni/contracts";
13
+ import { AsyncLocalStorage } from "node:async_hooks";
10
14
  import {
15
+ currentSessionRlsActorIdentityKey,
11
16
  getSessionAuthorityProjection,
12
17
  getSession,
13
18
  getSessionTurnForAttempt,
@@ -20,6 +25,77 @@ import type { AppDependencies } from "./dependencies";
20
25
 
21
26
  export type SessionAuthorizationDependencies = Pick<AppDependencies, "db" | "sessionAuthorization">;
22
27
 
28
+ /**
29
+ * Identical caller-session and attempt reads shared by the authorization
30
+ * checks of one first-party MCP request. Phases bound reuse to checks that run
31
+ * back to back: the route's own checks, then only checks a tool handler starts
32
+ * synchronously on entry. Anything later (a re-check after a wait or a write)
33
+ * reads fresh. Results are keyed by database handle and session RLS actor, so
34
+ * a read is only reused under the exact visibility it was made with.
35
+ */
36
+ type SessionAuthorizationReads = {
37
+ phase: "route" | "handoff" | "dispatch" | "closed";
38
+ readonly results: WeakMap<Database, Map<string, unknown>>;
39
+ };
40
+
41
+ const sessionAuthorizationReads = new AsyncLocalStorage<SessionAuthorizationReads>();
42
+
43
+ /** Scope read reuse to one request; it never outlives `fn`. */
44
+ export async function withSessionAuthorizationReadReuse<T>(
45
+ fn: (reuse: { handOffToToolDispatch(): void }) => Promise<T>,
46
+ ): Promise<T> {
47
+ const reads: SessionAuthorizationReads = { phase: "route", results: new WeakMap() };
48
+ const handOffToToolDispatch = () => {
49
+ if (reads.phase === "route") reads.phase = "handoff";
50
+ };
51
+ try {
52
+ return await sessionAuthorizationReads.run(reads, () => fn({ handOffToToolDispatch }));
53
+ } finally {
54
+ reads.phase = "closed";
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Invoke a tool handler so that authorization checks it starts before its
60
+ * first await reuse the route's reads. Later checks, and every other dispatch
61
+ * in the same request, read fresh.
62
+ */
63
+ export function dispatchWithSessionAuthorizationReadReuse<T>(handler: () => T): T {
64
+ const reads = sessionAuthorizationReads.getStore();
65
+ if (reads?.phase !== "handoff") return handler();
66
+ reads.phase = "dispatch";
67
+ try {
68
+ return handler();
69
+ } finally {
70
+ reads.phase = "closed";
71
+ }
72
+ }
73
+
74
+ /** Call before the first await, so the phase is the one the check started in. */
75
+ function claimSessionAuthorizationReads(): SessionAuthorizationReads | null {
76
+ const reads = sessionAuthorizationReads.getStore();
77
+ return reads?.phase === "route" || reads?.phase === "dispatch" ? reads : null;
78
+ }
79
+
80
+ async function reusableAuthorizationRead<T>(
81
+ reads: SessionAuthorizationReads | null,
82
+ db: Database,
83
+ key: readonly string[],
84
+ read: () => Promise<T>,
85
+ ): Promise<T> {
86
+ if (!reads) return await read();
87
+ const scopedKey = JSON.stringify([...key, currentSessionRlsActorIdentityKey()]);
88
+ let results = reads.results.get(db);
89
+ if (results?.has(scopedKey)) return results.get(scopedKey) as T;
90
+ const value = await read();
91
+ if (!results) {
92
+ results = new Map();
93
+ reads.results.set(db, results);
94
+ }
95
+ results.set(scopedKey, value);
96
+ return value;
97
+ }
98
+
23
99
  /** Maximum time an omitted host hint leaves a live session stream unchecked. */
24
100
  export const SESSION_AUTHORIZATION_DEFAULT_REAUTHORIZE_MS = 15_000;
25
101
 
@@ -48,17 +124,85 @@ export type ResolvedSessionAuthorization = {
48
124
  reauthorizeAfterMs: number | null;
49
125
  };
50
126
 
127
+ /** The frozen agent-access facts of one session, as the pairwise rule sees them. */
128
+ export type SessionAgentAccessFacts = {
129
+ agentAccess: SessionAgentAccess;
130
+ scopeSubjectId: SessionScopeSubjectId | null;
131
+ };
132
+
51
133
  type ResolvedSessionAuthorizationActor = {
52
134
  actor: SessionAuthorizationActor;
53
135
  callerParentSessionId: string | null;
136
+ /** The caller session's own access facts; null for non-agent principals. */
137
+ callerAccess: SessionAgentAccessFacts | null;
54
138
  };
55
139
 
140
+ function sameScopeSubject(
141
+ left: SessionScopeSubjectId | null,
142
+ right: SessionScopeSubjectId | null,
143
+ ): boolean {
144
+ return left !== null && right !== null && left === right;
145
+ }
146
+
147
+ /**
148
+ * The agent-to-agent reach rule (migration 0427) for one caller/target pair
149
+ * that live in DIFFERENT root trees. A caller always keeps its own tree, so
150
+ * this is never consulted for same-root access. Only the caller's task scope
151
+ * restricts outgoing reach. The target's visibility and ownership remain
152
+ * independently enforced; its agent scope does not block incoming access.
153
+ */
154
+ export function agentAccessPermitsCrossTreeAccess(
155
+ caller: SessionAgentAccessFacts,
156
+ target: SessionAgentAccessFacts,
157
+ ): boolean {
158
+ if (caller.agentAccess === "session") return false;
159
+ if (caller.agentAccess === "user") {
160
+ return sameScopeSubject(caller.scopeSubjectId, target.scopeSubjectId);
161
+ }
162
+ return true;
163
+ }
164
+
165
+ /**
166
+ * The in-database list scope for one calling attempt. A `session` caller is
167
+ * pinned to its own root tree when the host does not narrow it. Every caller
168
+ * carries the viewer so `sessionAuthorizationScopeFilter` intersects the host
169
+ * scope with the same pairwise rule in SQL, including for session callers.
170
+ */
171
+ export function agentAccessListScopeForViewer(
172
+ viewer: SessionAgentAccessViewer,
173
+ hostScope: SessionAuthorizationListScope | null = null,
174
+ ): SessionAuthorizationListScope {
175
+ if (viewer.agentAccess === "session" && (!hostScope || hostScope.kind === "all")) {
176
+ return {
177
+ kind: "scoped",
178
+ rootSessionIds: [viewer.callerRootSessionId],
179
+ sessionIds: [],
180
+ agentAccessViewer: viewer,
181
+ };
182
+ }
183
+ if (!hostScope || hostScope.kind === "all") {
184
+ return { kind: "all", agentAccessViewer: viewer };
185
+ }
186
+ return {
187
+ kind: "scoped",
188
+ rootSessionIds: [...new Set(hostScope.rootSessionIds)],
189
+ sessionIds: [...new Set(hostScope.sessionIds)],
190
+ agentAccessViewer: viewer,
191
+ };
192
+ }
193
+
56
194
  type ResolvedSessionAuthorizationTarget = {
57
195
  target: SessionAuthorizationTarget;
58
196
  parentSessionId: string | null;
59
197
  };
60
198
 
61
- function grantHasAgentAttemptAuthority(grant: AccessGrant): boolean {
199
+ /**
200
+ * Whether a grant acts with live agent-attempt authority: an explicit
201
+ * `agent_attempt` principal kind, or (for legacy tokens without one) any
202
+ * worker-signed exact attempt claim. Shared by the session seam and by the
203
+ * narrowing fences that let a human widen a session an agent may only narrow.
204
+ */
205
+ export function grantHasAgentAttemptAuthority(grant: AccessGrant): boolean {
62
206
  const hasAgentAttemptClaim =
63
207
  grant.metadata?.["turnId"] !== undefined ||
64
208
  grant.metadata?.["attemptId"] !== undefined ||
@@ -67,52 +211,16 @@ function grantHasAgentAttemptAuthority(grant: AccessGrant): boolean {
67
211
  }
68
212
 
69
213
  /**
70
- * Read-only access an immediate child may use against its parent. Upstream
71
- * mutation is deliberately limited to `session.append`: letting a child Pause,
72
- * Steer, or otherwise mutate its parent would let it influence siblings through
73
- * the parent's recursive control and shared state.
214
+ * Live agent attempts may address any workspace session that later private,
215
+ * Slack-owner, and optional host checks still allow. Parent/child lineage is
216
+ * not an access deny. Cross-session projections stay exact-target so a peer
217
+ * read does not receive another session's derived metadata.
74
218
  */
75
- const AGENT_PARENT_READ_OPERATIONS = new Set<SessionAuthorizationOperation>([
76
- "session.read",
77
- "session.events.read",
78
- "session.stream.read",
79
- "session.turns.read",
80
- "session.queue.read",
81
- "session.composer.read",
82
- "session.lineage.read",
83
- "session.capture.read",
84
- "session.files.read",
85
- "session.git.read",
86
- "session.terminal.read",
87
- "session.viewer.read",
88
- "session.goal.read",
89
- "session.human_input.read",
90
- ]);
91
-
92
- function enforceAgentSessionHierarchy(
219
+ function relatedSessionAccessForAgentAttempt(
93
220
  actor: Extract<SessionAuthorizationActor, { kind: "agent_attempt" }>,
94
- callerParentSessionId: string | null,
95
- target: ResolvedSessionAuthorizationTarget,
96
- operation: SessionAuthorizationOperation,
221
+ targetSessionId: string,
97
222
  ): "target" | "root" {
98
- if (target.target.sessionId === actor.callerSessionId) return "root";
99
-
100
- // A manager may inspect and operate an immediate child. Existing permission
101
- // and host-policy checks still apply; lineage never grants a capability.
102
- if (target.parentSessionId === actor.callerSessionId) return "target";
103
-
104
- // A child may report to and inspect its immediate parent, but cannot mutate
105
- // the parent except through the canonical machine-input message boundary.
106
- if (callerParentSessionId === target.target.sessionId) {
107
- if (operation === "session.append" || AGENT_PARENT_READ_OPERATIONS.has(operation)) {
108
- return "target";
109
- }
110
- throw new SessionAuthorizationDeniedError("forbidden");
111
- }
112
-
113
- // Siblings, skipped generations, other branches, and unrelated roots are
114
- // never cross-session authority for a live agent attempt.
115
- throw new SessionAuthorizationDeniedError("forbidden");
223
+ return targetSessionId === actor.callerSessionId ? "root" : "target";
116
224
  }
117
225
 
118
226
  export function sessionRlsActorForAuthorization(
@@ -143,7 +251,8 @@ export async function requireLiveAgentAttemptAuthorization(
143
251
  grant: AccessGrant,
144
252
  callerSessionId: string,
145
253
  ): Promise<Extract<SessionAuthorizationActor, { kind: "agent_attempt" }>> {
146
- const { actor } = await resolveSessionAuthorizationActor(db, grant);
254
+ const reads = claimSessionAuthorizationReads();
255
+ const { actor } = await resolveSessionAuthorizationActor(db, grant, reads);
147
256
  if (actor.kind !== "agent_attempt" || actor.callerSessionId !== callerSessionId) {
148
257
  throw new SessionAuthorizationDeniedError("caller_stale");
149
258
  }
@@ -170,16 +279,31 @@ export async function requireSessionAuthorization(
170
279
  surface: SessionAuthorizationSurface;
171
280
  },
172
281
  ): Promise<ResolvedSessionAuthorization | null> {
282
+ const reads = claimSessionAuthorizationReads();
173
283
  const port = deps.sessionAuthorization;
174
284
  const isAgentAttempt = grantHasAgentAttemptAuthority(grant);
175
- const [slackAccess, authority] = await Promise.all([
176
- getSlackInteractionSessionAccessForSession(deps.db, {
177
- accountId: grant.accountId,
178
- workspaceId: grant.workspaceId,
179
- sessionId: input.sessionId,
180
- }),
181
- getSessionAuthorityProjection(deps.db, grant.workspaceId, input.sessionId),
182
- ]);
285
+ // Callers can supply a transaction handle. Both reads open nested RLS
286
+ // savepoints, which must not race on the same PostgreSQL connection.
287
+ const slackAccess = await getSlackInteractionSessionAccessForSession(deps.db, {
288
+ accountId: grant.accountId,
289
+ workspaceId: grant.workspaceId,
290
+ sessionId: input.sessionId,
291
+ });
292
+ const authority = await getSessionAuthorityProjection(
293
+ deps.db,
294
+ grant.workspaceId,
295
+ input.sessionId,
296
+ );
297
+
298
+ // The projection is read under the caller's own RLS context, so a missing
299
+ // session and one the caller may not see (another member's private session)
300
+ // are the same answer. Every request-facing surface refuses both here,
301
+ // before a route-specific read can observe the difference or fail on the
302
+ // absent row. In-process `core` callers keep the standalone null result and
303
+ // their own not-found handling.
304
+ if (!authority && input.surface !== "core") {
305
+ throw new SessionAuthorizationDeniedError("not_found");
306
+ }
183
307
 
184
308
  // Preserve the standalone workspace-shared path. Private sessions continue
185
309
  // through the durable actor and ownership checks even without a host port.
@@ -193,22 +317,29 @@ export async function requireSessionAuthorization(
193
317
  }
194
318
  if (!authority) throw new SessionAuthorizationDeniedError("not_found");
195
319
 
196
- const [resolvedActor, resolvedTarget] = await Promise.all([
197
- resolveSessionAuthorizationActor(deps.db, grant),
198
- resolveSessionAuthorizationTarget(deps.db, grant, input.sessionId),
199
- ]);
320
+ const resolvedActor = await resolveSessionAuthorizationActor(deps.db, grant, reads);
321
+ const resolvedTarget = await resolveSessionAuthorizationTarget(
322
+ deps.db,
323
+ grant,
324
+ input.sessionId,
325
+ reads,
326
+ );
200
327
  const actor = resolvedActor.actor;
201
328
  const target = resolvedTarget.target;
202
329
  const agentRelatedSessionAccess =
203
330
  actor.kind === "agent_attempt"
204
- ? enforceAgentSessionHierarchy(
205
- actor,
206
- resolvedActor.callerParentSessionId,
207
- resolvedTarget,
208
- input.operation,
209
- )
331
+ ? relatedSessionAccessForAgentAttempt(actor, target.sessionId)
210
332
  : null;
211
333
 
334
+ // Tool approvals are a human decision. An agent attempt may answer another
335
+ // session's structured human-input request (`session.human_input.write`,
336
+ // through `session_human_input_respond`) but may never approve or reject a
337
+ // pending tool approval on any session, including a child it spawned; an
338
+ // embedding-host port cannot widen this.
339
+ if (actor.kind === "agent_attempt" && input.operation === "session.approval.write") {
340
+ throw new SessionAuthorizationDeniedError("forbidden");
341
+ }
342
+
212
343
  if (authority.visibility === "user_private") {
213
344
  const allowed =
214
345
  authority.ownerSubjectId !== null &&
@@ -224,6 +355,22 @@ export async function requireSessionAuthorization(
224
355
  : actor.callerRootSessionId === target.rootSessionId;
225
356
  if (!allowed) throw new SessionAuthorizationDeniedError("forbidden");
226
357
  }
358
+ // Agent-access scope (migration 0427): an attempt always keeps its own root
359
+ // tree; across trees only its own outgoing task scope restricts reach.
360
+ // User matching reads canonical target identity, not target task scope.
361
+ // Private ownership above and the host authorization below still apply.
362
+ if (actor.kind === "agent_attempt" && target.rootSessionId !== actor.callerRootSessionId) {
363
+ const callerAccess = resolvedActor.callerAccess;
364
+ if (
365
+ !callerAccess ||
366
+ !agentAccessPermitsCrossTreeAccess(callerAccess, {
367
+ agentAccess: authority.agentAccess,
368
+ scopeSubjectId: authority.scopeSubjectId,
369
+ })
370
+ ) {
371
+ throw new SessionAuthorizationDeniedError("forbidden");
372
+ }
373
+ }
227
374
  if (!port) {
228
375
  return {
229
376
  actor,
@@ -270,13 +417,24 @@ export async function requireSessionAuthorizationListScope(
270
417
  grant: AccessGrant,
271
418
  surface: SessionAuthorizationSurface,
272
419
  ): Promise<SessionAuthorizationListScope | null> {
420
+ const reads = claimSessionAuthorizationReads();
273
421
  const port = deps.sessionAuthorization;
274
422
  const isAgentAttempt = grantHasAgentAttemptAuthority(grant);
275
423
  if (!port && !isAgentAttempt) return null;
276
- const { actor } = await resolveSessionAuthorizationActor(deps.db, grant);
277
- // Standalone agents may retain compact workspace discovery, but only while
278
- // the signed caller attempt is still the exact live attempt.
279
- if (!port) return null;
424
+ const { actor, callerAccess } = await resolveSessionAuthorizationActor(deps.db, grant, reads);
425
+ // Standalone agents retain compact workspace discovery only while the signed
426
+ // caller attempt is still the exact live attempt, and only within their own
427
+ // declared agent-access reach (migration 0427). The viewer is derived from
428
+ // the caller session row and applied as one SQL predicate by every list.
429
+ const viewer: SessionAgentAccessViewer | null =
430
+ actor.kind === "agent_attempt" && callerAccess
431
+ ? {
432
+ callerRootSessionId: actor.callerRootSessionId,
433
+ agentAccess: callerAccess.agentAccess,
434
+ scopeSubjectId: callerAccess.scopeSubjectId,
435
+ }
436
+ : null;
437
+ if (!port) return viewer ? agentAccessListScopeForViewer(viewer) : null;
280
438
  let rawScope: unknown;
281
439
  try {
282
440
  rawScope = await port.resolveListScope({
@@ -292,20 +450,31 @@ export async function requireSessionAuthorizationListScope(
292
450
  if (!parsed.success) {
293
451
  throw new SessionAuthorizationUnavailableError({ cause: parsed.error });
294
452
  }
295
- if (parsed.data.kind === "all") return parsed.data;
296
- return {
297
- kind: "scoped",
298
- rootSessionIds: [...new Set(parsed.data.rootSessionIds)],
299
- sessionIds: [...new Set(parsed.data.sessionIds)],
300
- };
453
+ // A host never supplies the viewer: OpenGeni resolved it above from durable
454
+ // state, so a host-returned value is dropped before the intersection.
455
+ const hostScope: SessionAuthorizationListScope =
456
+ parsed.data.kind === "all"
457
+ ? { kind: "all" }
458
+ : {
459
+ kind: "scoped",
460
+ rootSessionIds: [...new Set(parsed.data.rootSessionIds)],
461
+ sessionIds: [...new Set(parsed.data.sessionIds)],
462
+ };
463
+ return viewer ? agentAccessListScopeForViewer(viewer, hostScope) : hostScope;
301
464
  }
302
465
 
303
466
  async function resolveSessionAuthorizationTarget(
304
467
  db: Database,
305
468
  grant: AccessGrant,
306
469
  sessionId: string,
470
+ reads: SessionAuthorizationReads | null,
307
471
  ): Promise<ResolvedSessionAuthorizationTarget> {
308
- const session = await getSession(db, grant.workspaceId, sessionId);
472
+ const session = await reusableAuthorizationRead(
473
+ reads,
474
+ db,
475
+ ["session", grant.workspaceId, sessionId],
476
+ () => getSession(db, grant.workspaceId, sessionId),
477
+ );
309
478
  if (!session || session.accountId !== grant.accountId) {
310
479
  throw new SessionAuthorizationDeniedError("not_found");
311
480
  }
@@ -317,6 +486,7 @@ async function resolveSessionAuthorizationTarget(
317
486
  async function resolveSessionAuthorizationActor(
318
487
  db: Database,
319
488
  grant: AccessGrant,
489
+ reads: SessionAuthorizationReads | null,
320
490
  ): Promise<ResolvedSessionAuthorizationActor> {
321
491
  const callerSessionId = grant.metadata?.["sessionId"];
322
492
  const turnId = grant.metadata?.["turnId"];
@@ -331,6 +501,7 @@ async function resolveSessionAuthorizationActor(
331
501
  ...(grant.subjectLabel ? { subjectLabel: grant.subjectLabel } : {}),
332
502
  }),
333
503
  callerParentSessionId: null,
504
+ callerAccess: null,
334
505
  };
335
506
  }
336
507
  if (
@@ -343,10 +514,19 @@ async function resolveSessionAuthorizationActor(
343
514
  ) {
344
515
  throw new SessionAuthorizationDeniedError("caller_stale");
345
516
  }
346
- const [callerSession, turn] = await Promise.all([
347
- getSession(db, grant.workspaceId, callerSessionId),
348
- getSessionTurnForAttempt(db, grant.workspaceId, callerSessionId, attemptId),
349
- ]);
517
+ // These scoped reads can also open nested savepoints on a transaction handle.
518
+ const callerSession = await reusableAuthorizationRead(
519
+ reads,
520
+ db,
521
+ ["session", grant.workspaceId, callerSessionId],
522
+ () => getSession(db, grant.workspaceId, callerSessionId),
523
+ );
524
+ const turn = await reusableAuthorizationRead(
525
+ reads,
526
+ db,
527
+ ["turn", grant.workspaceId, callerSessionId, attemptId],
528
+ () => getSessionTurnForAttempt(db, grant.workspaceId, callerSessionId, attemptId),
529
+ );
350
530
  if (
351
531
  !callerSession ||
352
532
  callerSession.accountId !== grant.accountId ||
@@ -373,5 +553,9 @@ async function resolveSessionAuthorizationActor(
373
553
  (turn.initiator.kind === "subject" ? turn.initiator.subjectId : null),
374
554
  }),
375
555
  callerParentSessionId: callerSession.parentSessionId,
556
+ callerAccess: {
557
+ agentAccess: callerSession.agentAccess,
558
+ scopeSubjectId: callerSession.scopeSubjectId,
559
+ },
376
560
  };
377
561
  }
@@ -0,0 +1,22 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ export type SiteSessionOrigin = { siteId: string; title: string };
4
+ const origins = new AsyncLocalStorage<SiteSessionOrigin>();
5
+
6
+ /** Request-local provenance, set only after the API validates a published Site version. */
7
+ export function withSiteSessionOrigin<T>(origin: SiteSessionOrigin, run: () => T): T {
8
+ return origins.run(origin, run);
9
+ }
10
+
11
+ export function sessionCreationMetadata(
12
+ metadata: Record<string, unknown>,
13
+ ): Record<string, unknown> {
14
+ const { _opengeniSiteOrigin: _ignored, ...rest } = metadata;
15
+ const origin = origins.getStore();
16
+ return origin ? { ...rest, _opengeniSiteOrigin: origin } : rest;
17
+ }
18
+
19
+ /** The validated Site origin of the current request, if any. Provenance only. */
20
+ export function currentSiteSessionOrigin(): SiteSessionOrigin | undefined {
21
+ return origins.getStore();
22
+ }
@@ -31,6 +31,9 @@ export type TranscriptionRequest = {
31
31
  providerDeadlineAt?: Date | undefined;
32
32
  /** Exact provider selected before a resumable segment is first sent upstream. */
33
33
  providerId?: string | undefined;
34
+ preferredProvider?: string | null | undefined;
35
+ fallbackEnabled?: boolean | undefined;
36
+ excludedProviders?: readonly string[] | undefined;
34
37
  };
35
38
 
36
39
  export type TranscriptionResult = TranscribeAudioResponse & {
@@ -44,18 +47,22 @@ export class TranscriptionServiceError extends Error {
44
47
  readonly code: VoiceInputErrorCode;
45
48
  readonly status: number;
46
49
  readonly retryable: boolean;
50
+ /** Explicit rejection before any transcription result; safe to try another provider. */
51
+ readonly fallbackSafe: boolean;
47
52
 
48
53
  constructor(input: {
49
54
  code: VoiceInputErrorCode;
50
55
  message: string;
51
56
  status?: number;
52
57
  retryable?: boolean;
58
+ fallbackSafe?: boolean;
53
59
  }) {
54
60
  super(input.message);
55
61
  this.name = "TranscriptionServiceError";
56
62
  this.code = input.code;
57
63
  this.status = input.status ?? statusForVoiceInputError(input.code);
58
64
  this.retryable = input.retryable ?? false;
65
+ this.fallbackSafe = input.fallbackSafe ?? false;
59
66
  }
60
67
  }
61
68
 
@@ -88,6 +95,10 @@ export function statusForVoiceInputError(code: VoiceInputErrorCode): number {
88
95
 
89
96
  /** Optional workspace scope for readiness checks during provider selection. */
90
97
  export type TranscriptionAvailabilityContext = {
98
+ afterProvider?: string | undefined;
99
+ preferredProvider?: string | null | undefined;
100
+ fallbackEnabled?: boolean | undefined;
101
+ excludedProviders?: readonly string[] | undefined;
91
102
  workspaceId?: string | undefined;
92
103
  subjectId?: string | undefined;
93
104
  };
@@ -95,7 +106,8 @@ export type TranscriptionAvailabilityContext = {
95
106
  /**
96
107
  * Extensible transcription provider port. Implementations own credentials and
97
108
  * upstream request shape. Selection happens before audio is sent; providers must
98
- * not fall back to another vendor after an upstream request may have started.
109
+ * only fall back after an explicit rejection and before any successful or
110
+ * uncertain provider attempt. Recording persistence owns that eligibility.
99
111
  */
100
112
  export type TranscriptionProvider = {
101
113
  readonly id: string;
@@ -0,0 +1,45 @@
1
+ import type { AccessGrant, SessionTurnSurface } from "@opengeni/contracts";
2
+ import type { AccessGrantAuthorization } from "./access";
3
+ import { currentSiteSessionOrigin } from "./site-session-origin";
4
+
5
+ /**
6
+ * Resolve the content-free product surface a request entered through.
7
+ *
8
+ * The result is an analytics label frozen on the accepted turn. It never
9
+ * authorizes anything, so it reads trusted provenance only where it already
10
+ * exists: an explicit entry-point choice (Slack, automations, maintenance), the
11
+ * API-validated Site scope, and the access path the grant was resolved from.
12
+ * A grant whose path this function cannot place yields null rather than a
13
+ * guess.
14
+ */
15
+ export function resolveTurnSurface(input: {
16
+ grant: AccessGrant;
17
+ authorization?: AccessGrantAuthorization | null | undefined;
18
+ /** An entry point that knows its surface (for example Slack) passes it here. */
19
+ requested?: SessionTurnSurface | null | undefined;
20
+ }): SessionTurnSurface | null {
21
+ if (input.requested) return input.requested;
22
+ if (currentSiteSessionOrigin()) return "site";
23
+ const { grant } = input;
24
+ const metadata = grant.metadata ?? {};
25
+ if (
26
+ grant.principalKind === "agent_attempt" ||
27
+ metadata.turnId !== undefined ||
28
+ metadata.attemptId !== undefined
29
+ ) {
30
+ return "agent";
31
+ }
32
+ if (metadata.externalActor !== undefined) return "embedded";
33
+ if (metadata.mcpOAuth === true) return "mcp";
34
+ if (grant.principalKind === "api_key" || grant.principalKind === "configured_key") {
35
+ return "api_key";
36
+ }
37
+ if (metadata.delegated === true || grant.serviceInitiator) return "embedded";
38
+ if (
39
+ input.authorization?.canonicalManagedHumanSession ||
40
+ input.authorization?.canonicalLocalHumanSession
41
+ ) {
42
+ return "web";
43
+ }
44
+ return grant.principalKind === "human_session" ? "web" : null;
45
+ }