@opengeni/api-router 0.30.3 → 2.2.0-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/dist/api-websocket.d.ts +18 -0
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth.d.ts +6 -0
  4. package/dist/{chunk-WCXHI3FF.js → chunk-3TP54PPX.js} +26692 -15389
  5. package/dist/chunk-3TP54PPX.js.map +1 -0
  6. package/dist/codemode.d.ts +8 -1
  7. package/dist/company-brain-okf.d.ts +24 -0
  8. package/dist/connected-machine-computer-access.d.ts +11 -0
  9. package/dist/connection-authority-owner.d.ts +59 -0
  10. package/dist/connection-ownership.d.ts +47 -0
  11. package/dist/controller-data-plane.d.ts +16 -0
  12. package/dist/editable-artifact-websocket.d.ts +5 -15
  13. package/dist/editable-artifact-workspace-files.d.ts +1 -1
  14. package/dist/http/api-error.d.ts +10 -0
  15. package/dist/http/cors.d.ts +5 -0
  16. package/dist/http/interaction-control-error.d.ts +13 -0
  17. package/dist/http/sse.d.ts +2 -3
  18. package/dist/index.d.ts +5 -2
  19. package/dist/index.js +326 -82
  20. package/dist/index.js.map +1 -1
  21. package/dist/integrations/atlassian.d.ts +17 -0
  22. package/dist/integrations/fiken.d.ts +7 -2
  23. package/dist/integrations/google-drive.d.ts +18 -0
  24. package/dist/integrations/oauth-client.d.ts +22 -3
  25. package/dist/integrations/oauth-profiles.d.ts +195 -0
  26. package/dist/integrations/personal-github-repositories.d.ts +45 -0
  27. package/dist/integrations/personal-github.d.ts +40 -0
  28. package/dist/integrations/pr-review-provider.d.ts +22 -0
  29. package/dist/integrations/provider-oauth.d.ts +8 -0
  30. package/dist/integrations/slack-app-home.d.ts +24 -0
  31. package/dist/integrations/slack-bot.d.ts +157 -9
  32. package/dist/integrations/slack-interactions.d.ts +39 -5
  33. package/dist/integrations/slack-routing.d.ts +120 -0
  34. package/dist/integrations/social-oauth.d.ts +5 -0
  35. package/dist/interaction-frame-proxy.d.ts +68 -0
  36. package/dist/mcp/company-brain-governed-writes.d.ts +26 -0
  37. package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
  38. package/dist/mcp/documents.d.ts +1 -0
  39. package/dist/mcp/remember.d.ts +28 -0
  40. package/dist/mcp/request-abort.d.ts +19 -0
  41. package/dist/mcp/scheduled-task-view.d.ts +4 -4
  42. package/dist/mcp/server.d.ts +15 -2
  43. package/dist/mcp/session-view.d.ts +4 -0
  44. package/dist/mcp/session-wait.d.ts +137 -0
  45. package/dist/routes/automations.d.ts +4 -0
  46. package/dist/routes/billing.d.ts +5 -0
  47. package/dist/routes/browser-sessions.d.ts +4 -1
  48. package/dist/routes/company-brain.d.ts +3 -0
  49. package/dist/routes/company-profile.d.ts +2 -1
  50. package/dist/routes/connection-authorities.d.ts +3 -0
  51. package/dist/routes/personal-github-git-broker.d.ts +29 -0
  52. package/dist/routes/personal-github.d.ts +3 -0
  53. package/dist/routes/pr-review.d.ts +3 -0
  54. package/dist/routes/sessions.d.ts +1 -0
  55. package/dist/routes/user-resource-authorities.d.ts +3 -0
  56. package/dist/routes/workspace-capture.d.ts +10 -2
  57. package/dist/routes/workspace-learning.d.ts +3 -0
  58. package/dist/routes/workspaces.d.ts +19 -0
  59. package/dist/sandbox/channel-a.d.ts +11 -1
  60. package/dist/sandbox/connection-authority.d.ts +3 -0
  61. package/dist/sandbox/enrollment.d.ts +2 -0
  62. package/dist/sandbox/machines.d.ts +2 -2
  63. package/dist/sandbox/metrics-ingestion.d.ts +10 -20
  64. package/dist/sandbox/viewer.d.ts +24 -1
  65. package/dist/sandbox-file-artifacts.d.ts +11 -0
  66. package/dist/scheduled-task-deletion.d.ts +14 -0
  67. package/dist/slack-reaction-files.d.ts +1 -1
  68. package/dist/temporal-schedule-cleanup.d.ts +1 -0
  69. package/package.json +20 -18
  70. package/src/api-websocket.ts +23 -0
  71. package/src/app.ts +161 -23
  72. package/src/auth/managed-auth.ts +25 -3
  73. package/src/codemode.ts +53 -23
  74. package/src/codex-realtime.ts +8 -2
  75. package/src/company-brain-okf.ts +340 -0
  76. package/src/connected-machine-computer-access.ts +33 -0
  77. package/src/connection-authority-owner.ts +61 -0
  78. package/src/connection-ownership.ts +180 -0
  79. package/src/controller-data-plane.ts +47 -0
  80. package/src/editable-artifact-native-kernel.ts +26 -15
  81. package/src/editable-artifact-office-import.ts +28 -3
  82. package/src/editable-artifact-production.ts +13 -7
  83. package/src/editable-artifact-websocket.ts +11 -18
  84. package/src/editable-artifact-workspace-files.ts +31 -6
  85. package/src/http/api-error.ts +28 -0
  86. package/src/http/auth.ts +4 -0
  87. package/src/http/cors.ts +35 -0
  88. package/src/http/interaction-control-error.ts +164 -0
  89. package/src/http/sse.ts +112 -66
  90. package/src/index.ts +46 -6
  91. package/src/integrations/atlassian.ts +146 -35
  92. package/src/integrations/fiken.ts +102 -22
  93. package/src/integrations/google-drive.ts +301 -92
  94. package/src/integrations/oauth-client.ts +272 -143
  95. package/src/integrations/oauth-profiles.ts +477 -0
  96. package/src/integrations/personal-github-repositories.ts +445 -0
  97. package/src/integrations/personal-github.ts +705 -0
  98. package/src/integrations/pr-review-provider.ts +246 -0
  99. package/src/integrations/provider-oauth.ts +121 -5
  100. package/src/integrations/slack-app-home.ts +300 -0
  101. package/src/integrations/slack-bot.ts +342 -57
  102. package/src/integrations/slack-interactions.ts +1898 -259
  103. package/src/integrations/slack-routing.ts +337 -0
  104. package/src/integrations/social-oauth.ts +25 -0
  105. package/src/interaction-frame-proxy.ts +409 -0
  106. package/src/mcp/company-brain-governed-writes.ts +262 -0
  107. package/src/mcp/company-profile-agent-admin.ts +205 -0
  108. package/src/mcp/documents.ts +37 -6
  109. package/src/mcp/files.ts +16 -1
  110. package/src/mcp/remember.ts +181 -0
  111. package/src/mcp/request-abort.ts +44 -0
  112. package/src/mcp/scheduled-task-view.ts +1 -0
  113. package/src/mcp/server.ts +719 -369
  114. package/src/mcp/session-view.ts +54 -0
  115. package/src/mcp/session-wait.ts +554 -0
  116. package/src/model-catalog.ts +20 -12
  117. package/src/routes/api-integrations.ts +4 -0
  118. package/src/routes/api-keys.ts +1 -0
  119. package/src/routes/automations.ts +534 -0
  120. package/src/routes/billing.ts +57 -1
  121. package/src/routes/browser-sessions.ts +329 -79
  122. package/src/routes/channels.ts +17 -1
  123. package/src/routes/codex.ts +50 -0
  124. package/src/routes/company-brain.ts +367 -0
  125. package/src/routes/company-profile.ts +1 -1
  126. package/src/routes/computer-sessions.ts +320 -93
  127. package/src/routes/connection-authorities.ts +139 -0
  128. package/src/routes/connections.ts +144 -34
  129. package/src/routes/documents.ts +353 -4
  130. package/src/routes/editable-artifacts.ts +11 -3
  131. package/src/routes/enrollments.ts +116 -26
  132. package/src/routes/environments.ts +127 -55
  133. package/src/routes/files.ts +72 -11
  134. package/src/routes/install.ts +82 -28
  135. package/src/routes/integration-facets.ts +29 -0
  136. package/src/routes/interaction-resources.ts +20 -2
  137. package/src/routes/machines.ts +275 -13
  138. package/src/routes/organization-memberships.ts +717 -28
  139. package/src/routes/packs.ts +4 -3
  140. package/src/routes/personal-github-git-broker.ts +785 -0
  141. package/src/routes/personal-github.ts +319 -0
  142. package/src/routes/pr-review.ts +531 -0
  143. package/src/routes/rigs.ts +104 -39
  144. package/src/routes/scheduled-tasks.ts +19 -21
  145. package/src/routes/sessions.ts +775 -38
  146. package/src/routes/social.ts +14 -2
  147. package/src/routes/supergrok.ts +23 -4
  148. package/src/routes/transcription-recordings.ts +8 -2
  149. package/src/routes/user-resource-authorities.ts +138 -0
  150. package/src/routes/workspace-artifacts.ts +4 -1
  151. package/src/routes/workspace-capture.ts +21 -0
  152. package/src/routes/workspace-learning.ts +228 -0
  153. package/src/routes/workspaces.ts +59 -6
  154. package/src/sandbox/auth-callout.ts +38 -12
  155. package/src/sandbox/channel-a.ts +242 -19
  156. package/src/sandbox/connection-authority.ts +3 -0
  157. package/src/sandbox/enrollment.ts +15 -3
  158. package/src/sandbox/machines.ts +186 -64
  159. package/src/sandbox/metrics-ingestion.ts +220 -58
  160. package/src/sandbox/viewer.ts +243 -19
  161. package/src/sandbox-file-artifacts.ts +329 -0
  162. package/src/scheduled-task-deletion.ts +127 -0
  163. package/src/slack-reaction-files.ts +16 -5
  164. package/src/temporal-schedule-cleanup.ts +13 -0
  165. package/dist/chunk-WCXHI3FF.js.map +0 -1
