@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
@@ -30,25 +30,30 @@ import type { Session } from "@opengeni/contracts";
30
30
  import {
31
31
  acquireLease,
32
32
  getSandboxSessionEnvelope,
33
- getEnrollment,
33
+ getLiveEnrollmentConnection,
34
34
  getSandbox,
35
+ touchLeaseHolder,
35
36
  loadWorkspaceEnvironmentForRun,
36
37
  markWarmLeaseInstanceLost,
37
38
  readActiveSandbox,
38
39
  readLease,
39
40
  releaseLeaseHolder,
41
+ SandboxImageConflictError,
40
42
  SandboxProviderReadLockUnavailableError,
43
+ SandboxRigConflictError,
41
44
  withSandboxProviderReadLock,
42
45
  type Database,
43
46
  type LeaseSnapshot,
44
47
  } from "@opengeni/db";
45
48
  import { appendAndPublishEvents, type EventBus } from "@opengeni/events";
46
49
  import {
50
+ recordTenancyCompatibilityLaneUse,
47
51
  sandboxLeaseTelemetryKey,
48
52
  sandboxOperationMetricObserver,
49
53
  type Observability,
50
54
  } from "@opengeni/observability";
51
55
  import { HTTPException } from "hono/http-exception";
56
+ import { ApiHttpError } from "../http/api-error";
52
57
 
