@fungi.computer/oyster 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +376 -72
- package/dist/agents.d.ts +3 -2
- package/dist/agents.js +4 -2
- package/dist/app-builds.d.ts +81 -8
- package/dist/app-builds.js +210 -60
- package/dist/app-storage.d.ts +3 -3
- package/dist/approval-capability.d.ts +5 -0
- package/dist/approval-capability.js +46 -0
- package/dist/approval-home.d.ts +51 -0
- package/dist/approval-home.js +226 -0
- package/dist/apps/catalog.d.ts +335 -0
- package/dist/apps/catalog.js +46 -0
- package/dist/apps/gate.d.ts +20 -6
- package/dist/apps/gate.js +42 -16
- package/dist/apps/index.d.ts +3 -1
- package/dist/apps/index.js +3 -1
- package/dist/apps/installation.d.ts +132 -51
- package/dist/apps/installation.js +176 -70
- package/dist/apps/permissions.d.ts +247 -0
- package/dist/apps/permissions.js +255 -0
- package/dist/boot-capability-route.d.ts +15 -0
- package/dist/boot-capability-route.js +196 -0
- package/dist/boot-capability.d.ts +103 -0
- package/dist/boot-capability.js +141 -0
- package/dist/boot-construction.d.ts +12 -0
- package/dist/boot-construction.js +175 -0
- package/dist/boot-gate.d.ts +267 -0
- package/dist/boot-gate.js +236 -0
- package/dist/boot-host-script.d.ts +2 -0
- package/dist/boot-host-script.js +1 -0
- package/dist/boot-mount.d.ts +21 -0
- package/dist/boot-mount.js +65 -0
- package/dist/browser/boot.d.ts +73 -0
- package/dist/browser/boot.js +502 -0
- package/dist/browser/browser.d.ts +104 -0
- package/dist/browser/browser.js +374 -0
- package/dist/browser/capability-source.d.ts +76 -0
- package/dist/browser/capability-source.js +338 -0
- package/dist/browser/cdp-client.d.ts +56 -0
- package/dist/browser/cdp-client.js +184 -0
- package/dist/browser/errors.d.ts +79 -0
- package/dist/browser/errors.js +32 -0
- package/dist/browser/index.d.ts +16 -0
- package/dist/browser/index.js +14 -0
- package/dist/browser/lease-registry.d.ts +61 -0
- package/dist/browser/lease-registry.js +101 -0
- package/dist/browser/operations.d.ts +225 -0
- package/dist/browser/operations.js +137 -0
- package/dist/browser/page.d.ts +25 -0
- package/dist/browser/page.js +172 -0
- package/dist/browser/quick-reads.d.ts +29 -0
- package/dist/browser/quick-reads.js +160 -0
- package/dist/browser/sessions.d.ts +52 -0
- package/dist/browser/sessions.js +118 -0
- package/dist/browser/url-policy.d.ts +18 -0
- package/dist/browser/url-policy.js +58 -0
- package/dist/build/app-imports.d.ts +12 -0
- package/dist/build/app-imports.js +105 -0
- package/dist/build/build-policy.d.ts +5 -3
- package/dist/build/build-policy.js +5 -3
- package/dist/build/candidate-builder.d.ts +1 -1
- package/dist/build/candidate-cli.mjs +8375 -334
- package/dist/build/local-build.d.ts +2 -0
- package/dist/build/local-build.js +28 -6
- package/dist/build/snapshot-package-store.d.ts +1 -1
- package/dist/build/snapshot-package-store.js +7 -2
- package/dist/build/workspace-build.d.ts +92 -5
- package/dist/build/workspace-build.js +93 -19
- package/dist/build.d.ts +1 -1
- package/dist/build.js +1 -1
- package/dist/catalog-frame.js +1 -1
- package/dist/cloudflare.d.ts +13 -3
- package/dist/cloudflare.js +102 -4
- package/dist/frame-binding-wire-script.js +1 -1
- package/dist/frame-binding-wire.js +4 -2
- package/dist/git-auth.d.ts +0 -2
- package/dist/git-auth.js +0 -2
- package/dist/git.d.ts +0 -1
- package/dist/git.js +0 -2
- package/dist/host.d.ts +39 -13
- package/dist/host.js +268 -142
- package/dist/index.d.ts +25 -15
- package/dist/index.js +39 -16
- package/dist/install-release-cli.d.ts +2 -0
- package/dist/install-release-cli.js +46 -0
- package/dist/installation-client.d.ts +43 -0
- package/dist/installation-client.js +178 -0
- package/dist/installation-execution.d.ts +11 -0
- package/dist/installation-execution.js +184 -0
- package/dist/installation-operations.d.ts +1437 -0
- package/dist/installation-operations.js +664 -0
- package/dist/installation-store-generation.d.ts +3 -0
- package/dist/installation-store-generation.js +16 -0
- package/dist/instance-id.d.ts +10 -0
- package/dist/instance-id.js +10 -0
- package/dist/instance-refusal.d.ts +11 -0
- package/dist/instance-refusal.js +21 -0
- package/dist/internal/delivery.js +4 -2
- package/dist/internal/host-gate.js +7 -1
- package/dist/internal/registry-installations.js +4 -4
- package/dist/keys.d.ts +5 -1
- package/dist/keys.js +10 -2
- package/dist/machine-link/authority.d.ts +89 -0
- package/dist/machine-link/authority.js +37 -0
- package/dist/machine-link/client.d.ts +66 -0
- package/dist/machine-link/client.js +94 -0
- package/dist/machine-link/delivery.d.ts +18 -0
- package/dist/machine-link/delivery.js +55 -0
- package/dist/machine-link/enrollment.d.ts +111 -0
- package/dist/machine-link/enrollment.js +286 -0
- package/dist/machine-link/fences.d.ts +37 -0
- package/dist/machine-link/fences.js +166 -0
- package/dist/machine-link/home.d.ts +20 -0
- package/dist/machine-link/home.js +24 -0
- package/dist/machine-link/host.d.ts +43 -0
- package/dist/machine-link/host.js +189 -0
- package/dist/machine-link/index.d.ts +15 -0
- package/dist/machine-link/index.js +15 -0
- package/dist/machine-link/link.d.ts +48 -0
- package/dist/machine-link/link.js +223 -0
- package/dist/machine-link/presence.d.ts +10 -0
- package/dist/machine-link/presence.js +25 -0
- package/dist/machine-link/workers.d.ts +6 -0
- package/dist/machine-link/workers.js +6 -0
- package/dist/mcp-owner.d.ts +2 -2
- package/dist/mcp-owner.js +41 -33
- package/dist/mcp.d.ts +15 -3
- package/dist/mcp.js +146 -25
- package/dist/pack.d.ts +14 -3
- package/dist/pack.js +44 -19
- package/dist/page/app-access.d.ts +7 -0
- package/dist/page/app-access.js +75 -0
- package/dist/page/boot-host.js +57 -0
- package/dist/page/capabilities.d.ts +3 -3
- package/dist/page/capabilities.js +16 -12
- package/dist/page/computer-handles.d.ts +3 -3
- package/dist/page/computer-target.d.ts +3 -0
- package/dist/page/computer-target.js +9 -0
- package/dist/page/mount.d.ts +23 -3
- package/dist/page/mount.js +152 -16
- package/dist/page-assets.d.ts +22 -0
- package/dist/page-assets.js +28 -0
- package/dist/page-script.d.ts +3 -0
- package/dist/page-script.js +5 -4
- package/dist/release.d.ts +13 -6
- package/dist/release.js +10 -7
- package/dist/root-data.d.ts +16 -6
- package/dist/root-data.js +2 -2
- package/dist/source/index.d.ts +1 -1
- package/dist/source/team-source.d.ts +20 -8
- package/dist/source/team-source.js +39 -10
- package/dist/source/template-seed.d.ts +2 -2
- package/dist/standalone/agent-mail.d.ts +24 -0
- package/dist/standalone/agent-mail.js +62 -0
- package/dist/standalone/agent-store.d.ts +9 -0
- package/dist/standalone/agent-store.js +30 -0
- package/dist/standalone/agents.d.ts +68 -9
- package/dist/standalone/agents.js +428 -56
- package/dist/standalone/app-builds-binding.d.ts +20 -2
- package/dist/standalone/app-builds-binding.js +147 -23
- package/dist/standalone/app-set.d.ts +5 -2
- package/dist/standalone/app-set.js +174 -143
- package/dist/standalone/browser.d.ts +11 -0
- package/dist/standalone/browser.js +72 -0
- package/dist/standalone/builds.d.ts +13 -4
- package/dist/standalone/builds.js +28 -44
- package/dist/standalone/computer-egress.d.ts +2 -0
- package/dist/standalone/computer-egress.js +13 -4
- package/dist/standalone/computer-fleet.d.ts +43 -0
- package/dist/standalone/computer-fleet.js +200 -0
- package/dist/standalone/computer.d.ts +44 -9
- package/dist/standalone/computer.js +129 -22
- package/dist/standalone/install-apps-cli.js +43 -6
- package/dist/standalone/instance.d.ts +2 -2
- package/dist/standalone/instance.js +8 -11
- package/dist/standalone/machine-link.d.ts +354 -0
- package/dist/standalone/machine-link.js +368 -0
- package/dist/standalone/media.d.ts +2 -2
- package/dist/standalone/media.js +19 -16
- package/dist/standalone/preview.d.ts +2 -2
- package/dist/standalone/preview.js +37 -10
- package/dist/standalone/registry.d.ts +149 -9
- package/dist/standalone/registry.js +299 -8
- package/dist/standalone/worker.d.ts +19 -5
- package/dist/standalone/worker.js +90 -54
- package/dist/standalone/workflow-binding.d.ts +18 -22
- package/dist/standalone/workflow-binding.js +18 -18
- package/dist/standalone/workflow-control.d.ts +4 -0
- package/dist/standalone/workflow-control.js +40 -0
- package/dist/standalone/workflow-events.d.ts +2 -2
- package/dist/standalone/workflow-events.js +9 -10
- package/dist/standalone/workflow-git-capture.js +4 -3
- package/dist/standalone/workflow-host.d.ts +162 -161
- package/dist/standalone/workflow-host.js +101 -21
- package/dist/standalone/workflow-node-capture.js +11 -4
- package/dist/standalone/workflow-schedules.d.ts +5 -2
- package/dist/standalone/workflow-schedules.js +9 -10
- package/dist/standalone/workflow-trigger-host.d.ts +65 -5
- package/dist/standalone/workflow-trigger-host.js +2 -2
- package/dist/standalone/workflow-webhooks.d.ts +22 -0
- package/dist/standalone/workflow-webhooks.js +39 -0
- package/dist/standalone/workflows.d.ts +140 -43
- package/dist/standalone/workflows.js +198 -39
- package/dist/storage-frame.d.ts +6 -6
- package/dist/testing/capabilities.js +14 -3
- package/dist/testing/installation.js +26 -2
- package/dist/testing/worker.js +1 -1
- package/dist/testing/workers-runtime.d.ts +8 -2
- package/dist/testing/workers-runtime.js +20 -2
- package/dist/webhook-abuse.d.ts +25 -0
- package/dist/webhook-abuse.js +54 -0
- package/dist/webhook-custody.d.ts +144 -0
- package/dist/webhook-custody.js +222 -0
- package/dist/webhook-ingress.d.ts +36 -0
- package/dist/webhook-ingress.js +146 -0
- package/dist/webhooks.d.ts +111 -0
- package/dist/webhooks.js +189 -0
- package/dist/workers-git.d.ts +12 -17
- package/dist/workers-git.js +60 -52
- package/dist/workers.d.ts +224 -13
- package/dist/workers.js +351 -51
- package/dist/workflow-agent-authority.d.ts +8 -0
- package/dist/workflow-agent-authority.js +42 -0
- package/dist/workflows.d.ts +66 -0
- package/dist/workflows.js +97 -0
- package/docs/api/README.md +63 -37
- package/docs/api/agents.md +17 -0
- package/docs/api/app-storage.md +1 -1
- package/docs/api/apps.md +1394 -543
- package/docs/api/browser/boot.md +132 -0
- package/docs/api/browser.md +1440 -0
- package/docs/api/build-contract.md +38 -25
- package/docs/api/build.md +263 -84
- package/docs/api/cloudflare.md +93 -7
- package/docs/api/git.md +3 -13
- package/docs/api/host.md +944 -118
- package/docs/api/index.md +320 -18
- package/docs/api/keys.md +24 -1
- package/docs/api/mcp.md +59 -12
- package/docs/api/modules.md +33 -40
- package/docs/api/pack.md +57 -8
- package/docs/api/page/mount.md +92 -4
- package/docs/api/workers-git.md +103 -24
- package/docs/api/workers.md +1581 -244
- package/docs/site/agent-quickstart.mdx +28 -14
- package/examples/standalone/worker.ts +3 -1
- package/examples/standalone/wrangler.jsonc +2 -0
- package/package.json +73 -71
- package/dist/installation-consent.d.ts +0 -81
- package/dist/installation-consent.js +0 -84
- package/dist/shell/app-directory.d.ts +0 -542
- package/dist/shell/app-directory.js +0 -196
- package/dist/shell/browser-actions.d.ts +0 -7
- package/dist/shell/browser-actions.js +0 -56
- package/dist/shell/command-host.d.ts +0 -112
- package/dist/shell/command-host.js +0 -502
- package/dist/shell/commands.d.ts +0 -25
- package/dist/shell/commands.js +0 -78
- package/dist/shell/index.d.ts +0 -8
- package/dist/shell/index.js +0 -8
- package/dist/shell/keys.d.ts +0 -41
- package/dist/shell/keys.js +0 -134
- package/dist/shell/shell-module.d.ts +0 -55
- package/dist/shell/shell-module.js +0 -86
- package/dist/shell/window-manager.d.ts +0 -128
- package/dist/shell/window-manager.js +0 -730
- package/dist/shell/workbench-contract.d.ts +0 -68
- package/dist/shell/workbench-directory.d.ts +0 -332
- package/dist/shell/workbench-directory.js +0 -35
- package/dist/shell/workbench-host-types.d.ts +0 -96
- package/dist/shell/workbench-host-types.js +0 -1
- package/dist/shell/workbench-host.d.ts +0 -34
- package/dist/shell/workbench-host.js +0 -352
- package/dist/shell/workbench-model.d.ts +0 -194
- package/dist/shell/workbench-model.js +0 -53
- package/dist/shell/workbench.d.ts +0 -4
- package/dist/shell/workbench.js +0 -2
- package/docs/api/shell/app-directory.md +0 -599
- package/docs/api/shell/command-host.md +0 -224
- package/docs/api/shell/commands.md +0 -197
- package/docs/api/shell/keys.md +0 -183
- package/docs/api/shell/shell-module.md +0 -204
- package/docs/api/shell/window-manager.md +0 -267
- package/docs/api/shell/workbench-directory.md +0 -119
- package/docs/api/shell/workbench.md +0 -205
- package/docs/api/shell.md +0 -1915
- /package/dist/{shell/workbench-contract.js → page/boot-host.d.ts} +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { BootUi } from "./boot-gate.js";
|
|
2
|
+
import { bootHostScript } from "#boot-host-script";
|
|
3
|
+
/** Resolve verified build paths with the browser's maintained URL semantics. */
|
|
4
|
+
export declare function bootAssetUrl(origin: string, base: string, path: string): string;
|
|
5
|
+
/** The trusted zero-grant parent and opaque candidate frame used by both hosts. */
|
|
6
|
+
export declare function bootParentDocument(identity: BootUi["identity"], entry: string, bridge?: string): string;
|
|
7
|
+
/** CSP for the zero-grant candidate; all network-capable destinations remain denied. */
|
|
8
|
+
export declare function candidateSecurityHeaders(origin: string): {
|
|
9
|
+
"cache-control": string;
|
|
10
|
+
"referrer-policy": string;
|
|
11
|
+
"content-security-policy": string;
|
|
12
|
+
};
|
|
13
|
+
/** CSP for the trusted parent and its local bootstrap only. */
|
|
14
|
+
export declare function parentSecurityHeaders(candidatePrefix: string): {
|
|
15
|
+
"cache-control": string;
|
|
16
|
+
"referrer-policy": string;
|
|
17
|
+
"content-security-policy": string;
|
|
18
|
+
};
|
|
19
|
+
/** Validate the shared mount paths before a host serves them. */
|
|
20
|
+
export declare function candidateAssetUrls(input: BootUi, origin: string): readonly string[];
|
|
21
|
+
export { bootHostScript };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { bootHostScript } from "#boot-host-script";
|
|
3
|
+
/** Resolve verified build paths with the browser's maintained URL semantics. */
|
|
4
|
+
export function bootAssetUrl(origin, base, path) {
|
|
5
|
+
const directory = new URL(origin);
|
|
6
|
+
if (!directory.pathname.endsWith("/"))
|
|
7
|
+
directory.pathname = `${directory.pathname}/`;
|
|
8
|
+
const baseUrl = new URL(base, directory);
|
|
9
|
+
return new URL(path.startsWith("/") ? path.slice(1) : path, baseUrl).href;
|
|
10
|
+
}
|
|
11
|
+
/** The trusted zero-grant parent and opaque candidate frame used by both hosts. */
|
|
12
|
+
export function bootParentDocument(identity, entry, bridge = "./bridge.js") {
|
|
13
|
+
const config = JSON.stringify({ identity, entry }).replaceAll("<", "\\u003c");
|
|
14
|
+
return `<!doctype html><html><body>
|
|
15
|
+
<iframe id="candidate" sandbox="allow-scripts" style="width:100vw;height:100vh;border:0"></iframe>
|
|
16
|
+
<script id="boot-config" type="application/json">${config}</script>
|
|
17
|
+
<script type="module" src="${bridge}"></script></body></html>`;
|
|
18
|
+
}
|
|
19
|
+
/** CSP for the zero-grant candidate; all network-capable destinations remain denied. */
|
|
20
|
+
export function candidateSecurityHeaders(origin) {
|
|
21
|
+
return {
|
|
22
|
+
"cache-control": "no-store",
|
|
23
|
+
"referrer-policy": "no-referrer",
|
|
24
|
+
"content-security-policy": "default-src 'none'; script-src 'unsafe-inline' " +
|
|
25
|
+
origin +
|
|
26
|
+
"; style-src 'unsafe-inline' " +
|
|
27
|
+
origin +
|
|
28
|
+
"; img-src data: blob: " +
|
|
29
|
+
origin +
|
|
30
|
+
"; font-src data: " +
|
|
31
|
+
origin +
|
|
32
|
+
"; connect-src 'none'; worker-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** CSP for the trusted parent and its local bootstrap only. */
|
|
36
|
+
export function parentSecurityHeaders(candidatePrefix) {
|
|
37
|
+
return {
|
|
38
|
+
"cache-control": "no-store",
|
|
39
|
+
"referrer-policy": "no-referrer",
|
|
40
|
+
"content-security-policy": "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; frame-src " +
|
|
41
|
+
candidatePrefix +
|
|
42
|
+
"; connect-src 'none'; object-src 'none'; form-action 'none'; base-uri 'none'",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Validate the shared mount paths before a host serves them. */
|
|
46
|
+
export function candidateAssetUrls(input, origin) {
|
|
47
|
+
const base = new URL(origin);
|
|
48
|
+
const prefix = base.pathname.endsWith("/")
|
|
49
|
+
? base.pathname
|
|
50
|
+
: `${base.pathname}/`;
|
|
51
|
+
const entry = bootAssetUrl(origin, input.base, "/index.html");
|
|
52
|
+
const assets = input.assets.map((asset) => bootAssetUrl(origin, input.base, asset.path));
|
|
53
|
+
if (!assets.includes(entry) ||
|
|
54
|
+
new Set(assets).size !== assets.length ||
|
|
55
|
+
assets.some((asset) => {
|
|
56
|
+
const parsed = new URL(asset);
|
|
57
|
+
return (parsed.origin !== base.origin || !parsed.pathname.startsWith(prefix));
|
|
58
|
+
}))
|
|
59
|
+
throw new Error("Candidate boot asset set is invalid");
|
|
60
|
+
// Keep schema validation at the representation boundary; URLs above use the
|
|
61
|
+
// browser platform parser, while this closed shape is Botanical's domain law.
|
|
62
|
+
z.array(z.url()).parse(assets);
|
|
63
|
+
return assets;
|
|
64
|
+
}
|
|
65
|
+
export { bootHostScript };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { BrowserSessions } from "./sessions.js";
|
|
2
|
+
/**
|
|
3
|
+
* The host boot check on the shared browser module: open one candidate's
|
|
4
|
+
* capability page, wait for its UI to mount, capture what the page did, and
|
|
5
|
+
* confirm the provider session closed.
|
|
6
|
+
*
|
|
7
|
+
* This is a separate port from the agent browser. It takes only a provider
|
|
8
|
+
* (`BrowserSessions`), never a meter, so a boot check is never billed to a
|
|
9
|
+
* Team, and the agent tool never receives it. It accepts no URL: it builds
|
|
10
|
+
* `<origin>/_boot/<releaseId>/index.html` itself from the host's configured
|
|
11
|
+
* origin. Its dedicated provider session is pinned to that host by the
|
|
12
|
+
* provider guardrail (a host-level backstop). The browser's request gate
|
|
13
|
+
* fails every request whose exact origin (scheme, host, port) differs, on
|
|
14
|
+
* the page and on every frame it admits, before that frame runs. The
|
|
15
|
+
* capability token is added by that gate, and only to exact-origin
|
|
16
|
+
* requests; nothing returned or reported contains it. Cleartext http is
|
|
17
|
+
* accepted only for a loopback origin.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* The version of this check's judgement. A recorded result is reused only
|
|
21
|
+
* for the same version; changing what counts as mounted, or what is
|
|
22
|
+
* recordable as a verdict, bumps it. v2: a host or transport failure (the
|
|
23
|
+
* parent page not loading, a dropped browser) is never a verdict.
|
|
24
|
+
*/
|
|
25
|
+
export declare const BOOT_CHECK_VERSION = "boot-page-v2";
|
|
26
|
+
/** One boot check: the host's capability-route origin, the candidate and its token. */
|
|
27
|
+
export type BootPageTarget = Readonly<{
|
|
28
|
+
/** The host's configured origin for its `/_boot/` capability route, e.g. `https://oyster.example`. */
|
|
29
|
+
origin: string;
|
|
30
|
+
/** The candidate Release whose host-boot page is opened. */
|
|
31
|
+
releaseId: string;
|
|
32
|
+
/** The capability header the route admits, by name, and its token. */
|
|
33
|
+
capability: Readonly<{
|
|
34
|
+
header: string;
|
|
35
|
+
token: string;
|
|
36
|
+
}>;
|
|
37
|
+
/** The host's boot deadline (epoch ms); cleanup gets one more second. */
|
|
38
|
+
deadlineAt: number;
|
|
39
|
+
}>;
|
|
40
|
+
/** Why a page that loaded did not count as mounted; the first observed wins. */
|
|
41
|
+
export type BootPageFailedPredicate = "unexpected_candidate_request" | "response_status" | "request_failed" | "page_error" | "candidate_not_mounted";
|
|
42
|
+
/** One failed request, by path only (`out-of-scope` for another origin). */
|
|
43
|
+
export type BootPageFailedRequest = Readonly<{
|
|
44
|
+
path: string;
|
|
45
|
+
status?: number;
|
|
46
|
+
failureKind: "unexpected_candidate_request" | "response_status" | "request_failed";
|
|
47
|
+
}>;
|
|
48
|
+
/** Bounded, page-supplied observations; the host's boot gate sanitizes the text. */
|
|
49
|
+
export type BootPageDiagnostics = Readonly<{
|
|
50
|
+
failedPredicate: BootPageFailedPredicate;
|
|
51
|
+
finalUrlPath: string;
|
|
52
|
+
consoleErrors: readonly string[];
|
|
53
|
+
pageErrors: readonly string[];
|
|
54
|
+
failedRequests: readonly BootPageFailedRequest[];
|
|
55
|
+
droppedCount: number;
|
|
56
|
+
}>;
|
|
57
|
+
/** The boot check's closed outcome. */
|
|
58
|
+
export type BootPageResult = Readonly<{
|
|
59
|
+
kind: "mounted";
|
|
60
|
+
}> | Readonly<{
|
|
61
|
+
kind: "refused";
|
|
62
|
+
code: "ui_mount_failed";
|
|
63
|
+
diagnostics: BootPageDiagnostics;
|
|
64
|
+
}> | Readonly<{
|
|
65
|
+
kind: "refused";
|
|
66
|
+
code: "boot_timeout" | "boot_cleanup_failed" | "boot_runner_unavailable";
|
|
67
|
+
}>;
|
|
68
|
+
/**
|
|
69
|
+
* Open one candidate's pinned boot page on a dedicated provider session,
|
|
70
|
+
* wait for its UI to mount, and confirm the session closed. Cleanup
|
|
71
|
+
* uncertainty outranks every other outcome.
|
|
72
|
+
*/
|
|
73
|
+
export declare function checkBootPage(sessions: BrowserSessions, target: BootPageTarget): Promise<BootPageResult>;
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import { parseReleaseId } from "@fungi.computer/puffball";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { causeClass } from "../cause-class.js";
|
|
4
|
+
import { CloudBrowserTimeout } from "./errors.js";
|
|
5
|
+
import { attachPage, navigatePage, pageLocation } from "./page.js";
|
|
6
|
+
/**
|
|
7
|
+
* The host boot check on the shared browser module: open one candidate's
|
|
8
|
+
* capability page, wait for its UI to mount, capture what the page did, and
|
|
9
|
+
* confirm the provider session closed.
|
|
10
|
+
*
|
|
11
|
+
* This is a separate port from the agent browser. It takes only a provider
|
|
12
|
+
* (`BrowserSessions`), never a meter, so a boot check is never billed to a
|
|
13
|
+
* Team, and the agent tool never receives it. It accepts no URL: it builds
|
|
14
|
+
* `<origin>/_boot/<releaseId>/index.html` itself from the host's configured
|
|
15
|
+
* origin. Its dedicated provider session is pinned to that host by the
|
|
16
|
+
* provider guardrail (a host-level backstop). The browser's request gate
|
|
17
|
+
* fails every request whose exact origin (scheme, host, port) differs, on
|
|
18
|
+
* the page and on every frame it admits, before that frame runs. The
|
|
19
|
+
* capability token is added by that gate, and only to exact-origin
|
|
20
|
+
* requests; nothing returned or reported contains it. Cleartext http is
|
|
21
|
+
* accepted only for a loopback origin.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* The version of this check's judgement. A recorded result is reused only
|
|
25
|
+
* for the same version; changing what counts as mounted, or what is
|
|
26
|
+
* recordable as a verdict, bumps it. v2: a host or transport failure (the
|
|
27
|
+
* parent page not loading, a dropped browser) is never a verdict.
|
|
28
|
+
*/
|
|
29
|
+
export const BOOT_CHECK_VERSION = "boot-page-v2";
|
|
30
|
+
const limits = Object.freeze({
|
|
31
|
+
maxEntriesPerKind: 20,
|
|
32
|
+
maxEntriesTotal: 48,
|
|
33
|
+
maxTextChars: 300,
|
|
34
|
+
maxPathChars: 2_048,
|
|
35
|
+
maxDroppedCount: 1_024,
|
|
36
|
+
maxTrackedRequests: 2_000,
|
|
37
|
+
cleanupGraceMs: 1_000,
|
|
38
|
+
pollMs: 50,
|
|
39
|
+
/** Browser Run's minimum keep-alive; the check itself is far shorter. */
|
|
40
|
+
keepAliveMs: 10_000,
|
|
41
|
+
});
|
|
42
|
+
/**
|
|
43
|
+
* Runs in an isolated world on the candidate frame: the page shares the DOM
|
|
44
|
+
* but cannot patch the globals this reads. Mounted means a visible body with
|
|
45
|
+
* text or a visible canvas, svg, img or video.
|
|
46
|
+
*/
|
|
47
|
+
const MOUNT_PROBE = `(() => {
|
|
48
|
+
const body = document.body;
|
|
49
|
+
if (body === null) return false;
|
|
50
|
+
const visible = (element) => {
|
|
51
|
+
const box = element.getBoundingClientRect();
|
|
52
|
+
const style = getComputedStyle(element);
|
|
53
|
+
return box.width > 0 && box.height > 0 &&
|
|
54
|
+
style.visibility !== "hidden" && style.display !== "none";
|
|
55
|
+
};
|
|
56
|
+
if (!visible(body)) return false;
|
|
57
|
+
if (body.innerText.trim().length > 0) return true;
|
|
58
|
+
return Array.from(document.querySelectorAll("canvas,svg,img,video")).some(visible);
|
|
59
|
+
})()`;
|
|
60
|
+
/** The one URL a boot check may open, built from host configuration only. */
|
|
61
|
+
function pinnedBootUrl(origin, releaseId) {
|
|
62
|
+
const base = new URL(origin);
|
|
63
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(base.hostname);
|
|
64
|
+
if ((base.protocol !== "https:" && !(base.protocol === "http:" && loopback)) ||
|
|
65
|
+
base.username !== "" ||
|
|
66
|
+
base.password !== "" ||
|
|
67
|
+
base.pathname !== "/" ||
|
|
68
|
+
base.search !== "" ||
|
|
69
|
+
base.hash !== "")
|
|
70
|
+
throw new TypeError("The boot origin must be a bare https origin (http only on loopback)");
|
|
71
|
+
const release = encodeURIComponent(parseReleaseId(releaseId));
|
|
72
|
+
const parent = new URL(`/_boot/${release}/index.html`, base.origin);
|
|
73
|
+
return {
|
|
74
|
+
parent,
|
|
75
|
+
candidatePrefix: new URL(`/_boot/${release}/candidate/`, base.origin).href,
|
|
76
|
+
releasePath: `/_boot/${release}/`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const consoleSchema = z.object({
|
|
80
|
+
type: z.string(),
|
|
81
|
+
args: z.array(z.object({
|
|
82
|
+
value: z.json().optional(),
|
|
83
|
+
description: z.string().optional(),
|
|
84
|
+
})),
|
|
85
|
+
});
|
|
86
|
+
const exceptionSchema = z.object({
|
|
87
|
+
exceptionDetails: z.object({
|
|
88
|
+
text: z.string(),
|
|
89
|
+
exception: z.object({ description: z.string().optional() }).optional(),
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
const requestSchema = z.object({
|
|
93
|
+
requestId: z.string(),
|
|
94
|
+
request: z.object({
|
|
95
|
+
url: z.string(),
|
|
96
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
const responseSchema = z.object({
|
|
100
|
+
requestId: z.string(),
|
|
101
|
+
response: z.object({ url: z.string(), status: z.number().int() }),
|
|
102
|
+
});
|
|
103
|
+
const loadingFailedSchema = z.object({ requestId: z.string() });
|
|
104
|
+
const createdSchema = z.object({
|
|
105
|
+
targetInfo: z.object({ targetId: z.string(), type: z.string() }),
|
|
106
|
+
});
|
|
107
|
+
const attachedSchema = z.object({
|
|
108
|
+
sessionId: z.string(),
|
|
109
|
+
targetInfo: z.object({ type: z.string(), url: z.string() }),
|
|
110
|
+
});
|
|
111
|
+
const frameNodeSchema = z.lazy(() => z.object({
|
|
112
|
+
frame: z.object({ id: z.string(), url: z.string() }),
|
|
113
|
+
childFrames: z.array(frameNodeSchema).optional(),
|
|
114
|
+
}));
|
|
115
|
+
const frameTreeSchema = z.object({ frameTree: frameNodeSchema });
|
|
116
|
+
const isolatedWorldSchema = z.object({ executionContextId: z.number().int() });
|
|
117
|
+
const evaluateSchema = z.object({
|
|
118
|
+
result: z.object({ value: z.json().optional() }),
|
|
119
|
+
});
|
|
120
|
+
/** Bounded observation state for one boot page. */
|
|
121
|
+
function createRecorder(origin) {
|
|
122
|
+
const consoleErrors = [];
|
|
123
|
+
const pageErrors = [];
|
|
124
|
+
const failedRequests = [];
|
|
125
|
+
const requests = new Map();
|
|
126
|
+
let droppedCount = 0;
|
|
127
|
+
let failure = null;
|
|
128
|
+
const total = () => consoleErrors.length + pageErrors.length + failedRequests.length;
|
|
129
|
+
const add = (list, entry) => {
|
|
130
|
+
if (list.length >= limits.maxEntriesPerKind ||
|
|
131
|
+
total() >= limits.maxEntriesTotal)
|
|
132
|
+
droppedCount = Math.min(droppedCount + 1, limits.maxDroppedCount);
|
|
133
|
+
else
|
|
134
|
+
list.push(entry);
|
|
135
|
+
};
|
|
136
|
+
const text = (value) => value.slice(0, limits.maxTextChars);
|
|
137
|
+
const fail = (predicate) => {
|
|
138
|
+
failure ??= predicate;
|
|
139
|
+
};
|
|
140
|
+
const inScope = (raw) => {
|
|
141
|
+
const parsed = URL.canParse(raw) ? new URL(raw) : null;
|
|
142
|
+
return parsed?.origin === origin ? parsed : null;
|
|
143
|
+
};
|
|
144
|
+
const path = (raw) => inScope(raw)?.pathname.slice(0, limits.maxPathChars) ?? "out-of-scope";
|
|
145
|
+
return {
|
|
146
|
+
inScope: (raw) => inScope(raw) !== null,
|
|
147
|
+
path,
|
|
148
|
+
fail,
|
|
149
|
+
get failure() {
|
|
150
|
+
return failure;
|
|
151
|
+
},
|
|
152
|
+
console: (params) => {
|
|
153
|
+
const parsed = consoleSchema.safeParse(params);
|
|
154
|
+
if (!parsed.success || parsed.data.type !== "error")
|
|
155
|
+
return;
|
|
156
|
+
add(consoleErrors, text(parsed.data.args
|
|
157
|
+
.map((arg) => {
|
|
158
|
+
const literal = z.string().safeParse(arg.value);
|
|
159
|
+
return literal.success
|
|
160
|
+
? literal.data
|
|
161
|
+
: (arg.description ?? JSON.stringify(arg.value ?? null));
|
|
162
|
+
})
|
|
163
|
+
.join(" ")));
|
|
164
|
+
},
|
|
165
|
+
exception: (params) => {
|
|
166
|
+
const parsed = exceptionSchema.safeParse(params);
|
|
167
|
+
if (!parsed.success)
|
|
168
|
+
return;
|
|
169
|
+
const details = parsed.data.exceptionDetails;
|
|
170
|
+
add(pageErrors, text(details.exception?.description ?? details.text));
|
|
171
|
+
fail("page_error");
|
|
172
|
+
},
|
|
173
|
+
request: (params) => {
|
|
174
|
+
const parsed = requestSchema.safeParse(params);
|
|
175
|
+
if (!parsed.success || requests.size >= limits.maxTrackedRequests)
|
|
176
|
+
return;
|
|
177
|
+
requests.set(parsed.data.requestId, parsed.data.request.url);
|
|
178
|
+
},
|
|
179
|
+
response: (params) => {
|
|
180
|
+
const parsed = responseSchema.safeParse(params);
|
|
181
|
+
if (!parsed.success)
|
|
182
|
+
return;
|
|
183
|
+
const { url, status } = parsed.data.response;
|
|
184
|
+
if (status < 400 || !inScope(url))
|
|
185
|
+
return;
|
|
186
|
+
add(failedRequests, {
|
|
187
|
+
path: path(url),
|
|
188
|
+
status,
|
|
189
|
+
failureKind: "response_status",
|
|
190
|
+
});
|
|
191
|
+
fail("response_status");
|
|
192
|
+
},
|
|
193
|
+
loadingFailed: (params) => {
|
|
194
|
+
const parsed = loadingFailedSchema.safeParse(params);
|
|
195
|
+
if (!parsed.success)
|
|
196
|
+
return;
|
|
197
|
+
const url = requests.get(parsed.data.requestId);
|
|
198
|
+
// Blocked foreign requests are already recorded as unexpected.
|
|
199
|
+
if (url === undefined || !inScope(url))
|
|
200
|
+
return;
|
|
201
|
+
add(failedRequests, { path: path(url), failureKind: "request_failed" });
|
|
202
|
+
fail("request_failed");
|
|
203
|
+
},
|
|
204
|
+
unexpected: () => {
|
|
205
|
+
add(failedRequests, {
|
|
206
|
+
path: "out-of-scope",
|
|
207
|
+
failureKind: "unexpected_candidate_request",
|
|
208
|
+
});
|
|
209
|
+
fail("unexpected_candidate_request");
|
|
210
|
+
},
|
|
211
|
+
/**
|
|
212
|
+
* A gate answer or child setup the browser did not take. The request or
|
|
213
|
+
* frame cannot complete, so the check cannot pass; the cause is logged
|
|
214
|
+
* code-only, never its message.
|
|
215
|
+
*/
|
|
216
|
+
transportFailed: (cause) => {
|
|
217
|
+
console.warn("Oyster boot check transport", { cause: causeClass(cause) });
|
|
218
|
+
fail("request_failed");
|
|
219
|
+
},
|
|
220
|
+
diagnostics: (failedPredicate, finalUrlPath) => ({
|
|
221
|
+
failedPredicate,
|
|
222
|
+
finalUrlPath,
|
|
223
|
+
consoleErrors: [...consoleErrors],
|
|
224
|
+
pageErrors: [...pageErrors],
|
|
225
|
+
failedRequests: [...failedRequests],
|
|
226
|
+
droppedCount,
|
|
227
|
+
}),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function findFrame(node, prefix) {
|
|
231
|
+
for (const child of node.childFrames ?? []) {
|
|
232
|
+
if (child.frame.url.startsWith(prefix))
|
|
233
|
+
return child.frame.id;
|
|
234
|
+
const nested = findFrame(child, prefix);
|
|
235
|
+
if (nested !== null)
|
|
236
|
+
return nested;
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Evaluate the mount probe in an isolated world on the candidate frame,
|
|
242
|
+
* wherever it lives: in the page's own frame tree or, out of process, as
|
|
243
|
+
* the main frame of an admitted child session.
|
|
244
|
+
*/
|
|
245
|
+
async function candidateMounted(client, sessionIds, candidatePrefix) {
|
|
246
|
+
for (const sessionId of sessionIds) {
|
|
247
|
+
const tree = frameTreeSchema.parse(await client.call("Page.getFrameTree", {}, { sessionId }));
|
|
248
|
+
const frameId = tree.frameTree.frame.url.startsWith(candidatePrefix)
|
|
249
|
+
? tree.frameTree.frame.id
|
|
250
|
+
: findFrame(tree.frameTree, candidatePrefix);
|
|
251
|
+
if (frameId === null)
|
|
252
|
+
continue;
|
|
253
|
+
const world = isolatedWorldSchema.parse(await client.call("Page.createIsolatedWorld", { frameId, worldName: "oyster-boot-probe" }, { sessionId }));
|
|
254
|
+
const evaluated = evaluateSchema.parse(await client.call("Runtime.evaluate", {
|
|
255
|
+
expression: MOUNT_PROBE,
|
|
256
|
+
contextId: world.executionContextId,
|
|
257
|
+
returnByValue: true,
|
|
258
|
+
}, { sessionId }));
|
|
259
|
+
if (evaluated.result.value === true)
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
/** Install the origin gate and observers on one CDP session of the boot page. */
|
|
265
|
+
async function instrument(client, sessionId) {
|
|
266
|
+
const options = { sessionId };
|
|
267
|
+
// The origin gate comes first, so no request leaves before it applies.
|
|
268
|
+
await client.call("Fetch.enable", { patterns: [{ urlPattern: "*", requestStage: "Request" }] }, options);
|
|
269
|
+
await client.call("Network.enable", {}, options);
|
|
270
|
+
// A service worker the candidate registers would see in-scope fetches.
|
|
271
|
+
await client.call("Network.setBypassServiceWorker", { bypass: true }, options);
|
|
272
|
+
await client.call("Runtime.enable", {}, options);
|
|
273
|
+
// Frames this target starts wait paused for the same instrumentation.
|
|
274
|
+
await client.call("Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }, options);
|
|
275
|
+
}
|
|
276
|
+
function observe(client, recorder, pinned, target, children, pages) {
|
|
277
|
+
const gate = (params, sessionId) => {
|
|
278
|
+
const paused = requestSchema.safeParse(params);
|
|
279
|
+
if (!paused.success)
|
|
280
|
+
return;
|
|
281
|
+
const { requestId } = paused.data;
|
|
282
|
+
const options = sessionId === undefined ? {} : { sessionId };
|
|
283
|
+
const header = target.capability.header.toLowerCase();
|
|
284
|
+
const { url } = paused.data.request;
|
|
285
|
+
// B4: the token rides only an exact-origin request under this release's
|
|
286
|
+
// boot path, added here; other same-origin requests go without it.
|
|
287
|
+
const headers = Object.entries(paused.data.request.headers ?? {})
|
|
288
|
+
.filter(([name]) => name.toLowerCase() !== header)
|
|
289
|
+
.map(([name, value]) => ({ name, value }));
|
|
290
|
+
if (recorder.inScope(url) &&
|
|
291
|
+
new URL(url).pathname.startsWith(pinned.releasePath))
|
|
292
|
+
headers.push({
|
|
293
|
+
name: target.capability.header,
|
|
294
|
+
value: target.capability.token,
|
|
295
|
+
});
|
|
296
|
+
const decision = recorder.inScope(url)
|
|
297
|
+
? client.call("Fetch.continueRequest", { requestId, headers }, options)
|
|
298
|
+
: (recorder.unexpected(),
|
|
299
|
+
client.call("Fetch.failRequest", { requestId, errorReason: "BlockedByClient" }, options));
|
|
300
|
+
decision.catch((cause) => {
|
|
301
|
+
recorder.transportFailed(cause);
|
|
302
|
+
});
|
|
303
|
+
};
|
|
304
|
+
const child = (params, parentSessionId) => {
|
|
305
|
+
// Auto-attached targets are reported on their parent's session; the
|
|
306
|
+
// browser-level event is this check's own explicit attach of the page.
|
|
307
|
+
if (parentSessionId === undefined)
|
|
308
|
+
return;
|
|
309
|
+
const attached = attachedSchema.safeParse(params);
|
|
310
|
+
if (!attached.success)
|
|
311
|
+
return;
|
|
312
|
+
const { sessionId, targetInfo } = attached.data;
|
|
313
|
+
// Only an in-scope frame is admitted; anything else stays paused until
|
|
314
|
+
// the session closes, and is reported.
|
|
315
|
+
const blank = targetInfo.url === "" || targetInfo.url === "about:blank";
|
|
316
|
+
if (targetInfo.type !== "iframe" ||
|
|
317
|
+
(!blank && !recorder.inScope(targetInfo.url))) {
|
|
318
|
+
recorder.unexpected();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
// An admitted frame starts paused; it runs only once its origin gate is
|
|
322
|
+
// in place. If that cannot be installed, it never runs.
|
|
323
|
+
(async () => {
|
|
324
|
+
await client.call("Page.enable", {}, { sessionId });
|
|
325
|
+
await instrument(client, sessionId);
|
|
326
|
+
children.add(sessionId);
|
|
327
|
+
await client.call("Runtime.runIfWaitingForDebugger", {}, { sessionId });
|
|
328
|
+
})().catch((cause) => {
|
|
329
|
+
// The frame stays paused, so it cannot mount; say why, code-only.
|
|
330
|
+
recorder.transportFailed(cause);
|
|
331
|
+
});
|
|
332
|
+
};
|
|
333
|
+
const unsubscribe = [
|
|
334
|
+
client.on("Runtime.consoleAPICalled", (params) => {
|
|
335
|
+
recorder.console(params);
|
|
336
|
+
}),
|
|
337
|
+
client.on("Runtime.exceptionThrown", (params) => {
|
|
338
|
+
recorder.exception(params);
|
|
339
|
+
}),
|
|
340
|
+
client.on("Network.requestWillBeSent", (params) => {
|
|
341
|
+
recorder.request(params);
|
|
342
|
+
}),
|
|
343
|
+
client.on("Network.responseReceived", (params) => {
|
|
344
|
+
recorder.response(params);
|
|
345
|
+
}),
|
|
346
|
+
client.on("Network.loadingFailed", (params) => {
|
|
347
|
+
recorder.loadingFailed(params);
|
|
348
|
+
}),
|
|
349
|
+
client.on("Fetch.requestPaused", gate),
|
|
350
|
+
client.on("Target.attachedToTarget", child),
|
|
351
|
+
client.on("Target.targetCreated", (params) => {
|
|
352
|
+
// Any page other than the ones that existed and the check's own is a
|
|
353
|
+
// page the candidate opened (window.open, with or without an opener),
|
|
354
|
+
// outside every gate.
|
|
355
|
+
const created = createdSchema.safeParse(params);
|
|
356
|
+
if (!created.success || created.data.targetInfo.type !== "page")
|
|
357
|
+
return;
|
|
358
|
+
const { targetId } = created.data.targetInfo;
|
|
359
|
+
if (!pages.sealed)
|
|
360
|
+
pages.known.add(targetId);
|
|
361
|
+
else if (!pages.known.has(targetId))
|
|
362
|
+
recorder.unexpected();
|
|
363
|
+
}),
|
|
364
|
+
];
|
|
365
|
+
return () => {
|
|
366
|
+
for (const stop of unsubscribe)
|
|
367
|
+
stop();
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
async function inspect(client, pinned, target) {
|
|
371
|
+
const origin = pinned.parent.origin;
|
|
372
|
+
const recorder = createRecorder(origin);
|
|
373
|
+
const children = new Set();
|
|
374
|
+
const pages = { known: new Set(), sealed: false };
|
|
375
|
+
const stop = observe(client, recorder, pinned, target, children, pages);
|
|
376
|
+
try {
|
|
377
|
+
// Browser-level discovery reports existing pages first, then any page the
|
|
378
|
+
// candidate opens.
|
|
379
|
+
await client.call("Target.setDiscoverTargets", { discover: true });
|
|
380
|
+
const page = await attachPage(client);
|
|
381
|
+
pages.known.add(page.targetId);
|
|
382
|
+
pages.sealed = true;
|
|
383
|
+
await instrument(client, page.cdpSessionId);
|
|
384
|
+
const load = await loads(page, pinned.parent, Math.max(1, target.deadlineAt - Date.now()));
|
|
385
|
+
// The pinned parent is the host's own page. A load still pending at the
|
|
386
|
+
// deadline, a navigation error on it (DNS, TLS, connection) or a browser
|
|
387
|
+
// the provider dropped says nothing about the candidate, which cannot
|
|
388
|
+
// navigate the top page, and is never recorded as its verdict.
|
|
389
|
+
if (load !== "loaded")
|
|
390
|
+
return { kind: "refused", code: load };
|
|
391
|
+
let mounted = false;
|
|
392
|
+
while (recorder.failure === null &&
|
|
393
|
+
Date.now() < target.deadlineAt - limits.pollMs) {
|
|
394
|
+
if (await candidateMounted(client, [page.cdpSessionId, ...children], pinned.candidatePrefix)) {
|
|
395
|
+
mounted = true;
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
await new Promise((resolve) => setTimeout(resolve, limits.pollMs));
|
|
399
|
+
}
|
|
400
|
+
if (mounted && recorder.failure === null)
|
|
401
|
+
return { kind: "mounted" };
|
|
402
|
+
return {
|
|
403
|
+
kind: "refused",
|
|
404
|
+
code: "ui_mount_failed",
|
|
405
|
+
diagnostics: recorder.diagnostics(recorder.failure ?? "candidate_not_mounted", await finalPath(page, pinned, recorder)),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
finally {
|
|
409
|
+
stop();
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
/** The page's final URL path, or out-of-scope when it cannot be read. */
|
|
413
|
+
async function finalPath(page, pinned, recorder) {
|
|
414
|
+
try {
|
|
415
|
+
return recorder.path((await pageLocation(page, pinned.parent.href)).url);
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return "out-of-scope";
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/** Whether the pinned page loaded, or why the check cannot judge it. */
|
|
422
|
+
async function loads(page, url, timeoutMs) {
|
|
423
|
+
try {
|
|
424
|
+
await navigatePage(page, url, timeoutMs);
|
|
425
|
+
return "loaded";
|
|
426
|
+
}
|
|
427
|
+
catch (cause) {
|
|
428
|
+
return cause instanceof CloudBrowserTimeout
|
|
429
|
+
? "boot_timeout"
|
|
430
|
+
: "boot_runner_unavailable";
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
async function beforeDeadline(work, deadlineAt) {
|
|
434
|
+
let timer;
|
|
435
|
+
try {
|
|
436
|
+
return await Promise.race([
|
|
437
|
+
work,
|
|
438
|
+
new Promise((resolve) => {
|
|
439
|
+
timer = setTimeout(() => {
|
|
440
|
+
resolve(null);
|
|
441
|
+
}, Math.max(0, deadlineAt - Date.now()));
|
|
442
|
+
}),
|
|
443
|
+
]);
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
if (timer !== undefined)
|
|
447
|
+
clearTimeout(timer);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/** Delete the dedicated session and confirm it ended before the deadline. */
|
|
451
|
+
async function closed(sessions, sessionId, deadlineAt) {
|
|
452
|
+
try {
|
|
453
|
+
const acknowledged = await sessions.release(sessionId, AbortSignal.timeout(Math.max(1, deadlineAt - Date.now())));
|
|
454
|
+
if (acknowledged === "closing")
|
|
455
|
+
await sessions.confirmClosed(sessionId, deadlineAt);
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Open one candidate's pinned boot page on a dedicated provider session,
|
|
464
|
+
* wait for its UI to mount, and confirm the session closed. Cleanup
|
|
465
|
+
* uncertainty outranks every other outcome.
|
|
466
|
+
*/
|
|
467
|
+
export async function checkBootPage(sessions, target) {
|
|
468
|
+
const pinned = pinnedBootUrl(target.origin, target.releaseId);
|
|
469
|
+
let sessionId;
|
|
470
|
+
try {
|
|
471
|
+
sessionId = await sessions.create({
|
|
472
|
+
keepAliveMs: limits.keepAliveMs,
|
|
473
|
+
allowedDomains: [pinned.parent.hostname],
|
|
474
|
+
}, AbortSignal.timeout(Math.max(1, target.deadlineAt - Date.now())));
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
return { kind: "refused", code: "boot_runner_unavailable" };
|
|
478
|
+
}
|
|
479
|
+
// The connect is part of the bounded check: a stalled upgrade times out
|
|
480
|
+
// with everything else, and a socket that arrives late is closed undriven.
|
|
481
|
+
const connecting = sessions.connect(sessionId, AbortSignal.timeout(Math.max(1, target.deadlineAt - Date.now())));
|
|
482
|
+
const lifetime = { expired: false };
|
|
483
|
+
const running = (async () => {
|
|
484
|
+
try {
|
|
485
|
+
const client = await connecting;
|
|
486
|
+
if (lifetime.expired)
|
|
487
|
+
return null;
|
|
488
|
+
return await inspect(client, pinned, target);
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
return { kind: "refused", code: "boot_runner_unavailable" };
|
|
492
|
+
}
|
|
493
|
+
})();
|
|
494
|
+
const outcome = (await beforeDeadline(running, target.deadlineAt)) ?? { kind: "refused", code: "boot_timeout" };
|
|
495
|
+
lifetime.expired = true;
|
|
496
|
+
connecting.then((client) => {
|
|
497
|
+
client.close();
|
|
498
|
+
}, () => undefined);
|
|
499
|
+
return (await closed(sessions, sessionId, target.deadlineAt + limits.cleanupGraceMs))
|
|
500
|
+
? outcome
|
|
501
|
+
: { kind: "refused", code: "boot_cleanup_failed" };
|
|
502
|
+
}
|