@opengeni/api-router 3.2.2 → 3.4.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 (45) hide show
  1. package/dist/access-grant-rls.d.ts +15 -0
  2. package/dist/app.js +1 -1
  3. package/dist/billing-stripe-customer.d.ts +18 -0
  4. package/dist/billing-subscription-store.d.ts +34 -0
  5. package/dist/billing-subscriptions.d.ts +64 -0
  6. package/dist/{chunk-PQBHZ2YH.js → chunk-6DZ4H5JQ.js} +3133 -1711
  7. package/dist/{chunk-PQBHZ2YH.js.map → chunk-6DZ4H5JQ.js.map} +1 -1
  8. package/dist/index.js +4 -2
  9. package/dist/index.js.map +1 -1
  10. package/dist/integrations/marketplace-plugin.d.ts +43 -43
  11. package/dist/mcp/server.d.ts +11 -1
  12. package/dist/routes/browser-sessions.d.ts +23 -2
  13. package/dist/routes/sessions.d.ts +1 -0
  14. package/dist/sandbox/channel-a.d.ts +10 -1
  15. package/dist/site-publishing/agent-data-tools.d.ts +5 -5
  16. package/dist/site-publishing/agent-session.d.ts +3 -2
  17. package/dist/site-publishing/runtime.d.ts +7 -6
  18. package/dist/site-publishing/service.d.ts +8 -11
  19. package/dist/site-publishing/session-request-authority.d.ts +3 -0
  20. package/package.json +24 -24
  21. package/src/access-grant-rls.ts +99 -10
  22. package/src/app.ts +105 -43
  23. package/src/billing-stripe-customer.ts +40 -0
  24. package/src/billing-subscription-store.ts +121 -0
  25. package/src/billing-subscriptions.ts +759 -0
  26. package/src/http/sse.ts +5 -1
  27. package/src/index.ts +3 -0
  28. package/src/mcp/action-catalog.gen.ts +113 -0
  29. package/src/mcp/action-schema-imports.gen.ts +5 -1
  30. package/src/mcp/server.ts +28 -0
  31. package/src/routes/billing.ts +161 -21
  32. package/src/routes/browser-sessions.ts +420 -26
  33. package/src/routes/computer-sessions.ts +102 -39
  34. package/src/routes/insights-usage.ts +22 -0
  35. package/src/routes/organization-memberships.ts +12 -0
  36. package/src/routes/sessions.ts +30 -5
  37. package/src/routes/site-runtime.ts +62 -20
  38. package/src/sandbox/channel-a.ts +18 -1
  39. package/src/sandbox/metrics-ingestion.ts +1 -0
  40. package/src/site-frame-guard.ts +11 -2
  41. package/src/site-publishing/agent-data-tools.ts +16 -8
  42. package/src/site-publishing/agent-session.ts +24 -6
  43. package/src/site-publishing/runtime.ts +29 -7
  44. package/src/site-publishing/service.ts +65 -77
  45. package/src/site-publishing/session-request-authority.ts +14 -0
@@ -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);
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  InsightsCallsResponse,
3
3
  InsightsUsageResponse,
4
+ InsightsSiteUsageQuery,
5
+ InsightsSiteUsageResponse,
4
6
  OrganizationInsightsCallsQuery,
5
7
  OrganizationInsightsUsageQuery,
6
8
  WorkspaceInsightsCallsQuery,
@@ -10,10 +12,12 @@ import type { AccessContext } from "@opengeni/contracts";
10
12
  import {
11
13
  accountScopedApiKeyWorkspaceAuthority,
12
14
  getInsightsUsage,
15
+ getInsightsSiteUsage,
13
16
  hasPermission,
14
17
  listInsightsCalls,
15
18
  requireAccessContext,
16
19
  requireAccessGrant,
20
+ siteViewerForGrant,
17
21
  type ApiRouteDeps,
18
22
  type InsightsQueryScope,
19
23
  } from "@opengeni/core";
@@ -221,6 +225,24 @@ export function registerInsightsUsageRoutes(app: Hono, deps: ApiRouteDeps): void
221
225
  });
222
226
  }
223
227
 