53
58
  import {
54
59
  buildSelfhostedBackendSession,
@@ -59,6 +64,8 @@ import {
59
64
  NatsOpStreamTransport,
60
65
  SandboxResumeIdentityMismatchError,
61
66
  SandboxResumeIdentityUnavailableError,
67
+ RoutingWorkspaceRootChangedError,
68
+ SelfhostedWorkspaceRootChangedError,
62
69
  ChannelAConflictError,
63
70
  ChannelANotFoundError,
64
71
  ChannelAUnsupportedError,
@@ -66,14 +73,23 @@ import {
66
73
  ChannelAValidationError,
67
74
  BrowserControlRequestError,
68
75
  BrowserControlTransportError,
76
+ SelfhostedControlError,
77
+ agentErrorToControlError,
69
78
  codemodeTokenFileFromEnvironment,
79
+ offlineAgentError,
80
+ resolveConnectedMachineWorkspaceRoot,
70
81
  withCodemodeTokenSession,
71
82
  withRunCredentialsSession,
72
83
  type ChannelASession,
73
84
  type EstablishedSandboxSession,
74
85
  type RoutingSandboxSession,
75
86
  } from "@opengeni/runtime/sandbox";
76
- import { relayConfigFromSettings, wrapChannelABoxWithRouting } from "@opengeni/core";
87
+ import {
88
+ providerSettingsForSessionSandboxRuntime,
89
+ relayConfigFromSettings,
90
+ resolveSessionSandboxRuntime,
91
+ wrapChannelABoxWithRouting,
92
+ } from "@opengeni/core";
77
93
  import { establishApiSandboxSpawner } from "./rematerialize";
78
94
 
79
95
  export type ChannelAServices = {
@@ -87,6 +103,7 @@ export type ChannelAOperation =
87
103
  | "fs.list"
88
104
  | "fs.list-batch"
89
105
  | "fs.read"
106
+ | "artifact.publish"
90
107
  | "fs.write"
91
108
  | "fs.delete"
92
109
  | "fs.move"
@@ -522,12 +539,12 @@ async function withChannelAOperation<T>(
522
539
 
523
540
  // The STABLE run-environment used by both a cloud home and a machine home.
524
541
  // It also carries the per-session Codemode pointer selected below.
525
- const workspaceEnvironment = await loadWorkspaceEnvironmentForRun(
526
- db,
527
- settings,
542
+ const workspaceEnvironment = await loadWorkspaceEnvironmentForRun(db, settings, {
543
+ accountId,
528
544
  workspaceId,
529
- session.environmentId,
530
- );
545
+ variableSetId: session.environmentId,
546
+ authority: { kind: "session_attach", sessionId: session.id, subjectId: ctx.subjectId },
547
+ });
531
548
  const settingsForSession =
532
549
  session.sandboxBackend !== settings.sandboxBackend
533
550
  ? { ...settings, sandboxBackend: session.sandboxBackend }
@@ -566,12 +583,20 @@ async function withChannelAOperation<T>(
566
583
  codemodeTokenFileFromEnvironment(environment, session.id),
567
584
  )
568
585
  : credentialSession;
586
+ const fileSystemRoot = await routingSession.fileSystemRoot();
569
587
  const service = new SandboxChannelAService({
570
588
  session: scopedSession as ChannelASession,
589
+ workspaceRoot: fileSystemRoot,
571
590
  leaseEpoch: lease?.leaseEpoch ?? session.activeEpoch,
572
591
  emit,
573
592
  });
574
- const result = await fn({ service, lease, homeSession, routingSession, requestId });
593
+ const result = await fn({
594
+ service,
595
+ lease,
596
+ homeSession,
597
+ routingSession,
598
+ requestId,
599
+ });
575
600
  // The direct request has accepted the result in memory. Finalize every
576
601
  // Connected Machine backend the routing proxy reached so a mid-request
577
602
  // route transition cannot leave completed output retained until TTL.
@@ -588,27 +613,76 @@ async function withChannelAOperation<T>(
588
613
  ctx.waitSignal?.throwIfAborted();
589
614
  const pointer = await readActiveSandbox(db, workspaceId, session.id);
590
615
  if (!pointer?.activeSandboxId) {
616
+ // A machine-home session with no selected machine uses the deployment's
617
+ // managed group box, exactly like the worker turn path. Keep the durable
618
+ // home label honest; only this request's effective backend changes.
619
+ if (settings.sandboxBackend !== "none" && settings.sandboxBackend !== "selfhosted") {
620
+ return await withChannelAOperation(
621
+ services,
622
+ {
623
+ ...ctx,
624
+ session: {
625
+ ...session,
626
+ sandboxBackend: settings.sandboxBackend,
627
+ sandboxOs: "linux",
628
+ },
629
+ },
630
+ readOnly,
631
+ fn,
632
+ );
633
+ }
591
634
  throw new HTTPException(409, {
592
635
  message: "machine-home session has no active Connected Machine",
593
636
  });
594
637
  }
595
- const sandbox = await getSandbox(db, workspaceId, pointer.activeSandboxId);
638
+ const sandbox = await getSandbox(
639
+ db,
640
+ { accountId: ctx.accountId, workspaceId, subjectId: ctx.subjectId },
641
+ pointer.activeSandboxId,
642
+ );
596
643
  if (sandbox?.kind !== "selfhosted" || !sandbox.enrollmentId) {
597
644
  throw new HTTPException(409, {
598
645
  message: "machine-home session points to an unavailable Connected Machine",
599
646
  });
600
647
  }
601
- const enrollment = await getEnrollment(db, workspaceId, sandbox.enrollmentId);
648
+ const originWorkspaceId = sandbox.workspaceId;
649
+ const enrollment = await getLiveEnrollmentConnection(
650
+ db,
651
+ { accountId, workspaceId, subjectId: ctx.subjectId },
652
+ sandbox.enrollmentId,
653
+ );
654
+ if (!enrollment?.connectionInstanceId) {
655
+ // Preserve causal machine liveness through Channel-A. Generic callers
656
+ // retain the established 409 mapping below; Browser/Computer callers can
657
+ // project the same bounded `agent_offline` contract as control RPC.
658
+ throw agentErrorToControlError(
659
+ offlineAgentError("Connected Machine has no live runner connection", true),
660
+ );
661
+ }
662
+ if (!enrollment.workspaceRoot) {
663
+ throw new HTTPException(409, {
664
+ message:
665
+ "Connected Machine has not reported an absolute workspace root; reconnect it with a current agent",
666
+ });
667
+ }
602
668
  const built = await buildSelfhostedBackendSession({
603
- workspaceId,
669
+ workspaceId: originWorkspaceId,
604
670
  agentId: sandbox.enrollmentId,
671
+ connectionInstanceId: enrollment.connectionInstanceId,
672
+ workspaceRoot: resolveConnectedMachineWorkspaceRoot(
673
+ enrollment.workspaceRoot,
674
+ pointer.workingDir,
675
+ ),
605
676
  relay: relayConfigFromSettings(settings),
606
677
  controlRpcFactory: () => new NatsControlRpc(async () => bus.getRequestConnection()),
607
678
  epoch: pointer.activeEpoch,
608
679
  environment,
609
- workingDir: pointer.workingDir,
610
680
  timeoutMs: settings.sandboxSelfhostedControlTimeoutMs,
611
681
  execTimeoutMs: settings.sandboxSelfhostedExecTimeoutMs,
682
+ operationResourcePolicy: enrollment.operationPolicy,
683
+ operationResourcePolicySupported:
684
+ enrollment.agentCapabilities.operationResourcePolicy === true,
685
+ operationCpuQuotaSupported: enrollment.agentCapabilities.operationCpuQuota === true,
612
686
  ...(settings.agentOpStreamEnabled === true &&
613
687
  enrollment?.opStream === true &&
614
688
  bus.getOpStreamConnection
@@ -640,6 +714,7 @@ async function withChannelAOperation<T>(
640
714
  accountId,
641
715
  workspaceId,
642
716
  sessionId: session.id,
717
+ resourceSubjectId: ctx.subjectId,
643
718
  pinnedSelfhosted: {
644
719
  sandboxId: sandbox.id,
645
720
  epoch: pointer.activeEpoch,
@@ -665,6 +740,12 @@ async function withChannelAOperation<T>(
665
740
  }
666
741
  }
667
742
 
743
+ // One session has one logical runtime across turns and every API-direct
744
+ // surface. Without this, Terminal/Files/Browser/Computer/viewers could rearm
745
+ // a stale deployment image after the worker had resolved a newer Pack/Rig or
746
+ // deployment image for the same durable sandbox group.
747
+ const sandboxRuntime = await resolveSessionSandboxRuntime(db, settings, session);
748
+
668
749
  const release = async (): Promise<void> => {
669
750
  await releaseLeaseHolder(db, {
670
751
  accountId,
@@ -693,6 +774,8 @@ async function withChannelAOperation<T>(
693
774
  subjectId: session.id,
694
775
  backend: session.sandboxBackend,
695
776
  os: session.sandboxOs,
777
+ image: sandboxRuntime.image,
778
+ rigVersionId: session.rigVersionId,
696
779
  leaseTtlMs,
697
780
  warmingLeaseTtlMs: settings.sandboxWarmingTimeoutMs,
698
781
  captureWaitMs: sandboxLifecycleTransitionWaitMs(settings),
@@ -716,6 +799,56 @@ async function withChannelAOperation<T>(
716
799
  });
717
800
  }
718
801
  } catch (error) {
802
+ let mappedError: unknown = error;
803
+ if (error instanceof SandboxImageConflictError) {
804
+ // A durable Browser/Computer holder can outlive its provider container.
805
+ // Runtime drift is checked before a new direct holder is admitted, so a
806
+ // dead old container would otherwise strand the lease behind a permanent
807
+ // conflict. Probe the exact old identity without replacement; only an
808
+ // authoritative provider-missing result may retire it and unblock the
809
+ // next request's cold successor election.
810
+ const live = await readLease(db, workspaceId, sandboxGroupId).catch(() => null);
811
+ if (live?.liveness === "warm" && live.instanceId !== null && live.resumeState !== null) {
812
+ let probe: EstablishedSandboxSession | undefined;
813
+ try {
814
+ probe = await establishSandboxSessionFromEnvelope(
815
+ sandboxRuntime.settings,
816
+ live.resumeState,
817
+ {
818
+ sessionId: session.id,
819
+ recovery: "resume-only",
820
+ backendOverride: session.sandboxBackend,
821
+ environment,
822
+ },
823
+ );
824
+ } catch (probeError) {
825
+ if (isProviderSandboxNotFoundError(session.sandboxBackend, probeError)) {
826
+ const marked = await markWarmLeaseInstanceLost(db, {
827
+ accountId,
828
+ workspaceId,
829
+ sandboxGroupId,
830
+ expectedEpoch: live.leaseEpoch,
831
+ expectedInstanceId: live.instanceId,
832
+ }).catch(() => null);
833
+ if (marked?.status === "marked") {
834
+ await appendAndPublishEvents(db, bus, workspaceId, session.id, [
835
+ {
836
+ type: "sandbox.box.lost",
837
+ payload: { sandboxId: live.instanceId },
838
+ },
839
+ ]).catch(() => undefined);
840
+ mappedError = new ApiHttpError(409, {
841
+ code: "conflict",
842
+ message: "sandbox instance was lost; retry to restore it",
843
+ retryable: true,
844
+ });
845
+ }
846
+ }
847
+ } finally {
848
+ await dropEstablishedHandle(probe);
849
+ }
850
+ }
851
+ }
719
852
  // Release is idempotent. If acquisition committed before the request was
720
853
  // cancelled, this removes that exact direct holder; a transient DB failure
721
854
  // must not overwrite the original structural error (holder TTL is the final
@@ -727,12 +860,28 @@ async function withChannelAOperation<T>(
727
860
  backend: session.sandboxBackend,
728
861
  operation,
729
862
  durationMs: performance.now() - operationStartedAt,
730
- error,
863
+ error: mappedError,
731
864
  waitSignal: ctx.waitSignal,
732
865
  });
733
- throw mapChannelAError(error, ctx.waitSignal);
866
+ throw mapChannelAError(mappedError, ctx.waitSignal);
734
867
  }
735
868
 
869
+ // Keep the exact direct-request owner visible for the full operation. A
870
+ // yielded command is also tracked by a non-TTL process holder, but the
871
+ // retained-process reconciler must not poll that provider session while this
872
+ // request is still its active owner. Stale direct holders remain crash-
873
+ // recoverable through the ordinary holder TTL reaper.
874
+ const directHolderHeartbeat = setInterval(() => {
875
+ void touchLeaseHolder(db, {
876
+ accountId,
877
+ workspaceId,
878
+ sandboxGroupId,
879
+ kind: "direct",
880
+ holderId,
881
+ }).catch(() => undefined);
882
+ }, 10_000);
883
+ directHolderHeartbeat.unref?.();
884
+
736
885
  let established: EstablishedSandboxSession | undefined;
737
886
  let leaseSnapshot: LeaseSnapshot = acquired.lease;
738
887
  let establishedCacheKey: string | null = null;
@@ -747,7 +896,7 @@ async function withChannelAOperation<T>(
747
896
  ): Promise<{ established: EstablishedSandboxSession; cacheKey: string }> => {
748
897
  const cacheKey = establishedHandleCacheKey(workspaceId, session.id, live);
749
898
  const establish = () =>
750
- establishSandboxSessionFromEnvelope(settings, live.resumeState, {
899
+ establishSandboxSessionFromEnvelope(sandboxRuntime.settings, live.resumeState, {
751
900
  sessionId: session.id,
752
901
  recovery: "resume-only",
753
902
  backendOverride: session.sandboxBackend,
@@ -781,8 +930,10 @@ async function withChannelAOperation<T>(
781
930
  },
782
931
  ]);
783
932
  }
784
- throw new HTTPException(409, {
933
+ throw new ApiHttpError(409, {
934
+ code: "conflict",
785
935
  message: `sandbox instance was lost; retry to restore it`,
936
+ retryable: true,
786
937
  });
787
938
  }
788
939
  };
@@ -802,9 +953,13 @@ async function withChannelAOperation<T>(
802
953
  // resume_state is the lease's authoritative box descriptor; the session
803
954
  // `_sandbox` envelope is only the per-session fallback.
804
955
  try {
956
+ const providerSettings = await providerSettingsForSessionSandboxRuntime(
957
+ sandboxRuntime,
958
+ session.sandboxBackend,
959
+ );
805
960
  const result = await establishApiSandboxSpawner({
806
961
  db,
807
- settings,
962
+ settings: providerSettings,
808
963
  accountId,
809
964
  workspaceId,
810
965
  sandboxGroupId,
@@ -863,6 +1018,7 @@ async function withChannelAOperation<T>(
863
1018
  accountId,
864
1019
  workspaceId,
865
1020
  sessionId: session.id,
1021
+ resourceSubjectId: ctx.subjectId,
866
1022
  homeLease: {
867
1023
  sandboxGroupId,
868
1024
  leaseEpoch: leaseSnapshot.leaseEpoch,
@@ -990,6 +1146,7 @@ async function withChannelAOperation<T>(
990
1146
  });
991
1147
  throw mapChannelAError(error, ctx.waitSignal);
992
1148
  } finally {
1149
+ clearInterval(directHolderHeartbeat);
993
1150
  await release();
994
1151
  await dropEstablishedHandle(established);
995
1152
  }
@@ -1009,8 +1166,25 @@ export function mapChannelAError(error: unknown, waitSignal?: AbortSignal): unkn
1009
1166
  error instanceof SandboxResumeIdentityUnavailableError
1010
1167
  )
1011
1168
  return new HTTPException(409, { message: error.message });
1169
+ if (
1170
+ error instanceof RoutingWorkspaceRootChangedError ||
1171
+ error instanceof SelfhostedWorkspaceRootChangedError
1172
+ )
1173
+ return new ApiHttpError(409, {
1174
+ code: "conflict",
1175
+ message: error.message,
1176
+ retryable: true,
1177
+ });
1012
1178
  if (error instanceof SandboxProviderReadLockUnavailableError)
1013
1179
  return new HTTPException(503, { message: error.message });
1180
+ if (error instanceof SandboxImageConflictError || error instanceof SandboxRigConflictError)
1181
+ return new ApiHttpError(409, {
1182
+ code: "conflict",
1183
+ message: "sandbox runtime changed while this session still has active operations; retry",
1184
+ retryable: true,
1185
+ });
1186
+ if (error instanceof SelfhostedControlError && error.agentOffline)
1187
+ return new HTTPException(409, { message: error.message, cause: error });
1014
1188
  if (error instanceof ChannelAUnavailableError)
1015
1189
  return new HTTPException(503, { message: error.message });
1016
1190
  if (error instanceof ChannelAValidationError)
@@ -1021,9 +1195,31 @@ export function mapChannelAError(error: unknown, waitSignal?: AbortSignal): unkn
1021
1195
  return new HTTPException(409, { message: error.message });
1022
1196
  if (error instanceof ChannelAUnsupportedError)
1023
1197
  return new HTTPException(409, { message: error.message });
1198
+ const authorityFence = workspaceMutationAuthorityFenceCode(error);
1199
+ // A revoked grant is an authorization outcome, not a server fault; an
1200
+ // unattributed pre-0277 writer is a conflict the caller resolves by starting
1201
+ // fresh work. Both must be visible instead of surfacing as a 500.
1202
+ if (authorityFence === "authority_revoked")
1203
+ return new HTTPException(403, { message: (error as Error).message });
1204
+ if (authorityFence === "authority_unattributed")
1205
+ return new HTTPException(409, { message: (error as Error).message });
1024
1206
  return error;
1025
1207
  }
1026
1208
 
1209
+ /** Structural check: the fence type lives in `@opengeni/db` and is raised deep
1210
+ * inside admission, so match it the same way the worker does. */
1211
+ function workspaceMutationAuthorityFenceCode(error: unknown): string | null {
1212
+ if (
1213
+ !(error instanceof Error) ||
1214
+ (error.name !== "SandboxWorkspaceMutationFencedError" &&
1215
+ error.name !== "SandboxRetainedProcessPromotionFencedError")
1216
+ ) {
1217
+ return null;
1218
+ }
1219
+ const code = (error as Error & { code?: unknown }).code;
1220
+ return typeof code === "string" ? code : null;
1221
+ }
1222
+
1027
1223
  export function isChannelARequestCancellation(error: unknown, waitSignal?: AbortSignal): boolean {
1028
1224
  if (waitSignal?.aborted !== true) return false;
1029
1225
  const isSignalReason = waitSignal.reason !== undefined && error === waitSignal.reason;
@@ -1053,7 +1249,14 @@ export function channelAOperationFailureDiagnostic(
1053
1249
  errorCode: "sandbox_channel_a_provider_busy",
1054
1250
  };
1055
1251
  }
1056
- if (error instanceof ChannelAUnavailableError) {
1252
+ if (error instanceof SandboxImageConflictError || error instanceof SandboxRigConflictError) {
1253
+ return {
1254
+ reason: "lifecycle_conflict",
1255
+ status: 409,
1256
+ errorCode: "sandbox_channel_a_lifecycle_conflict",
1257
+ };
1258
+ }
1259
+ if (error instanceof ChannelAUnavailableError || error instanceof BrowserControlTransportError) {
1057
1260
  return {
1058
1261
  reason: "provider_unavailable",
1059
1262
  status: 503,
@@ -1063,6 +1266,8 @@ export function channelAOperationFailureDiagnostic(
1063
1266
  if (
1064
1267
  error instanceof SandboxResumeIdentityMismatchError ||
1065
1268
  error instanceof SandboxResumeIdentityUnavailableError ||
1269
+ error instanceof RoutingWorkspaceRootChangedError ||
1270
+ error instanceof SelfhostedWorkspaceRootChangedError ||
1066
1271
  (error instanceof HTTPException && error.status === 409)
1067
1272
  ) {
1068
1273
  return {
@@ -1091,6 +1296,16 @@ export function channelAOperationFailureDiagnostic(
1091
1296
  errorCode: "sandbox_channel_a_operation_failed",
1092
1297
  };
1093
1298
  }
1299
+ // An authority fence is a deliberate rejection, not a fault: keep it out of
1300
+ // the unexpected-failure signal that operators page on.
1301
+ const authorityFence = workspaceMutationAuthorityFenceCode(error);
1302
+ if (authorityFence === "authority_revoked" || authorityFence === "authority_unattributed") {
1303
+ return {
1304
+ reason: "request_rejected",
1305
+ status: authorityFence === "authority_revoked" ? 403 : 409,
1306
+ errorCode: "sandbox_channel_a_operation_failed",
1307
+ };
1308
+ }
1094
1309
  return {
1095
1310
  reason: "unexpected",
1096
1311
  status: 500,
@@ -1098,7 +1313,8 @@ export function channelAOperationFailureDiagnostic(
1098
1313
  };
1099
1314
  }
1100
1315
 
1101
- function observeChannelAOperationFailure(
1316
+ /** Exported for the telemetry contract tests; not a route surface. */
1317
+ export function observeChannelAOperationFailure(
1102
1318
  services: ChannelAServices,
1103
1319
  input: {
1104
1320
  workspaceId: string;
@@ -1111,6 +1327,13 @@ function observeChannelAOperationFailure(
1111
1327
  },
1112
1328
  ): void {
1113
1329
  if (!services.observability) return;
1330
+ // A writer with no recorded authority is a compatibility lane, not a fault:
1331
+ // count the lane itself so an operator can see whether it is still live,
1332
+ // separately from the structural failure signal operators page on below.
1333
+ // Lane name only - never the workspace, sandbox group, backend, or operation.
1334
+ if (workspaceMutationAuthorityFenceCode(input.error) === "authority_unattributed") {
1335
+ recordTenancyCompatibilityLaneUse(services.observability, "workspace_writer_unattributed");
1336
+ }
1114
1337
  const diagnostic = channelAOperationFailureDiagnostic(input.error, input.waitSignal);
1115
1338
  const attributes = {
1116
1339
  sandboxLeaseKey: sandboxLeaseTelemetryKey(input.workspaceId, input.sandboxGroupId),
@@ -0,0 +1,3 @@
1
+ /** Five-second heartbeats get four missed intervals before another daemon may
2
+ * claim the enrollment. Server time is authoritative. */
3
+ export const AGENT_CONNECTION_LEASE_MS = 20_000;
@@ -172,7 +172,7 @@ export async function startDeviceEnrollment(
172
172
  throw lastError instanceof Error ? lastError : new Error("failed to start device enrollment");
173
173
  }
174
174
 
175
- const base = input.verificationOrigin.replace(/\/$/, "");
175
+ const base = input.verificationOrigin.replace(/\/+$/, "");
176
176
  const verificationUri = `${base}/device`;
177
177
  const verificationUriComplete = `${verificationUri}?user_code=${encodeURIComponent(request.userCode)}`;
178
178
  return {
@@ -193,12 +193,18 @@ export async function approveDeviceEnrollment(
193
193
  input: {
194
194
  accountId: string;
195
195
  workspaceId: string;
196
+ scope?: "organization" | "workspace" | "user";
197
+ allowOrganization?: boolean;
196
198
  userCode: string;
197
199
  allowScreenControl: boolean;
198
200
  approvedBySubjectId: string;
199
201
  approvedBySubjectLabel?: string | null;
200
202
  },
201
- ): Promise<{ enrollmentId: string; sandboxId: string; allowScreenControl: boolean } | null> {
203
+ ): Promise<{
204
+ enrollmentId: string;
205
+ sandboxId: string;
206
+ allowScreenControl: boolean;
207
+ } | null> {
202
208
  const { db } = services;
203
209
  const pending = await getPendingDeviceEnrollmentRequestByUserCode(
204
210
  db,
@@ -213,6 +219,8 @@ export async function approveDeviceEnrollment(
213
219
  const result = await approveDeviceEnrollmentRequest(db, {
214
220
  accountId: input.accountId,
215
221
  workspaceId: input.workspaceId,
222
+ ...(input.scope ? { scope: input.scope } : {}),
223
+ allowOrganization: input.allowOrganization === true,
216
224
  requestId: pending.id,
217
225
  allowScreenControl: input.allowScreenControl,
218
226
  approvedBySubjectId: input.approvedBySubjectId,
@@ -291,7 +299,11 @@ export async function denyDeviceEnrollment(
291
299
  * mirror poll's "disabled" handling. The token value is NEVER logged. */
292
300
  export async function mintEnrollToken(
293
301
  services: EnrollmentServices,
294
- input: { accountId: string; workspaceId: string; allowScreenControl: boolean },
302
+ input: {
303
+ accountId: string;
304
+ workspaceId: string;
305
+ allowScreenControl: boolean;
306
+ },
295
307
  ): Promise<MintEnrollTokenResponse | null> {
296
308
  const { settings } = services;
297
309
  const secret = resolveEnrollmentSigningSecret(settings);