@opengeni/runtime 4.0.1 → 4.0.3
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/{agent-RJQLKFIW.js → agent-ME6HT4DH.js} +2 -2
- package/dist/assets/codemode-client.json +1 -1
- package/dist/browser-action-json-schema.d.ts +4 -0
- package/dist/{chunk-J5AR5XAJ.js → chunk-GBVN7WSK.js} +377 -210
- package/dist/chunk-GBVN7WSK.js.map +1 -0
- package/dist/{chunk-ZG2YH25H.js → chunk-JAYHA6BF.js} +2 -2
- package/dist/{chunk-J6J3VYFW.js → chunk-LL2YQO5H.js} +527 -48
- package/dist/chunk-LL2YQO5H.js.map +1 -0
- package/dist/{chunk-UNICCRW5.js → chunk-LZIXCZUV.js} +6 -3
- package/dist/{chunk-UNICCRW5.js.map → chunk-LZIXCZUV.js.map} +1 -1
- package/dist/{chunk-SPT3PLMX.js → chunk-PRHMSM6C.js} +2 -2
- package/dist/index.js +7 -7
- package/dist/interaction-tools.d.ts +1 -1
- package/dist/{lazy-manifest-OEYEHNVY.js → lazy-manifest-GCGUFIWP.js} +3 -3
- package/dist/{localSources-TWHTEKGO.js → localSources-FDH65AFI.js} +4 -4
- package/dist/sandbox/browser-control-client.d.ts +28 -1
- package/dist/sandbox/index.d.ts +2 -2
- package/dist/sandbox/index.js +6 -6
- package/dist/sandbox/providers/modal-command-router-wire.d.ts +8 -0
- package/dist/sandbox/providers/modal.d.ts +5 -6
- package/dist/workspace-tool-gateway.js +5 -5
- package/package.json +7 -7
- package/src/browser-action-json-schema.ts +10 -0
- package/src/bundled_artifact_skills/opengeni-documents/SKILL.md +4 -1
- package/src/bundled_artifact_skills/opengeni-documents/references/api.md +20 -0
- package/src/bundled_default_skills/opengeni-client/SKILL.md +4 -1
- package/src/bundled_default_skills/opengeni-client/references/discovery-and-autonomy.md +31 -0
- package/src/bundled_default_skills/opengeni-client/references/runtime-profile-and-verification.md +32 -0
- package/src/index.ts +9 -0
- package/src/interaction-tools.ts +656 -48
- package/src/sandbox/browser-control-client.ts +241 -26
- package/src/sandbox/index.ts +3 -1
- package/src/sandbox/providers/modal-command-control.ts +7 -1
- package/src/sandbox/providers/modal-command-router-wire.ts +47 -6
- package/src/sandbox/providers/modal.ts +16 -24
- package/src/sandbox/routing/routing-session.ts +4 -2
- package/dist/chunk-J5AR5XAJ.js.map +0 -1
- package/dist/chunk-J6J3VYFW.js.map +0 -1
- /package/dist/{agent-RJQLKFIW.js.map → agent-ME6HT4DH.js.map} +0 -0
- /package/dist/{chunk-ZG2YH25H.js.map → chunk-JAYHA6BF.js.map} +0 -0
- /package/dist/{chunk-SPT3PLMX.js.map → chunk-PRHMSM6C.js.map} +0 -0
- /package/dist/{lazy-manifest-OEYEHNVY.js.map → lazy-manifest-GCGUFIWP.js.map} +0 -0
- /package/dist/{localSources-TWHTEKGO.js.map → localSources-FDH65AFI.js.map} +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { posix as posixPath } from "node:path";
|
|
3
|
+
import { parseBrowserFrameMetadata, type BrowserFrameMetadata } from "@opengeni/sdk";
|
|
3
4
|
import {
|
|
4
5
|
BROWSER_CONTROL_MAX_JSON_BYTES,
|
|
5
6
|
BROWSER_CONTROL_PORT,
|
|
@@ -14,6 +15,8 @@ import {
|
|
|
14
15
|
BrowserDownloadExportReceipt,
|
|
15
16
|
BrowserDownloadExportRequest,
|
|
16
17
|
BrowserDownloadListResponse,
|
|
18
|
+
BrowserDomReadRequest,
|
|
19
|
+
BrowserDomReadResponse,
|
|
17
20
|
BrowserExternalAuthCommand,
|
|
18
21
|
BrowserExternalAuthResult,
|
|
19
22
|
BrowserObservation,
|
|
@@ -21,6 +24,7 @@ import {
|
|
|
21
24
|
BrowserProtectedAuthFillReceipt,
|
|
22
25
|
BrowserRevisionMaterialization,
|
|
23
26
|
BrowserTarget,
|
|
27
|
+
BrowserTargetState,
|
|
24
28
|
BrowserWorkspaceFileStageRequest,
|
|
25
29
|
BrowserWorkspaceFileStageResponse,
|
|
26
30
|
ComputerActionCommand,
|
|
@@ -40,6 +44,8 @@ import {
|
|
|
40
44
|
type BrowserDownloadExportReceipt as BrowserDownloadExportReceiptValue,
|
|
41
45
|
type BrowserDownloadExportRequest as BrowserDownloadExportRequestValue,
|
|
42
46
|
type BrowserDownloadListResponse as BrowserDownloadListResponseValue,
|
|
47
|
+
type BrowserDomReadRequest as BrowserDomReadRequestValue,
|
|
48
|
+
type BrowserDomReadResponse as BrowserDomReadResponseValue,
|
|
43
49
|
type BrowserExternalAuthCommand as BrowserExternalAuthCommandValue,
|
|
44
50
|
type BrowserExternalAuthResult as BrowserExternalAuthResultValue,
|
|
45
51
|
type BrowserDiagnosticKind,
|
|
@@ -48,6 +54,7 @@ import {
|
|
|
48
54
|
type BrowserProtectedAuthFillReceipt as BrowserProtectedAuthFillReceiptValue,
|
|
49
55
|
type BrowserRevisionMaterialization as BrowserRevisionMaterializationValue,
|
|
50
56
|
type BrowserTarget as BrowserTargetValue,
|
|
57
|
+
type BrowserTargetState as BrowserTargetStateValue,
|
|
51
58
|
type BrowserWorkspaceFileStageRequest as BrowserWorkspaceFileStageRequestValue,
|
|
52
59
|
type BrowserWorkspaceFileStageResponse as BrowserWorkspaceFileStageResponseValue,
|
|
53
60
|
type ComputerActionCommand as ComputerActionCommandValue,
|
|
@@ -165,6 +172,35 @@ export type ComputerControlFrame = {
|
|
|
165
172
|
metadata: ComputerControlFrameMetadata;
|
|
166
173
|
};
|
|
167
174
|
|
|
175
|
+
export type BrowserControlFrame = {
|
|
176
|
+
data: Uint8Array;
|
|
177
|
+
mediaType: "image/jpeg" | "image/png";
|
|
178
|
+
metadataHeader: string;
|
|
179
|
+
metadata: BrowserFrameMetadata;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export type BrowserControlScreenshotOptions = {
|
|
183
|
+
fullPage?: boolean;
|
|
184
|
+
format?: "jpeg" | "png";
|
|
185
|
+
quality?: number;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
type ExpectedBrowserFrameEvidence = {
|
|
189
|
+
browserSessionId: string;
|
|
190
|
+
controllerGeneration: string;
|
|
191
|
+
targetId: string;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
type FrameRequest = {
|
|
195
|
+
method: "GET";
|
|
196
|
+
path: string;
|
|
197
|
+
token: string;
|
|
198
|
+
timeoutMs?: number;
|
|
199
|
+
} & (
|
|
200
|
+
| { surface: "browser"; expectedFrameEvidence: ExpectedBrowserFrameEvidence }
|
|
201
|
+
| { surface: "computer"; expectedFrameEvidence: ExpectedComputerFrameEvidence }
|
|
202
|
+
);
|
|
203
|
+
|
|
168
204
|
export type ComputerControlFrameMetadata = {
|
|
169
205
|
frameId: string;
|
|
170
206
|
computerSessionId: string;
|
|
@@ -947,7 +983,17 @@ export class BrowserControlClient {
|
|
|
947
983
|
expectedFrameEvidence: ExpectedComputerFrameEvidence;
|
|
948
984
|
timeoutMs?: number;
|
|
949
985
|
}): Promise<ComputerControlFrame> {
|
|
950
|
-
return await this.requestBytes(input);
|
|
986
|
+
return (await this.requestBytes({ ...input, surface: "computer" })) as ComputerControlFrame;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
async requestBrowserBytesForSession(input: {
|
|
990
|
+
method: "GET";
|
|
991
|
+
path: string;
|
|
992
|
+
token: string;
|
|
993
|
+
expectedFrameEvidence: ExpectedBrowserFrameEvidence;
|
|
994
|
+
timeoutMs?: number;
|
|
995
|
+
}): Promise<BrowserControlFrame> {
|
|
996
|
+
return (await this.requestBytes({ ...input, surface: "browser" })) as BrowserControlFrame;
|
|
951
997
|
}
|
|
952
998
|
|
|
953
999
|
private async requestJson(
|
|
@@ -1124,15 +1170,9 @@ export class BrowserControlClient {
|
|
|
1124
1170
|
}
|
|
1125
1171
|
|
|
1126
1172
|
private async requestBytes(
|
|
1127
|
-
input:
|
|
1128
|
-
method: "GET";
|
|
1129
|
-
path: string;
|
|
1130
|
-
token: string;
|
|
1131
|
-
expectedFrameEvidence: ExpectedComputerFrameEvidence;
|
|
1132
|
-
timeoutMs?: number;
|
|
1133
|
-
},
|
|
1173
|
+
input: FrameRequest,
|
|
1134
1174
|
retryNativeEndpoint = true,
|
|
1135
|
-
): Promise<ComputerControlFrame> {
|
|
1175
|
+
): Promise<ComputerControlFrame | BrowserControlFrame> {
|
|
1136
1176
|
const requireHostFetch = this.session.requireHostFetchController === true;
|
|
1137
1177
|
if (this.session.resolveExposedPort && !this.session.ensureBrowserControl) {
|
|
1138
1178
|
try {
|
|
@@ -1247,10 +1287,10 @@ export class BrowserControlClient {
|
|
|
1247
1287
|
const data = await readBytes(
|
|
1248
1288
|
this.session,
|
|
1249
1289
|
responsePath,
|
|
1250
|
-
COMPUTER_SCREENSHOT_MAX_BYTES,
|
|
1290
|
+
input.surface === "browser" ? 24 * 1024 * 1024 : COMPUTER_SCREENSHOT_MAX_BYTES,
|
|
1251
1291
|
timeoutMs,
|
|
1252
1292
|
);
|
|
1253
|
-
return
|
|
1293
|
+
return controllerImageFrame(data, headers, input);
|
|
1254
1294
|
} catch (error) {
|
|
1255
1295
|
if (
|
|
1256
1296
|
error instanceof BrowserControlRequestError ||
|
|
@@ -1432,6 +1472,104 @@ export class BrowserControlSessionClient {
|
|
|
1432
1472
|
);
|
|
1433
1473
|
}
|
|
1434
1474
|
|
|
1475
|
+
async targetState(targetId: string): Promise<BrowserTargetStateValue> {
|
|
1476
|
+
let state: BrowserTargetStateValue;
|
|
1477
|
+
try {
|
|
1478
|
+
state = BrowserTargetState.parse(
|
|
1479
|
+
await this.parent.requestForSession({
|
|
1480
|
+
method: "GET",
|
|
1481
|
+
path: this.targetPath(targetId, "state"),
|
|
1482
|
+
token: this.viewToken,
|
|
1483
|
+
}),
|
|
1484
|
+
);
|
|
1485
|
+
} catch (error) {
|
|
1486
|
+
// Active Connected Machines can run an older browserd during an API rollout.
|
|
1487
|
+
// Only its exact unknown-route response permits the existing observation path.
|
|
1488
|
+
// A missing session or target must keep its original error.
|
|
1489
|
+
if (
|
|
1490
|
+
!(error instanceof BrowserControlRequestError) ||
|
|
1491
|
+
error.status !== 404 ||
|
|
1492
|
+
error.error.code !== "resource_not_found" ||
|
|
1493
|
+
error.error.message !== "route not found"
|
|
1494
|
+
) {
|
|
1495
|
+
throw error;
|
|
1496
|
+
}
|
|
1497
|
+
const observation = await this.observe(targetId);
|
|
1498
|
+
state = BrowserTargetState.parse({
|
|
1499
|
+
browserSessionId: observation.browserSessionId,
|
|
1500
|
+
controllerGeneration: observation.target.controllerGeneration,
|
|
1501
|
+
targetId: observation.target.id,
|
|
1502
|
+
targetGeneration: observation.target.targetGeneration,
|
|
1503
|
+
documentGeneration: observation.target.documentGeneration,
|
|
1504
|
+
frameId: observation.frameId,
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
if (
|
|
1508
|
+
state.browserSessionId !== this.reference.browserSessionId ||
|
|
1509
|
+
state.controllerGeneration !== this.reference.controllerGeneration ||
|
|
1510
|
+
state.targetId !== targetId
|
|
1511
|
+
) {
|
|
1512
|
+
throw new BrowserControlProtocolError("browser target state belongs to another binding");
|
|
1513
|
+
}
|
|
1514
|
+
return state;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
async readDom(
|
|
1518
|
+
targetId: string,
|
|
1519
|
+
requestInput: BrowserDomReadRequestValue,
|
|
1520
|
+
): Promise<BrowserDomReadResponseValue> {
|
|
1521
|
+
const request = BrowserDomReadRequest.parse(requestInput);
|
|
1522
|
+
const result = BrowserDomReadResponse.parse(
|
|
1523
|
+
await this.parent.requestForSession({
|
|
1524
|
+
method: "POST",
|
|
1525
|
+
path: this.targetPath(targetId, "dom-read"),
|
|
1526
|
+
token: this.viewToken,
|
|
1527
|
+
body: request,
|
|
1528
|
+
}),
|
|
1529
|
+
);
|
|
1530
|
+
if (
|
|
1531
|
+
result.browserSessionId !== this.reference.browserSessionId ||
|
|
1532
|
+
result.controllerGeneration !== this.reference.controllerGeneration ||
|
|
1533
|
+
result.targetId !== targetId ||
|
|
1534
|
+
result.targetGeneration !== request.expectedTargetGeneration ||
|
|
1535
|
+
result.documentGeneration !== request.expectedDocumentGeneration ||
|
|
1536
|
+
result.frameId !== request.expectedFrameId ||
|
|
1537
|
+
result.kind !== request.kind
|
|
1538
|
+
) {
|
|
1539
|
+
throw new BrowserControlProtocolError("browser DOM read belongs to another binding");
|
|
1540
|
+
}
|
|
1541
|
+
return result;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
async capture(
|
|
1545
|
+
targetId: string,
|
|
1546
|
+
options: BrowserControlScreenshotOptions = {},
|
|
1547
|
+
): Promise<BrowserControlFrame> {
|
|
1548
|
+
const query = new URLSearchParams({
|
|
1549
|
+
format: options.format ?? "jpeg",
|
|
1550
|
+
quality: String(boundedInteger(options.quality ?? 55, 1, 100, "browser screenshot quality")),
|
|
1551
|
+
});
|
|
1552
|
+
if (options.format !== undefined && options.format !== "jpeg" && options.format !== "png") {
|
|
1553
|
+
throw new RangeError("browser screenshot format is invalid");
|
|
1554
|
+
}
|
|
1555
|
+
if (options.fullPage !== undefined) {
|
|
1556
|
+
if (typeof options.fullPage !== "boolean") {
|
|
1557
|
+
throw new RangeError("browser screenshot fullPage is invalid");
|
|
1558
|
+
}
|
|
1559
|
+
query.set("fullPage", String(options.fullPage));
|
|
1560
|
+
}
|
|
1561
|
+
return await this.parent.requestBrowserBytesForSession({
|
|
1562
|
+
method: "GET",
|
|
1563
|
+
path: this.targetPath(targetId, `screenshot?${query}`),
|
|
1564
|
+
token: this.viewToken,
|
|
1565
|
+
expectedFrameEvidence: {
|
|
1566
|
+
browserSessionId: this.reference.browserSessionId,
|
|
1567
|
+
controllerGeneration: this.reference.controllerGeneration,
|
|
1568
|
+
targetId: requireOpaqueId(targetId, "browser target id"),
|
|
1569
|
+
},
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1435
1573
|
async readClipboard(): Promise<BrowserClipboardValue> {
|
|
1436
1574
|
return BrowserClipboard.parse(
|
|
1437
1575
|
await this.parent.requestForSession({
|
|
@@ -1840,15 +1978,9 @@ async function requestExposedController(
|
|
|
1840
1978
|
|
|
1841
1979
|
async function requestExposedControllerBytes(
|
|
1842
1980
|
endpoint: ExposedPortEndpoint,
|
|
1843
|
-
input:
|
|
1844
|
-
method: "GET";
|
|
1845
|
-
path: string;
|
|
1846
|
-
token: string;
|
|
1847
|
-
expectedFrameEvidence: ExpectedComputerFrameEvidence;
|
|
1848
|
-
timeoutMs?: number;
|
|
1849
|
-
},
|
|
1981
|
+
input: FrameRequest,
|
|
1850
1982
|
defaultTimeoutMs: number,
|
|
1851
|
-
): Promise<ComputerControlFrame> {
|
|
1983
|
+
): Promise<ComputerControlFrame | BrowserControlFrame> {
|
|
1852
1984
|
const token = requireToken(input.token, "browser controller token");
|
|
1853
1985
|
const timeoutMs = boundedTimeout(input.timeoutMs ?? defaultTimeoutMs);
|
|
1854
1986
|
const streamUrl = exposedControllerUrl(endpoint, input.path);
|
|
@@ -1872,12 +2004,91 @@ async function requestExposedControllerBytes(
|
|
|
1872
2004
|
throw new BrowserControlProtocolError("browser controller image response is invalid");
|
|
1873
2005
|
}
|
|
1874
2006
|
const declaredLength = Number(response.headers.get("content-length"));
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
2007
|
+
const maxBytes = input.surface === "browser" ? 24 * 1024 * 1024 : COMPUTER_SCREENSHOT_MAX_BYTES;
|
|
2008
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
2009
|
+
await response.body?.cancel("frame exceeds its byte bound").catch(() => undefined);
|
|
2010
|
+
throw new RangeError("frame exceeds its byte bound");
|
|
2011
|
+
}
|
|
2012
|
+
const data = await readBoundedFrameResponse(response, maxBytes);
|
|
2013
|
+
return controllerImageFrame(data, response.headers, input);
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
async function readBoundedFrameResponse(response: Response, maxBytes: number): Promise<Uint8Array> {
|
|
2017
|
+
if (!response.body) throw new BrowserControlProtocolError("browser controller image is empty");
|
|
2018
|
+
const reader = response.body.getReader();
|
|
2019
|
+
const chunks: Uint8Array[] = [];
|
|
2020
|
+
let size = 0;
|
|
2021
|
+
try {
|
|
2022
|
+
for (;;) {
|
|
2023
|
+
const { done, value } = await reader.read();
|
|
2024
|
+
if (done) break;
|
|
2025
|
+
size += value.byteLength;
|
|
2026
|
+
if (size > maxBytes) {
|
|
2027
|
+
await reader.cancel("frame exceeds its byte bound").catch(() => undefined);
|
|
2028
|
+
throw new RangeError("frame exceeds its byte bound");
|
|
2029
|
+
}
|
|
2030
|
+
chunks.push(value);
|
|
2031
|
+
}
|
|
2032
|
+
} finally {
|
|
2033
|
+
reader.releaseLock();
|
|
1878
2034
|
}
|
|
1879
|
-
const data = new Uint8Array(
|
|
1880
|
-
|
|
2035
|
+
const data = new Uint8Array(size);
|
|
2036
|
+
let offset = 0;
|
|
2037
|
+
for (const chunk of chunks) {
|
|
2038
|
+
data.set(chunk, offset);
|
|
2039
|
+
offset += chunk.byteLength;
|
|
2040
|
+
}
|
|
2041
|
+
return data;
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
function controllerImageFrame(
|
|
2045
|
+
data: Uint8Array,
|
|
2046
|
+
headers: { get(name: string): string | null | undefined },
|
|
2047
|
+
input: FrameRequest,
|
|
2048
|
+
): ComputerControlFrame | BrowserControlFrame {
|
|
2049
|
+
return input.surface === "browser"
|
|
2050
|
+
? browserControlFrame(data, headers, input.expectedFrameEvidence)
|
|
2051
|
+
: computerControlFrame(data, headers, input.expectedFrameEvidence);
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
function browserControlFrame(
|
|
2055
|
+
data: Uint8Array,
|
|
2056
|
+
headers: { get(name: string): string | null | undefined },
|
|
2057
|
+
expected: ExpectedBrowserFrameEvidence,
|
|
2058
|
+
): BrowserControlFrame {
|
|
2059
|
+
if (data.byteLength < 1 || data.byteLength > 24 * 1024 * 1024) {
|
|
2060
|
+
throw new RangeError("browser frame exceeds its byte bound");
|
|
2061
|
+
}
|
|
2062
|
+
const get = (name: string): string | null => headers.get(name.toLowerCase()) ?? null;
|
|
2063
|
+
const mediaType = get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
|
|
2064
|
+
if (mediaType !== "image/jpeg" && mediaType !== "image/png") {
|
|
2065
|
+
throw new BrowserControlProtocolError("browser frame media type is invalid");
|
|
2066
|
+
}
|
|
2067
|
+
const metadataHeader = get("x-opengeni-browser-frame");
|
|
2068
|
+
if (
|
|
2069
|
+
!metadataHeader ||
|
|
2070
|
+
metadataHeader.length > 64 * 1024 ||
|
|
2071
|
+
!/^[A-Za-z0-9_-]+$/u.test(metadataHeader)
|
|
2072
|
+
) {
|
|
2073
|
+
throw new BrowserControlProtocolError("browser frame metadata is invalid");
|
|
2074
|
+
}
|
|
2075
|
+
let metadata: BrowserFrameMetadata;
|
|
2076
|
+
try {
|
|
2077
|
+
metadata = parseBrowserFrameMetadata(
|
|
2078
|
+
JSON.parse(Buffer.from(metadataHeader, "base64url").toString("utf8")),
|
|
2079
|
+
);
|
|
2080
|
+
} catch {
|
|
2081
|
+
throw new BrowserControlProtocolError("browser frame metadata is invalid");
|
|
2082
|
+
}
|
|
2083
|
+
if (
|
|
2084
|
+
metadata.browserSessionId !== expected.browserSessionId ||
|
|
2085
|
+
metadata.controllerGeneration !== expected.controllerGeneration ||
|
|
2086
|
+
metadata.targetId !== expected.targetId ||
|
|
2087
|
+
metadata.mediaType !== mediaType
|
|
2088
|
+
) {
|
|
2089
|
+
throw new BrowserControlProtocolError("browser frame evidence does not match its request");
|
|
2090
|
+
}
|
|
2091
|
+
return { data, mediaType, metadataHeader, metadata };
|
|
1881
2092
|
}
|
|
1882
2093
|
|
|
1883
2094
|
function computerControlFrame(
|
|
@@ -2764,7 +2975,11 @@ async function readBytes(
|
|
|
2764
2975
|
timeoutMs: number,
|
|
2765
2976
|
): Promise<Uint8Array> {
|
|
2766
2977
|
const privatePath = absolutePrivatePath(path, "browser private binary response path");
|
|
2767
|
-
if (
|
|
2978
|
+
if (
|
|
2979
|
+
!Number.isSafeInteger(maxBytes) ||
|
|
2980
|
+
maxBytes < 1 ||
|
|
2981
|
+
maxBytes > Math.max(24 * 1024 * 1024, COMPUTER_SCREENSHOT_MAX_BYTES)
|
|
2982
|
+
) {
|
|
2768
2983
|
throw new RangeError("browser private binary response limit is invalid");
|
|
2769
2984
|
}
|
|
2770
2985
|
const sizeMarker = `OPENGENI_BROWSER_PRIVATE_SIZE_${randomUUID()}`;
|
|
@@ -2783,7 +2998,7 @@ async function readBytes(
|
|
|
2783
2998
|
"browser private binary response size",
|
|
2784
2999
|
);
|
|
2785
3000
|
if (size < 1 || size > maxBytes) {
|
|
2786
|
-
throw new RangeError("
|
|
3001
|
+
throw new RangeError("frame exceeds its byte bound");
|
|
2787
3002
|
}
|
|
2788
3003
|
const chunks: Buffer[] = [];
|
|
2789
3004
|
for (let offset = 0; offset < size; offset += PRIVATE_READ_CHUNK_BYTES) {
|
package/src/sandbox/index.ts
CHANGED
|
@@ -175,7 +175,7 @@ export {
|
|
|
175
175
|
ensureModalRegistryImage,
|
|
176
176
|
deleteModalCheckpointSnapshot,
|
|
177
177
|
inspectModalSandboxLifecycle,
|
|
178
|
-
|
|
178
|
+
isModalTaskExecStartPreDispatchUnavailableError,
|
|
179
179
|
modalSessionMatchesCheckpointProviderBinding,
|
|
180
180
|
modalSandboxAttributionEnvironment,
|
|
181
181
|
modalSandboxAttributionTags,
|
|
@@ -303,6 +303,8 @@ export {
|
|
|
303
303
|
provisionBrowserControlClient,
|
|
304
304
|
validateComputerControlFrameEvidence,
|
|
305
305
|
type BrowserControlPlacementSession,
|
|
306
|
+
type BrowserControlScreenshotOptions,
|
|
307
|
+
type BrowserControlFrame,
|
|
306
308
|
type BrowserStateDownloadGrant,
|
|
307
309
|
type BrowserStateUploadGrant,
|
|
308
310
|
type BrowserViewGrant,
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from "./modal-legacy-command-control";
|
|
21
21
|
import {
|
|
22
22
|
ModalCommandRouterWire,
|
|
23
|
+
ModalCommandStartPreDispatchUnavailableError,
|
|
23
24
|
ModalCommandStartRejectedError,
|
|
24
25
|
} from "./modal-command-router-wire";
|
|
25
26
|
|
|
@@ -236,7 +237,12 @@ export class ModalCommandControl {
|
|
|
236
237
|
// A client-chosen router id remains the only possible invocation. The
|
|
237
238
|
// supervisor is idle, so an ambiguous launch never ran user code. Retain
|
|
238
239
|
// the descriptor and reconcile that id; do not replay the start.
|
|
239
|
-
if (
|
|
240
|
+
if (
|
|
241
|
+
!supervision ||
|
|
242
|
+
error instanceof ModalCommandStartRejectedError ||
|
|
243
|
+
error instanceof ModalCommandStartPreDispatchUnavailableError
|
|
244
|
+
)
|
|
245
|
+
throw error;
|
|
240
246
|
}
|
|
241
247
|
return command;
|
|
242
248
|
}
|
|
@@ -47,6 +47,35 @@ export class ModalCommandStartRejectedError extends ProviderCommandStartRejected
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** Only a local channel-readiness failure BEFORE TaskExecStart is issued can
|
|
51
|
+
* prove that start was never dispatched. RPC status/details, even if they look
|
|
52
|
+
* like a DNS resolver error, can originate from an accepting server. */
|
|
53
|
+
export class ModalCommandStartPreDispatchUnavailableError extends Error {
|
|
54
|
+
private constructor(cause: Error) {
|
|
55
|
+
super("Modal command router was not ready before Start dispatch", { cause });
|
|
56
|
+
this.name = "ModalCommandStartPreDispatchUnavailableError";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
static async ensureReady(client: Client, signal?: AbortSignal): Promise<void> {
|
|
60
|
+
signal?.throwIfAborted();
|
|
61
|
+
await new Promise<void>((resolve, reject) => {
|
|
62
|
+
let settled = false;
|
|
63
|
+
const finish = (error?: Error) => {
|
|
64
|
+
if (settled) return;
|
|
65
|
+
settled = true;
|
|
66
|
+
signal?.removeEventListener("abort", abort);
|
|
67
|
+
if (signal?.aborted) reject(signal.reason);
|
|
68
|
+
else if (error) reject(new ModalCommandStartPreDispatchUnavailableError(error));
|
|
69
|
+
else resolve();
|
|
70
|
+
};
|
|
71
|
+
const abort = () => finish();
|
|
72
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
73
|
+
client.waitForReady(Date.now() + 5_000, finish);
|
|
74
|
+
if (signal?.aborted) abort();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
50
79
|
export type ModalRouterIdentity = { taskId: string; execId: string };
|
|
51
80
|
export type ModalRouterAccess = { url: string; jwt: string };
|
|
52
81
|
export type ModalRouterStart = ModalRouterIdentity & {
|
|
@@ -76,16 +105,21 @@ export class ModalCommandRouterWire {
|
|
|
76
105
|
private readonly client: Client;
|
|
77
106
|
private readonly metadata: Metadata;
|
|
78
107
|
private closed = false;
|
|
79
|
-
|
|
80
108
|
constructor(access: ModalRouterAccess, trustedRoots?: Buffer) {
|
|
81
109
|
const url = new URL(access.url);
|
|
82
110
|
if (url.protocol !== "https:" || url.username || url.password || !access.jwt)
|
|
83
111
|
throw new Error("Modal command router requires authenticated TLS");
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
112
|
+
// URL.host elides HTTPS's default :443; use the actual normalized gRPC
|
|
113
|
+
// target for both the resolver and the authenticated TLS transport.
|
|
114
|
+
this.client = new Client(
|
|
115
|
+
`${url.hostname}:${url.port || "443"}`,
|
|
116
|
+
credentials.createSsl(trustedRoots),
|
|
117
|
+
{
|
|
118
|
+
"grpc.max_receive_message_length": maxWireBytes,
|
|
119
|
+
"grpc.max_send_message_length": maxWireBytes,
|
|
120
|
+
"grpc.enable_retries": 0,
|
|
121
|
+
},
|
|
122
|
+
);
|
|
89
123
|
this.metadata = new Metadata();
|
|
90
124
|
this.metadata.set("authorization", `Bearer ${access.jwt}`);
|
|
91
125
|
}
|
|
@@ -127,6 +161,13 @@ export class ModalCommandRouterWire {
|
|
|
127
161
|
}
|
|
128
162
|
|
|
129
163
|
async start(request: ModalRouterStart, signal?: AbortSignal): Promise<void> {
|
|
164
|
+
if (this.closed) throw new Error("Modal command router is closed");
|
|
165
|
+
try {
|
|
166
|
+
await ModalCommandStartPreDispatchUnavailableError.ensureReady(this.client, signal);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (this.closed) throw new Error("Modal command router is closed", { cause: error });
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
130
171
|
try {
|
|
131
172
|
await this.unary(
|
|
132
173
|
"TaskExecStart",
|
|
@@ -15,6 +15,8 @@ import { CAPABILITY_DESCRIPTORS } from "../capabilities";
|
|
|
15
15
|
import { SandboxChannelAService, type ChannelASession } from "../channel-a";
|
|
16
16
|
import { installModalCommandSession } from "./modal-command-session";
|
|
17
17
|
import { ModalCommandControl } from "./modal-command-control";
|
|
18
|
+
import { ModalCommandStartPreDispatchUnavailableError } from "./modal-command-router-wire";
|
|
19
|
+
import { isRoutingMutationOutcomeUnknownError } from "../routing/routing-session";
|
|
18
20
|
import type { ModalClient } from "modal";
|
|
19
21
|
import { ModalProcessObservationUnavailableError, SandboxConfigError } from "../errors";
|
|
20
22
|
export { ModalProcessObservationUnavailableError } from "../errors";
|
|
@@ -207,9 +209,6 @@ const MODAL_EXEC_STDIN_WRITE_PATH =
|
|
|
207
209
|
"/modal.task_command_router.TaskCommandRouter/TaskExecStdinWrite";
|
|
208
210
|
const MODAL_EXEC_ALREADY_COMPLETED_DETAILS =
|
|
209
211
|
/^Exec has already completed; stdin is no longer accepting writes(?: \(Error code: [A-Z0-9]+\))?$/;
|
|
210
|
-
const MODAL_TASK_EXEC_START_PATH = "/modal.task_command_router.TaskCommandRouter/TaskExecStart";
|
|
211
|
-
const MODAL_TASK_EXEC_START_DNS_RESOLUTION_DETAILS =
|
|
212
|
-
/^Name resolution failed for target dns:task-[a-z0-9]+\.w\.modal\.host:443$/;
|
|
213
212
|
const MODAL_TASK_EXEC_START_ERROR_MAX_DEPTH = 8;
|
|
214
213
|
const MODAL_TASK_EXEC_START_ERROR_MAX_NODES = 64;
|
|
215
214
|
const MODAL_TASK_EXEC_START_ERROR_MAX_AGGREGATE_ERRORS = 32;
|
|
@@ -234,24 +233,13 @@ function hasContradictoryModalHttpStatus(record: Record<string, unknown>): boole
|
|
|
234
233
|
return values.some((value) => modalHttpStatus(value) !== null);
|
|
235
234
|
}
|
|
236
235
|
|
|
237
|
-
function isModalTaskExecStartDnsResolutionLeaf(record: Record<string, unknown>): boolean {
|
|
238
|
-
return (
|
|
239
|
-
record.name === "ClientError" &&
|
|
240
|
-
record.path === MODAL_TASK_EXEC_START_PATH &&
|
|
241
|
-
(record.code === 14 || record.code === "UNAVAILABLE") &&
|
|
242
|
-
typeof record.details === "string" &&
|
|
243
|
-
MODAL_TASK_EXEC_START_DNS_RESOLUTION_DETAILS.test(record.details)
|
|
244
|
-
);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
236
|
/**
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* or mixed graph is rejected.
|
|
237
|
+
* Only the native client's pre-dispatch readiness gate proves Start was not
|
|
238
|
+
* sent. Server-controlled gRPC/SDK status and DNS-looking details prove nothing.
|
|
239
|
+
* Any retained or outcome-unknown routing boundary vetoes recovery even when
|
|
240
|
+
* it contains a genuine pre-dispatch error among its causes.
|
|
253
241
|
*/
|
|
254
|
-
export function
|
|
242
|
+
export function isModalTaskExecStartPreDispatchUnavailableError(error: unknown): boolean {
|
|
255
243
|
const pending: Array<{ depth: number; value: unknown }> = [{ depth: 0, value: error }];
|
|
256
244
|
const seen = new WeakSet<object>();
|
|
257
245
|
let inspected = 0;
|
|
@@ -270,8 +258,16 @@ export function isModalTaskExecStartDnsResolutionError(error: unknown): boolean
|
|
|
270
258
|
let nested: unknown[];
|
|
271
259
|
try {
|
|
272
260
|
const record = current.value as Record<string, unknown>;
|
|
261
|
+
if (isRoutingMutationOutcomeUnknownError(current.value)) return false;
|
|
273
262
|
if (hasContradictoryModalHttpStatus(record)) return false;
|
|
274
263
|
|
|
264
|
+
// This instance is created only by the client's readiness gate before
|
|
265
|
+
// Start dispatch; an RPC's own status or details never enters this path.
|
|
266
|
+
if (current.value instanceof ModalCommandStartPreDispatchUnavailableError) {
|
|
267
|
+
matchingLeaves += 1;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
275
271
|
nested = [];
|
|
276
272
|
for (const key of ["cause", "error"] as const) {
|
|
277
273
|
const value = record[key];
|
|
@@ -290,11 +286,7 @@ export function isModalTaskExecStartDnsResolutionError(error: unknown): boolean
|
|
|
290
286
|
nested.push(...errors);
|
|
291
287
|
}
|
|
292
288
|
|
|
293
|
-
if (nested.length === 0)
|
|
294
|
-
if (!isModalTaskExecStartDnsResolutionLeaf(record)) return false;
|
|
295
|
-
matchingLeaves += 1;
|
|
296
|
-
continue;
|
|
297
|
-
}
|
|
289
|
+
if (nested.length === 0) return false;
|
|
298
290
|
} catch {
|
|
299
291
|
return false;
|
|
300
292
|
}
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
// `@opengeni/db`.
|
|
29
29
|
|
|
30
30
|
import type { ExposedPortEndpoint } from "../stream-port";
|
|
31
|
+
import { ModalCommandStartPreDispatchUnavailableError } from "../providers/modal-command-router-wire";
|
|
31
32
|
import { isDeepStrictEqual } from "node:util";
|
|
32
33
|
import {
|
|
33
34
|
withProviderCommandHandle,
|
|
@@ -1687,7 +1688,8 @@ export class RoutingSandboxSession implements RoutableBackendSession {
|
|
|
1687
1688
|
} catch (error) {
|
|
1688
1689
|
if (reservedProcess) {
|
|
1689
1690
|
if (
|
|
1690
|
-
error instanceof ProviderCommandStartRejectedError
|
|
1691
|
+
(error instanceof ProviderCommandStartRejectedError ||
|
|
1692
|
+
error instanceof ModalCommandStartPreDispatchUnavailableError) &&
|
|
1691
1693
|
reservedProcess.providerCommand?.kind === "modal-router-v1"
|
|
1692
1694
|
) {
|
|
1693
1695
|
const persistence = this.deps.providerCommandPersistence?.(reservedProcess);
|
|
@@ -1697,7 +1699,7 @@ export class RoutingSandboxSession implements RoutableBackendSession {
|
|
|
1697
1699
|
} catch (cause) {
|
|
1698
1700
|
throw new RoutingMutationOutcomeUnknownError(
|
|
1699
1701
|
op,
|
|
1700
|
-
"Never-started provider
|
|
1702
|
+
"Never-started provider call could not settle its exact reservation",
|
|
1701
1703
|
{ cause, retainedProcess: reservedProcess },
|
|
1702
1704
|
);
|
|
1703
1705
|
}
|