@@ -33,6 +33,7 @@ import { githubAppBotIdentity } from "@opengeni/github";
33
33
  import { type Session, type StreamUrlRotatedPayload } from "@opengeni/contracts";
34
34
  import {
35
35
  acquireLease,
36
+ getLiveEnrollmentConnection,
36
37
  getSandbox,
37
38
  getSandboxSessionEnvelope,
38
39
  heartbeatLeaseHolder,
@@ -48,6 +49,8 @@ import {
48
49
  type Database,
49
50
  type LeaseSnapshot,
50
51
  type SandboxRecord,
52
+ getSessionAuthorityEpoch,
53
+ namedSubjectHasLiveWorkspaceAuthority,
51
54
  } from "@opengeni/db";
52
55
  import { appendAndPublishEvents, type EventBus } from "@opengeni/events";
53
56
  import { HTTPException } from "hono/http-exception";
@@ -71,11 +74,16 @@ import {
71
74
  TerminalServerUnsupportedError,
72
75
  verifySandboxExecReadiness,
73
76
  StreamPortUnavailableError,
77
+ renewSandboxProviderExpiration,
74
78
  type ControlRpc,
75
79
  type EstablishedSandboxSession,
76
80
  type NatsRequestConnection,
77
81
  } from "@opengeni/runtime/sandbox";
78
- import { relayConfigFromSettings } from "@opengeni/core";
82
+ import {
83
+ providerSettingsForSessionSandboxRuntime,
84
+ relayConfigFromSettings,
85
+ resolveSessionSandboxRuntime,
86
+ } from "@opengeni/core";
79
87
  import { establishApiSandboxSpawner } from "./rematerialize";
80
88
  import { establishCachedChannelAHandle } from "./channel-a";
81
89
 
@@ -137,12 +145,19 @@ export async function sessionAttachEnvironment(
137
145
  services: ViewerServices,
138
146
  workspaceId: string,
139
147
  session: Session,
148
+ /** The authenticated route subject driving this attach (0282); recorded on
149
+ * the materialization audit fact. Null records the legacy service sentinel. */
150
+ attachSubjectId: string | null,
140
151
  ): Promise<Record<string, string>> {
141
152
  const workspaceEnvironment = await loadWorkspaceEnvironmentForRun(
142
153
  services.db,
143
154
  services.settings,
144
- workspaceId,
145
- session.environmentId,
155
+ {
156
+ accountId: session.accountId,
157
+ workspaceId,
158
+ variableSetId: session.environmentId,
159
+ authority: { kind: "session_attach", sessionId: session.id, subjectId: attachSubjectId },
160
+ },
146
161
  );
147
162
  // Build the env with the SESSION's backend, not the deployment default: the
148
163
  // stable base is backend-aware (HOME = the descriptor workspaceRoot, and the
@@ -187,6 +202,9 @@ export async function attachViewer(
187
202
  workspaceId: string;
188
203
  session: Session;
189
204
  viewerId?: string;
205
+ /** The authenticated subject attaching this viewer (0281): recorded on
206
+ * the holder row together with the live session authority epoch. */
207
+ viewerSubjectId?: string;
190
208
  /** Cancel lifecycle waiting when the originating HTTP request disconnects. */
191
209
  waitSignal?: AbortSignal;
192
210
  },
@@ -194,8 +212,13 @@ export async function attachViewer(
194
212
  const { db, settings } = services;
195
213
  const { accountId, workspaceId, session } = input;
196
214
  const viewerId = input.viewerId ?? crypto.randomUUID();
215
+ const attachSubjectId = claimableSubjectId(input.viewerSubjectId ?? null);
216
+ const attachAuthorityEpoch = attachSubjectId
217
+ ? await getSessionAuthorityEpoch(db, { accountId, workspaceId, sessionId: session.id })
218
+ : null;
197
219
  const leaseTtlMs = settings.sandboxLeaseTtlMs;
198
220
  const sandboxGroupId = session.sandboxGroupId;
221
+ const sandboxRuntime = await resolveSessionSandboxRuntime(db, settings, session);
199
222
 
200
223
  const release = async (): Promise<void> => {
201
224
  await releaseLeaseHolder(db, {
@@ -217,8 +240,12 @@ export async function attachViewer(
217
240
  kind: "viewer",
218
241
  holderId: viewerId,
219
242
  subjectId: session.id,
243
+ ...(attachSubjectId ? { viewerSubjectId: attachSubjectId } : {}),
244
+ ...(attachAuthorityEpoch !== null ? { viewerAuthorityEpoch: attachAuthorityEpoch } : {}),
220
245
  backend: session.sandboxBackend,
221
246
  os: session.sandboxOs,
247
+ image: sandboxRuntime.image,
248
+ rigVersionId: session.rigVersionId,
222
249
  leaseTtlMs,
223
250
  warmingLeaseTtlMs: settings.sandboxWarmingTimeoutMs,
224
251
  captureWaitMs: sandboxLifecycleTransitionWaitMs(settings),
@@ -270,7 +297,16 @@ export async function attachViewer(
270
297
  // so the next turn's agent-manifest apply finds an EMPTY environment delta in
271
298
  // the SDK's validateNoEnvironmentDelta (otherwise: "Live sandbox sessions
272
299
  // cannot change manifest environment variables").
273
- const environment = await sessionAttachEnvironment(services, workspaceId, session);
300
+ const environment = await sessionAttachEnvironment(
301
+ services,
302
+ workspaceId,
303
+ session,
304
+ input.viewerSubjectId ?? null,
305
+ );
306
+ const providerSettings = await providerSettingsForSessionSandboxRuntime(
307
+ sandboxRuntime,
308
+ session.sandboxBackend,
309
+ );
274
310
  // Prefer the COLD lease's preserved resume_state when it carries a persisted
275
311
  // /workspace snapshot (confirmDrainCold keeps a minimal archive-only envelope
276
312
  // across draining->cold). establishSandboxSessionFromEnvelope cold-creates a
@@ -279,7 +315,7 @@ export async function attachViewer(
279
315
  // session envelope (a never-warmed cold start).
280
316
  const result = await establishApiSandboxSpawner({
281
317
  db,
282
- settings,
318
+ settings: providerSettings,
283
319
  accountId,
284
320
  workspaceId,
285
321
  sandboxGroupId,
@@ -353,11 +389,16 @@ export async function attachViewer(
353
389
  message: `sandbox is ${live.recovery.restore.status} at epoch ${live.leaseEpoch}`,
354
390
  });
355
391
  }
356
- const environment = await sessionAttachEnvironment(services, workspaceId, session);
392
+ const environment = await sessionAttachEnvironment(
393
+ services,
394
+ workspaceId,
395
+ session,
396
+ input.viewerSubjectId ?? null,
397
+ );
357
398
  let observed: EstablishedSandboxSession | undefined;
358
399
  try {
359
400
  const establish = services.establishSandboxSession ?? establishSandboxSessionFromEnvelope;
360
- observed = await establish(settings, live.resumeState, {
401
+ observed = await establish(sandboxRuntime.settings, live.resumeState, {
361
402
  sessionId: session.id,
362
403
  recovery: "resume-only",
363
404
  backendOverride: session.sandboxBackend,
@@ -430,13 +471,26 @@ export type SessionGroupReadinessHold = {
430
471
  * it. */
431
472
  export async function ensureSessionGroupReady(
432
473
  services: ViewerServices,
433
- input: { accountId: string; workspaceId: string; session: Session },
474
+ input: {
475
+ accountId: string;
476
+ workspaceId: string;
477
+ session: Session;
478
+ /** The authenticated subject driving the fleet attach/swap (0282): recorded
479
+ * on the viewer holder and the materialization audit fact. Omitting it
480
+ * records the explicit service sentinel, so forward the route subject. */
481
+ subjectId?: string | null;
482
+ },
434
483
  ): Promise<SessionGroupReadinessHold> {
435
484
  let lastError: unknown;
436
485
  for (let attempt = 0; attempt < 2; attempt += 1) {
437
486
  let release: (() => Promise<void>) | undefined;
438
487
  try {
439
- const attached = await attachViewer(services, input);
488
+ const attached = await attachViewer(services, {
489
+ accountId: input.accountId,
490
+ workspaceId: input.workspaceId,
491
+ session: input.session,
492
+ ...(input.subjectId ? { viewerSubjectId: input.subjectId } : {}),
493
+ });
440
494
  let releasePromise: Promise<void> | undefined;
441
495
  release = () =>
442
496
  (releasePromise ??= detachViewer(services, {
@@ -480,7 +534,7 @@ export async function heartbeatViewer(
480
534
  expectedEpoch: number;
481
535
  },
482
536
  ): Promise<boolean> {
483
- return await heartbeatLeaseHolder(services.db, {
537
+ const alive = await heartbeatLeaseHolder(services.db, {
484
538
  accountId: input.accountId,
485
539
  workspaceId: input.workspaceId,
486
540
  sandboxGroupId: input.sandboxGroupId,
@@ -489,6 +543,16 @@ export async function heartbeatViewer(
489
543
  leaseTtlMs: services.settings.sandboxLeaseTtlMs,
490
544
  expectedEpoch: input.expectedEpoch,
491
545
  });
546
+ if (!alive) return false;
547
+ const lease = await readLease(services.db, input.workspaceId, input.sandboxGroupId);
548
+ if (lease?.liveness === "warm" && lease.leaseEpoch === input.expectedEpoch && lease.instanceId) {
549
+ await renewSandboxProviderExpiration({
550
+ backend: lease.backend as Settings["sandboxBackend"],
551
+ settings: services.settings,
552
+ instanceId: lease.instanceId,
553
+ }).catch(() => false);
554
+ }
555
+ return true;
492
556
  }
493
557
 
494
558
  /**
@@ -498,7 +562,12 @@ export async function heartbeatViewer(
498
562
  */
499
563
  export async function detachViewer(
500
564
  services: ViewerServices,
501
- input: { accountId: string; workspaceId: string; sandboxGroupId: string; viewerId: string },
565
+ input: {
566
+ accountId: string;
567
+ workspaceId: string;
568
+ sandboxGroupId: string;
569
+ viewerId: string;
570
+ },
502
571
  ): Promise<{ liveness: LeaseSnapshot["liveness"]; refcount: number } | null> {
503
572
  return await releaseLeaseHolder(services.db, {
504
573
  accountId: input.accountId,
@@ -625,7 +694,11 @@ export function resolveActiveDesktopTransport(
625
694
  if (selfhostedActive) {
626
695
  return { transport: "relay-frames", client: "frames", mode: "read-only" };
627
696
  }
628
- return { transport: "vnc-ws", client: "novnc", mode: interactive ? "interactive" : "read-only" };
697
+ return {
698
+ transport: "vnc-ws",
699
+ client: "novnc",
700
+ mode: interactive ? "interactive" : "read-only",
701
+ };
629
702
  }
630
703
 
631
704
  /** The minted pixel cell the handshake/attach folds into the DesktopStream
@@ -639,9 +712,73 @@ export type DesktopStreamMint = {
639
712
  leaseEpoch: number;
640
713
  };
641
714
 
715
+ /** A subject claim must satisfy the StreamTokenPayload bounds and the 0281
716
+ * holder CHECK (non-blank, <= 512). An out-of-bounds subject (possible only
717
+ * from a host-signed delegated token) is omitted from the claims rather than
718
+ * turning the mint into a 500. */
719
+ function claimableSubjectId(subjectId: string | null): string | null {
720
+ if (!subjectId) return null;
721
+ const trimmed = subjectId.trim();
722
+ return trimmed.length >= 1 && subjectId.length <= 512 ? subjectId : null;
723
+ }
724
+
725
+ /**
726
+ * Resolve the viewer-facing authority claims stamped into a scoped stream
727
+ * token and recorded on the viewer holder (0281): the authenticated viewer
728
+ * subject and the session's LIVE authority epoch. Re-verifies a human
729
+ * (`user:`) subject's current workspace authority at mint time through the
730
+ * same model the route-time access builder uses - a membership row with an
731
+ * active owning organization membership, or an active organization
732
+ * membership's personal-workspace pointer - so a viewer whose authority was
733
+ * revoked since the route authorized them degrades to null (transport:null)
734
+ * instead of receiving a fresh token. Delegated token-borne grants
735
+ * (`subjectDelegated`) are authorized by their signed token, not by rows this
736
+ * check can see; they keep their route authorization, as do API-key and
737
+ * service principals. Identity and epoch only - never a secret value.
738
+ */
739
+ async function resolveViewerAuthorityClaims(
740
+ db: ViewerServices["db"],
741
+ input: {
742
+ accountId: string;
743
+ workspaceId: string;
744
+ sessionId: string;
745
+ subjectId: string | null;
746
+ subjectDelegated?: boolean;
747
+ },
748
+ ): Promise<{ subjectId?: string; authorityEpoch: number } | null> {
749
+ const authorityEpoch = await getSessionAuthorityEpoch(db, {
750
+ accountId: input.accountId,
751
+ workspaceId: input.workspaceId,
752
+ sessionId: input.sessionId,
753
+ });
754
+ if (authorityEpoch === null) {
755
+ return null;
756
+ }
757
+ if (input.subjectId?.startsWith("user:") && input.subjectDelegated !== true) {
758
+ const live = await namedSubjectHasLiveWorkspaceAuthority(db, {
759
+ accountId: input.accountId,
760
+ workspaceId: input.workspaceId,
761
+ subjectId: input.subjectId,
762
+ });
763
+ if (!live) {
764
+ return null;
765
+ }
766
+ }
767
+ const subjectId = claimableSubjectId(input.subjectId);
768
+ return {
769
+ ...(subjectId ? { subjectId } : {}),
770
+ authorityEpoch,
771
+ };
772
+ }
773
+
642
774
  export type MintDesktopStreamInput = {
643
775
  accountId: string;
644
776
  workspaceId: string;
777
+ resourceSubjectId?: string;
778
+ /** True when the route grant is a signed delegated token (metadata.delegated):
779
+ * its authority is the token, not membership rows, so the mint-time
780
+ * live-authority recheck does not apply. */
781
+ resourceSubjectDelegated?: boolean;
645
782
  session: Session;
646
783
  /** The viewer holder id the scoped token is minted for. */
647
784
  viewerId: string;
@@ -704,20 +841,44 @@ export async function mintDesktopStream(
704
841
  return null;
705
842
  }
706
843
 
844
+ // 0281: the viewer's authority claims. A missing epoch (session not
845
+ // visible) or a removed human membership degrades the mint instead of
846
+ // issuing a fresh token.
847
+ const viewerAuthority = await resolveViewerAuthorityClaims(db, {
848
+ accountId,
849
+ workspaceId,
850
+ sessionId: session.id,
851
+ subjectId: input.resourceSubjectId ?? null,
852
+ ...(input.resourceSubjectDelegated !== undefined
853
+ ? { subjectDelegated: input.resourceSubjectDelegated }
854
+ : {}),
855
+ });
856
+ if (!viewerAuthority) {
857
+ return null;
858
+ }
859
+
707
860
  // SELFHOSTED ACTIVE: when the session's active sandbox is a selfhosted machine,
708
861
  // route to the relay (NOT the Modal group-box path — it would resume the wrong
709
862
  // box and return a Modal URL). No Modal lease required.
710
863
  if (session.activeSandboxId) {
711
- const active = await getSandbox(db, workspaceId, session.activeSandboxId);
864
+ const active = await getSandbox(
865
+ db,
866
+ input.resourceSubjectId
867
+ ? { accountId, workspaceId, subjectId: input.resourceSubjectId }
868
+ : workspaceId,
869
+ session.activeSandboxId,
870
+ );
712
871
  if (active?.kind === "selfhosted") {
713
872
  const m = await tryMintActiveSelfhostedStream(
714
873
  services,
715
874
  {
716
875
  session,
717
876
  viewerId: input.viewerId,
718
- workspaceId,
877
+ workspaceId: active.workspaceId,
878
+ ...(input.resourceSubjectId ? { resourceSubjectId: input.resourceSubjectId } : {}),
719
879
  port: DESKTOP_STREAM_PORT,
720
880
  sandbox: active,
881
+ viewerAuthority,
721
882
  },
722
883
  input.resolveSelfhostedSession,
723
884
  );
@@ -749,7 +910,12 @@ export async function mintDesktopStream(
749
910
  try {
750
911
  // On a cold-restore (the lease's box is gone) this create() must carry the
751
912
  // SAME stable run-env the turn declares, so a later turn finds no env delta.
752
- const environment = await sessionAttachEnvironment(services, workspaceId, session);
913
+ const environment = await sessionAttachEnvironment(
914
+ services,
915
+ workspaceId,
916
+ session,
917
+ input.resourceSubjectId ?? null,
918
+ );
753
919
  try {
754
920
  const establish = () =>
755
921
  (services.establishSandboxSession ?? establishSandboxSessionFromEnvelope)(
@@ -799,6 +965,7 @@ export async function mintDesktopStream(
799
965
  leaseEpoch: lease.leaseEpoch,
800
966
  streamTokenSecret: secret,
801
967
  resolution: defaultResolution(settings),
968
+ ...viewerAuthority,
802
969
  });
803
970
  } catch (error) {
804
971
  // A transient/headless provider failure degrades the desktop cell.
@@ -899,6 +1066,9 @@ type SelfhostedStreamMint = Omit<TerminalStreamMint, "transport">;
899
1066
  export type MintTerminalStreamInput = {
900
1067
  accountId: string;
901
1068
  workspaceId: string;
1069
+ resourceSubjectId?: string;
1070
+ /** See MintDesktopStreamInput.resourceSubjectDelegated. */
1071
+ resourceSubjectDelegated?: boolean;
902
1072
  session: Session;
903
1073
  /** The viewer holder / principal id the scoped token is minted for. */
904
1074
  viewerId: string;
@@ -947,20 +1117,44 @@ export async function mintTerminalStream(
947
1117
  return null;
948
1118
  }
949
1119
 
1120
+ // 0281: the viewer's authority claims. A missing epoch (session not
1121
+ // visible) or a removed human membership degrades the mint instead of
1122
+ // issuing a fresh token.
1123
+ const viewerAuthority = await resolveViewerAuthorityClaims(db, {
1124
+ accountId,
1125
+ workspaceId,
1126
+ sessionId: session.id,
1127
+ subjectId: input.resourceSubjectId ?? null,
1128
+ ...(input.resourceSubjectDelegated !== undefined
1129
+ ? { subjectDelegated: input.resourceSubjectDelegated }
1130
+ : {}),
1131
+ });
1132
+ if (!viewerAuthority) {
1133
+ return null;
1134
+ }
1135
+
950
1136
  // SELFHOSTED ACTIVE: when the session's active sandbox is a selfhosted machine,
951
1137
  // route to the relay. NEVER fall through to the Modal group-box path (it would
952
1138
  // resume the wrong box / return a Modal URL).
953
1139
  if (session.activeSandboxId) {
954
- const active = await getSandbox(db, workspaceId, session.activeSandboxId);
1140
+ const active = await getSandbox(
1141
+ db,
1142
+ input.resourceSubjectId
1143
+ ? { accountId, workspaceId, subjectId: input.resourceSubjectId }
1144
+ : workspaceId,
1145
+ session.activeSandboxId,
1146
+ );
955
1147
  if (active?.kind === "selfhosted") {
956
1148
  const stream = await tryMintActiveSelfhostedStream(
957
1149
  services,
958
1150
  {
959
1151
  session,
960
1152
  viewerId: input.viewerId,
961
- workspaceId,
1153
+ workspaceId: active.workspaceId,
1154
+ ...(input.resourceSubjectId ? { resourceSubjectId: input.resourceSubjectId } : {}),
962
1155
  port: TERMINAL_STREAM_PORT,
963
1156
  sandbox: active,
1157
+ viewerAuthority,
964
1158
  },
965
1159
  input.resolveSelfhostedSession,
966
1160
  );
@@ -982,7 +1176,12 @@ export async function mintTerminalStream(
982
1176
  try {
983
1177
  // On a cold-restore this create() must carry the SAME stable run-env the turn
984
1178
  // declares, so a later turn finds no manifest-env delta.
985
- const environment = await sessionAttachEnvironment(services, workspaceId, session);
1179
+ const environment = await sessionAttachEnvironment(
1180
+ services,
1181
+ workspaceId,
1182
+ session,
1183
+ input.resourceSubjectId ?? null,
1184
+ );
986
1185
  try {
987
1186
  const establish = () =>
988
1187
  (services.establishSandboxSession ?? establishSandboxSessionFromEnvelope)(
@@ -1008,7 +1207,9 @@ export async function mintTerminalStream(
1008
1207
  // Idempotent ttyd launch (flock-guarded; a no-op when already up). A box that
1009
1208
  // genuinely can't run it degrades to the sse-events firehose, not a throw.
1010
1209
  try {
1011
- await ensureTerminalServer(established.session, { port: TERMINAL_STREAM_PORT });
1210
+ await ensureTerminalServer(established.session, {
1211
+ port: TERMINAL_STREAM_PORT,
1212
+ });
1012
1213
  } catch (error) {
1013
1214
  if (error instanceof TerminalServerUnsupportedError) {
1014
1215
  return null;
@@ -1029,6 +1230,7 @@ export async function mintTerminalStream(
1029
1230
  leaseEpoch: lease.leaseEpoch,
1030
1231
  streamTokenSecret: secret,
1031
1232
  port: TERMINAL_STREAM_PORT,
1233
+ ...viewerAuthority,
1032
1234
  });
1033
1235
  } catch (error) {
1034
1236
  if (error instanceof StreamPortUnavailableError) {
@@ -1104,8 +1306,11 @@ async function tryMintActiveSelfhostedStream(
1104
1306
  session: Session;
1105
1307
  viewerId: string;
1106
1308
  workspaceId: string;
1309
+ resourceSubjectId?: string;
1107
1310
  port: number;
1108
1311
  sandbox: SandboxRecord;
1312
+ /** 0281 viewer authority claims stamped into the relay stream token. */
1313
+ viewerAuthority?: { subjectId?: string; authorityEpoch: number };
1109
1314
  },
1110
1315
  // optional test seam (mirrors the existing `establish?` seam pattern): inject a
1111
1316
  // fake relay-resolving session; production NEVER passes it.
@@ -1127,11 +1332,26 @@ async function tryMintActiveSelfhostedStream(
1127
1332
  if (resolveSelfhostedSession) {
1128
1333
  shSession = await resolveSelfhostedSession(sandbox);
1129
1334
  } else {
1335
+ const enrollment = await getLiveEnrollmentConnection(
1336
+ services.db,
1337
+ input.resourceSubjectId
1338
+ ? {
1339
+ accountId: input.session.accountId,
1340
+ workspaceId: input.session.workspaceId,
1341
+ subjectId: input.resourceSubjectId,
1342
+ }
1343
+ : workspaceId,
1344
+ sandbox.enrollmentId,
1345
+ );
1346
+ if (!enrollment?.connectionInstanceId) return null;
1347
+ if (!enrollment.workspaceRoot) return null;
1130
1348
  const client = new SelfhostedSandboxClient({
1131
1349
  workspaceId,
1350
+ workspaceRoot: enrollment.workspaceRoot,
1132
1351
  relay: relayConfigFromSettings(settings),
1133
1352
  controlRpcFactory: () => controlRpc(bus),
1134
1353
  agentId: sandbox.enrollmentId,
1354
+ connectionInstanceId: enrollment.connectionInstanceId,
1135
1355
  epoch: session.activeEpoch,
1136
1356
  // Stream authority rotates; terminal process identity does not.
1137
1357
  terminalScopeId: session.id,
@@ -1151,6 +1371,7 @@ async function tryMintActiveSelfhostedStream(
1151
1371
  sessionId: session.id,
1152
1372
  viewerId: input.viewerId,
1153
1373
  activeEpoch: session.activeEpoch,
1374
+ ...(input.viewerAuthority ? { viewerAuthority: input.viewerAuthority } : {}),
1154
1375
  port,
1155
1376
  session: shSession,
1156
1377
  });
@@ -1170,6 +1391,8 @@ export type MintSelfhostedStreamInput = {
1170
1391
  * THIS as its leaseEpoch claim so the relay rejects a stale-epoch (swapped-away)
1171
1392
  * viewer. */
1172
1393
  activeEpoch: number;
1394
+ /** 0281 viewer authority claims stamped into the relay stream token. */
1395
+ viewerAuthority?: { subjectId?: string; authorityEpoch: number };
1173
1396
  /** The exposed stream port (6080 desktop / 7681 terminal). */
1174
1397
  port: number;
1175
1398
  /** The resolvable selfhosted session (the routing proxy resolves the active
@@ -1213,6 +1436,7 @@ export async function mintSelfhostedStream(
1213
1436
  leaseEpoch: input.activeEpoch,
1214
1437
  streamTokenSecret: secret,
1215
1438
  port: input.port,
1439
+ ...(input.viewerAuthority ?? {}),
1216
1440
  });
1217
1441
  return {
1218
1442
  url: exposed.url,