@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.
Files changed (287) hide show
  1. package/README.md +376 -72
  2. package/dist/agents.d.ts +3 -2
  3. package/dist/agents.js +4 -2
  4. package/dist/app-builds.d.ts +81 -8
  5. package/dist/app-builds.js +210 -60
  6. package/dist/app-storage.d.ts +3 -3
  7. package/dist/approval-capability.d.ts +5 -0
  8. package/dist/approval-capability.js +46 -0
  9. package/dist/approval-home.d.ts +51 -0
  10. package/dist/approval-home.js +226 -0
  11. package/dist/apps/catalog.d.ts +335 -0
  12. package/dist/apps/catalog.js +46 -0
  13. package/dist/apps/gate.d.ts +20 -6
  14. package/dist/apps/gate.js +42 -16
  15. package/dist/apps/index.d.ts +3 -1
  16. package/dist/apps/index.js +3 -1
  17. package/dist/apps/installation.d.ts +132 -51
  18. package/dist/apps/installation.js +176 -70
  19. package/dist/apps/permissions.d.ts +247 -0
  20. package/dist/apps/permissions.js +255 -0
  21. package/dist/boot-capability-route.d.ts +15 -0
  22. package/dist/boot-capability-route.js +196 -0
  23. package/dist/boot-capability.d.ts +103 -0
  24. package/dist/boot-capability.js +141 -0
  25. package/dist/boot-construction.d.ts +12 -0
  26. package/dist/boot-construction.js +175 -0
  27. package/dist/boot-gate.d.ts +267 -0
  28. package/dist/boot-gate.js +236 -0
  29. package/dist/boot-host-script.d.ts +2 -0
  30. package/dist/boot-host-script.js +1 -0
  31. package/dist/boot-mount.d.ts +21 -0
  32. package/dist/boot-mount.js +65 -0
  33. package/dist/browser/boot.d.ts +73 -0
  34. package/dist/browser/boot.js +502 -0
  35. package/dist/browser/browser.d.ts +104 -0
  36. package/dist/browser/browser.js +374 -0
  37. package/dist/browser/capability-source.d.ts +76 -0
  38. package/dist/browser/capability-source.js +338 -0
  39. package/dist/browser/cdp-client.d.ts +56 -0
  40. package/dist/browser/cdp-client.js +184 -0
  41. package/dist/browser/errors.d.ts +79 -0
  42. package/dist/browser/errors.js +32 -0
  43. package/dist/browser/index.d.ts +16 -0
  44. package/dist/browser/index.js +14 -0
  45. package/dist/browser/lease-registry.d.ts +61 -0
  46. package/dist/browser/lease-registry.js +101 -0
  47. package/dist/browser/operations.d.ts +225 -0
  48. package/dist/browser/operations.js +137 -0
  49. package/dist/browser/page.d.ts +25 -0
  50. package/dist/browser/page.js +172 -0
  51. package/dist/browser/quick-reads.d.ts +29 -0
  52. package/dist/browser/quick-reads.js +160 -0
  53. package/dist/browser/sessions.d.ts +52 -0
  54. package/dist/browser/sessions.js +118 -0
  55. package/dist/browser/url-policy.d.ts +18 -0
  56. package/dist/browser/url-policy.js +58 -0
  57. package/dist/build/app-imports.d.ts +12 -0
  58. package/dist/build/app-imports.js +105 -0
  59. package/dist/build/build-policy.d.ts +5 -3
  60. package/dist/build/build-policy.js +5 -3
  61. package/dist/build/candidate-builder.d.ts +1 -1
  62. package/dist/build/candidate-cli.mjs +8375 -334
  63. package/dist/build/local-build.d.ts +2 -0
  64. package/dist/build/local-build.js +28 -6
  65. package/dist/build/snapshot-package-store.d.ts +1 -1
  66. package/dist/build/snapshot-package-store.js +7 -2
  67. package/dist/build/workspace-build.d.ts +92 -5
  68. package/dist/build/workspace-build.js +93 -19
  69. package/dist/build.d.ts +1 -1
  70. package/dist/build.js +1 -1
  71. package/dist/catalog-frame.js +1 -1
  72. package/dist/cloudflare.d.ts +13 -3
  73. package/dist/cloudflare.js +102 -4
  74. package/dist/frame-binding-wire-script.js +1 -1
  75. package/dist/frame-binding-wire.js +4 -2
  76. package/dist/git-auth.d.ts +0 -2
  77. package/dist/git-auth.js +0 -2
  78. package/dist/git.d.ts +0 -1
  79. package/dist/git.js +0 -2
  80. package/dist/host.d.ts +39 -13
  81. package/dist/host.js +268 -142
  82. package/dist/index.d.ts +25 -15
  83. package/dist/index.js +39 -16
  84. package/dist/install-release-cli.d.ts +2 -0
  85. package/dist/install-release-cli.js +46 -0
  86. package/dist/installation-client.d.ts +43 -0
  87. package/dist/installation-client.js +178 -0
  88. package/dist/installation-execution.d.ts +11 -0
  89. package/dist/installation-execution.js +184 -0
  90. package/dist/installation-operations.d.ts +1437 -0
  91. package/dist/installation-operations.js +664 -0
  92. package/dist/installation-store-generation.d.ts +3 -0
  93. package/dist/installation-store-generation.js +16 -0
  94. package/dist/instance-id.d.ts +10 -0
  95. package/dist/instance-id.js +10 -0
  96. package/dist/instance-refusal.d.ts +11 -0
  97. package/dist/instance-refusal.js +21 -0
  98. package/dist/internal/delivery.js +4 -2
  99. package/dist/internal/host-gate.js +7 -1
  100. package/dist/internal/registry-installations.js +4 -4
  101. package/dist/keys.d.ts +5 -1
  102. package/dist/keys.js +10 -2
  103. package/dist/machine-link/authority.d.ts +89 -0
  104. package/dist/machine-link/authority.js +37 -0
  105. package/dist/machine-link/client.d.ts +66 -0
  106. package/dist/machine-link/client.js +94 -0
  107. package/dist/machine-link/delivery.d.ts +18 -0
  108. package/dist/machine-link/delivery.js +55 -0
  109. package/dist/machine-link/enrollment.d.ts +111 -0
  110. package/dist/machine-link/enrollment.js +286 -0
  111. package/dist/machine-link/fences.d.ts +37 -0
  112. package/dist/machine-link/fences.js +166 -0
  113. package/dist/machine-link/home.d.ts +20 -0
  114. package/dist/machine-link/home.js +24 -0
  115. package/dist/machine-link/host.d.ts +43 -0
  116. package/dist/machine-link/host.js +189 -0
  117. package/dist/machine-link/index.d.ts +15 -0
  118. package/dist/machine-link/index.js +15 -0
  119. package/dist/machine-link/link.d.ts +48 -0
  120. package/dist/machine-link/link.js +223 -0
  121. package/dist/machine-link/presence.d.ts +10 -0
  122. package/dist/machine-link/presence.js +25 -0
  123. package/dist/machine-link/workers.d.ts +6 -0
  124. package/dist/machine-link/workers.js +6 -0
  125. package/dist/mcp-owner.d.ts +2 -2
  126. package/dist/mcp-owner.js +41 -33
  127. package/dist/mcp.d.ts +15 -3
  128. package/dist/mcp.js +146 -25
  129. package/dist/pack.d.ts +14 -3
  130. package/dist/pack.js +44 -19
  131. package/dist/page/app-access.d.ts +7 -0
  132. package/dist/page/app-access.js +75 -0
  133. package/dist/page/boot-host.js +57 -0
  134. package/dist/page/capabilities.d.ts +3 -3
  135. package/dist/page/capabilities.js +16 -12
  136. package/dist/page/computer-handles.d.ts +3 -3
  137. package/dist/page/computer-target.d.ts +3 -0
  138. package/dist/page/computer-target.js +9 -0
  139. package/dist/page/mount.d.ts +23 -3
  140. package/dist/page/mount.js +152 -16
  141. package/dist/page-assets.d.ts +22 -0
  142. package/dist/page-assets.js +28 -0
  143. package/dist/page-script.d.ts +3 -0
  144. package/dist/page-script.js +5 -4
  145. package/dist/release.d.ts +13 -6
  146. package/dist/release.js +10 -7
  147. package/dist/root-data.d.ts +16 -6
  148. package/dist/root-data.js +2 -2
  149. package/dist/source/index.d.ts +1 -1
  150. package/dist/source/team-source.d.ts +20 -8
  151. package/dist/source/team-source.js +39 -10
  152. package/dist/source/template-seed.d.ts +2 -2
  153. package/dist/standalone/agent-mail.d.ts +24 -0
  154. package/dist/standalone/agent-mail.js +62 -0
  155. package/dist/standalone/agent-store.d.ts +9 -0
  156. package/dist/standalone/agent-store.js +30 -0
  157. package/dist/standalone/agents.d.ts +68 -9
  158. package/dist/standalone/agents.js +428 -56
  159. package/dist/standalone/app-builds-binding.d.ts +20 -2
  160. package/dist/standalone/app-builds-binding.js +147 -23
  161. package/dist/standalone/app-set.d.ts +5 -2
  162. package/dist/standalone/app-set.js +174 -143
  163. package/dist/standalone/browser.d.ts +11 -0
  164. package/dist/standalone/browser.js +72 -0
  165. package/dist/standalone/builds.d.ts +13 -4
  166. package/dist/standalone/builds.js +28 -44
  167. package/dist/standalone/computer-egress.d.ts +2 -0
  168. package/dist/standalone/computer-egress.js +13 -4
  169. package/dist/standalone/computer-fleet.d.ts +43 -0
  170. package/dist/standalone/computer-fleet.js +200 -0
  171. package/dist/standalone/computer.d.ts +44 -9
  172. package/dist/standalone/computer.js +129 -22
  173. package/dist/standalone/install-apps-cli.js +43 -6
  174. package/dist/standalone/instance.d.ts +2 -2
  175. package/dist/standalone/instance.js +8 -11
  176. package/dist/standalone/machine-link.d.ts +354 -0
  177. package/dist/standalone/machine-link.js +368 -0
  178. package/dist/standalone/media.d.ts +2 -2
  179. package/dist/standalone/media.js +19 -16
  180. package/dist/standalone/preview.d.ts +2 -2
  181. package/dist/standalone/preview.js +37 -10
  182. package/dist/standalone/registry.d.ts +149 -9
  183. package/dist/standalone/registry.js +299 -8
  184. package/dist/standalone/worker.d.ts +19 -5
  185. package/dist/standalone/worker.js +90 -54
  186. package/dist/standalone/workflow-binding.d.ts +18 -22
  187. package/dist/standalone/workflow-binding.js +18 -18
  188. package/dist/standalone/workflow-control.d.ts +4 -0
  189. package/dist/standalone/workflow-control.js +40 -0
  190. package/dist/standalone/workflow-events.d.ts +2 -2
  191. package/dist/standalone/workflow-events.js +9 -10
  192. package/dist/standalone/workflow-git-capture.js +4 -3
  193. package/dist/standalone/workflow-host.d.ts +162 -161
  194. package/dist/standalone/workflow-host.js +101 -21
  195. package/dist/standalone/workflow-node-capture.js +11 -4
  196. package/dist/standalone/workflow-schedules.d.ts +5 -2
  197. package/dist/standalone/workflow-schedules.js +9 -10
  198. package/dist/standalone/workflow-trigger-host.d.ts +65 -5
  199. package/dist/standalone/workflow-trigger-host.js +2 -2
  200. package/dist/standalone/workflow-webhooks.d.ts +22 -0
  201. package/dist/standalone/workflow-webhooks.js +39 -0
  202. package/dist/standalone/workflows.d.ts +140 -43
  203. package/dist/standalone/workflows.js +198 -39
  204. package/dist/storage-frame.d.ts +6 -6
  205. package/dist/testing/capabilities.js +14 -3
  206. package/dist/testing/installation.js +26 -2
  207. package/dist/testing/worker.js +1 -1
  208. package/dist/testing/workers-runtime.d.ts +8 -2
  209. package/dist/testing/workers-runtime.js +20 -2
  210. package/dist/webhook-abuse.d.ts +25 -0
  211. package/dist/webhook-abuse.js +54 -0
  212. package/dist/webhook-custody.d.ts +144 -0
  213. package/dist/webhook-custody.js +222 -0
  214. package/dist/webhook-ingress.d.ts +36 -0
  215. package/dist/webhook-ingress.js +146 -0
  216. package/dist/webhooks.d.ts +111 -0
  217. package/dist/webhooks.js +189 -0
  218. package/dist/workers-git.d.ts +12 -17
  219. package/dist/workers-git.js +60 -52
  220. package/dist/workers.d.ts +224 -13
  221. package/dist/workers.js +351 -51
  222. package/dist/workflow-agent-authority.d.ts +8 -0
  223. package/dist/workflow-agent-authority.js +42 -0
  224. package/dist/workflows.d.ts +66 -0
  225. package/dist/workflows.js +97 -0
  226. package/docs/api/README.md +63 -37
  227. package/docs/api/agents.md +17 -0
  228. package/docs/api/app-storage.md +1 -1
  229. package/docs/api/apps.md +1394 -543
  230. package/docs/api/browser/boot.md +132 -0
  231. package/docs/api/browser.md +1440 -0
  232. package/docs/api/build-contract.md +38 -25
  233. package/docs/api/build.md +263 -84
  234. package/docs/api/cloudflare.md +93 -7
  235. package/docs/api/git.md +3 -13
  236. package/docs/api/host.md +944 -118
  237. package/docs/api/index.md +320 -18
  238. package/docs/api/keys.md +24 -1
  239. package/docs/api/mcp.md +59 -12
  240. package/docs/api/modules.md +33 -40
  241. package/docs/api/pack.md +57 -8
  242. package/docs/api/page/mount.md +92 -4
  243. package/docs/api/workers-git.md +103 -24
  244. package/docs/api/workers.md +1581 -244
  245. package/docs/site/agent-quickstart.mdx +28 -14
  246. package/examples/standalone/worker.ts +3 -1
  247. package/examples/standalone/wrangler.jsonc +2 -0
  248. package/package.json +73 -71
  249. package/dist/installation-consent.d.ts +0 -81
  250. package/dist/installation-consent.js +0 -84
  251. package/dist/shell/app-directory.d.ts +0 -542
  252. package/dist/shell/app-directory.js +0 -196
  253. package/dist/shell/browser-actions.d.ts +0 -7
  254. package/dist/shell/browser-actions.js +0 -56
  255. package/dist/shell/command-host.d.ts +0 -112
  256. package/dist/shell/command-host.js +0 -502
  257. package/dist/shell/commands.d.ts +0 -25
  258. package/dist/shell/commands.js +0 -78
  259. package/dist/shell/index.d.ts +0 -8
  260. package/dist/shell/index.js +0 -8
  261. package/dist/shell/keys.d.ts +0 -41
  262. package/dist/shell/keys.js +0 -134
  263. package/dist/shell/shell-module.d.ts +0 -55
  264. package/dist/shell/shell-module.js +0 -86
  265. package/dist/shell/window-manager.d.ts +0 -128
  266. package/dist/shell/window-manager.js +0 -730
  267. package/dist/shell/workbench-contract.d.ts +0 -68
  268. package/dist/shell/workbench-directory.d.ts +0 -332
  269. package/dist/shell/workbench-directory.js +0 -35
  270. package/dist/shell/workbench-host-types.d.ts +0 -96
  271. package/dist/shell/workbench-host-types.js +0 -1
  272. package/dist/shell/workbench-host.d.ts +0 -34
  273. package/dist/shell/workbench-host.js +0 -352
  274. package/dist/shell/workbench-model.d.ts +0 -194
  275. package/dist/shell/workbench-model.js +0 -53
  276. package/dist/shell/workbench.d.ts +0 -4
  277. package/dist/shell/workbench.js +0 -2
  278. package/docs/api/shell/app-directory.md +0 -599
  279. package/docs/api/shell/command-host.md +0 -224
  280. package/docs/api/shell/commands.md +0 -197
  281. package/docs/api/shell/keys.md +0 -183
  282. package/docs/api/shell/shell-module.md +0 -204
  283. package/docs/api/shell/window-manager.md +0 -267
  284. package/docs/api/shell/workbench-directory.md +0 -119
  285. package/docs/api/shell/workbench.md +0 -205
  286. package/docs/api/shell.md +0 -1915
  287. /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
+ }