@opengeni/api-router 3.2.2 → 3.3.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.
@@ -83,8 +83,8 @@ export declare function scheduledTaskMcpSummary(task: ScheduledTask): {
83
83
  runMode: "existing_session" | "new_session_per_run" | "reusable_session";
84
84
  overlapPolicy: "allow_concurrent" | "buffer_one" | "skip";
85
85
  action: {
86
- readonly sourceId?: never;
87
86
  kind: "agent_turn";
87
+ sourceId?: never;
88
88
  sourceGeneration?: never;
89
89
  sourceLifecycleGeneration?: never;
90
90
  destination?: never;
@@ -221,8 +221,8 @@ export declare function boundScheduledTaskDetailMcp(task: ScheduledTask): {
221
221
  runMode: "existing_session" | "new_session_per_run" | "reusable_session";
222
222
  overlapPolicy: "allow_concurrent" | "buffer_one" | "skip";
223
223
  action: {
224
- readonly sourceId?: never;
225
224
  kind: "agent_turn";
225
+ sourceId?: never;
226
226
  sourceGeneration?: never;
227
227
  sourceLifecycleGeneration?: never;
228
228
  destination?: never;
@@ -408,8 +408,8 @@ export declare function boundScheduledTaskDetailMcp(task: ScheduledTask): {
408
408
  runMode: "existing_session" | "new_session_per_run" | "reusable_session";
409
409
  overlapPolicy: "allow_concurrent" | "buffer_one" | "skip";
410
410
  action: {
411
- readonly sourceId?: never;
412
411
  kind: "agent_turn";
412
+ sourceId?: never;
413
413
  sourceGeneration?: never;
414
414
  sourceLifecycleGeneration?: never;
415
415
  destination?: never;
@@ -601,8 +601,8 @@ export declare function boundScheduledTaskMcpPage(input: {
601
601
  runMode: "existing_session" | "new_session_per_run" | "reusable_session";
602
602
  overlapPolicy: "allow_concurrent" | "buffer_one" | "skip";
603
603
  action: {
604
- readonly sourceId?: never;
605
604
  kind: "agent_turn";
605
+ sourceId?: never;
606
606
  sourceGeneration?: never;
607
607
  sourceLifecycleGeneration?: never;
608
608
  destination?: never;
@@ -1,6 +1,6 @@
1
1
  import { type Settings } from "@opengeni/config";
2
2
  import type { AccessGrant, Session } from "@opengeni/contracts";
3
- import { type Database, type LeaseSnapshot } from "@opengeni/db";
3
+ import { type Database, type EnrollmentRecord, type LeaseSnapshot } from "@opengeni/db";
4
4
  import { type EventBus } from "@opengeni/events";
5
5
  import { type Observability } from "@opengeni/observability";
6
6
  import type { ObjectStorage } from "@opengeni/storage";
@@ -56,6 +56,15 @@ export type ChannelAHandle = {
56
56
  * who started an agent's turn, or the calling person. Routes that look up
57
57
  * the routed machine again must use it rather than the raw caller grant. */
58
58
  machineAuthority: () => Promise<ChannelAMachineAuthority>;
59
+ /** For a machine-home request, the Connected Machine and live runner this
60
+ * request was pinned to under `machineAuthority`. The routing session fails
61
+ * rather than follow a changed pointer, so a route that resolved this same
62
+ * sandbox can use it instead of looking the machine up again. */
63
+ pinnedMachine: {
64
+ sandboxId: string;
65
+ sandboxWorkspaceId: string;
66
+ enrollment: EnrollmentRecord;
67
+ } | null;
59
68
  requestId: string;
60
69
  };
61
70
  export type EstablishedHandleCacheKind = "read" | "process" | "none";
@@ -1,5 +1,5 @@
1
- import { type FirstPartyMcpToolName, type SiteLimitProfileKind } from "@opengeni/contracts";
2
- import type { ApiRouteDeps } from "@opengeni/core";
1
+ import { type FirstPartyMcpToolName, type Permission, type SiteLimitProfileKind } from "@opengeni/contracts";
2
+ import { type ApiRouteDeps } from "@opengeni/core";
3
3
  import { type SiteRuntimeConversationViewerKind } from "@opengeni/db";
4
4
  import type { PublishedSiteDataViewer } from "../site-data/service.js";
5
5
  import { type SiteRuntimeHost } from "./runtime.js";
@@ -21,8 +21,6 @@ import { type SiteRuntimeHost } from "./runtime.js";
21
21
  export declare const SITE_AGENT_DATA_TOOLS: readonly ["site_data_query", "site_data_write"];
22
22
  export declare const SITE_AGENT_SQL_TOOLS: readonly ["site_sql_run"];
23
23
  export declare const SITE_AGENT_MEMBER_SQL_TOOLS: readonly ["site_sql_query"];
24
- /** The first-party permissions those tools need (Site Data writes need publish). */
25
- export declare const SITE_AGENT_DATA_TOOL_PERMISSIONS: readonly ["artifacts:read", "artifacts:publish"];
26
24
  export declare const SITE_AGENT_OTHER_SITE_MESSAGE = "This Site's assistant can only use this Site's data";
27
25
  export declare const SITE_AGENT_MANAGEMENT_MESSAGE = "This Site's assistant can use the Site's data, not manage the Site";
28
26
  /** The data tools one viewer's Site agent conversation gets, within the deployment's tools. */
@@ -32,6 +30,8 @@ export declare function siteAgentDataToolNames(input: {
32
30
  siteSql: boolean;
33
31
  /** The deployment's first-party tool allowlist. */
34
32
  allowed: readonly string[];
33
+ /** Live member ceiling; omitted only where the native caller filters tools itself. */
34
+ memberPermissions?: readonly Permission[];
35
35
  }): FirstPartyMcpToolName[];
36
36
  /** Whether Site SQL is offered on this deployment and turned on for the Site. */
37
37
  export declare function siteAgentSiteSqlOn(deps: Pick<ApiRouteDeps, "db" | "settings">, scope: {
@@ -42,7 +42,7 @@ export declare function siteAgentSiteSqlOn(deps: Pick<ApiRouteDeps, "db" | "sett
42
42
  export declare function siteAgentDataToolsForStart(deps: Pick<ApiRouteDeps, "db" | "settings">, scope: {
43
43
  accountId: string;
44
44
  workspaceId: string;
45
- }, siteId: string, viewerKind: SiteRuntimeConversationViewerKind): Promise<FirstPartyMcpToolName[]>;
45
+ }, siteId: string, viewerKind: SiteRuntimeConversationViewerKind, memberPermissions?: readonly Permission[]): Promise<FirstPartyMcpToolName[]>;
46
46
  /** This Site's viewer (`user:<subject>` or `guest:<visitor>`) as Site Data and Site SQL see it. */
47
47
  export declare function sitePublishedDataViewer(host: Pick<SiteRuntimeHost, "scope" | "site">, principal: string, guest: boolean): PublishedSiteDataViewer;
48
48
  /**
@@ -1,7 +1,8 @@
1
+ import { type Permission } from "@opengeni/contracts";
1
2
  import { type ApiRouteDeps } from "@opengeni/core";
2
3
  import type { SiteRuntimeHost } from "./runtime.js";
3
4
  import type { SiteRuntimeViewer } from "./viewer.js";
4
- export declare const SITE_AGENT_DEFAULTS_VERSION = 1;
5
+ export declare const SITE_AGENT_DEFAULTS_VERSION = 2;
5
6
  /**
6
7
  * Before admitting more work, converge pre-redesign published-host sessions to
7
8
  * the same Site-local tools as a new conversation. This is a trusted host
@@ -13,4 +14,4 @@ export declare const SITE_AGENT_DEFAULTS_VERSION = 1;
13
14
  * audit event and completion marker atomic. Failed writes remain retryable.
14
15
  * Existing attempts keep their accepted execution policy; no turn is rewritten.
15
16
  */
16
- export declare function normalizeSiteAgentSession(deps: Pick<ApiRouteDeps, "db" | "settings" | "bus" | "objectStorage">, host: SiteRuntimeHost, viewer: SiteRuntimeViewer, sessionId: string, retainedToolIds: readonly string[]): Promise<void>;
17
+ export declare function normalizeSiteAgentSession(deps: Pick<ApiRouteDeps, "db" | "settings" | "bus" | "objectStorage">, host: SiteRuntimeHost, viewer: SiteRuntimeViewer, sessionId: string, retainedToolIds: readonly string[], memberPermissions?: readonly Permission[]): Promise<void>;
@@ -7,8 +7,8 @@ import { type SiteRuntimeViewer } from "./viewer.js";
7
7
  /**
8
8
  * Site-runtime: the narrow API a Site gateway calls for one published Site
9
9
  * host, after the gateway signature passed. Everything here works for exactly
10
- * one Site: the host names it, the viewer is resolved for it, and the agent
11
- * principal is `site:<siteId>`.
10
+ * one Site: the host names it and the viewer is resolved for it. Members retain
11
+ * their human identity; outsiders use the viewer-bound `site:<siteId>` principal.
12
12
  */
13
13
  export type SiteRuntimeDeps = Pick<ApiRouteDeps, "db" | "settings" | "objectStorage">;
14
14
  export type SiteRuntimeHost = {
@@ -43,7 +43,7 @@ export declare function resolveSiteRuntimeViewer(deps: SiteRuntimeDeps, host: Si
43
43
  ignoreViewAs?: boolean;
44
44
  }): Promise<SiteRuntimeViewer>;
45
45
  /** What a viewer cannot do on a Site: 403 with `details.code` `not_available_for_viewer`. */
46
- export type SiteViewerFeature = "agent" | "agent_model" | "workspace_tools" | "site_data" | "site_sql";
46
+ export type SiteViewerFeature = "agent" | "private_conversations" | "agent_model" | "workspace_tools" | "site_data" | "site_sql";
47
47
  export declare function siteNotAvailableForViewer(feature: SiteViewerFeature, message: string, extra?: Record<string, unknown>): import("../http/api-error.js").ApiHttpError;
48
48
  /** The viewer capabilities a page reads from `site.config` to hide what won't work. */
49
49
  export declare function siteViewerCapabilities(deps: SiteRuntimeDeps, host: SiteRuntimeHost, viewer: SiteRuntimeViewer, limits: SiteLimits): SiteViewerCapabilities;
@@ -160,9 +160,9 @@ export declare function meterSiteRuntime(deps: SiteRuntimeDeps, host: SiteRuntim
160
160
  viewer?: SiteRuntimeViewer;
161
161
  }): Promise<void>;
162
162
  /**
163
- * Run an ordinary session route in this process as the Site's service
164
- * principal (`site:<siteId>`), with a 60-second delegated token carrying only
165
- * session permissions and the viewer as its display label. The Site and the
163
+ * Run an ordinary session route in this process as the verified member, or
164
+ * the Site service principal for an outsider. The latter uses a 60-second
165
+ * delegated token plus the separate trusted viewer audience. The Site and the
166
166
  * served version ride along as the request's Site origin, so new sessions and
167
167
  * turns record where they came from. The route authorizes as usual.
168
168
  */
@@ -174,6 +174,7 @@ export declare function siteRuntimeSessionRequest(deps: SiteRuntimeDeps, host: S
174
174
  path: string;
175
175
  body?: unknown;
176
176
  extraPermissions?: readonly Permission[];
177
+ reauthorize: () => Promise<void>;
177
178
  }): Promise<Request>;
178
179
  /** A forwarded response without cookies (site-runtime never sets any). */
179
180
  export declare function siteRuntimeForwardedResponse(response: Response): Response;
@@ -1,6 +1,6 @@
1
1
  import { type ErrorCode, type Permission, type SiteHostMapRecord, type SiteHostnameAvailability, type SitePublication, type UpsertSitePublicationRequest } from "@opengeni/contracts";
2
2
  import type { AccessGrant } from "@opengeni/contracts";
3
- import { type ApiRouteDeps } from "@opengeni/core";
3
+ import { type ApiRouteDeps, type cancelPublishedSiteSession } from "@opengeni/core";
4
4
  import { SitePublishingError, type PublishedSiteRecord, type SiteDataSite, type SiteHostnameEdgeState, type SitePublicationRecord, type SitePublishingScope, type StoredSiteLimitProfile } from "@opengeni/db";
5
5
  import { ApiHttpError } from "../http/api-error.js";
6
6
  import { type SiteContentScanner } from "./content-scanner.js";
@@ -40,13 +40,12 @@ export type SitePublishingEffects = {
40
40
  };
41
41
  /** Background effects running at once per process. */
42
42
  export declare const SITE_EFFECT_CONCURRENCY = 4;
43
+ type SiteCancellation = (input: Parameters<typeof cancelPublishedSiteSession>[1]) => Promise<unknown>;
43
44
  /**
44
- * The app's in-process dispatch, registered with the site-runtime routes, so
45
- * publication changes can cancel a Site's conversations through the ordinary
46
- * session routes. Without it (the operator script) cancellation is skipped.
45
+ * Internal host lifecycle adapter. This never grants the Site service bearer
46
+ * ordinary session access and never impersonates a conversation's viewer.
47
47
  */
48
- export declare function setSitePublishingDispatch(settings: ApiRouteDeps["settings"], dispatch: (request: Request) => Promise<Response>): void;
49
- export declare function sitePublishingDispatch(settings: ApiRouteDeps["settings"]): ((request: Request) => Promise<Response>) | null;
48
+ export declare function setSitePublishingCancellation(settings: ApiRouteDeps["settings"], cancel: SiteCancellation): void;
50
49
  type SitePublishingEffectsOptions = {
51
50
  fetch?: typeof fetch;
52
51
  backoffMs?: number;
@@ -164,11 +163,9 @@ export declare function siteServiceSessionRequest(deps: Pick<SitePublishingDeps,
164
163
  * Stop a Site's viewers after a takedown, unpublish or narrower access:
165
164
  * revoke every site-session now, and after commit cancel the Site's agent
166
165
  * conversations that may still be working (in-flight turns and queued
167
- * messages), as the Site itself through the ordinary control route. The
168
- * gateway refuses new work at once regardless: every site-runtime call
169
- * re-checks the publication. Without an in-process dispatch (the operator
170
- * script) the cancellation is skipped and logged; per-conversation caps
171
- * still bound what running turns can spend.
166
+ * messages), through the native cancel-only lifecycle adapter. The original
167
+ * actor survives queued background execution. No transcript access is granted.
168
+ * Every page is drained, including old conversations; there is no age cutoff.
172
169
  */
173
170
  export declare function stopSiteViewers(deps: SitePublishingDeps, scope: SitePublishingScope, siteId: string, options: {
174
171
  revokeSessions?: boolean;
@@ -0,0 +1,3 @@
1
+ export declare function bindSiteSessionRequestAuthority(request: Request, reauthorize: () => Promise<void>): void;
2
+ /** Periodic SSE reauthorization must check the Site cookie, not only its delegate. */
3
+ export declare function reauthorizeSiteSessionRequest(request: Request): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/api-router",
3
- "version": "3.2.2",
3
+ "version": "3.3.0",
4
4
  "description": "Opengeni HTTP surface: the Hono adapter/router (createApp), routes, MCP HTTP transport, and HTTP access adapters over @opengeni/core. An engine-distribution surface — its runtime closure includes engine-internal packages.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -45,29 +45,29 @@
45
45
  "@keeper-security/secrets-manager-core": "17.6.0",
46
46
  "@llamaindex/liteparse": "2.14.2",
47
47
  "@modelcontextprotocol/sdk": "^1.29.0",
48
- "@opengeni/agent-proto": "3.2.2",
49
- "@opengeni/artifact-tool": "3.2.2",
50
- "@opengeni/capabilities": "3.2.2",
51
- "@opengeni/codemode": "3.2.2",
52
- "@opengeni/codex": "3.2.2",
53
- "@opengeni/config": "3.2.2",
54
- "@opengeni/contracts": "3.2.2",
55
- "@opengeni/core": "3.2.2",
56
- "@opengeni/db": "3.2.2",
57
- "@opengeni/documents": "3.2.2",
58
- "@opengeni/events": "3.2.2",
59
- "@opengeni/github": "3.2.2",
60
- "@opengeni/interaction": "3.2.2",
61
- "@opengeni/network": "3.2.2",
62
- "@opengeni/observability": "3.2.2",
63
- "@opengeni/runtime": "3.2.2",
64
- "@opengeni/storage": "3.2.2",
65
- "@opengeni/tool-gateway": "3.2.2",
66
- "@opengeni/vaults": "3.2.2",
67
- "@opengeni/vaults-keeper": "3.2.2",
68
- "@opengeni/vaults-onepassword": "3.2.2",
69
- "@opengeni/vaults-protonpass": "3.2.2",
70
- "@opengeni/xai-subscription": "3.2.2",
48
+ "@opengeni/agent-proto": "3.3.0",
49
+ "@opengeni/artifact-tool": "3.3.0",
50
+ "@opengeni/capabilities": "3.3.0",
51
+ "@opengeni/codemode": "3.3.0",
52
+ "@opengeni/codex": "3.3.0",
53
+ "@opengeni/config": "3.3.0",
54
+ "@opengeni/contracts": "3.3.0",
55
+ "@opengeni/core": "3.3.0",
56
+ "@opengeni/db": "3.3.0",
57
+ "@opengeni/documents": "3.3.0",
58
+ "@opengeni/events": "3.3.0",
59
+ "@opengeni/github": "3.3.0",
60
+ "@opengeni/interaction": "3.3.0",
61
+ "@opengeni/network": "3.3.0",
62
+ "@opengeni/observability": "3.3.0",
63
+ "@opengeni/runtime": "3.3.0",
64
+ "@opengeni/storage": "3.3.0",
65
+ "@opengeni/tool-gateway": "3.3.0",
66
+ "@opengeni/vaults": "3.3.0",
67
+ "@opengeni/vaults-keeper": "3.3.0",
68
+ "@opengeni/vaults-onepassword": "3.3.0",
69
+ "@opengeni/vaults-protonpass": "3.3.0",
70
+ "@opengeni/xai-subscription": "3.3.0",
71
71
  "@temporalio/client": "^1.17.0",
72
72
  "ajv": "^8.20.0",
73
73
  "better-auth": "^1.6.14",
@@ -87,6 +87,7 @@ export async function withAccessGrantSessionRlsContext<T>(
87
87
  {
88
88
  subjectId: actor.subjectId,
89
89
  initiatingHumanSubjectId: actor.initiatingHumanSubjectId,
90
+ agentRootSessionId: actor.callerRootSessionId,
90
91
  privateFileOwnerSubjectId:
91
92
  learning.defaultScope === "personal" ? learning.subjectId : null,
92
93
  },
package/src/http/sse.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  getWorkspaceInteractionRevisionState,
8
8
  listSessionEventPage,
9
9
  listWorkspaceControlEvents,
10
+ getWorkspaceControlEvent,
10
11
  readSessionEventStorageGapEnd,
11
12
  type Database,
12
13
  } from "@opengeni/db";
@@ -677,7 +678,10 @@ export async function sseWorkspaceControlStream(
677
678
  }
678
679
  }
679
680
  }
680
- await writeFrame(formatWorkspaceControlEventSse(event));
681
+ // The bus is workspace-wide, not an authorization projection. Re-read the
682
+ // exact durable row under this stream's actor before delivering live data.
683
+ const visible = await getWorkspaceControlEvent(db, workspaceId, event.id);
684
+ if (visible) await writeFrame(formatWorkspaceControlEventSse(visible));
681
685
  lastSent = event.sequence;
682
686
  };
683
687
  delivery = createLatestWinsDelivery(send, fail);
@@ -151,6 +151,7 @@ import {
151
151
  import {
152
152
  requireAccessGrant,
153
153
  requireSessionAuthorization,
154
+ withSessionAuthorizationReadReuse,
154
155
  recordWorkspaceUsage,
155
156
  requireLimit,
156
157
  relayConfigFromSettings,
@@ -2834,6 +2835,7 @@ export function registerBrowserSessionRoutes(
2834
2835
  enrollment.agentCapabilities.operationResourcePolicy === true,
2835
2836
  operationCpuQuotaSupported: enrollment.agentCapabilities.operationCpuQuota === true,
2836
2837
  transactionalFsWriteSupported: enrollment.agentCapabilities.transactionalFsWrite === true,
2838
+ browserControlHttpSupported: enrollment.agentCapabilities.browserControlHttp === true,
2837
2839
  ...(deps.settings.agentOpStreamEnabled === true &&
2838
2840
  enrollment.opStream === true &&
2839
2841
  deps.bus.getOpStreamConnection
@@ -3157,16 +3159,40 @@ export function registerBrowserSessionRoutes(
3157
3159
  browserSessionId: string,
3158
3160
  authorizationOperation: SessionAuthorizationOperation,
3159
3161
  channelOperation: ChannelAOperation,
3160
- callback: (input: {
3161
- client: BrowserControlClient;
3162
- sessionClient: ReturnType<BrowserControlClient["sessionClient"]>;
3163
- record: BrowserSessionControlRecord;
3164
- binding: NonNullable<BrowserSessionControlRecord["session"]["controller"]>;
3165
- placement: BrowserPlacement;
3166
- sourceAuthorization: ResolvedSessionAuthorization | null;
3167
- }) => Promise<T>,
3162
+ callback: ActiveBrowserControllerCallback<T>,
3168
3163
  recoverMissing = true,
3169
3164
  stages?: InteractionRouteStages,
3165
+ ): Promise<T> {
3166
+ // The source-session check and the machine-route check that follows it
3167
+ // prove the same live agent attempt; they share those reads. Everything
3168
+ // from the controller call onward reads fresh.
3169
+ return await withSessionAuthorizationReadReuse((authorizationReads) =>
3170
+ withActiveBrowserControllerReads(
3171
+ context,
3172
+ grant,
3173
+ workspaceId,
3174
+ browserSessionId,
3175
+ authorizationOperation,
3176
+ channelOperation,
3177
+ callback,
3178
+ recoverMissing,
3179
+ stages,
3180
+ authorizationReads,
3181
+ ),
3182
+ );
3183
+ }
3184
+
3185
+ async function withActiveBrowserControllerReads<T>(
3186
+ context: Context,
3187
+ grant: AccessGrant,
3188
+ workspaceId: string,
3189
+ browserSessionId: string,
3190
+ authorizationOperation: SessionAuthorizationOperation,
3191
+ channelOperation: ChannelAOperation,
3192
+ callback: ActiveBrowserControllerCallback<T>,
3193
+ recoverMissing: boolean,
3194
+ stages: InteractionRouteStages | undefined,
3195
+ authorizationReads: { handOffToToolDispatch(): void },
3170
3196
  ): Promise<T> {
3171
3197
  try {
3172
3198
  const record = await getBrowserSessionControlRecord(deps.db, {
@@ -3175,17 +3201,25 @@ export function registerBrowserSessionRoutes(
3175
3201
  browserSessionId,
3176
3202
  });
3177
3203
  stages?.mark("record");
3178
- const sourceAuthorization = await authorizeSourceSession(
3179
- deps,
3180
- grant,
3181
- record.sourceSessionId,
3182
- authorizationOperation,
3183
- );
3204
+ // A Connected Machine (or any placement without a cached controller URL)
3205
+ // always needs the source session; read it alongside authorization. A
3206
+ // denial still wins, and the session is only used once authorized.
3207
+ const sourceSessionNeeded =
3208
+ record.session.placement?.kind === "connected_machine" ||
3209
+ !record.controllerHostSandboxGroupId;
3210
+ const [authorized, sourceRead] = await Promise.allSettled([
3211
+ authorizeSourceSession(deps, grant, record.sourceSessionId, authorizationOperation),
3212
+ sourceSessionNeeded
3213
+ ? requireSourceSession(deps, workspaceId, record.sourceSessionId)
3214
+ : Promise.resolve(null),
3215
+ ]);
3216
+ if (authorized.status === "rejected") throw authorized.reason;
3217
+ if (sourceRead.status === "rejected") throw sourceRead.reason;
3218
+ const sourceAuthorization = authorized.value;
3184
3219
  stages?.mark("authorize");
3185
- let sourceSession: Session | null = null;
3220
+ let sourceSession: Session | null = sourceRead.value;
3186
3221
  if (record.session.placement?.kind === "connected_machine") {
3187
- sourceSession = await requireSourceSession(deps, workspaceId, record.sourceSessionId);
3188
- if (sourceSession.activeSandboxId !== record.session.placement.sandboxId) {
3222
+ if (sourceSession?.activeSandboxId !== record.session.placement.sandboxId) {
3189
3223
  return await throwBrowserSourcePlacementChanged(
3190
3224
  grant,
3191
3225
  record.sourceSessionId,
@@ -3217,6 +3251,7 @@ export function registerBrowserSessionRoutes(
3217
3251
  placement: BrowserPlacement,
3218
3252
  provisionedClient?: BrowserControlClient,
3219
3253
  ): Promise<T> => {
3254
+ authorizationReads.handOffToToolDispatch();
3220
3255
  // An active binding already proves browserd was provisioned. Reusing
3221
3256
  // it avoids restarting/checking the sidecar on every live input.
3222
3257
  const client = provisionedClient ?? connectController(deps, grant, record, placement);
@@ -4631,6 +4666,15 @@ async function settleBrowserRevisionCaptureFailure(
4631
4666
  });
4632
4667
  }
4633
4668
 
4669
+ type ActiveBrowserControllerCallback<T> = (input: {
4670
+ client: BrowserControlClient;
4671
+ sessionClient: ReturnType<BrowserControlClient["sessionClient"]>;
4672
+ record: BrowserSessionControlRecord;
4673
+ binding: NonNullable<BrowserSessionControlRecord["session"]["controller"]>;
4674
+ placement: BrowserPlacement;
4675
+ sourceAuthorization: ResolvedSessionAuthorization | null;
4676
+ }) => Promise<T>;
4677
+
4634
4678
  async function requireSourceSession(
4635
4679
  deps: ApiRouteDeps,
4636
4680
  workspaceId: string,
@@ -58,12 +58,14 @@ import {
58
58
  terminalizeStaleConnectedInteractionPlacement,
59
59
  touchComputerSessionController,
60
60
  type ComputerSessionControlRecord,
61
+ type EnrollmentRecord,
61
62
  type LeaseSnapshot,
62
63
  } from "@opengeni/db";
63
64
  import {
64
65
  hasPermission,
65
66
  requireAccessGrant,
66
67
  requireSessionAuthorization,
68
+ withSessionAuthorizationReadReuse,
67
69
  relayConfigFromSettings,
68
70
  resolveChannelAMachineConnection,
69
71
  resolveSessionSandboxRuntime,
@@ -125,7 +127,12 @@ import {
125
127
  observeComputerFrameEvidenceMismatch,
126
128
  observeLifecycleResult,
127
129
  } from "../interaction-metrics";
128
- import { withChannelA, withChannelARead, type ChannelAOperation } from "../sandbox/channel-a";
130
+ import {
131
+ withChannelA,
132
+ withChannelARead,
133
+ type ChannelAHandle,
134
+ type ChannelAOperation,
135
+ } from "../sandbox/channel-a";
129
136
  import { USER_CONTENT_SECURITY_HEADERS } from "../http/user-content";
130
137
  import { wrongInteractionPlacementMessage } from "./interaction-placement-message";
131
138
  import {
@@ -144,6 +151,14 @@ type ComputerPlacement = {
144
151
  screenControlAllowed?: boolean;
145
152
  };
146
153
 
154
+ type ActiveComputerControllerCallback<T> = (input: {
155
+ client: BrowserControlClient;
156
+ sessionClient: ReturnType<BrowserControlClient["computerSessionClient"]>;
157
+ record: ComputerSessionControlRecord;
158
+ binding: NonNullable<ComputerSessionControlRecord["session"]["controller"]>;
159
+ placement: ComputerPlacement;
160
+ }) => Promise<T>;
161
+
147
162
  const MODEL_COMPUTER_FRAME_MAX_BYTES = 256 * 1024;
148
163
 
149
164
  type ComputerFrameCaptureClient = {
@@ -1117,6 +1132,38 @@ export function registerComputerSessionRoutes(
1117
1132
  },
1118
1133
  );
1119
1134
 
1135
+ async function routedMachineConnection(
1136
+ handle: ChannelAHandle,
1137
+ grant: AccessGrant,
1138
+ sourceSession: Session,
1139
+ sandboxId: string,
1140
+ ): Promise<EnrollmentRecord | null> {
1141
+ const machineAuthority = await handle.machineAuthority();
1142
+ const sandbox = await getSandbox(
1143
+ deps.db,
1144
+ {
1145
+ accountId: grant.accountId,
1146
+ workspaceId: sourceSession.workspaceId,
1147
+ subjectId: machineAuthority.subjectId,
1148
+ },
1149
+ sandboxId,
1150
+ );
1151
+ if (sandbox?.kind !== "selfhosted" || !sandbox.enrollmentId) {
1152
+ throw new ComputerSessionStateError("Connected Machine placement is unavailable");
1153
+ }
1154
+ return await resolveChannelAMachineConnection(
1155
+ deps.db,
1156
+ {
1157
+ accountId: grant.accountId,
1158
+ workspaceId: sourceSession.workspaceId,
1159
+ sessionId: sourceSession.id,
1160
+ machineWorkspaceId: sandbox.workspaceId,
1161
+ enrollmentId: sandbox.enrollmentId,
1162
+ },
1163
+ machineAuthority,
1164
+ );
1165
+ }
1166
+
1120
1167
  async function withComputerPlacement<T>(
1121
1168
  sourceSession: Session,
1122
1169
  grant: AccessGrant,
@@ -1174,6 +1221,7 @@ export function registerComputerSessionRoutes(
1174
1221
  enrollment.agentCapabilities.operationResourcePolicy === true,
1175
1222
  operationCpuQuotaSupported: enrollment.agentCapabilities.operationCpuQuota === true,
1176
1223
  transactionalFsWriteSupported: enrollment.agentCapabilities.transactionalFsWrite === true,
1224
+ browserControlHttpSupported: enrollment.agentCapabilities.browserControlHttp === true,
1177
1225
  ...(deps.settings.agentOpStreamEnabled === true &&
1178
1226
  enrollment.opStream === true &&
1179
1227
  deps.bus.getOpStreamConnection
@@ -1237,33 +1285,15 @@ export function registerComputerSessionRoutes(
1237
1285
  const resolved = await handle.routingSession.prime();
1238
1286
  let screenControlAllowed = false;
1239
1287
  if (operation !== "computer.end" && resolved.kind === "selfhosted" && resolved.sandboxId) {
1240
- // Look the routed machine up as the routing did: an agent acts for
1241
- // the person who started its turn, so a personal (Only me) machine
1242
- // stays reachable, subject to that attempt's per-chat permission.
1243
- const machineAuthority = await handle.machineAuthority();
1244
- const sandbox = await getSandbox(
1245
- deps.db,
1246
- {
1247
- accountId: grant.accountId,
1248
- workspaceId: sourceSession.workspaceId,
1249
- subjectId: machineAuthority.subjectId,
1250
- },
1251
- resolved.sandboxId,
1252
- );
1253
- if (sandbox?.kind !== "selfhosted" || !sandbox.enrollmentId) {
1254
- throw new ComputerSessionStateError("Connected Machine placement is unavailable");
1255
- }
1256
- const enrollment = await resolveChannelAMachineConnection(
1257
- deps.db,
1258
- {
1259
- accountId: grant.accountId,
1260
- workspaceId: sourceSession.workspaceId,
1261
- sessionId: sourceSession.id,
1262
- machineWorkspaceId: sandbox.workspaceId,
1263
- enrollmentId: sandbox.enrollmentId,
1264
- },
1265
- machineAuthority,
1266
- );
1288
+ // The request already resolved this machine's live runner when it
1289
+ // pinned the route; otherwise look the routed machine up as the
1290
+ // routing did: an agent acts for the person who started its turn, so
1291
+ // a personal (Only me) machine stays reachable, subject to that
1292
+ // attempt's per-chat permission.
1293
+ const enrollment =
1294
+ handle.pinnedMachine?.sandboxId === resolved.sandboxId
1295
+ ? handle.pinnedMachine.enrollment
1296
+ : await routedMachineConnection(handle, grant, sourceSession, resolved.sandboxId);
1267
1297
  if (!enrollment?.connectionInstanceId) {
1268
1298
  throw new ComputerSessionStateError("Connected Machine is unavailable");
1269
1299
  }
@@ -1476,14 +1506,37 @@ export function registerComputerSessionRoutes(
1476
1506
  computerSessionId: string,
1477
1507
  authorizationOperation: SessionAuthorizationOperation,
1478
1508
  channelOperation: ChannelAOperation,
1479
- callback: (input: {
1480
- client: BrowserControlClient;
1481
- sessionClient: ReturnType<BrowserControlClient["computerSessionClient"]>;
1482
- record: ComputerSessionControlRecord;
1483
- binding: NonNullable<ComputerSessionControlRecord["session"]["controller"]>;
1484
- placement: ComputerPlacement;
1485
- }) => Promise<T>,
1509
+ callback: ActiveComputerControllerCallback<T>,
1486
1510
  recoverMissing = true,
1511
+ ): Promise<T> {
1512
+ // The source-session check and the machine-route check that follows it
1513
+ // prove the same live agent attempt; they share those reads. Everything
1514
+ // from the controller call onward reads fresh.
1515
+ return await withSessionAuthorizationReadReuse((authorizationReads) =>
1516
+ withActiveComputerControllerReads(
1517
+ context,
1518
+ grant,
1519
+ workspaceId,
1520
+ computerSessionId,
1521
+ authorizationOperation,
1522
+ channelOperation,
1523
+ callback,
1524
+ recoverMissing,
1525
+ authorizationReads,
1526
+ ),
1527
+ );
1528
+ }
1529
+
1530
+ async function withActiveComputerControllerReads<T>(
1531
+ context: Context,
1532
+ grant: AccessGrant,
1533
+ workspaceId: string,
1534
+ computerSessionId: string,
1535
+ authorizationOperation: SessionAuthorizationOperation,
1536
+ channelOperation: ChannelAOperation,
1537
+ callback: ActiveComputerControllerCallback<T>,
1538
+ recoverMissing: boolean,
1539
+ authorizationReads: { handOffToToolDispatch(): void },
1487
1540
  ): Promise<T> {
1488
1541
  try {
1489
1542
  const record = await getComputerSessionControlRecord(deps.db, {
@@ -1491,11 +1544,20 @@ export function registerComputerSessionRoutes(
1491
1544
  workspaceId,
1492
1545
  computerSessionId,
1493
1546
  });
1494
- await authorizeSourceSession(deps, grant, record.sourceSessionId, authorizationOperation);
1495
- let sourceSession: Session | null = null;
1547
+ // Only a cached managed controller can skip the source session; read it
1548
+ // alongside authorization otherwise. A denial still wins.
1549
+ const sourceSessionNeeded = record.session.placement?.kind !== "sandbox_group";
1550
+ const [authorized, sourceRead] = await Promise.allSettled([
1551
+ authorizeSourceSession(deps, grant, record.sourceSessionId, authorizationOperation),
1552
+ sourceSessionNeeded
1553
+ ? requireSourceSession(deps, workspaceId, record.sourceSessionId)
1554
+ : Promise.resolve(null),
1555
+ ]);
1556
+ if (authorized.status === "rejected") throw authorized.reason;
1557
+ if (sourceRead.status === "rejected") throw sourceRead.reason;
1558
+ let sourceSession: Session | null = sourceRead.value;
1496
1559
  if (record.session.placement?.kind === "connected_machine") {
1497
- sourceSession = await requireSourceSession(deps, workspaceId, record.sourceSessionId);
1498
- if (sourceSession.activeSandboxId !== record.session.placement.sandboxId) {
1560
+ if (sourceSession?.activeSandboxId !== record.session.placement.sandboxId) {
1499
1561
  return await throwComputerSourcePlacementChanged(
1500
1562
  grant,
1501
1563
  record.sourceSessionId,
@@ -1517,6 +1579,7 @@ export function registerComputerSessionRoutes(
1517
1579
  throw new ComputerSessionStateError("ComputerSession controller authority changed");
1518
1580
  }
1519
1581
  const run = async (placement: ComputerPlacement): Promise<T> => {
1582
+ authorizationReads.handOffToToolDispatch();
1520
1583
  // The active controller binding is the provisioning fence. Live
1521
1584
  // input connects to it directly instead of re-ensuring the sidecar.
1522
1585
  const client = connectController(grant, record, placement);