228
+ app.get("/v1/workspaces/:workspaceId/insights/sites", async (c) => {
229
+ const workspaceId = c.req.param("workspaceId");
230
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
231
+ const query = InsightsSiteUsageQuery.safeParse(insightsQueryParameters(c.req.queries()));
232
+ if (!query.success) throw new HTTPException(400, { message: "invalid Site Insights query" });
233
+ const viewer = await siteViewerForGrant(deps.db, grant);
234
+ const response = await insightsWithFriendlyTimeout(() =>
235
+ getInsightsSiteUsage(deps.db, {
236
+ accountId: grant.accountId,
237
+ workspaceId,
238
+ query: query.data,
239
+ viewerSubjectId: viewer.subjectId,
240
+ }),
241
+ );
242
+ c.header("cache-control", "private, no-store");
243
+ return c.json(InsightsSiteUsageResponse.parse(response));
244
+ });
245
+
224
246
  app.get("/v1/workspaces/:workspaceId/insights/usage", async (c) => {
225
247
  const workspaceId = c.req.param("workspaceId");
226
248
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
@@ -247,6 +247,18 @@ function parseId(schema: z.ZodString, value: string, label: string): string {
247
247
 
248
248
  function rethrowMembershipError(error: unknown, resourceLimitMessage?: string): never {
249
249
  const sqlState = nestedPostgresSqlState(error);
250
+ if (sqlState === "P0S01") {
251
+ throw new HTTPException(402, {
252
+ res: Response.json(
253
+ {
254
+ error: "seat_limit_reached",
255
+ message: "Organization seat limit reached. Upgrade your plan to add people.",
256
+ action: { type: "upgrade", href: "/billing" },
257
+ },
258
+ { status: 402 },
259
+ ),
260
+ });
261
+ }
250
262
  if (sqlState === "54000" && resourceLimitMessage) {
251
263
  throw new HTTPException(409, {
252
264
  message: resourceLimitMessage,
@@ -30,7 +30,12 @@ import {
30
30
  SITE_FRAME_SESSION_CREATE_FIELDS,
31
31
  siteFrameBodyRefusal,
32
32
  } from "@opengeni/contracts/site-session-http";
33
- import { requireWorkspace, scheduledSessionIds } from "@opengeni/db";
33
+ import {
34
+ requireWorkspace,
35
+ scheduledSessionIds,
36
+ currentSiteViewerRlsContext,
37
+ nestedPostgresSqlState,
38
+ } from "@opengeni/db";
34
39
  import {
35
40
  currentSiteSessionOrigin,
36
41
  isVerifiedDelegatedHumanAuthorization,
@@ -329,6 +334,7 @@ import {
329
334
  } from "@opengeni/core";
330
335
  import type { ApiRouteDeps } from "@opengeni/core";
331
336
  import { SessionRetryConflictError } from "@opengeni/db";
337
+ import { reauthorizeSiteSessionRequest } from "../site-publishing/session-request-authority";
332
338
  import {
333
339
  attachViewer,
334
340
  detachViewer,
@@ -761,7 +767,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
761
767
  // nothing else: the session gets the minimal Site profile. Only the Site
762
768
  // itself, running its published agent as `site:<siteId>`, chooses tools.
763
769
  const siteOrigin = origin ?? currentSiteSessionOrigin();
764
- if (siteOrigin && grant.subjectId !== `site:${siteOrigin.siteId}`) {
770
+ if (siteOrigin && currentSiteViewerRlsContext()?.siteId !== siteOrigin.siteId) {
765
771
  const refused = siteFrameBodyRefusal("sessions.create", "/sessions", payload);
766
772
  if (refused)
767
773
  return c.json(
@@ -778,6 +784,11 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
778
784
  siteId: siteOrigin.siteId,
779
785
  versionId: siteOrigin.versionId ?? null,
780
786
  });
787
+ // Ordinary Site viewers use native Only-me ownership. An agent preview
788
+ // inherits its parent's native visibility; the Site audience binding
789
+ // additionally restricts that execution to the parent's real owner.
790
+ if (grant.principalKind !== "agent_attempt")
791
+ (payload as Record<string, unknown>).visibility = "private";
781
792
  }
782
793
  const create = () =>
783
794
  createSessionForRequest(deps, grant, workspaceId, payload, authorization);
@@ -993,6 +1004,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
993
1004
  ...(query.includeTotals ? { includeTotals: true } : {}),
994
1005
  ...(query.needsYouOnly ? { needsYouOnly: true } : {}),
995
1006
  ...(query.contentArchivedOnly ? { contentArchivedOnly: true } : {}),
1007
+ ...(query.mineOnly ? { mineOnly: true } : {}),
996
1008
  ...(query.includePinned === false ? { includePinned: false } : {}),
997
1009
  ...(query.archivedOnly ? { archivedOnly: true } : {}),
998
1010
  ...(query.sortBy ? { sortBy: query.sortBy } : {}),
@@ -3597,6 +3609,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
3597
3609
  reauthorizeAfterMs:
3598
3610
  authorization?.reauthorizeAfterMs ?? SESSION_AUTHORIZATION_DEFAULT_REAUTHORIZE_MS,
3599
3611
  reauthorize: async () => {
3612
+ await reauthorizeSiteSessionRequest(c.req.raw);
3600
3613
  const freshGrant = await requireFreshAccessGrant(c, deps, workspaceId, "sessions:read");
3601
3614
  await requireSessionAuthorization(deps, freshGrant, {
3602
3615
  sessionId,
@@ -5671,7 +5684,10 @@ export function sessionTenancyHttpError(error: unknown): Error {
5671
5684
  retryable: false,
5672
5685
  });
5673
5686
  }
5674
- if (error instanceof SessionTenancyNotActivatedError) {
5687
+ if (
5688
+ error instanceof SessionTenancyNotActivatedError ||
5689
+ nestedPostgresSqlState(error) === "P0793"
5690
+ ) {
5675
5691
  return new ApiHttpError(409, {
5676
5692
  code: "conflict",
5677
5693
  message: "Only-me chats are not enabled for this organization.",
@@ -5820,6 +5836,7 @@ export function sessionListQuery(
5820
5836
  includeTotals: boolean;
5821
5837
  needsYouOnly: boolean;
5822
5838
  contentArchivedOnly: boolean;
5839
+ mineOnly: boolean;
5823
5840
  archivedOnly: boolean;
5824
5841
  sortBy: "updatedAt" | "createdAt" | "name" | undefined;
5825
5842
  archiveStatus: "active" | "archived" | "all" | undefined;
@@ -5869,17 +5886,20 @@ export function sessionListQuery(
5869
5886
  throw new HTTPException(400, { message: 'includePinned must be "true" or "false"' });
5870
5887
  }
5871
5888
  const includePinned = query.includePinned !== "false";
5872
- for (const key of ["includeTotals", "needsYouOnly", "contentArchivedOnly"]) {
5889
+ for (const key of ["includeTotals", "needsYouOnly", "contentArchivedOnly", "mineOnly"]) {
5873
5890
  if (query[key] !== undefined && !["true", "false"].includes(query[key]!))
5874
5891
  throw new HTTPException(400, { message: `${key} must be "true" or "false"` });
5875
5892
  }
5876
5893
  const includeTotals = query.includeTotals === "true";
5877
5894
  const needsYouOnly = query.needsYouOnly === "true";
5878
5895
  const contentArchivedOnly = query.contentArchivedOnly === "true";
5896
+ const mineOnly = query.mineOnly === "true";
5879
5897
  if (includeTotals && (!allowCursor || (parentSessionId !== "null" && !pinsOnly)))
5880
5898
  throw new HTTPException(400, { message: "includeTotals requires a root page" });
5881
5899
  if (needsYouOnly && !allowCursor)
5882
5900
  throw new HTTPException(400, { message: 'needsYouOnly requires view="page"' });
5901
+ if (mineOnly && !allowCursor)
5902
+ throw new HTTPException(400, { message: 'mineOnly requires view="page"' });
5883
5903
  if (pinsOnly && !includePinned) {
5884
5904
  throw new HTTPException(400, { message: "pinsOnly requires includePinned" });
5885
5905
  }
@@ -5975,6 +5995,7 @@ export function sessionListQuery(
5975
5995
  const hasPageFilters =
5976
5996
  needsYouOnly ||
5977
5997
  contentArchivedOnly ||
5998
+ mineOnly ||
5978
5999
  originSiteId !== undefined ||
5979
6000
  channelId !== undefined ||
5980
6001
  createdByKind !== undefined ||
@@ -6010,6 +6031,7 @@ export function sessionListQuery(
6010
6031
  includeTotals,
6011
6032
  needsYouOnly,
6012
6033
  contentArchivedOnly,
6034
+ mineOnly,
6013
6035
  archivedOnly,
6014
6036
  sortBy: sortBy.data,
6015
6037
  archiveStatus: archiveStatus.data,
@@ -6552,7 +6574,10 @@ export function sessionCreateErrorResponse(c: Context, error: unknown): Response
6552
6574
  403,
6553
6575
  );
6554
6576
  }
6555
- if (error instanceof SessionTenancyNotActivatedError) {
6577
+ if (
6578
+ error instanceof SessionTenancyNotActivatedError ||
6579
+ nestedPostgresSqlState(error) === "P0793"
6580
+ ) {
6556
6581
  return c.json(
6557
6582
  {
6558
6583
  code: "SESSION_TENANCY_NOT_ACTIVATED",
@@ -1,5 +1,8 @@
1
1
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import {
3
+ ReasoningEffort,
4
+ LatencyMode,
5
+ permissionsRequiredByFirstPartyTools,
3
6
  DeleteSessionQueueItemRequest,
4
7
  EditSessionQueueItemRequest,
5
8
  matchSiteOperation,
@@ -30,6 +33,7 @@ import {
30
33
  type ToolRef,
31
34
  } from "@opengeni/contracts";
32
35
  import {
36
+ cancelPublishedSiteSession,
33
37
  withHostSessionUsageLimit,
34
38
  withSiteSessionOrigin,
35
39
  admissibleWorkspaceModel,
@@ -42,6 +46,7 @@ import {
42
46
  consumeSiteAuthCode,
43
47
  finishSiteRuntimeAdmission,
44
48
  withSiteRuntimeAdmission,
49
+ withSiteViewerRlsContext,
45
50
  createSiteAbuseReport,
46
51
  getSiteWorkspaceMemberPermissions,
47
52
  readSitePlatformUsage,
@@ -65,7 +70,6 @@ import {
65
70
  type SiteRuntimeConversationRecord,
66
71
  } from "@opengeni/db";
67
72
  import {
68
- SITE_AGENT_DATA_TOOL_PERMISSIONS,
69
73
  siteAgentDataToolsForStart,
70
74
  siteAgentInstructions,
71
75
  sitePublishedDataViewer,
@@ -145,7 +149,7 @@ import {
145
149
  configuredSitesDomain,
146
150
  healSiteEdgeIfStale,
147
151
  notifySiteAbuse,
148
- setSitePublishingDispatch,
152
+ setSitePublishingCancellation,
149
153
  siteHostMapRecord,
150
154
  sitePublishingError,
151
155
  } from "../site-publishing/service";
@@ -212,6 +216,9 @@ const CreateConversationRequest = z
212
216
  .object({
213
217
  initialMessage: z.string().trim().min(1).max(MESSAGE_MAX_CHARS),
214
218
  idempotencyKey: z.string().min(1).max(200).optional(),
219
+ model: z.string().min(1).optional(),
220
+ reasoningEffort: ReasoningEffort.optional(),
221
+ latencyMode: LatencyMode.optional(),
215
222
  })
216
223
  .passthrough();
217
224
  const SendMessageRequest = z
@@ -497,8 +504,8 @@ type RuntimeRequest = {
497
504
  export function registerSiteRuntimeRoutes(app: Hono, deps: ApiRouteDeps): void {
498
505
  const dispatch = async (request: Request): Promise<Response> =>
499
506
  await app.fetch(markLocalInternalDispatch(request));
500
- // Publication changes cancel a Site's conversations through this dispatch.
501
- setSitePublishingDispatch(deps.settings, dispatch);
507
+ // Lifecycle cancellation is a native internal command, never viewer dispatch.
508
+ setSitePublishingCancellation(deps.settings, (input) => cancelPublishedSiteSession(deps, input));
502
509
 
503
510
  const handler = async (context: Context): Promise<Response> => {
504
511
  const domain = configuredSitesDomain(deps.settings);
@@ -1232,7 +1239,7 @@ export function registerSiteRuntimeRoutes(app: Hono, deps: ApiRouteDeps): void {
1232
1239
  throw unsupported();
1233
1240
  };
1234
1241
 
1235
- /** The viewer's own agent conversations on this Site, run as `site:<siteId>`. */
1242
+ /** The viewer's own conversations, with native member or viewer-bound outsider authority. */
1236
1243
  const sessions = async (
1237
1244
  request: RuntimeRequest,
1238
1245
  id: string,
@@ -1246,6 +1253,20 @@ export function registerSiteRuntimeRoutes(app: Hono, deps: ApiRouteDeps): void {
1246
1253
  // carries the Site and version headers the API's Site frame guard
1247
1254
  // validates; sessions and turns it starts record this Site and version.
1248
1255
  const siteOrigin = { siteId: host.site.id, title: host.site.title, versionId: host.versionId };
1256
+ const reauthorizeViewer = async () => {
1257
+ const liveHost = await requireServedSiteRuntimeHost(deps, host.domain, request.fields.host);
1258
+ const liveViewer = await resolveSiteRuntimeViewer(deps, liveHost, request.fields);
1259
+ if (
1260
+ liveHost.site.id !== host.site.id ||
1261
+ liveViewer.principal !== viewer.principal ||
1262
+ liveViewer.kind !== viewer.kind ||
1263
+ liveViewer.sessionId !== viewer.sessionId
1264
+ )
1265
+ throw siteNotAvailableForViewer(
1266
+ "agent",
1267
+ "This conversation is no longer available to this viewer",
1268
+ );
1269
+ };
1249
1270
  let pendingAdmission: { sessionId: string; admissionId: string } | undefined;
1250
1271
  const send = async (
1251
1272
  method: string,
@@ -1253,16 +1274,20 @@ export function registerSiteRuntimeRoutes(app: Hono, deps: ApiRouteDeps): void {
1253
1274
  body?: unknown,
1254
1275
  extraPermissions?: readonly Permission[],
1255
1276
  ) => {
1277
+ await reauthorizeViewer();
1256
1278
  const execute = () =>
1257
- withSiteSessionOrigin(siteOrigin, async () =>
1258
- dispatch(
1259
- await siteRuntimeSessionRequest(deps, host, viewer, {
1260
- origin: url.origin,
1261
- method,
1262
- path,
1263
- ...(body === undefined ? {} : { body }),
1264
- ...(extraPermissions ? { extraPermissions } : {}),
1265
- }),
1279
+ withSiteViewerRlsContext({ siteId: host.site.id, principal: viewer.principal }, () =>
1280
+ withSiteSessionOrigin(siteOrigin, async () =>
1281
+ dispatch(
1282
+ await siteRuntimeSessionRequest(deps, host, viewer, {
1283
+ origin: url.origin,
1284
+ method,
1285
+ path,
1286
+ reauthorize: reauthorizeViewer,
1287
+ ...(body === undefined ? {} : { body }),
1288
+ ...(extraPermissions ? { extraPermissions } : {}),
1289
+ }),
1290
+ ),
1266
1291
  ),
1267
1292
  );
1268
1293
  const pending = pendingAdmission;
@@ -1344,6 +1369,11 @@ export function registerSiteRuntimeRoutes(app: Hono, deps: ApiRouteDeps): void {
1344
1369
  if (viewer.kind === "guest" || viewer.kind === "user")
1345
1370
  await requireSitePlatformOpen(deps, "guest_agent");
1346
1371
  const models = await siteViewerModels(host, viewer, limits);
1372
+ if (input.model && !models.allowed.includes(input.model))
1373
+ throw siteNotAvailableForViewer(
1374
+ "agent_model",
1375
+ "This model is not available to you on this Site",
1376
+ );
1347
1377
  if (models.unavailable)
1348
1378
  throw siteNotAvailableForViewer(
1349
1379
  "agent_model",
@@ -1402,6 +1432,11 @@ export function registerSiteRuntimeRoutes(app: Hono, deps: ApiRouteDeps): void {
1402
1432
  }
1403
1433
  }
1404
1434
  // Settle the viewer's recent spend so the day budget sees it.
1435
+ if (!repair && viewer.kind !== "member" && deps.settings.privacyOnlyMeAllowed === false)
1436
+ throw siteNotAvailableForViewer(
1437
+ "private_conversations",
1438
+ "Private Site conversations are disabled by this deployment",
1439
+ );
1405
1440
  for (const conversation of await listOwnedSiteRuntimeConversations(deps.db, host.scope, {
1406
1441
  siteId: host.site.id,
1407
1442
  viewerPrincipal: viewer.principal,
@@ -1455,7 +1490,9 @@ export function registerSiteRuntimeRoutes(app: Hono, deps: ApiRouteDeps): void {
1455
1490
  host.scope,
1456
1491
  host.site.id,
1457
1492
  viewer.kind,
1493
+ viewer.kind === "member" ? (memberPermissions ?? []) : undefined,
1458
1494
  );
1495
+ const dataToolPermissions = permissionsRequiredByFirstPartyTools(dataTools);
1459
1496
  // One transaction counts the start and reserves a per-turn estimate
1460
1497
  // against the visitor's and the Site's windows; only the funded allowance
1461
1498
  // becomes the session's usage limit, set in the create transaction. The
@@ -1491,15 +1528,14 @@ export function registerSiteRuntimeRoutes(app: Hono, deps: ApiRouteDeps): void {
1491
1528
  const body = {
1492
1529
  initialMessage: input.initialMessage,
1493
1530
  requestedSessionId,
1531
+ ...(viewer.kind === "member" ? { visibility: "private" } : {}),
1494
1532
  startMode: "standard",
1495
1533
  agentAccess: "session",
1496
1534
  memoryScope: "off",
1497
1535
  rigId: null,
1498
1536
  // Only this Site's data tools; never management or workspace tools.
1499
1537
  firstPartyMcpTools: dataTools,
1500
- ...(dataTools.length > 0
1501
- ? { firstPartyMcpPermissions: [...SITE_AGENT_DATA_TOOL_PERMISSIONS] }
1502
- : {}),
1538
+ ...(dataTools.length > 0 ? { firstPartyMcpPermissions: dataToolPermissions } : {}),
1503
1539
  agent: {
1504
1540
  capabilities: {
1505
1541
  from: "none",
@@ -1511,7 +1547,13 @@ export function registerSiteRuntimeRoutes(app: Hono, deps: ApiRouteDeps): void {
1511
1547
  // No sandbox: a Site's agent answers from the page and the Site's
1512
1548
  // data, with no shell or workspace files.
1513
1549
  sandboxBackend: "none",
1514
- ...(repair ? { model: repair.model } : models.default ? { model: models.default } : {}),
1550
+ ...(repair
1551
+ ? { model: repair.model }
1552
+ : input.model || models.default
1553
+ ? { model: input.model ?? models.default }
1554
+ : {}),
1555
+ ...(input.reasoningEffort === undefined ? {} : { reasoningEffort: input.reasoningEffort }),
1556
+ ...(input.latencyMode === undefined ? {} : { latencyMode: input.latencyMode }),
1515
1557
  instructions: siteAgentInstructions(host, viewer, dataTools, agent.instructions),
1516
1558
  // Always explicit: an empty list means no workspace tools at all.
1517
1559
  tools: mcpTools,
@@ -1525,8 +1567,7 @@ export function registerSiteRuntimeRoutes(app: Hono, deps: ApiRouteDeps): void {
1525
1567
  },
1526
1568
  ...(input.idempotencyKey ? { idempotencyKey: `site-runtime:${requestedSessionId}` } : {}),
1527
1569
  };
1528
- const start = () =>
1529
- send("POST", base, body, dataTools.length > 0 ? SITE_AGENT_DATA_TOOL_PERMISSIONS : []);
1570
+ const start = () => send("POST", base, body, dataToolPermissions);
1530
1571
  // Workspace members get no Site budget at all: their conversation is an
1531
1572
  // ordinary session under the workspace's normal limits, so no session
1532
1573
  // usage limit is stamped on it, whatever the Site's limits say.
@@ -1694,6 +1735,7 @@ export function registerSiteRuntimeRoutes(app: Hono, deps: ApiRouteDeps): void {
1694
1735
  viewer,
1695
1736
  conversation.sessionId,
1696
1737
  conversation.toolIds,
1738
+ viewer.kind === "member" ? (memberPermissions ?? []) : undefined,
1697
1739
  );
1698
1740
  const usage = await settleSiteRuntimeSpend(deps, host, conversation);
1699
1741
  // Clear the stamped session cap before dropping the durable conversation
@@ -43,6 +43,7 @@ import {
43
43
  SandboxPaidComputeAdmissionError,
44
44
  withSandboxProviderReadLock,
45
45
  type Database,
46
+ type EnrollmentRecord,
46
47
  type LeaseSnapshot,
47
48
  } from "@opengeni/db";
48
49
  import { appendAndPublishEvents, type EventBus } from "@opengeni/events";
@@ -207,6 +208,15 @@ export type ChannelAHandle = {
207
208
  * who started an agent's turn, or the calling person. Routes that look up
208
209
  * the routed machine again must use it rather than the raw caller grant. */
209
210
  machineAuthority: () => Promise<ChannelAMachineAuthority>;
211
+ /** For a machine-home request, the Connected Machine and live runner this
212
+ * request was pinned to under `machineAuthority`. The routing session fails
213
+ * rather than follow a changed pointer, so a route that resolved this same
214
+ * sandbox can use it instead of looking the machine up again. */
215
+ pinnedMachine: {
216
+ sandboxId: string;
217
+ sandboxWorkspaceId: string;
218
+ enrollment: EnrollmentRecord;
219
+ } | null;
210
220
  requestId: string;
211
221
  };
212
222
 
@@ -629,6 +639,7 @@ async function withChannelAOperation<T>(
629
639
  routed: EstablishedSandboxSession,
630
640
  lease: LeaseSnapshot | null,
631
641
  homeSession: ChannelASession,
642
+ pinnedMachine: ChannelAHandle["pinnedMachine"] = null,
632
643
  ): Promise<T> => {
633
644
  const emit = async (events: { type: string; payload: unknown }[]): Promise<void> => {
634
645
  await appendAndPublishEvents(
@@ -667,6 +678,7 @@ async function withChannelAOperation<T>(
667
678
  homeSession,
668
679
  routingSession,
669
680
  machineAuthority,
681
+ pinnedMachine,
670
682
  requestId,
671
683
  });
672
684
  // The direct request has accepted the result in memory. Finalize every
@@ -770,6 +782,7 @@ async function withChannelAOperation<T>(
770
782
  enrollment.agentCapabilities.operationResourcePolicy === true,
771
783
  operationCpuQuotaSupported: enrollment.agentCapabilities.operationCpuQuota === true,
772
784
  transactionalFsWriteSupported: enrollment.agentCapabilities.transactionalFsWrite === true,
785
+ browserControlHttpSupported: enrollment.agentCapabilities.browserControlHttp === true,
773
786
  ...(settings.agentOpStreamEnabled === true &&
774
787
  enrollment?.opStream === true &&
775
788
  bus.getOpStreamConnection
@@ -812,7 +825,11 @@ async function withChannelAOperation<T>(
812
825
  },
813
826
  established,
814
827
  );
815
- return await runEstablished(routed, null, established.session as ChannelASession);
828
+ return await runEstablished(routed, null, established.session as ChannelASession, {
829
+ sandboxId: sandbox.id,
830
+ sandboxWorkspaceId: originWorkspaceId,
831
+ enrollment,
832
+ });
816
833
  } catch (error) {
817
834
  observeChannelAOperationFailure(services, {
818
835
  workspaceId,
@@ -843,6 +843,7 @@ export function helloRuntimeCapabilities(hello: Hello): Record<string, boolean>
843
843
  operationCpuQuota: caps.operationCpuQuota === true,
844
844
  transactionalFsWrite: caps.transactionalFsWrite === true,
845
845
  credentialRenew: caps.credentialRenew === true,
846
+ browserControlHttp: caps.browserControlHttp === true,
846
847
  screenControl: caps.consentedScreenControl === true,
847
848
  ...(caps.macPermissions ? enrollmentMacPermissionCapabilities(caps.macPermissions) : {}),
848
849
  };