@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.
- package/dist/access-grant-rls.d.ts +15 -0
- package/dist/app.js +1 -1
- package/dist/billing-stripe-customer.d.ts +18 -0
- package/dist/billing-subscription-store.d.ts +34 -0
- package/dist/billing-subscriptions.d.ts +64 -0
- package/dist/{chunk-PQBHZ2YH.js → chunk-6DZ4H5JQ.js} +3133 -1711
- package/dist/{chunk-PQBHZ2YH.js.map → chunk-6DZ4H5JQ.js.map} +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/integrations/marketplace-plugin.d.ts +43 -43
- package/dist/mcp/server.d.ts +11 -1
- package/dist/routes/browser-sessions.d.ts +23 -2
- package/dist/routes/sessions.d.ts +1 -0
- package/dist/sandbox/channel-a.d.ts +10 -1
- package/dist/site-publishing/agent-data-tools.d.ts +5 -5
- package/dist/site-publishing/agent-session.d.ts +3 -2
- package/dist/site-publishing/runtime.d.ts +7 -6
- package/dist/site-publishing/service.d.ts +8 -11
- package/dist/site-publishing/session-request-authority.d.ts +3 -0
- package/package.json +24 -24
- package/src/access-grant-rls.ts +99 -10
- package/src/app.ts +105 -43
- package/src/billing-stripe-customer.ts +40 -0
- package/src/billing-subscription-store.ts +121 -0
- package/src/billing-subscriptions.ts +759 -0
- package/src/http/sse.ts +5 -1
- package/src/index.ts +3 -0
- package/src/mcp/action-catalog.gen.ts +113 -0
- package/src/mcp/action-schema-imports.gen.ts +5 -1
- package/src/mcp/server.ts +28 -0
- package/src/routes/billing.ts +161 -21
- package/src/routes/browser-sessions.ts +420 -26
- package/src/routes/computer-sessions.ts +102 -39
- package/src/routes/insights-usage.ts +22 -0
- package/src/routes/organization-memberships.ts +12 -0
- package/src/routes/sessions.ts +30 -5
- package/src/routes/site-runtime.ts +62 -20
- package/src/sandbox/channel-a.ts +18 -1
- package/src/sandbox/metrics-ingestion.ts +1 -0
- package/src/site-frame-guard.ts +11 -2
- package/src/site-publishing/agent-data-tools.ts +16 -8
- package/src/site-publishing/agent-session.ts +24 -6
- package/src/site-publishing/runtime.ts +29 -7
- package/src/site-publishing/service.ts +65 -77
- package/src/site-publishing/session-request-authority.ts +14 -0
|
@@ -40,8 +40,12 @@ import {
|
|
|
40
40
|
BrowserSessionLifecycleRequest,
|
|
41
41
|
BrowserSessionListResponse,
|
|
42
42
|
BrowserSessionMutationResponse,
|
|
43
|
+
type BrowserTarget,
|
|
43
44
|
BrowserTargetListResponse,
|
|
44
45
|
BrowserTargetState,
|
|
46
|
+
BrowserWorkingCopyAdoptionRequest,
|
|
47
|
+
BrowserWorkingCopyAdoptionResponse,
|
|
48
|
+
BrowserWorkingCopyInspection,
|
|
45
49
|
CreateBrowserSessionRequest,
|
|
46
50
|
ExternalAuthInteractiveRequest,
|
|
47
51
|
ExternalAuthInteractiveResponse,
|
|
@@ -57,6 +61,8 @@ import {
|
|
|
57
61
|
type BrowserActionRequest as BrowserActionRequestValue,
|
|
58
62
|
type BrowserDownload as BrowserDownloadValue,
|
|
59
63
|
type BrowserSession as BrowserSessionValue,
|
|
64
|
+
type BrowserWorkingCopyAdoption as BrowserWorkingCopyAdoptionValue,
|
|
65
|
+
type BrowserWorkingCopyRefusal as BrowserWorkingCopyRefusalValue,
|
|
60
66
|
type FileAsset,
|
|
61
67
|
type CreateBrowserSessionRequest as CreateBrowserSessionRequestValue,
|
|
62
68
|
type InteractionPlacement,
|
|
@@ -143,6 +149,7 @@ import {
|
|
|
143
149
|
verifyAuthRun,
|
|
144
150
|
settleBrowserDownloadSaveFailure,
|
|
145
151
|
terminalizeStaleConnectedInteractionPlacement,
|
|
152
|
+
recordAuditEvent,
|
|
146
153
|
type BrowserSessionControlRecord,
|
|
147
154
|
type BrowserPrivateCheckpointAuthority,
|
|
148
155
|
type BrowserRevisionArtifactAuthority,
|
|
@@ -151,6 +158,7 @@ import {
|
|
|
151
158
|
import {
|
|
152
159
|
requireAccessGrant,
|
|
153
160
|
requireSessionAuthorization,
|
|
161
|
+
withSessionAuthorizationReadReuse,
|
|
154
162
|
recordWorkspaceUsage,
|
|
155
163
|
requireLimit,
|
|
156
164
|
relayConfigFromSettings,
|
|
@@ -181,6 +189,9 @@ import {
|
|
|
181
189
|
type BrowserControlScreenshotOptions,
|
|
182
190
|
type BrowserControlFrame,
|
|
183
191
|
type PlacementBrowserStateCaptureReceipt,
|
|
192
|
+
type PlacementBrowserWorkingCopyAdoption,
|
|
193
|
+
type PlacementBrowserWorkingCopyAuthority,
|
|
194
|
+
type PlacementBrowserWorkingCopyInspection,
|
|
184
195
|
type PlacementBrowserNetworkRoute,
|
|
185
196
|
type PlacementBrowserTransport,
|
|
186
197
|
type RestorePlacementBrowserStateInput,
|
|
@@ -351,22 +362,36 @@ export function registerBrowserSessionRoutes(
|
|
|
351
362
|
const request = await parseJsonBody(context, CreateBrowserSessionRequest);
|
|
352
363
|
const startedAtMs = performance.now();
|
|
353
364
|
const stages = new InteractionRouteStages("create");
|
|
354
|
-
|
|
365
|
+
// The replay lookup and the source session are read alongside the
|
|
366
|
+
// authorization check. A denial still wins, and neither read is used
|
|
367
|
+
// until the request is authorized; each failure surfaces where it did.
|
|
368
|
+
const [authorized, existingRead, sourceRead] = await Promise.allSettled([
|
|
369
|
+
authorizeSourceSession(deps, grant, request.sessionId, "session.control"),
|
|
370
|
+
findBrowserSessionControlRecordByOperation(deps.db, {
|
|
371
|
+
accountId: grant.accountId,
|
|
372
|
+
workspaceId,
|
|
373
|
+
operationId: request.operationId,
|
|
374
|
+
}),
|
|
375
|
+
requireSourceSession(deps, workspaceId, request.sessionId),
|
|
376
|
+
]);
|
|
377
|
+
if (authorized.status === "rejected") throw authorized.reason;
|
|
355
378
|
stages.mark("authorize");
|
|
356
379
|
const origin = requestOrigin(context, deps.settings);
|
|
357
380
|
const authority = browserAuthorityRoot(deps);
|
|
358
381
|
let prepared: Awaited<ReturnType<typeof prepareBrowserSessionCreate>> | null = null;
|
|
382
|
+
// What the controller showed when it launched this browser, returned so
|
|
383
|
+
// the caller can present the opened tab without two more reads.
|
|
384
|
+
const launch: { result: Awaited<ReturnType<BrowserControlClient["createSession"]>> | null } = {
|
|
385
|
+
result: null,
|
|
386
|
+
};
|
|
359
387
|
|
|
360
388
|
try {
|
|
361
389
|
assertEphemeralBrowserCreateEnabled(
|
|
362
390
|
request,
|
|
363
391
|
deps.settings.experimentalBrowserContextPoolEnabled,
|
|
364
392
|
);
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
workspaceId,
|
|
368
|
-
operationId: request.operationId,
|
|
369
|
-
});
|
|
393
|
+
if (existingRead.status === "rejected") throw existingRead.reason;
|
|
394
|
+
const existing = existingRead.value;
|
|
370
395
|
if (existing && existing.sourceSessionId !== request.sessionId) {
|
|
371
396
|
throw new BrowserSessionOperationConflictError(
|
|
372
397
|
"BrowserSession create operation belongs to another source session",
|
|
@@ -400,7 +425,8 @@ export function registerBrowserSessionRoutes(
|
|
|
400
425
|
}
|
|
401
426
|
}
|
|
402
427
|
|
|
403
|
-
|
|
428
|
+
if (sourceRead.status === "rejected") throw sourceRead.reason;
|
|
429
|
+
const sourceSession = sourceRead.value;
|
|
404
430
|
stages.mark("source_session");
|
|
405
431
|
const response = await withBrowserPlacement(
|
|
406
432
|
sourceSession,
|
|
@@ -535,7 +561,7 @@ export function registerBrowserSessionRoutes(
|
|
|
535
561
|
linkedComputer !== null,
|
|
536
562
|
);
|
|
537
563
|
stages.mark("display");
|
|
538
|
-
|
|
564
|
+
launch.result = await client.createSession({
|
|
539
565
|
browserSessionId: preparedSession.id,
|
|
540
566
|
controllerGeneration,
|
|
541
567
|
tokenGeneration: record.tokenGeneration,
|
|
@@ -552,6 +578,7 @@ export function registerBrowserSessionRoutes(
|
|
|
552
578
|
...(request.initialUrl ? { initialUrl: request.initialUrl } : {}),
|
|
553
579
|
...(restore ? { restore } : {}),
|
|
554
580
|
});
|
|
581
|
+
return launch.result;
|
|
555
582
|
},
|
|
556
583
|
);
|
|
557
584
|
},
|
|
@@ -613,7 +640,7 @@ export function registerBrowserSessionRoutes(
|
|
|
613
640
|
}
|
|
614
641
|
},
|
|
615
642
|
);
|
|
616
|
-
const parsed = BrowserSessionMutationResponse.parse(response);
|
|
643
|
+
const parsed = BrowserSessionMutationResponse.parse(withInitialView(response, launch.result));
|
|
617
644
|
observeLifecycleResult(deps.observability, startedAtMs, parsed);
|
|
618
645
|
reportInteractionRouteStages(context, deps.observability, stages);
|
|
619
646
|
return context.json(
|
|
@@ -2611,6 +2638,217 @@ export function registerBrowserSessionRoutes(
|
|
|
2611
2638
|
},
|
|
2612
2639
|
);
|
|
2613
2640
|
|
|
2641
|
+
/** Run one working-copy request against an active browser's own controller
|
|
2642
|
+
* generation, with the same session.control and placement authority as any
|
|
2643
|
+
* browser control. The controller binds every request to the exact
|
|
2644
|
+
* controller authority the copy was launched under. Never replayed. */
|
|
2645
|
+
async function withPreservedWorkingCopy<T>(
|
|
2646
|
+
context: Context,
|
|
2647
|
+
callback: (input: {
|
|
2648
|
+
grant: AccessGrant;
|
|
2649
|
+
workspaceId: string;
|
|
2650
|
+
browserSessionId: string;
|
|
2651
|
+
client: BrowserControlClient;
|
|
2652
|
+
authority: PlacementBrowserWorkingCopyAuthority;
|
|
2653
|
+
}) => Promise<T>,
|
|
2654
|
+
): Promise<T> {
|
|
2655
|
+
const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
|
|
2656
|
+
context,
|
|
2657
|
+
"sessions:control",
|
|
2658
|
+
);
|
|
2659
|
+
return await withActiveBrowserController(
|
|
2660
|
+
context,
|
|
2661
|
+
grant,
|
|
2662
|
+
workspaceId,
|
|
2663
|
+
browserSessionId,
|
|
2664
|
+
"session.control",
|
|
2665
|
+
"browser.control",
|
|
2666
|
+
async ({ client, record, binding, placement }) => {
|
|
2667
|
+
const transport = browserRuntimeTransport(record.session, placement.transport);
|
|
2668
|
+
if (
|
|
2669
|
+
transport.kind !== "managed" ||
|
|
2670
|
+
transport.engine !== "chromium" ||
|
|
2671
|
+
transport.ephemeralPartition ||
|
|
2672
|
+
browserSessionStorageMode(record.session) !== "private_profile"
|
|
2673
|
+
) {
|
|
2674
|
+
throw new BrowserSessionStateError(WORKING_COPY_REFUSAL_EXPLANATIONS.unsupported_browser);
|
|
2675
|
+
}
|
|
2676
|
+
const tokens = deriveBrowserSessionControllerTokens({
|
|
2677
|
+
rootSecret: browserAuthorityRoot(deps),
|
|
2678
|
+
accountId: grant.accountId,
|
|
2679
|
+
workspaceId,
|
|
2680
|
+
placement: record.session.placement,
|
|
2681
|
+
placementInstanceId: placement.placementInstanceId,
|
|
2682
|
+
browserSessionId,
|
|
2683
|
+
controllerGeneration: binding.controllerGeneration,
|
|
2684
|
+
tokenGeneration: record.tokenGeneration,
|
|
2685
|
+
});
|
|
2686
|
+
return await callback({
|
|
2687
|
+
grant,
|
|
2688
|
+
workspaceId,
|
|
2689
|
+
browserSessionId,
|
|
2690
|
+
client,
|
|
2691
|
+
authority: {
|
|
2692
|
+
browserSessionId,
|
|
2693
|
+
controllerGeneration: binding.controllerGeneration,
|
|
2694
|
+
tokenGeneration: record.tokenGeneration,
|
|
2695
|
+
...tokens,
|
|
2696
|
+
},
|
|
2697
|
+
});
|
|
2698
|
+
},
|
|
2699
|
+
// A missing controller session is the state being inspected; never
|
|
2700
|
+
// launch anything to "recover" it here.
|
|
2701
|
+
false,
|
|
2702
|
+
);
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
app.post(
|
|
2706
|
+
"/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/working-copy/inspection",
|
|
2707
|
+
async (context) => {
|
|
2708
|
+
await parseEmptyJsonBody(context);
|
|
2709
|
+
const result = await withPreservedWorkingCopy(
|
|
2710
|
+
context,
|
|
2711
|
+
async ({ grant, workspaceId, browserSessionId, client, authority }) => {
|
|
2712
|
+
const inspection = await client.inspectWorkingCopy(authority);
|
|
2713
|
+
const checkpoint = await getBrowserPrivateCheckpointAuthority(deps.db, {
|
|
2714
|
+
accountId: grant.accountId,
|
|
2715
|
+
workspaceId,
|
|
2716
|
+
browserSessionId,
|
|
2717
|
+
}).catch(() => null);
|
|
2718
|
+
return BrowserWorkingCopyInspection.parse({
|
|
2719
|
+
browserSessionId,
|
|
2720
|
+
controllerGeneration: authority.controllerGeneration,
|
|
2721
|
+
adoptable: inspection.refusal === null && inspection.manifest !== null,
|
|
2722
|
+
refusal: inspection.refusal,
|
|
2723
|
+
explanation: workingCopyInspectionExplanation(
|
|
2724
|
+
inspection,
|
|
2725
|
+
checkpoint?.createdAt ?? null,
|
|
2726
|
+
),
|
|
2727
|
+
manifest: inspection.manifest,
|
|
2728
|
+
platformSupported: inspection.platformSupported,
|
|
2729
|
+
profileIdentityMatches: inspection.profileIdentityMatches,
|
|
2730
|
+
profileInUse: inspection.profileInUse,
|
|
2731
|
+
receipt: inspection.receipt,
|
|
2732
|
+
freshness: { status: "unknown", latestCheckpointAt: checkpoint?.createdAt ?? null },
|
|
2733
|
+
activeAdoption: inspection.activeAdoption,
|
|
2734
|
+
});
|
|
2735
|
+
},
|
|
2736
|
+
);
|
|
2737
|
+
return context.json(result);
|
|
2738
|
+
},
|
|
2739
|
+
);
|
|
2740
|
+
|
|
2741
|
+
app.post(
|
|
2742
|
+
"/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/working-copy/adoptions",
|
|
2743
|
+
async (context) => {
|
|
2744
|
+
const request = await parseJsonBody(context, BrowserWorkingCopyAdoptionRequest);
|
|
2745
|
+
const result = await withPreservedWorkingCopy(
|
|
2746
|
+
context,
|
|
2747
|
+
async ({ grant, workspaceId, browserSessionId, client, authority }) => {
|
|
2748
|
+
// Durable attribution before any effect: who selected which exact
|
|
2749
|
+
// bytes as this browser's state. Freshness stays unproven.
|
|
2750
|
+
await recordAuditEvent(deps.db, {
|
|
2751
|
+
accountId: grant.accountId,
|
|
2752
|
+
workspaceId,
|
|
2753
|
+
subjectId: grant.subjectId,
|
|
2754
|
+
action: "browserSession.working_copy.adoption_requested",
|
|
2755
|
+
targetType: "browser_session",
|
|
2756
|
+
targetId: browserSessionId,
|
|
2757
|
+
metadata: {
|
|
2758
|
+
operationId: request.operationId,
|
|
2759
|
+
manifestDigest: request.manifestDigest,
|
|
2760
|
+
controllerGeneration: authority.controllerGeneration,
|
|
2761
|
+
},
|
|
2762
|
+
});
|
|
2763
|
+
const adoption = await client.adoptWorkingCopy({
|
|
2764
|
+
...authority,
|
|
2765
|
+
operationId: request.operationId,
|
|
2766
|
+
manifestDigest: request.manifestDigest,
|
|
2767
|
+
});
|
|
2768
|
+
await recordAuditEvent(deps.db, {
|
|
2769
|
+
accountId: grant.accountId,
|
|
2770
|
+
workspaceId,
|
|
2771
|
+
subjectId: grant.subjectId,
|
|
2772
|
+
action: "browserSession.working_copy.adoption_settled",
|
|
2773
|
+
targetType: "browser_session",
|
|
2774
|
+
targetId: browserSessionId,
|
|
2775
|
+
metadata: {
|
|
2776
|
+
operationId: adoption.operationId,
|
|
2777
|
+
manifestDigest: adoption.manifestDigest,
|
|
2778
|
+
controllerGeneration: adoption.controllerGeneration,
|
|
2779
|
+
phase: adoption.phase,
|
|
2780
|
+
failureCode: adoption.failureCode,
|
|
2781
|
+
},
|
|
2782
|
+
}).catch(() => undefined);
|
|
2783
|
+
return BrowserWorkingCopyAdoptionResponse.parse({
|
|
2784
|
+
inFlight: false,
|
|
2785
|
+
adoption: publicWorkingCopyAdoption(adoption),
|
|
2786
|
+
explanation: workingCopyAdoptionExplanation(false, adoption),
|
|
2787
|
+
});
|
|
2788
|
+
},
|
|
2789
|
+
);
|
|
2790
|
+
return context.json(result);
|
|
2791
|
+
},
|
|
2792
|
+
);
|
|
2793
|
+
|
|
2794
|
+
app.get(
|
|
2795
|
+
"/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/working-copy/adoptions/:operationId",
|
|
2796
|
+
async (context) => {
|
|
2797
|
+
const operationId = requireUuidParam(context, "operationId");
|
|
2798
|
+
const result = await withPreservedWorkingCopy(context, async ({ client, authority }) => {
|
|
2799
|
+
const status = await client.workingCopyAdoption(authority, operationId);
|
|
2800
|
+
return BrowserWorkingCopyAdoptionResponse.parse({
|
|
2801
|
+
inFlight: status.inFlight,
|
|
2802
|
+
adoption: status.adoption ? publicWorkingCopyAdoption(status.adoption) : null,
|
|
2803
|
+
explanation: workingCopyAdoptionExplanation(status.inFlight, status.adoption),
|
|
2804
|
+
});
|
|
2805
|
+
});
|
|
2806
|
+
return context.json(result);
|
|
2807
|
+
},
|
|
2808
|
+
);
|
|
2809
|
+
|
|
2810
|
+
app.post(
|
|
2811
|
+
"/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/working-copy/adoptions/:operationId/revert",
|
|
2812
|
+
async (context) => {
|
|
2813
|
+
const operationId = requireUuidParam(context, "operationId");
|
|
2814
|
+
const result = await withPreservedWorkingCopy(
|
|
2815
|
+
context,
|
|
2816
|
+
async ({ grant, workspaceId, browserSessionId, client, authority }) => {
|
|
2817
|
+
await recordAuditEvent(deps.db, {
|
|
2818
|
+
accountId: grant.accountId,
|
|
2819
|
+
workspaceId,
|
|
2820
|
+
subjectId: grant.subjectId,
|
|
2821
|
+
action: "browserSession.working_copy.revert_requested",
|
|
2822
|
+
targetType: "browser_session",
|
|
2823
|
+
targetId: browserSessionId,
|
|
2824
|
+
metadata: { operationId, controllerGeneration: authority.controllerGeneration },
|
|
2825
|
+
});
|
|
2826
|
+
const status = await client.revertWorkingCopyAdoption({ ...authority, operationId });
|
|
2827
|
+
await recordAuditEvent(deps.db, {
|
|
2828
|
+
accountId: grant.accountId,
|
|
2829
|
+
workspaceId,
|
|
2830
|
+
subjectId: grant.subjectId,
|
|
2831
|
+
action: "browserSession.working_copy.revert_settled",
|
|
2832
|
+
targetType: "browser_session",
|
|
2833
|
+
targetId: browserSessionId,
|
|
2834
|
+
metadata: {
|
|
2835
|
+
operationId,
|
|
2836
|
+
controllerGeneration: authority.controllerGeneration,
|
|
2837
|
+
phase: status.adoption?.phase ?? null,
|
|
2838
|
+
failureCode: status.adoption?.failureCode ?? null,
|
|
2839
|
+
},
|
|
2840
|
+
}).catch(() => undefined);
|
|
2841
|
+
return BrowserWorkingCopyAdoptionResponse.parse({
|
|
2842
|
+
inFlight: status.inFlight,
|
|
2843
|
+
adoption: status.adoption ? publicWorkingCopyAdoption(status.adoption) : null,
|
|
2844
|
+
explanation: workingCopyAdoptionExplanation(status.inFlight, status.adoption),
|
|
2845
|
+
});
|
|
2846
|
+
},
|
|
2847
|
+
);
|
|
2848
|
+
return context.json(result);
|
|
2849
|
+
},
|
|
2850
|
+
);
|
|
2851
|
+
|
|
2614
2852
|
app.post(
|
|
2615
2853
|
"/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/end",
|
|
2616
2854
|
async (context) => {
|
|
@@ -2834,6 +3072,7 @@ export function registerBrowserSessionRoutes(
|
|
|
2834
3072
|
enrollment.agentCapabilities.operationResourcePolicy === true,
|
|
2835
3073
|
operationCpuQuotaSupported: enrollment.agentCapabilities.operationCpuQuota === true,
|
|
2836
3074
|
transactionalFsWriteSupported: enrollment.agentCapabilities.transactionalFsWrite === true,
|
|
3075
|
+
browserControlHttpSupported: enrollment.agentCapabilities.browserControlHttp === true,
|
|
2837
3076
|
...(deps.settings.agentOpStreamEnabled === true &&
|
|
2838
3077
|
enrollment.opStream === true &&
|
|
2839
3078
|
deps.bus.getOpStreamConnection
|
|
@@ -3157,16 +3396,40 @@ export function registerBrowserSessionRoutes(
|
|
|
3157
3396
|
browserSessionId: string,
|
|
3158
3397
|
authorizationOperation: SessionAuthorizationOperation,
|
|
3159
3398
|
channelOperation: ChannelAOperation,
|
|
3160
|
-
callback:
|
|
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>,
|
|
3399
|
+
callback: ActiveBrowserControllerCallback<T>,
|
|
3168
3400
|
recoverMissing = true,
|
|
3169
3401
|
stages?: InteractionRouteStages,
|
|
3402
|
+
): Promise<T> {
|
|
3403
|
+
// The source-session check and the machine-route check that follows it
|
|
3404
|
+
// prove the same live agent attempt; they share those reads. Everything
|
|
3405
|
+
// from the controller call onward reads fresh.
|
|
3406
|
+
return await withSessionAuthorizationReadReuse((authorizationReads) =>
|
|
3407
|
+
withActiveBrowserControllerReads(
|
|
3408
|
+
context,
|
|
3409
|
+
grant,
|
|
3410
|
+
workspaceId,
|
|
3411
|
+
browserSessionId,
|
|
3412
|
+
authorizationOperation,
|
|
3413
|
+
channelOperation,
|
|
3414
|
+
callback,
|
|
3415
|
+
recoverMissing,
|
|
3416
|
+
stages,
|
|
3417
|
+
authorizationReads,
|
|
3418
|
+
),
|
|
3419
|
+
);
|
|
3420
|
+
}
|
|
3421
|
+
|
|
3422
|
+
async function withActiveBrowserControllerReads<T>(
|
|
3423
|
+
context: Context,
|
|
3424
|
+
grant: AccessGrant,
|
|
3425
|
+
workspaceId: string,
|
|
3426
|
+
browserSessionId: string,
|
|
3427
|
+
authorizationOperation: SessionAuthorizationOperation,
|
|
3428
|
+
channelOperation: ChannelAOperation,
|
|
3429
|
+
callback: ActiveBrowserControllerCallback<T>,
|
|
3430
|
+
recoverMissing: boolean,
|
|
3431
|
+
stages: InteractionRouteStages | undefined,
|
|
3432
|
+
authorizationReads: { handOffToToolDispatch(): void },
|
|
3170
3433
|
): Promise<T> {
|
|
3171
3434
|
try {
|
|
3172
3435
|
const record = await getBrowserSessionControlRecord(deps.db, {
|
|
@@ -3175,17 +3438,25 @@ export function registerBrowserSessionRoutes(
|
|
|
3175
3438
|
browserSessionId,
|
|
3176
3439
|
});
|
|
3177
3440
|
stages?.mark("record");
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3441
|
+
// A Connected Machine (or any placement without a cached controller URL)
|
|
3442
|
+
// always needs the source session; read it alongside authorization. A
|
|
3443
|
+
// denial still wins, and the session is only used once authorized.
|
|
3444
|
+
const sourceSessionNeeded =
|
|
3445
|
+
record.session.placement?.kind === "connected_machine" ||
|
|
3446
|
+
!record.controllerHostSandboxGroupId;
|
|
3447
|
+
const [authorized, sourceRead] = await Promise.allSettled([
|
|
3448
|
+
authorizeSourceSession(deps, grant, record.sourceSessionId, authorizationOperation),
|
|
3449
|
+
sourceSessionNeeded
|
|
3450
|
+
? requireSourceSession(deps, workspaceId, record.sourceSessionId)
|
|
3451
|
+
: Promise.resolve(null),
|
|
3452
|
+
]);
|
|
3453
|
+
if (authorized.status === "rejected") throw authorized.reason;
|
|
3454
|
+
if (sourceRead.status === "rejected") throw sourceRead.reason;
|
|
3455
|
+
const sourceAuthorization = authorized.value;
|
|
3184
3456
|
stages?.mark("authorize");
|
|
3185
|
-
let sourceSession: Session | null =
|
|
3457
|
+
let sourceSession: Session | null = sourceRead.value;
|
|
3186
3458
|
if (record.session.placement?.kind === "connected_machine") {
|
|
3187
|
-
|
|
3188
|
-
if (sourceSession.activeSandboxId !== record.session.placement.sandboxId) {
|
|
3459
|
+
if (sourceSession?.activeSandboxId !== record.session.placement.sandboxId) {
|
|
3189
3460
|
return await throwBrowserSourcePlacementChanged(
|
|
3190
3461
|
grant,
|
|
3191
3462
|
record.sourceSessionId,
|
|
@@ -3217,6 +3488,7 @@ export function registerBrowserSessionRoutes(
|
|
|
3217
3488
|
placement: BrowserPlacement,
|
|
3218
3489
|
provisionedClient?: BrowserControlClient,
|
|
3219
3490
|
): Promise<T> => {
|
|
3491
|
+
authorizationReads.handOffToToolDispatch();
|
|
3220
3492
|
// An active binding already proves browserd was provisioned. Reusing
|
|
3221
3493
|
// it avoids restarting/checking the sidecar on every live input.
|
|
3222
3494
|
const client = provisionedClient ?? connectController(deps, grant, record, placement);
|
|
@@ -4422,6 +4694,34 @@ function requireOperationGeneration(record: BrowserSessionControlRecord): string
|
|
|
4422
4694
|
return generation;
|
|
4423
4695
|
}
|
|
4424
4696
|
|
|
4697
|
+
/** Attach the controller's launch view to a create that activated exactly that
|
|
4698
|
+
* launch in this request. Replays, failures and other bindings get nothing, so
|
|
4699
|
+
* a caller then reads the targets and observes as before. */
|
|
4700
|
+
export function withInitialView<
|
|
4701
|
+
T extends {
|
|
4702
|
+
session: { id: string; lifecycle: string; controller: { controllerGeneration: string } | null };
|
|
4703
|
+
operation: { state: string; replayed: boolean };
|
|
4704
|
+
},
|
|
4705
|
+
>(
|
|
4706
|
+
response: T,
|
|
4707
|
+
launched: Awaited<ReturnType<BrowserControlClient["createSession"]>> | null,
|
|
4708
|
+
): T | (T & { initialView: { targets: BrowserTarget[]; observation: BrowserObservation } }) {
|
|
4709
|
+
if (
|
|
4710
|
+
!launched?.targets ||
|
|
4711
|
+
response.operation.state !== "completed" ||
|
|
4712
|
+
response.operation.replayed ||
|
|
4713
|
+
response.session.lifecycle !== "active" ||
|
|
4714
|
+
response.session.id !== launched.browserSessionId ||
|
|
4715
|
+
response.session.controller?.controllerGeneration !== launched.controllerGeneration
|
|
4716
|
+
) {
|
|
4717
|
+
return response;
|
|
4718
|
+
}
|
|
4719
|
+
return {
|
|
4720
|
+
...response,
|
|
4721
|
+
initialView: { targets: launched.targets, observation: launched.observation },
|
|
4722
|
+
};
|
|
4723
|
+
}
|
|
4724
|
+
|
|
4425
4725
|
async function provisionController(
|
|
4426
4726
|
deps: ApiRouteDeps,
|
|
4427
4727
|
grant: AccessGrant,
|
|
@@ -4631,6 +4931,15 @@ async function settleBrowserRevisionCaptureFailure(
|
|
|
4631
4931
|
});
|
|
4632
4932
|
}
|
|
4633
4933
|
|
|
4934
|
+
type ActiveBrowserControllerCallback<T> = (input: {
|
|
4935
|
+
client: BrowserControlClient;
|
|
4936
|
+
sessionClient: ReturnType<BrowserControlClient["sessionClient"]>;
|
|
4937
|
+
record: BrowserSessionControlRecord;
|
|
4938
|
+
binding: NonNullable<BrowserSessionControlRecord["session"]["controller"]>;
|
|
4939
|
+
placement: BrowserPlacement;
|
|
4940
|
+
sourceAuthorization: ResolvedSessionAuthorization | null;
|
|
4941
|
+
}) => Promise<T>;
|
|
4942
|
+
|
|
4634
4943
|
async function requireSourceSession(
|
|
4635
4944
|
deps: ApiRouteDeps,
|
|
4636
4945
|
workspaceId: string,
|
|
@@ -5332,6 +5641,91 @@ function recordAttachedBrowserUnavailable(
|
|
|
5332
5641
|
}
|
|
5333
5642
|
}
|
|
5334
5643
|
|
|
5644
|
+
const WORKING_COPY_REFUSAL_EXPLANATIONS: Record<BrowserWorkingCopyRefusalValue, string> = {
|
|
5645
|
+
unsupported_platform:
|
|
5646
|
+
"Adopting a working copy is supported only for browsers on Linux machines, where the controller can prove no process uses the copy. Nothing was changed.",
|
|
5647
|
+
unsupported_browser:
|
|
5648
|
+
"Only a managed Chromium browser with a private profile keeps a working copy that can be adopted. Nothing was changed.",
|
|
5649
|
+
browser_running:
|
|
5650
|
+
"The browser is running, starting or being ended on its controller, so there is no stopped working copy to adopt now. If it is running, use it normally; otherwise inspect again once it has stopped.",
|
|
5651
|
+
working_copy_invalid:
|
|
5652
|
+
"The browser's working directory on its machine is missing, incomplete or not a plain directory, so it cannot be adopted. Nothing was changed.",
|
|
5653
|
+
receipt_not_adoptable:
|
|
5654
|
+
"The working copy has no completed record of a browser launch, so it cannot be adopted. Its record and recovery state are preserved.",
|
|
5655
|
+
authority_changed:
|
|
5656
|
+
"The working copy was launched under a different controller authority than this browser now has, so it cannot be adopted for it. Nothing was changed.",
|
|
5657
|
+
process_state_unknown:
|
|
5658
|
+
"It cannot be proven that no process uses the copy's browser profile. Close any browser using it, then inspect again. Nothing was changed.",
|
|
5659
|
+
adoption_not_needed:
|
|
5660
|
+
"The working copy is the same directory the browser was launched in, so it needs no adoption; the browser continues from it when it is next used.",
|
|
5661
|
+
adoption_unresolved:
|
|
5662
|
+
"An earlier adoption for this browser has not finished. Check it with getBrowserSessionWorkingCopyAdoption and its operation id (see activeAdoption). Inspecting and checking never change it; retrying that same adoption with the same operationId and manifestDigest settles it. Do not start another.",
|
|
5663
|
+
};
|
|
5664
|
+
|
|
5665
|
+
function workingCopyInspectionExplanation(
|
|
5666
|
+
inspection: PlacementBrowserWorkingCopyInspection,
|
|
5667
|
+
latestCheckpointAt: string | null,
|
|
5668
|
+
): string {
|
|
5669
|
+
if (inspection.refusal) return WORKING_COPY_REFUSAL_EXPLANATIONS[inspection.refusal];
|
|
5670
|
+
const manifest = inspection.manifest!;
|
|
5671
|
+
return [
|
|
5672
|
+
`This working copy can be adopted: ${manifest.entries} entries, ${manifest.bytes} bytes, manifest digest ${manifest.digest}.`,
|
|
5673
|
+
manifest.excluded.length > 0
|
|
5674
|
+
? `Left out because the controller rebuilds them: ${manifest.excluded.join(", ")}. Everything else is in the digest.`
|
|
5675
|
+
: "Every entry is in the digest.",
|
|
5676
|
+
"It is not the directory the browser was launched in (it was moved, copied or restored), so the browser refuses to continue from it until it is adopted.",
|
|
5677
|
+
latestCheckpointAt
|
|
5678
|
+
? `How recent its contents are is not proven; the browser's latest saved checkpoint is from ${latestCheckpointAt}.`
|
|
5679
|
+
: "How recent its contents are is not proven.",
|
|
5680
|
+
"Adopting selects exactly these bytes as the browser's state and is never replaced by an older checkpoint afterwards; the directory it replaces is kept.",
|
|
5681
|
+
"To adopt, call adoptBrowserSessionWorkingCopy with this manifestDigest and a new operationId. If the bytes change first, the adoption is refused.",
|
|
5682
|
+
].join(" ");
|
|
5683
|
+
}
|
|
5684
|
+
|
|
5685
|
+
function publicWorkingCopyAdoption(
|
|
5686
|
+
adoption: PlacementBrowserWorkingCopyAdoption,
|
|
5687
|
+
): BrowserWorkingCopyAdoptionValue {
|
|
5688
|
+
return {
|
|
5689
|
+
operationId: adoption.operationId,
|
|
5690
|
+
browserSessionId: adoption.browserSessionId,
|
|
5691
|
+
controllerGeneration: adoption.controllerGeneration,
|
|
5692
|
+
manifestDigest: adoption.manifestDigest,
|
|
5693
|
+
phase: adoption.phase,
|
|
5694
|
+
failureCode: adoption.failureCode,
|
|
5695
|
+
retainedDirectories: adoption.retainedDirectories,
|
|
5696
|
+
createdAt: adoption.createdAt,
|
|
5697
|
+
updatedAt: adoption.updatedAt,
|
|
5698
|
+
};
|
|
5699
|
+
}
|
|
5700
|
+
|
|
5701
|
+
function workingCopyAdoptionExplanation(
|
|
5702
|
+
inFlight: boolean,
|
|
5703
|
+
adoption: PlacementBrowserWorkingCopyAdoption | null,
|
|
5704
|
+
): string {
|
|
5705
|
+
const kept = adoption?.retainedDirectories.length
|
|
5706
|
+
? ` Kept on the machine for separate cleanup: ${adoption.retainedDirectories.join(", ")}.`
|
|
5707
|
+
: "";
|
|
5708
|
+
if (inFlight)
|
|
5709
|
+
return "The adoption is still running on the controller. Check again with getBrowserSessionWorkingCopyAdoption; do not start another.";
|
|
5710
|
+
if (!adoption)
|
|
5711
|
+
return "The controller has no record of this adoption, which proves it changed nothing there.";
|
|
5712
|
+
switch (adoption.phase) {
|
|
5713
|
+
case "adopted":
|
|
5714
|
+
return `Adopted. The browser continues from the copy with manifest digest ${adoption.manifestDigest} the next time it is used, and is never replaced by an older checkpoint.${kept}`;
|
|
5715
|
+
case "interrupted":
|
|
5716
|
+
case "failed":
|
|
5717
|
+
return `The adoption stopped before the copy took the browser's place${adoption.failureCode ? ` (${adoption.failureCode})` : ""}; the browser's own directory is unchanged.${kept}`;
|
|
5718
|
+
case "collision":
|
|
5719
|
+
return `The adoption found the browser's directories in an unexpected state (${adoption.failureCode ?? "unknown"}) and stopped without moving or removing anything more. The browser stays unavailable until this adoption is reverted with revertBrowserSessionWorkingCopyAdoption, which puts the browser's own directory back and keeps the staged copy. If the revert refuses, the directories need a person on that machine.${kept}`;
|
|
5720
|
+
case "reverting":
|
|
5721
|
+
return "A revert of this adoption stopped part way and is not running. Retry revertBrowserSessionWorkingCopyAdoption with the same operationId to finish it; a retry repeats nothing already done.";
|
|
5722
|
+
case "reverted":
|
|
5723
|
+
return `Reverted. The browser's own directory is back in place and the staged copy was not used.${kept}`;
|
|
5724
|
+
default:
|
|
5725
|
+
return "The adoption stopped part way and is not running. Checking it never changes it. To finish it, retry the same adoption with the same operationId and manifestDigest under the authority that approved it; a retry repeats nothing already done. To undo it instead, revert it with revertBrowserSessionWorkingCopyAdoption. Do not start another.";
|
|
5726
|
+
}
|
|
5727
|
+
}
|
|
5728
|
+
|
|
5335
5729
|
function browserRouteError(error: unknown): HTTPException {
|
|
5336
5730
|
const tabDiagnosis = browserTabDiagnosisError(error);
|
|
5337
5731
|
if (tabDiagnosis instanceof ApiHttpError) return tabDiagnosis;
|