@opengeni/core 0.24.1 → 0.28.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.
@@ -1,5 +1,7 @@
1
1
  import type { Context } from "hono";
2
2
  import type { ManagedAuth } from "./managed-auth-type";
3
+ import type { Database } from "@opengeni/db";
4
+ import { validateCanonicalHumanSession } from "@opengeni/db/canonical-human-identities";
3
5
 
4
6
  /**
5
7
  * Read a Better Auth session without bypassing its sliding-cookie renewal.
@@ -8,7 +10,11 @@ import type { ManagedAuth } from "./managed-auth-type";
8
10
  * Programmatic callers must explicitly request and forward the returned cookie
9
11
  * headers; the HTTP handler does this automatically, but direct API calls do not.
10
12
  */
11
- export async function getManagedSession(c: Context, auth: ManagedAuth) {
13
+ export async function getManagedSession(
14
+ c: Context,
15
+ auth: ManagedAuth,
16
+ options?: { db?: Database; allowIdentityRecovery?: boolean },
17
+ ) {
12
18
  const result = await auth.api.getSession({
13
19
  headers: c.req.raw.headers,
14
20
  returnHeaders: true,
@@ -18,7 +24,18 @@ export async function getManagedSession(c: Context, auth: ManagedAuth) {
18
24
  c.header("set-cookie", cookie, { append: true });
19
25
  }
20
26
 
21
- return result.response;
27
+ const session = result.response;
28
+ if (!session?.user || !options?.db) return session;
29
+ const authSessionId = session.session?.id;
30
+ if (typeof authSessionId !== "string") return null;
31
+ const valid = await validateCanonicalHumanSession(options.db, {
32
+ authSessionId,
33
+ authUserId: session.user.id,
34
+ ...(options.allowIdentityRecovery === undefined
35
+ ? {}
36
+ : { allowRecovery: options.allowIdentityRecovery }),
37
+ });
38
+ return valid ? session : null;
22
39
  }
23
40
 
24
41
  function setCookieHeaders(headers: Headers): string[] {
@@ -37,7 +37,6 @@ import {
37
37
  type SelfhostedRelayConfig,
38
38
  type SelfhostedOpStreamDeps,
39
39
  } from "@opengeni/runtime/sandbox";
40
- import { HTTPException } from "hono/http-exception";
41
40
  import { relayConfigFromSettings } from "./routing";
42
41
 
43
42
  export type FleetServices = {
@@ -69,8 +68,9 @@ export type FleetContext = {
69
68
 
70
69
  /**
71
70
  * Build a session-scoped {@link FleetContext}: load the session (workspace-
72
- * scoped), reject a session with no box (backend:none the fleet is only
73
- * meaningful for a sandboxed session), and project its group backend/id. Shared
71
+ * scoped) and project its group backend/id. A backend:none session has no home
72
+ * box, but it may still discover, run on, and attach an owned Connected Machine.
73
+ * Shared
74
74
  * by the worker-signed MCP fleet tools and the user-authenticated swap REST
75
75
  * route so both resolve the SAME context (no drift). The `accountId`/`workspaceId`/
76
76
  * `sessionId` come from the trusted grant/route; the backend + group id come from
@@ -81,11 +81,6 @@ export async function buildFleetContextForSession(
81
81
  ctx: { accountId: string; workspaceId: string; sessionId: string },
82
82
  ): Promise<FleetContext> {
83
83
  const session = await requireSession(deps.db, ctx.workspaceId, ctx.sessionId);
84
- if (session.sandboxBackend === "none") {
85
- throw new HTTPException(422, {
86
- message: "this session has no sandbox (backend: none); the fleet is unavailable",
87
- });
88
- }
89
84
  return {
90
85
  accountId: ctx.accountId,
91
86
  workspaceId: ctx.workspaceId,
@@ -229,9 +224,11 @@ async function probeEnrollment(
229
224
  }
230
225
 
231
226
  /**
232
- * List the fleet: the session's own Modal group box (a synthetic entry) + the
233
- * workspace's first-class selfhosted sandboxes (each probed for liveness), each
234
- * with an `active` marker derived from the session's active pointer.
227
+ * List the fleet: the session's own group box when it has one (a synthetic
228
+ * entry) + the workspace's first-class selfhosted sandboxes (each probed for
229
+ * liveness), each with an `active` marker derived from the session's active
230
+ * pointer. A backend:none session has no synthetic home entry; a null pointer
231
+ * then means no compute is attached.
235
232
  */
236
233
  export async function listFleet(
237
234
  services: FleetServices,
@@ -245,61 +242,63 @@ export async function listFleet(
245
242
 
246
243
  const entries: FleetSandboxEntry[] = [];
247
244
 
248
- // The session's own group box (the default/home sandbox; null active pointer ==
249
- // this box). A session/group row is not provider existence. Online requires a
250
- // warm lease, observed provider existence, and verified workspace readiness.
251
- const groupActive = pointer.activeSandboxId === null;
252
- const groupLease = await readLease(db, ctx.workspaceId, ctx.sessionGroupId);
253
- const groupOnline = Boolean(
254
- groupLease?.liveness === "warm" &&
255
- groupLease.recovery.provider.status === "exists" &&
256
- groupLease.recovery.workspace.status === "ready",
257
- );
258
- const groupRecovering = Boolean(
259
- groupLease &&
260
- (groupLease.liveness === "warming" ||
261
- groupLease.recovery.restore.status === "pending" ||
262
- groupLease.recovery.restore.status === "restoring" ||
263
- groupLease.recovery.restore.status === "verifying"),
264
- );
265
- const groupRecoveryUnavailable = Boolean(
266
- groupLease &&
267
- (groupLease.recovery.restore.status === "degraded" ||
268
- groupLease.recovery.restore.status === "unrecoverable" ||
269
- groupLease.recovery.workspace.status === "degraded" ||
270
- groupLease.recovery.workspace.status === "unrecoverable"),
271
- );
272
- const groupOperationAvailability: FleetOperationAvailability = groupOnline
273
- ? "ready"
274
- : groupRecoveryUnavailable
275
- ? "unavailable"
276
- : groupRecovering
277
- ? "recovering"
278
- : ctx.sessionBackend === "selfhosted"
279
- ? "unavailable"
280
- : "wakeable";
281
- entries.push({
282
- id: ctx.sessionGroupId,
283
- kind: ctx.sessionBackend === "selfhosted" ? "selfhosted" : "modal",
284
- name: "session sandbox",
285
- liveness: groupOnline ? "online" : groupRecovering ? "reconnecting" : "offline",
286
- active: groupActive,
287
- isSessionGroup: true,
288
- enrollmentId: null,
289
- attachable: groupOnline,
290
- operationAvailability: groupOperationAvailability,
291
- providerStatus: groupLease?.recovery.provider.status ?? "not_created",
292
- leaseLiveness: groupLease?.liveness ?? null,
293
- routeStatus: groupActive ? "attached" : "detached",
294
- archiveStatus: groupLease?.recovery.archive.status ?? "none",
295
- restoreStatus: groupLease?.recovery.restore.status ?? "not_required",
296
- workspaceStatus: groupLease?.recovery.workspace.status ?? "unknown",
297
- leaseEpoch: groupLease?.leaseEpoch ?? null,
298
- routeEpoch: pointer.activeEpoch,
299
- workspaceGeneration: groupLease?.workspaceGeneration ?? null,
300
- archiveGeneration: groupLease?.archiveGeneration ?? null,
301
- archiveComplete: groupLease?.archiveComplete ?? false,
302
- });
245
+ if (ctx.sessionBackend !== "none") {
246
+ // The session's own group box (the default/home sandbox; null active pointer ==
247
+ // this box). A session/group row is not provider existence. Online requires a
248
+ // warm lease, observed provider existence, and verified workspace readiness.
249
+ const groupActive = pointer.activeSandboxId === null;
250
+ const groupLease = await readLease(db, ctx.workspaceId, ctx.sessionGroupId);
251
+ const groupOnline = Boolean(
252
+ groupLease?.liveness === "warm" &&
253
+ groupLease.recovery.provider.status === "exists" &&
254
+ groupLease.recovery.workspace.status === "ready",
255
+ );
256
+ const groupRecovering = Boolean(
257
+ groupLease &&
258
+ (groupLease.liveness === "warming" ||
259
+ groupLease.recovery.restore.status === "pending" ||
260
+ groupLease.recovery.restore.status === "restoring" ||
261
+ groupLease.recovery.restore.status === "verifying"),
262
+ );
263
+ const groupRecoveryUnavailable = Boolean(
264
+ groupLease &&
265
+ (groupLease.recovery.restore.status === "degraded" ||
266
+ groupLease.recovery.restore.status === "unrecoverable" ||
267
+ groupLease.recovery.workspace.status === "degraded" ||
268
+ groupLease.recovery.workspace.status === "unrecoverable"),
269
+ );
270
+ const groupOperationAvailability: FleetOperationAvailability = groupOnline
271
+ ? "ready"
272
+ : groupRecoveryUnavailable
273
+ ? "unavailable"
274
+ : groupRecovering
275
+ ? "recovering"
276
+ : ctx.sessionBackend === "selfhosted"
277
+ ? "unavailable"
278
+ : "wakeable";
279
+ entries.push({
280
+ id: ctx.sessionGroupId,
281
+ kind: ctx.sessionBackend === "selfhosted" ? "selfhosted" : "modal",
282
+ name: "session sandbox",
283
+ liveness: groupOnline ? "online" : groupRecovering ? "reconnecting" : "offline",
284
+ active: groupActive,
285
+ isSessionGroup: true,
286
+ enrollmentId: null,
287
+ attachable: groupOnline,
288
+ operationAvailability: groupOperationAvailability,
289
+ providerStatus: groupLease?.recovery.provider.status ?? "not_created",
290
+ leaseLiveness: groupLease?.liveness ?? null,
291
+ routeStatus: groupActive ? "attached" : "detached",
292
+ archiveStatus: groupLease?.recovery.archive.status ?? "none",
293
+ restoreStatus: groupLease?.recovery.restore.status ?? "not_required",
294
+ workspaceStatus: groupLease?.recovery.workspace.status ?? "unknown",
295
+ leaseEpoch: groupLease?.leaseEpoch ?? null,
296
+ routeEpoch: pointer.activeEpoch,
297
+ workspaceGeneration: groupLease?.workspaceGeneration ?? null,
298
+ archiveGeneration: groupLease?.archiveGeneration ?? null,
299
+ archiveComplete: groupLease?.archiveComplete ?? false,
300
+ });
301
+ }
303
302
 
304
303
  // The workspace's first-class selfhosted sandboxes (enrolled machines). Probe
305
304
  // each for liveness; a missing enrollment is offline.
@@ -374,6 +373,13 @@ async function resolveTarget(
374
373
  > {
375
374
  // The session's own group box → the default pointer (null).
376
375
  if (target === ctx.sessionGroupId || target === "session" || target === "default") {
376
+ if (ctx.sessionBackend === "none") {
377
+ return {
378
+ ok: false,
379
+ reason: "this session has no home sandbox; attach a Connected Machine",
380
+ code: "unsupported_backend_context",
381
+ };
382
+ }
377
383
  return { ok: true, targetSandboxId: null };
378
384
  }
379
385
  const sandbox = await getSandbox(services.db, ctx.workspaceId, target);
@@ -8,11 +8,13 @@ import {
8
8
  type SessionAuthorizationTarget,
9
9
  } from "@opengeni/contracts";
10
10
  import {
11
+ getSessionAuthorityProjection,
11
12
  getSession,
12
- getSessionRootId,
13
13
  getSessionTurnForAttempt,
14
14
  getSlackInteractionSessionAccessForSession,
15
+ withSessionRlsActorContext,
15
16
  type Database,
17
+ type SessionRlsActorContext,
16
18
  } from "@opengeni/db";
17
19
  import type { AppDependencies } from "./dependencies";
18
20
 
@@ -46,6 +48,91 @@ export type ResolvedSessionAuthorization = {
46
48
  reauthorizeAfterMs: number | null;
47
49
  };
48
50
 
51
+ type ResolvedSessionAuthorizationActor = {
52
+ actor: SessionAuthorizationActor;
53
+ callerParentSessionId: string | null;
54
+ };
55
+
56
+ type ResolvedSessionAuthorizationTarget = {
57
+ target: SessionAuthorizationTarget;
58
+ parentSessionId: string | null;
59
+ };
60
+
61
+ function grantHasAgentAttemptAuthority(grant: AccessGrant): boolean {
62
+ const hasAgentAttemptClaim =
63
+ grant.metadata?.["turnId"] !== undefined ||
64
+ grant.metadata?.["attemptId"] !== undefined ||
65
+ grant.metadata?.["executionGeneration"] !== undefined;
66
+ return grant.principalKind ? grant.principalKind === "agent_attempt" : hasAgentAttemptClaim;
67
+ }
68
+
69
+ /**
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.
74
+ */
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(
93
+ actor: Extract<SessionAuthorizationActor, { kind: "agent_attempt" }>,
94
+ callerParentSessionId: string | null,
95
+ target: ResolvedSessionAuthorizationTarget,
96
+ operation: SessionAuthorizationOperation,
97
+ ): "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");
116
+ }
117
+
118
+ export function sessionRlsActorForAuthorization(
119
+ authorization: ResolvedSessionAuthorization,
120
+ ): SessionRlsActorContext {
121
+ return authorization.actor.kind === "agent_attempt"
122
+ ? {
123
+ subjectId: authorization.actor.subjectId,
124
+ initiatingHumanSubjectId: authorization.actor.initiatingHumanSubjectId,
125
+ }
126
+ : { subjectId: authorization.actor.subjectId };
127
+ }
128
+
129
+ export async function withResolvedSessionAuthorization<T>(
130
+ authorization: ResolvedSessionAuthorization,
131
+ fn: () => Promise<T>,
132
+ ): Promise<T> {
133
+ return await withSessionRlsActorContext(sessionRlsActorForAuthorization(authorization), fn);
134
+ }
135
+
49
136
  /**
50
137
  * Prove that a first-party request belongs to the exact currently active
51
138
  * attempt of the named caller session. Unlike the optional embedding-host ACL
@@ -56,7 +143,7 @@ export async function requireLiveAgentAttemptAuthorization(
56
143
  grant: AccessGrant,
57
144
  callerSessionId: string,
58
145
  ): Promise<Extract<SessionAuthorizationActor, { kind: "agent_attempt" }>> {
59
- const actor = await resolveSessionAuthorizationActor(db, grant);
146
+ const { actor } = await resolveSessionAuthorizationActor(db, grant);
60
147
  if (actor.kind !== "agent_attempt" || actor.callerSessionId !== callerSessionId) {
61
148
  throw new SessionAuthorizationDeniedError("caller_stale");
62
149
  }
@@ -84,17 +171,52 @@ export async function requireSessionAuthorization(
84
171
  },
85
172
  ): Promise<ResolvedSessionAuthorization | null> {
86
173
  const port = deps.sessionAuthorization;
87
- const slackAccess = await getSlackInteractionSessionAccessForSession(deps.db, {
88
- accountId: grant.accountId,
89
- workspaceId: grant.workspaceId,
90
- sessionId: input.sessionId,
91
- });
92
- if (!port && slackAccess?.visibility !== "private") return null;
93
-
94
- const actor = await resolveSessionAuthorizationActor(deps.db, grant);
95
- const target = slackAccess
96
- ? { sessionId: input.sessionId, rootSessionId: slackAccess.rootSessionId }
97
- : await resolveSessionAuthorizationTarget(deps.db, grant, input.sessionId);
174
+ 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
+ ]);
183
+
184
+ // Preserve the standalone workspace-shared path. Private sessions continue
185
+ // through the durable actor and ownership checks even without a host port.
186
+ if (
187
+ !port &&
188
+ !isAgentAttempt &&
189
+ slackAccess?.visibility !== "private" &&
190
+ authority?.visibility !== "user_private"
191
+ ) {
192
+ return null;
193
+ }
194
+ if (!authority) throw new SessionAuthorizationDeniedError("not_found");
195
+
196
+ const [resolvedActor, resolvedTarget] = await Promise.all([
197
+ resolveSessionAuthorizationActor(deps.db, grant),
198
+ resolveSessionAuthorizationTarget(deps.db, grant, input.sessionId),
199
+ ]);
200
+ const actor = resolvedActor.actor;
201
+ const target = resolvedTarget.target;
202
+ const agentRelatedSessionAccess =
203
+ actor.kind === "agent_attempt"
204
+ ? enforceAgentSessionHierarchy(
205
+ actor,
206
+ resolvedActor.callerParentSessionId,
207
+ resolvedTarget,
208
+ input.operation,
209
+ )
210
+ : null;
211
+
212
+ if (authority.visibility === "user_private") {
213
+ const allowed =
214
+ authority.ownerSubjectId !== null &&
215
+ (actor.kind === "subject"
216
+ ? actor.subjectId === authority.ownerSubjectId
217
+ : actor.initiatingHumanSubjectId === authority.ownerSubjectId);
218
+ if (!allowed) throw new SessionAuthorizationDeniedError("forbidden");
219
+ }
98
220
  if (slackAccess?.visibility === "private") {
99
221
  const allowed =
100
222
  actor.kind === "subject"
@@ -102,7 +224,14 @@ export async function requireSessionAuthorization(
102
224
  : actor.callerRootSessionId === target.rootSessionId;
103
225
  if (!allowed) throw new SessionAuthorizationDeniedError("forbidden");
104
226
  }
105
- if (!port) return null;
227
+ if (!port) {
228
+ return {
229
+ actor,
230
+ target,
231
+ relatedSessionAccess: agentRelatedSessionAccess ?? "root",
232
+ reauthorizeAfterMs: null,
233
+ };
234
+ }
106
235
 
107
236
  let rawDecision: unknown;
108
237
  try {
@@ -127,7 +256,10 @@ export async function requireSessionAuthorization(
127
256
  return {
128
257
  actor,
129
258
  target,
130
- relatedSessionAccess: parsed.data.relatedSessionAccess ?? "target",
259
+ relatedSessionAccess:
260
+ agentRelatedSessionAccess === "target"
261
+ ? "target"
262
+ : (parsed.data.relatedSessionAccess ?? "target"),
131
263
  reauthorizeAfterMs: parsed.data.reauthorizeAfterMs ?? null,
132
264
  };
133
265
  }
@@ -139,8 +271,12 @@ export async function requireSessionAuthorizationListScope(
139
271
  surface: SessionAuthorizationSurface,
140
272
  ): Promise<SessionAuthorizationListScope | null> {
141
273
  const port = deps.sessionAuthorization;
274
+ const isAgentAttempt = grantHasAgentAttemptAuthority(grant);
275
+ 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.
142
279
  if (!port) return null;
143
- const actor = await resolveSessionAuthorizationActor(deps.db, grant);
144
280
  let rawScope: unknown;
145
281
  try {
146
282
  rawScope = await port.resolveListScope({
@@ -168,39 +304,34 @@ async function resolveSessionAuthorizationTarget(
168
304
  db: Database,
169
305
  grant: AccessGrant,
170
306
  sessionId: string,
171
- ): Promise<SessionAuthorizationTarget> {
307
+ ): Promise<ResolvedSessionAuthorizationTarget> {
172
308
  const session = await getSession(db, grant.workspaceId, sessionId);
173
309
  if (!session || session.accountId !== grant.accountId) {
174
310
  throw new SessionAuthorizationDeniedError("not_found");
175
311
  }
176
- let rootSessionId: string | null;
177
- try {
178
- rootSessionId = await getSessionRootId(db, grant.workspaceId, session.id);
179
- } catch (error) {
180
- throw new SessionAuthorizationUnavailableError({ cause: error });
181
- }
182
- if (!rootSessionId) {
183
- throw new SessionAuthorizationDeniedError("not_found");
184
- }
185
- return { sessionId: session.id, rootSessionId };
312
+ return {
313
+ target: { sessionId: session.id, rootSessionId: session.rootSessionId },
314
+ parentSessionId: session.parentSessionId,
315
+ };
186
316
  }
187
-
188
317
  async function resolveSessionAuthorizationActor(
189
318
  db: Database,
190
319
  grant: AccessGrant,
191
- ): Promise<SessionAuthorizationActor> {
320
+ ): Promise<ResolvedSessionAuthorizationActor> {
192
321
  const callerSessionId = grant.metadata?.["sessionId"];
193
322
  const turnId = grant.metadata?.["turnId"];
194
323
  const attemptId = grant.metadata?.["attemptId"];
195
324
  const executionGeneration = grant.metadata?.["executionGeneration"];
196
- const hasAttemptClaim =
197
- turnId !== undefined || attemptId !== undefined || executionGeneration !== undefined;
198
- if (!hasAttemptClaim) {
199
- return SessionAuthorizationActor.parse({
200
- kind: "subject",
201
- subjectId: grant.subjectId,
202
- ...(grant.subjectLabel ? { subjectLabel: grant.subjectLabel } : {}),
203
- });
325
+ const isAgentAttempt = grantHasAgentAttemptAuthority(grant);
326
+ if (!isAgentAttempt) {
327
+ return {
328
+ actor: SessionAuthorizationActor.parse({
329
+ kind: "subject",
330
+ subjectId: grant.subjectId,
331
+ ...(grant.subjectLabel ? { subjectLabel: grant.subjectLabel } : {}),
332
+ }),
333
+ callerParentSessionId: null,
334
+ };
204
335
  }
205
336
  if (
206
337
  typeof callerSessionId !== "string" ||
@@ -212,10 +343,9 @@ async function resolveSessionAuthorizationActor(
212
343
  ) {
213
344
  throw new SessionAuthorizationDeniedError("caller_stale");
214
345
  }
215
- const [callerSession, turn, callerRootSessionId] = await Promise.all([
346
+ const [callerSession, turn] = await Promise.all([
216
347
  getSession(db, grant.workspaceId, callerSessionId),
217
348
  getSessionTurnForAttempt(db, grant.workspaceId, callerSessionId, attemptId),
218
- getSessionRootId(db, grant.workspaceId, callerSessionId).catch(() => null),
219
349
  ]);
220
350
  if (
221
351
  !callerSession ||
@@ -223,23 +353,25 @@ async function resolveSessionAuthorizationActor(
223
353
  !turn ||
224
354
  turn.id !== turnId ||
225
355
  turn.executionGeneration !== executionGeneration ||
226
- callerSession.activeTurnId !== turn.id ||
227
- !callerRootSessionId
356
+ callerSession.activeTurnId !== turn.id
228
357
  ) {
229
358
  throw new SessionAuthorizationDeniedError("caller_stale");
230
359
  }
231
- return SessionAuthorizationActor.parse({
232
- kind: "agent_attempt",
233
- subjectId: grant.subjectId,
234
- callerSessionId,
235
- callerRootSessionId,
236
- turnId,
237
- attemptId,
238
- executionGeneration,
239
- initiator: turn.initiator,
240
- initiatorContext: turn.initiatorContext,
241
- initiatingHumanSubjectId:
242
- turn.initiatingHumanSubjectId ??
243
- (turn.initiator.kind === "subject" ? turn.initiator.subjectId : null),
244
- });
360
+ return {
361
+ actor: SessionAuthorizationActor.parse({
362
+ kind: "agent_attempt",
363
+ subjectId: grant.subjectId,
364
+ callerSessionId,
365
+ callerRootSessionId: callerSession.rootSessionId,
366
+ turnId,
367
+ attemptId,
368
+ executionGeneration,
369
+ initiator: turn.initiator,
370
+ initiatorContext: turn.initiatorContext,
371
+ initiatingHumanSubjectId:
372
+ turn.initiatingHumanSubjectId ??
373
+ (turn.initiator.kind === "subject" ? turn.initiator.subjectId : null),
374
+ }),
375
+ callerParentSessionId: callerSession.parentSessionId,
376
+ };
245
377
  }
@@ -19,6 +19,8 @@ export type TranscriptionLimits = {
19
19
  export type TranscriptionRequest = {
20
20
  workspaceId: string;
21
21
  accountId: string;
22
+ /** Authenticated human/service subject used for provider-account authority. */
23
+ subjectId: string;
22
24
  audio: Uint8Array;
23
25
  mimeType: string;
24
26
  /** Optional client-reported duration; enforced as a soft ceiling before upstream. */
@@ -87,6 +89,7 @@ export function statusForVoiceInputError(code: VoiceInputErrorCode): number {
87
89
  /** Optional workspace scope for readiness checks during provider selection. */
88
90
  export type TranscriptionAvailabilityContext = {
89
91
  workspaceId?: string | undefined;
92
+ subjectId?: string | undefined;
90
93
  };
91
94
 
92
95
  /**
@@ -109,6 +112,8 @@ export type TranscriptionProvider = {
109
112
  mimeType: string;
110
113
  filename: string;
111
114
  workspaceId: string;
115
+ accountId: string;
116
+ subjectId: string;
112
117
  requestId: string;
113
118
  signal?: AbortSignal | undefined;
114
119
  }): Promise<{ text: string; languages: string[] }>;