@hraness/ghostget 0.17.2
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/CHANGELOG.md +458 -0
- package/LICENSE +21 -0
- package/README.md +1359 -0
- package/bunfig.toml +2 -0
- package/dist/apple-photos-client.js +704 -0
- package/dist/beeper-client.js +4602 -0
- package/dist/client.js +1605 -0
- package/dist/index-26yq8q16.js +31 -0
- package/dist/index-9t13b4k7.js +508 -0
- package/dist/index-axwm9b3v.js +5 -0
- package/dist/index-gwk7rbyj.js +30 -0
- package/dist/index.js +701 -0
- package/dist/messaging.js +1130 -0
- package/dist/omni-client.js +1406 -0
- package/dist/whatsapp-client.js +355 -0
- package/docs/imessage-direct-provider.md +106 -0
- package/docs/rental-listings.md +94 -0
- package/package.json +600 -0
- package/skills/ghostget/SKILL.md +251 -0
- package/skills/ghostget/agents/openai.yaml +4 -0
- package/skills/ghostget/references/adapter-contract.md +181 -0
- package/skills/ghostget/references/apple-photos.md +115 -0
- package/skills/ghostget/references/article-drafts.md +235 -0
- package/skills/ghostget/references/code-owned-provider-scaffold.md +134 -0
- package/skills/ghostget/references/cross-posting-video.md +139 -0
- package/skills/ghostget/references/cross-posting.md +82 -0
- package/skills/ghostget/references/derivation.md +390 -0
- package/skills/ghostget/references/hraness-social-profile-stats.json +269 -0
- package/skills/ghostget/references/install.md +66 -0
- package/skills/ghostget/references/linkedin-adapter.md +442 -0
- package/skills/ghostget/references/messaging.md +168 -0
- package/skills/ghostget/references/meta-comet-contract.md +153 -0
- package/skills/ghostget/references/platform-patterns.md +122 -0
- package/skills/ghostget/references/provider-plugins.md +496 -0
- package/skills/ghostget/references/reddit-flair.md +78 -0
- package/skills/ghostget/references/safety-and-state.md +214 -0
- package/skills/ghostget/references/settlement-and-duplicate-cleanup.md +79 -0
- package/skills/ghostget/references/social-platform-routing.md +66 -0
- package/skills/ghostget/references/social-profile-stats.md +195 -0
- package/skills/ghostget/references/social-video-platform-routing.md +89 -0
- package/skills/ghostget/references/whatsapp-adapter.md +101 -0
- package/skills/ghostget/references/x-adapter.md +332 -0
- package/skills/ghostget/references/x-ai-disclosure.md +71 -0
- package/src/apple-photos-cli.ts +639 -0
- package/src/apple-photos-client-types.ts +168 -0
- package/src/apple-photos-client.ts +201 -0
- package/src/apple-photos-contact-evidence.ts +838 -0
- package/src/apple-photos-local-source.ts +1131 -0
- package/src/args.ts +2053 -0
- package/src/article-draft-document.ts +417 -0
- package/src/article-draft-embeds.ts +123 -0
- package/src/article-draft-images.ts +214 -0
- package/src/assets/adapter-template/wrench-adapter.json +40 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.json +1500 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v1.0.0.json +129 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v1.1.0.json +207 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.0.0.json +1463 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.1.0.json +1463 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.2.0.json +1475 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.3.0.json +1488 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.json +684 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.0.0.json +581 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.1.0.json +581 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.2.0.json +581 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.3.0.json +615 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.4.0.json +684 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.5.0.json +684 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.6.0.json +684 -0
- package/src/assets/adapters/clasificados/wrench-web-adapter.json +54 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.json +750 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.v1.0.0.json +706 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.v1.1.0.json +703 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.v1.2.0.json +750 -0
- package/src/assets/adapters/facebook-group/wrench-web-adapter.json +682 -0
- package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.0.0.json +583 -0
- package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.1.0.json +682 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.json +437 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.0.0.json +383 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.0.json +437 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.1.json +437 -0
- package/src/assets/adapters/facebook-page/wrench-web-adapter.json +880 -0
- package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.0.0.json +740 -0
- package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.1.0.json +880 -0
- package/src/assets/adapters/github/wrench-web-adapter.json +69 -0
- package/src/assets/adapters/github/wrench-web-adapter.v1.0.0.json +42 -0
- package/src/assets/adapters/github/wrench-web-adapter.v1.1.0.json +69 -0
- package/src/assets/adapters/gmail/wrench-adapter.json +161 -0
- package/src/assets/adapters/gmail/wrench-adapter.v1.2.0.json +157 -0
- package/src/assets/adapters/hacker-news/wrench-web-adapter.json +315 -0
- package/src/assets/adapters/hacker-news/wrench-web-adapter.v1.0.0.json +315 -0
- package/src/assets/adapters/imessage/wrench-web-adapter.json +236 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.json +719 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.0.0.json +627 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.1.0.json +609 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.2.0.json +640 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.3.0.json +671 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.4.0.json +710 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.5.0.json +710 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.6.0.json +719 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.7.0.json +719 -0
- package/src/assets/adapters/linkedin/wrench-adapter.json +442 -0
- package/src/assets/adapters/linkedin/wrench-adapter.v0.4.0.json +124 -0
- package/src/assets/adapters/linkedin/wrench-adapter.v1.0.0.json +411 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.json +914 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.0.0.json +604 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.1.0.json +604 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.10.0.json +809 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.11.0.json +809 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.12.0.json +820 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.13.0.json +819 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.14.0.json +819 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.15.0.json +819 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.16.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.17.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.18.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.19.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.2.0.json +731 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.3.0.json +768 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.4.0.json +808 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.5.0.json +808 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.6.0.json +808 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.7.0.json +825 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.8.0.json +792 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.9.0.json +809 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.json +855 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.0.0.json +577 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.1.0.json +604 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.10.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.11.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.12.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.2.0.json +653 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.3.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.4.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.5.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.6.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.7.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.8.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.9.0.json +713 -0
- package/src/assets/adapters/substack/wrench-web-adapter.json +936 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.0.0.json +817 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.1.0.json +812 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.2.0.json +812 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.3.0.json +902 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.4.0.json +936 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.5.0.json +936 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.6.0.json +936 -0
- package/src/assets/adapters/threads/wrench-web-adapter.json +692 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.0.0.json +629 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.1.0.json +623 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.2.0.json +622 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.3.0.json +622 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.4.0.json +622 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.5.0.json +699 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.6.0.json +693 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.7.0.json +692 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.json +762 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.0.0.json +681 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.1.0.json +708 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.2.0.json +762 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.3.0.json +762 -0
- package/src/assets/adapters/twitch/wrench-web-adapter.json +42 -0
- package/src/assets/adapters/twitch/wrench-web-adapter.v1.0.0.json +42 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.json +400 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.0.0.json +355 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.1.0.json +386 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.2.0.json +386 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.3.0.json +400 -0
- package/src/assets/adapters/x/wrench-adapter.json +749 -0
- package/src/assets/adapters/x/wrench-adapter.v1.0.0.json +700 -0
- package/src/assets/adapters/x/wrench-adapter.v1.1.0.json +707 -0
- package/src/assets/adapters/x/wrench-web-adapter.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.1.0.json +579 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.10.0.json +685 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.11.0.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.12.0.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.13.0.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.2.0.json +578 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.3.0.json +610 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.4.0.json +617 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.5.0.json +634 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.6.0.json +632 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.7.0.json +649 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.8.0.json +649 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.9.0.json +683 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.json +561 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.0.0.json +465 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.1.0.json +519 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.2.0.json +561 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.3.0.json +561 -0
- package/src/assets/code-owned-provider-template/plugin.test.ts.template +54 -0
- package/src/assets/code-owned-provider-template/plugin.ts.template +102 -0
- package/src/assets/code-owned-provider-template/runtime.internal.test.ts.template +44 -0
- package/src/assets/code-owned-provider-template/runtime.ts.template +59 -0
- package/src/assets/code-owned-provider-template/wrench-adapter.json.template +33 -0
- package/src/assets/fonts/nebula-sans/LICENSE.txt +96 -0
- package/src/assets/fonts/nebula-sans/NebulaSans-Book.woff2 +0 -0
- package/src/assets/fonts/nebula-sans/PROVENANCE.md +14 -0
- package/src/assets.d.ts +4 -0
- package/src/auth.ts +1157 -0
- package/src/beeper-client-types.ts +169 -0
- package/src/beeper-client.ts +250 -0
- package/src/beeper-contact-interactions-cli.ts +180 -0
- package/src/beeper-contact-interactions.ts +1612 -0
- package/src/beeper-message-bundle-v1.ts +257 -0
- package/src/beeper-message-like-me-cli.ts +84 -0
- package/src/beeper-message-like-me-export.ts +2274 -0
- package/src/beeper-message-like-me-recovery.ts +931 -0
- package/src/beeper-message-like-me-source.ts +4034 -0
- package/src/browser-admission.ts +928 -0
- package/src/browser-snapshots.ts +260 -0
- package/src/browser.ts +3873 -0
- package/src/canonical-json.ts +60 -0
- package/src/catalog-cli.ts +638 -0
- package/src/cli.ts +208 -0
- package/src/client-types.ts +301 -0
- package/src/client.ts +2726 -0
- package/src/confirmed-write-failure.ts +44 -0
- package/src/confirmed-write-model.ts +72 -0
- package/src/confirmed-write-platform.ts +1055 -0
- package/src/confirmed-write-program.ts +224 -0
- package/src/confirmed-write-runtime.ts +16 -0
- package/src/cursor-token.ts +228 -0
- package/src/derivation-dnr-readiness.ts +367 -0
- package/src/derivation-file-chooser.ts +384 -0
- package/src/derivation-network-boundary.ts +835 -0
- package/src/derivation-network-guard.ts +782 -0
- package/src/derivation-network-proxy-helper.ts +440 -0
- package/src/derivation-network-proxy.ts +385 -0
- package/src/derive-command-helper.ts +693 -0
- package/src/derive-fixtures.ts +343 -0
- package/src/derive-review.ts +980 -0
- package/src/derive.ts +4754 -0
- package/src/fixtures/beeper-message-like-me-v1/accounts.ndjson +2 -0
- package/src/fixtures/beeper-message-like-me-v1/conversations.ndjson +1 -0
- package/src/fixtures/beeper-message-like-me-v1/manifest.json +1 -0
- package/src/fixtures/beeper-message-like-me-v1/messages.ndjson +2 -0
- package/src/fixtures/beeper-message-like-me-v1/participants.ndjson +3 -0
- package/src/fixtures/beeper-message-like-me-v1/reactions.ndjson +1 -0
- package/src/fixtures/beeper-message-like-me-v1/tombstones.ndjson +1 -0
- package/src/ghostget.ts +3672 -0
- package/src/gmail-capture.ts +1244 -0
- package/src/har-internal.ts +1242 -0
- package/src/har.ts +743 -0
- package/src/index.ts +218 -0
- package/src/invocation-read-platform.ts +55 -0
- package/src/invocation-read-program.ts +93 -0
- package/src/invocation-read-runtime.ts +11 -0
- package/src/linked-device-lifecycle-admission.ts +450 -0
- package/src/linked-device-lifecycle-journal.ts +1560 -0
- package/src/linked-device-lifecycle-runtime.ts +1602 -0
- package/src/local-cli-admission.ts +135 -0
- package/src/local-cli-contracts.ts +271 -0
- package/src/local-cli-execution.ts +98 -0
- package/src/local-cli-surface-contract.ts +1510 -0
- package/src/local-cli-tool-identity.ts +336 -0
- package/src/media/archive.ts +2325 -0
- package/src/media/args.ts +303 -0
- package/src/media/cli.ts +287 -0
- package/src/media/doctor.ts +475 -0
- package/src/media/ffmpeg.ts +1330 -0
- package/src/media/http-capture.ts +711 -0
- package/src/media/http-probe.ts +593 -0
- package/src/media/http.ts +387 -0
- package/src/media/index.ts +10 -0
- package/src/media/local-transcription.ts +613 -0
- package/src/media/lock.ts +321 -0
- package/src/media/manifest.ts +1712 -0
- package/src/media/metadata.ts +906 -0
- package/src/media/process.ts +723 -0
- package/src/media/revision.ts +166 -0
- package/src/media/runtime-closure.ts +1063 -0
- package/src/media/source-router.ts +187 -0
- package/src/media/transcriber-config.ts +1122 -0
- package/src/media/transcript-persistence-model.ts +15 -0
- package/src/media/transcript-persistence-platform.ts +89 -0
- package/src/media/transcript-persistence-program.ts +39 -0
- package/src/media/transcript-persistence-runtime.ts +24 -0
- package/src/media/transcript.ts +616 -0
- package/src/media/utf8-order.ts +30 -0
- package/src/media/whisper-cpp.ts +464 -0
- package/src/media/whisper-language.ts +58 -0
- package/src/media/yt-dlp.ts +439 -0
- package/src/message-like-me-agentic-messaging.ts +882 -0
- package/src/messaging-action-store.ts +793 -0
- package/src/messaging-runtime.ts +2136 -0
- package/src/messaging-store.ts +539 -0
- package/src/messaging-types.ts +1663 -0
- package/src/messaging.ts +334 -0
- package/src/model.ts +2671 -0
- package/src/oauth-google.ts +888 -0
- package/src/omni-client-types.ts +306 -0
- package/src/omni-client.ts +2225 -0
- package/src/omni-limits.ts +34 -0
- package/src/omni-model.ts +1614 -0
- package/src/omni-request.ts +379 -0
- package/src/omni-runtime.ts +1632 -0
- package/src/operation-deadline.ts +189 -0
- package/src/path-helper.ts +1656 -0
- package/src/pinned-https.ts +194 -0
- package/src/plan-assets.ts +832 -0
- package/src/platform-catalog.ts +1832 -0
- package/src/plugins/beeper-linked-device/plugin.ts +310 -0
- package/src/plugins/bluesky-web/plugin.ts +178 -0
- package/src/plugins/clasificados-web/plugin.ts +66 -0
- package/src/plugins/github-web/plugin.ts +80 -0
- package/src/plugins/gmail-official/plugin.ts +110 -0
- package/src/plugins/hacker-news-web/plugin.ts +52 -0
- package/src/plugins/imessage-direct/plugin.ts +195 -0
- package/src/plugins/imessage-direct/vendor/0001-fix-keep-AppleScript-send-payloads-out-of-child-argv.patch +1430 -0
- package/src/plugins/imessage-direct/vendor/0002-feat-rpc-add-exact-chat-lookup.patch +225 -0
- package/src/plugins/imessage-direct/vendor/provenance.json +80 -0
- package/src/plugins/linkedin-official/plugin.ts +84 -0
- package/src/plugins/linkedin-web/plugin.ts +412 -0
- package/src/plugins/meta-web/plugin.ts +317 -0
- package/src/plugins/reddit-web/flair.ts +492 -0
- package/src/plugins/reddit-web/plugin.ts +477 -0
- package/src/plugins/substack-web/plugin.ts +124 -0
- package/src/plugins/tiktok-web/plugin.ts +67 -0
- package/src/plugins/twitch-web/plugin.ts +54 -0
- package/src/plugins/whatsapp-linked-device/plugin.ts +105 -0
- package/src/plugins/x-official/plugin.ts +64 -0
- package/src/plugins/x-web/plugin.ts +488 -0
- package/src/plugins/youtube-web/plugin.ts +101 -0
- package/src/portable-run-recovery.ts +466 -0
- package/src/process-identity.ts +501 -0
- package/src/profile-clone-helper.ts +104 -0
- package/src/provider-catalog-views.ts +51 -0
- package/src/provider-context.ts +69 -0
- package/src/provider-contract-definitions-x.ts +45 -0
- package/src/provider-contract-definitions.ts +948 -0
- package/src/provider-contract-input-gmail.ts +176 -0
- package/src/provider-contract-input-linkedin.ts +77 -0
- package/src/provider-contract-input-x.ts +304 -0
- package/src/provider-contract-planning.ts +23 -0
- package/src/provider-contract-semantic-identity.ts +54 -0
- package/src/provider-contracts.ts +251 -0
- package/src/provider-http.ts +723 -0
- package/src/provider-plugin-auth.ts +36 -0
- package/src/provider-plugin-builtins.ts +229 -0
- package/src/provider-plugin-cleanup-barrier.ts +229 -0
- package/src/provider-plugin-cleanup-execution.ts +79 -0
- package/src/provider-plugin-cleanup-resource.ts +470 -0
- package/src/provider-plugin-contract-identity.ts +707 -0
- package/src/provider-plugin-host.ts +1972 -0
- package/src/provider-plugin-identifiers.ts +60 -0
- package/src/provider-plugin-invocation-lease.ts +889 -0
- package/src/provider-plugin-lifecycle-kernel.ts +566 -0
- package/src/provider-plugin-lifecycle.ts +1455 -0
- package/src/provider-plugin-module-analysis.ts +336 -0
- package/src/provider-plugin-package.ts +2530 -0
- package/src/provider-plugin-portable-authority.ts +106 -0
- package/src/provider-plugin-portable-catalog.ts +94 -0
- package/src/provider-plugin-portable-identity.ts +315 -0
- package/src/provider-plugin-portable-runtime.ts +2057 -0
- package/src/provider-plugin-protocol.ts +1625 -0
- package/src/provider-plugin-registry.ts +3342 -0
- package/src/provider-plugin-store.ts +2155 -0
- package/src/provider-plugin-test-fixture.ts +5 -0
- package/src/provider-plugin.ts +4907 -0
- package/src/provider-plugins.generated.ts +43 -0
- package/src/provider-plugins.ts +8 -0
- package/src/provider-subject.ts +41 -0
- package/src/provider.ts +315 -0
- package/src/providers/beeper-local-runtime.ts +5426 -0
- package/src/providers/beeper-local.ts +2739 -0
- package/src/providers/beeper-messaging.ts +462 -0
- package/src/providers/beeper-omni.ts +1159 -0
- package/src/providers/bluesky-web-runtime.ts +2895 -0
- package/src/providers/bluesky-web.ts +1614 -0
- package/src/providers/clasificados-web-runtime.ts +255 -0
- package/src/providers/clasificados-web.ts +503 -0
- package/src/providers/contact-projection.ts +301 -0
- package/src/providers/github-read-model.ts +94 -0
- package/src/providers/github-read-platform.ts +165 -0
- package/src/providers/github-read-program.ts +97 -0
- package/src/providers/github-web-runtime.ts +320 -0
- package/src/providers/github-web.ts +403 -0
- package/src/providers/gmail-api.ts +2834 -0
- package/src/providers/gmail-omni.ts +830 -0
- package/src/providers/gmail.ts +1130 -0
- package/src/providers/hacker-news-web-runtime.ts +220 -0
- package/src/providers/hacker-news-web.ts +809 -0
- package/src/providers/imessage-direct-install.ts +354 -0
- package/src/providers/imessage-direct-messaging.ts +532 -0
- package/src/providers/imessage-direct-omni.ts +400 -0
- package/src/providers/imessage-direct-runtime.ts +1498 -0
- package/src/providers/imessage-direct.ts +351 -0
- package/src/providers/instagram-video-foundations.ts +913 -0
- package/src/providers/instagram-web-profile-browser.ts +600 -0
- package/src/providers/iso-bmff.ts +338 -0
- package/src/providers/linkedin-company-platform.ts +42 -0
- package/src/providers/linkedin-company-program.ts +88 -0
- package/src/providers/linkedin-contact-failure.ts +84 -0
- package/src/providers/linkedin-contact-platform.ts +45 -0
- package/src/providers/linkedin-contact-program.ts +91 -0
- package/src/providers/linkedin-profile-activity-failure.ts +76 -0
- package/src/providers/linkedin-profile-activity-platform.ts +41 -0
- package/src/providers/linkedin-profile-activity-program.ts +67 -0
- package/src/providers/linkedin-read-failure.ts +52 -0
- package/src/providers/linkedin-self-platform.ts +53 -0
- package/src/providers/linkedin-self-program.ts +122 -0
- package/src/providers/linkedin-web-article-browser.ts +794 -0
- package/src/providers/linkedin-web-bootstrap.ts +146 -0
- package/src/providers/linkedin-web-contact.ts +830 -0
- package/src/providers/linkedin-web-feed-browser.ts +832 -0
- package/src/providers/linkedin-web-feed.ts +864 -0
- package/src/providers/linkedin-web-post-browser.ts +653 -0
- package/src/providers/linkedin-web-profile-browser.ts +683 -0
- package/src/providers/linkedin-web-runtime.ts +2388 -0
- package/src/providers/linkedin-web.ts +3305 -0
- package/src/providers/linkedin.ts +1885 -0
- package/src/providers/meta-bootstrap.ts +1043 -0
- package/src/providers/meta-facebook-group.ts +690 -0
- package/src/providers/meta-marketplace-relay.ts +747 -0
- package/src/providers/meta-omni.ts +373 -0
- package/src/providers/meta-relay-bundle.ts +1049 -0
- package/src/providers/meta-web-descriptors.ts +1740 -0
- package/src/providers/meta-web-runtime.ts +3388 -0
- package/src/providers/meta-web.ts +2931 -0
- package/src/providers/read-failure.ts +139 -0
- package/src/providers/reddit-omni.ts +462 -0
- package/src/providers/reddit-web-runtime.ts +1929 -0
- package/src/providers/reddit-web.ts +1611 -0
- package/src/providers/rental-listings.ts +460 -0
- package/src/providers/substack-omni.ts +406 -0
- package/src/providers/substack-video-mp4.ts +33 -0
- package/src/providers/substack-web-runtime.ts +2812 -0
- package/src/providers/substack-web.ts +1502 -0
- package/src/providers/tiktok-video-mp4.ts +33 -0
- package/src/providers/tiktok-web-runtime.ts +770 -0
- package/src/providers/tiktok-web.ts +2786 -0
- package/src/providers/twitch-web-runtime.ts +226 -0
- package/src/providers/twitch-web.ts +274 -0
- package/src/providers/whatsapp-account-identity.ts +55 -0
- package/src/providers/whatsapp-contact-projection-helper.ts +874 -0
- package/src/providers/whatsapp-contact-projection-protocol.ts +403 -0
- package/src/providers/whatsapp-interaction-projection-helper.ts +1452 -0
- package/src/providers/whatsapp-interaction-projection-protocol.ts +462 -0
- package/src/providers/whatsapp-message-export-projection-protocol.ts +532 -0
- package/src/providers/whatsapp-messaging.ts +132 -0
- package/src/providers/whatsapp-omni.ts +586 -0
- package/src/providers/whatsapp-web-runtime.ts +3108 -0
- package/src/providers/whatsapp-web.ts +1037 -0
- package/src/providers/x-image-provenance.ts +523 -0
- package/src/providers/x-made-with-ai.ts +106 -0
- package/src/providers/x-transaction-id.ts +413 -0
- package/src/providers/x-web-runtime.ts +3485 -0
- package/src/providers/x-web.ts +2332 -0
- package/src/providers/x.ts +1889 -0
- package/src/providers/youtube-web-runtime.ts +1400 -0
- package/src/providers/youtube-web.ts +1221 -0
- package/src/read-admission-runtime.ts +91 -0
- package/src/read-client.ts +521 -0
- package/src/read-effect-platform.ts +7 -0
- package/src/read-effect-runtime.ts +14 -0
- package/src/read-effect.ts +52 -0
- package/src/read-projection-admission.ts +866 -0
- package/src/read-projections.ts +3151 -0
- package/src/recovery.ts +1492 -0
- package/src/reviewed-template.ts +146 -0
- package/src/run-journal.ts +1272 -0
- package/src/runtime.ts +5116 -0
- package/src/scripts/generate-provider-plugin-catalog.ts +121 -0
- package/src/scripts/install-whatsapp-protocol.sh +232 -0
- package/src/scripts/recover-install-stages.ts +1491 -0
- package/src/scripts/resolve-state-home.ts +30 -0
- package/src/scripts/scaffold-web-provider.ts +973 -0
- package/src/scripts/sync-bundled-adapters.ts +695 -0
- package/src/session-secrets.ts +1183 -0
- package/src/state-helper.bunfig.toml +2 -0
- package/src/state-helper.ts +2914 -0
- package/src/storage.ts +3842 -0
- package/src/transport-policy.ts +2 -0
- package/src/usage.ts +182 -0
- package/src/version.ts +2 -0
- package/src/web-session-authentication-policy.ts +136 -0
- package/src/web-session-cleanup-admission.ts +2344 -0
- package/src/web-session-cleanup-join.ts +99 -0
- package/src/web-session-client.ts +996 -0
- package/src/web-session-contract-definitions.ts +820 -0
- package/src/web-session-contract-planning.ts +42 -0
- package/src/web-session-contracts.ts +228 -0
- package/src/web-session-cookies.ts +500 -0
- package/src/web-session-execution.ts +385 -0
- package/src/web-session-http.ts +373 -0
- package/src/web-session-read-errors.ts +19 -0
- package/src/web-session-read-runtime.ts +124 -0
- package/src/web-session-recovery.ts +818 -0
- package/src/web-session-sites.ts +58 -0
- package/src/web-session-template.ts +994 -0
- package/src/web-session.ts +93 -0
- package/src/whatsapp-client-binding.ts +21 -0
- package/src/whatsapp-client-types.ts +75 -0
- package/src/whatsapp-client.ts +395 -0
- package/src/whatsapp-export-coordinate.ts +17 -0
- package/src/whatsapp-message-bundle-v2.ts +195 -0
- package/src/whatsapp-message-like-me-cli.ts +195 -0
- package/src/whatsapp-message-like-me-export.ts +1072 -0
- package/src/whatsapp-message-like-me-source.ts +1012 -0
- package/tsconfig.json +67 -0
package/src/browser.ts
ADDED
|
@@ -0,0 +1,3873 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
lstatSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { createConnection } from "node:net";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
12
|
+
import { types as nodeTypes } from "node:util";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
acquireCookieRecords,
|
|
16
|
+
agentBrowserCommand as packageAgentBrowserCommand,
|
|
17
|
+
browserCookieCommands,
|
|
18
|
+
browserProxyArguments,
|
|
19
|
+
type CookieRecordReader,
|
|
20
|
+
} from "@hraness/kb/clip/acquire";
|
|
21
|
+
import { BoundedByteBuffer } from "@hraness/kb/clip/bounded-byte-buffer";
|
|
22
|
+
import {
|
|
23
|
+
cloneBrowserProfile,
|
|
24
|
+
cloneProfile,
|
|
25
|
+
copyBoundedLocalState,
|
|
26
|
+
isSafeNamedProfile,
|
|
27
|
+
profilePath,
|
|
28
|
+
type ClonedBrowserProfile,
|
|
29
|
+
} from "@hraness/kb/browser-profiles";
|
|
30
|
+
import { startNetworkProxy, type LocalNetworkProxy } from "@hraness/kb/clip/network-proxy";
|
|
31
|
+
import { redactSensitiveText } from "@hraness/kb/clip/persist";
|
|
32
|
+
import { sanitizeTerminalLine } from "@hraness/kb/clip/terminal";
|
|
33
|
+
import type { GhostgetAuth } from "./auth";
|
|
34
|
+
import type { OperationDeadline } from "./operation-deadline";
|
|
35
|
+
import type {
|
|
36
|
+
BrowserRecipe,
|
|
37
|
+
FileInputValue,
|
|
38
|
+
OperationInput,
|
|
39
|
+
GhostgetManifest,
|
|
40
|
+
} from "./model";
|
|
41
|
+
import { localBrowserCdpUrl } from "./derivation-file-chooser";
|
|
42
|
+
import { DOM_ACTION_TRANSPORT_DISABLED_MESSAGE } from "./transport-policy";
|
|
43
|
+
import {
|
|
44
|
+
captureProcessOwnerIdentity,
|
|
45
|
+
processOwnerStatus,
|
|
46
|
+
type ProcessOwnerIdentity,
|
|
47
|
+
type ProcessOwnerStatus,
|
|
48
|
+
} from "./process-identity";
|
|
49
|
+
|
|
50
|
+
type JsonRecord = Record<string, unknown>;
|
|
51
|
+
|
|
52
|
+
/** Browser recipes are never an action transport for registered signed-in sites. */
|
|
53
|
+
export function assertBrowserManifestOriginPolicy(
|
|
54
|
+
manifest: GhostgetManifest,
|
|
55
|
+
isProtectedHostname: (hostname: string) => boolean,
|
|
56
|
+
): void {
|
|
57
|
+
for (const domain of manifest.browserDomains) {
|
|
58
|
+
if (isProtectedHostname(domain)) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`scripted browser actions are prohibited on protected signed-in site domain ${domain}; use a code-owned schemaVersion 3 official provider or schemaVersion 4 authenticated internal API`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const origin of manifest.origins) {
|
|
65
|
+
let hostname: string;
|
|
66
|
+
try {
|
|
67
|
+
hostname = new URL(origin).hostname;
|
|
68
|
+
} catch {
|
|
69
|
+
throw new Error("browser action manifest contains an invalid origin");
|
|
70
|
+
}
|
|
71
|
+
if (isProtectedHostname(hostname)) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`scripted browser actions are prohibited on protected signed-in site hostname ${hostname}; use a code-owned schemaVersion 3 official provider or schemaVersion 4 authenticated internal API`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type BrowserExecution = {
|
|
80
|
+
readonly status: "succeeded" | "failed" | "partial" | "indeterminate";
|
|
81
|
+
readonly output: unknown;
|
|
82
|
+
readonly finalUrl: string | null;
|
|
83
|
+
readonly dispatchStarted: boolean;
|
|
84
|
+
readonly dispatch: {
|
|
85
|
+
readonly planned: number;
|
|
86
|
+
readonly started: number;
|
|
87
|
+
readonly verified: number;
|
|
88
|
+
};
|
|
89
|
+
readonly error?: string;
|
|
90
|
+
readonly privateArtifactsPreserved?: boolean;
|
|
91
|
+
readonly recoveryHandle?: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export type BrowserDispatchEvent = {
|
|
95
|
+
readonly id: string;
|
|
96
|
+
readonly index: number;
|
|
97
|
+
readonly progress: BrowserExecution["dispatch"];
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type BrowserFileResolver = (
|
|
101
|
+
files: readonly FileInputValue[],
|
|
102
|
+
) => Promise<readonly string[]>;
|
|
103
|
+
|
|
104
|
+
export type CommandResult = {
|
|
105
|
+
readonly stdout: string;
|
|
106
|
+
readonly stderr: string;
|
|
107
|
+
readonly exitCode: number;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export type BrowserSession = {
|
|
111
|
+
readonly runBatch: (commands: readonly (readonly string[])[], timeoutMs: number, maxOutputBytes: number) => Promise<readonly JsonRecord[]>;
|
|
112
|
+
/** Waits for every in-flight batch to settle before closing the browser. */
|
|
113
|
+
readonly close: () => Promise<void>;
|
|
114
|
+
/** Refuses to touch private resources until close is acknowledged or independently proved. */
|
|
115
|
+
readonly cleanup: () => Promise<void>;
|
|
116
|
+
readonly recoveryHandle?: string;
|
|
117
|
+
readonly cleanupResourceIdentity?: BrowserCleanupResourceIdentity;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
type BrowserPrivateArtifactRemover = (path: string) => void;
|
|
121
|
+
|
|
122
|
+
export type BrowserSessionDependencies = {
|
|
123
|
+
/** Abort-aware runner that settles only after its child process tree is reaped. */
|
|
124
|
+
readonly runCommand: typeof runCommand;
|
|
125
|
+
readonly startNetworkProxy: typeof startNetworkProxy;
|
|
126
|
+
readonly acquireCookieRecords: CookieRecordReader;
|
|
127
|
+
/** Internal deterministic seam for independently removing owned private roots. */
|
|
128
|
+
readonly removePrivateArtifact: BrowserPrivateArtifactRemover;
|
|
129
|
+
/** Internal seam for deterministic cleanup quiescence and deletion reproof. */
|
|
130
|
+
readonly cleanupLifecycle: AgentBrowserLifecycleDependencies;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Provider-neutral borrowed operation budget used by browser bootstraps. */
|
|
134
|
+
export type BrowserOperationDeadline = Pick<
|
|
135
|
+
OperationDeadline,
|
|
136
|
+
"signal" | "remainingTimeMs" | "run" | "throwIfUnavailable"
|
|
137
|
+
>;
|
|
138
|
+
|
|
139
|
+
export type CreateBrowserSessionOptions = {
|
|
140
|
+
readonly headed: boolean;
|
|
141
|
+
readonly timeoutMs: number;
|
|
142
|
+
readonly maxOutputBytes: number;
|
|
143
|
+
/** Kernel-owned total budget. Browser setup borrows and never disposes it. */
|
|
144
|
+
readonly operationDeadline?: BrowserOperationDeadline;
|
|
145
|
+
/**
|
|
146
|
+
* Internal code-owned resolvers may opt into the pinned evaluate command.
|
|
147
|
+
* Manifest recipes and derivation must never populate this option.
|
|
148
|
+
*/
|
|
149
|
+
readonly allowCodeOwnedEvaluation?: true;
|
|
150
|
+
/**
|
|
151
|
+
* Internal code-owned bootstraps may inspect bounded request metadata while
|
|
152
|
+
* discovering a current first-party operation revision. Raw observations
|
|
153
|
+
* must be projected before they leave the provider module.
|
|
154
|
+
*/
|
|
155
|
+
readonly allowCodeOwnedNetworkObservation?: true;
|
|
156
|
+
/**
|
|
157
|
+
* Persists the exact private roots before a proxy or browser can launch.
|
|
158
|
+
* The durable registrar reconciles an ambiguous commit before returning.
|
|
159
|
+
* Any other throw is still treated as potentially committed, so Ghostget
|
|
160
|
+
* preserves the roots instead of destroying recovery evidence.
|
|
161
|
+
*/
|
|
162
|
+
readonly publishCleanupResource?: ((
|
|
163
|
+
resource: BrowserCleanupResourceIdentity,
|
|
164
|
+
) => void) & {
|
|
165
|
+
/** Durably records that the closed browser and its private roots are idle. */
|
|
166
|
+
readonly markBrowserCleanupQuiescent?: (
|
|
167
|
+
resource: BrowserCleanupResourceIdentity,
|
|
168
|
+
) => void;
|
|
169
|
+
/** Journals one exact root only after its identity-bound removal succeeds. */
|
|
170
|
+
readonly markBrowserCleanupRootRemoved?: (
|
|
171
|
+
resource: BrowserCleanupResourceIdentity,
|
|
172
|
+
root: BrowserCleanupResourceRoot,
|
|
173
|
+
) => void;
|
|
174
|
+
};
|
|
175
|
+
readonly dependencies?: Partial<BrowserSessionDependencies>;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export type BrowserPrivateDirectoryIdentityV1 = {
|
|
179
|
+
readonly device: string;
|
|
180
|
+
readonly inode: string;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** Legacy public name retained for v1 claim parsing compatibility. */
|
|
184
|
+
export type BrowserPrivateDirectoryIdentity =
|
|
185
|
+
BrowserPrivateDirectoryIdentityV1;
|
|
186
|
+
|
|
187
|
+
export type BrowserPrivateDirectoryIdentityV2 =
|
|
188
|
+
BrowserPrivateDirectoryIdentityV1 & {
|
|
189
|
+
readonly birthtimeNs: string;
|
|
190
|
+
/** Decimal POSIX permission bits. V2 admits only 0700 (`448`). */
|
|
191
|
+
readonly mode: "448";
|
|
192
|
+
readonly uid: string;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export type AgentBrowserControlWitnessV1 = {
|
|
196
|
+
readonly kind: "agent-browser-control-v1";
|
|
197
|
+
readonly version: "0.32.3";
|
|
198
|
+
readonly session: string;
|
|
199
|
+
readonly socketDirectory: string;
|
|
200
|
+
readonly daemonOwner: ProcessOwnerIdentity;
|
|
201
|
+
readonly engine: "chrome";
|
|
202
|
+
readonly launchHash: string;
|
|
203
|
+
readonly cdpUrl: string;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export type BrowserCleanupResourceIdentityV1 = {
|
|
207
|
+
readonly kind: "agent-browser-session-v1";
|
|
208
|
+
readonly recoveryHandle: string;
|
|
209
|
+
readonly session: string;
|
|
210
|
+
readonly socketDirectory: string;
|
|
211
|
+
readonly socketDirectoryIdentity: BrowserPrivateDirectoryIdentityV1;
|
|
212
|
+
readonly artifactsDirectory: string;
|
|
213
|
+
readonly artifactsDirectoryIdentity: BrowserPrivateDirectoryIdentityV1;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
type BrowserCleanupResourceIdentityV2Base = {
|
|
217
|
+
readonly kind: "agent-browser-session-v2";
|
|
218
|
+
readonly recoveryHandle: string;
|
|
219
|
+
readonly session: string;
|
|
220
|
+
readonly socketDirectory: string;
|
|
221
|
+
readonly socketDirectoryIdentity: BrowserPrivateDirectoryIdentityV2;
|
|
222
|
+
readonly artifactsDirectory: string;
|
|
223
|
+
readonly artifactsDirectoryIdentity: BrowserPrivateDirectoryIdentityV2;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Durable browser cleanup progression. Prepared roots have no launch intent,
|
|
228
|
+
* launch-intent is persisted immediately before launch, and controlled binds
|
|
229
|
+
* one immutable daemon/browser/CDP witness. Progression is strictly monotonic.
|
|
230
|
+
*/
|
|
231
|
+
export type BrowserCleanupResourceIdentityV2 =
|
|
232
|
+
BrowserCleanupResourceIdentityV2Base & (
|
|
233
|
+
| {
|
|
234
|
+
readonly phase: "prepared";
|
|
235
|
+
readonly control: null;
|
|
236
|
+
}
|
|
237
|
+
| {
|
|
238
|
+
readonly phase: "launch-intent";
|
|
239
|
+
readonly control: null;
|
|
240
|
+
}
|
|
241
|
+
| {
|
|
242
|
+
readonly phase: "controlled";
|
|
243
|
+
readonly control: AgentBrowserControlWitnessV1;
|
|
244
|
+
}
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
export type BrowserCleanupResourceIdentity =
|
|
248
|
+
| BrowserCleanupResourceIdentityV1
|
|
249
|
+
| BrowserCleanupResourceIdentityV2;
|
|
250
|
+
|
|
251
|
+
export type BrowserClosedCleanupEvidence = {
|
|
252
|
+
readonly kind: "agent-browser-closed-artifacts-v1";
|
|
253
|
+
readonly resource: BrowserCleanupResourceIdentity;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
export class PreservedBrowserArtifactsError extends Error {
|
|
257
|
+
readonly recoveryHandle: string;
|
|
258
|
+
readonly cleanupEvidence?: BrowserClosedCleanupEvidence;
|
|
259
|
+
|
|
260
|
+
constructor(
|
|
261
|
+
message: string,
|
|
262
|
+
recoveryHandle: string,
|
|
263
|
+
cause: unknown,
|
|
264
|
+
cleanupEvidence?: BrowserClosedCleanupEvidence,
|
|
265
|
+
) {
|
|
266
|
+
super(message, { cause });
|
|
267
|
+
this.name = "PreservedBrowserArtifactsError";
|
|
268
|
+
this.recoveryHandle = recoveryHandle;
|
|
269
|
+
if (cleanupEvidence !== undefined) {
|
|
270
|
+
this.cleanupEvidence = cleanupEvidence;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export class AgentBrowserLiveControlUnavailableError extends Error {
|
|
276
|
+
constructor() {
|
|
277
|
+
super("agent-browser live control identity is unavailable");
|
|
278
|
+
this.name = "AgentBrowserLiveControlUnavailableError";
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
type BrowserCommandStreamObservation = {
|
|
283
|
+
bytes: number;
|
|
284
|
+
naturalEof: boolean;
|
|
285
|
+
failed: boolean;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
type BrowserCommandObservation = {
|
|
289
|
+
readonly elapsedMs: number;
|
|
290
|
+
readonly wrapperExited: boolean;
|
|
291
|
+
readonly wrapperExitCode: number | null;
|
|
292
|
+
readonly stdout: Readonly<BrowserCommandStreamObservation>;
|
|
293
|
+
readonly stderr: Readonly<BrowserCommandStreamObservation>;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
export type BrowserCommandLifecycle = {
|
|
297
|
+
readonly schemaVersion: 1;
|
|
298
|
+
readonly stopReason: "timeout" | "cancelled" | "stdout-error" | "stderr-error" | "command-error";
|
|
299
|
+
readonly beforeStop: BrowserCommandObservation;
|
|
300
|
+
readonly terminal: BrowserCommandObservation & {
|
|
301
|
+
readonly originalGroupTerminationSucceeded: boolean;
|
|
302
|
+
readonly resourcesSettled: boolean;
|
|
303
|
+
};
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
// Content-free observations never grant cleanup authority or modify an Error.
|
|
307
|
+
const browserCommandObservations = new WeakMap<object, BrowserCommandLifecycle>();
|
|
308
|
+
export function browserCommandLifecycle(error: unknown): BrowserCommandLifecycle | null {
|
|
309
|
+
return typeof error === "object" && error !== null
|
|
310
|
+
? browserCommandObservations.get(error) ?? null
|
|
311
|
+
: null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
class BrowserCommandCleanupError extends Error {
|
|
315
|
+
constructor(message: string, cause?: unknown) {
|
|
316
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
317
|
+
this.name = "BrowserCommandCleanupError";
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
class AgentBrowserLifecycleCommandUnavailableError extends Error {
|
|
322
|
+
constructor(label: string) {
|
|
323
|
+
super(`agent-browser ${label} could not be verified`);
|
|
324
|
+
this.name = "AgentBrowserLifecycleCommandUnavailableError";
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
class AgentBrowserCleanupOwnerStillLiveError extends Error {
|
|
329
|
+
constructor() {
|
|
330
|
+
super("browser cleanup pinned owner is not quiescent");
|
|
331
|
+
this.name = "AgentBrowserCleanupOwnerStillLiveError";
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
class AgentBrowserPostCloseTransitionStillSettlingError extends Error {
|
|
336
|
+
constructor() {
|
|
337
|
+
super("browser cleanup post-close transition is still settling");
|
|
338
|
+
this.name = "AgentBrowserPostCloseTransitionStillSettlingError";
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function recoveryHandleComponent(value: string): string {
|
|
343
|
+
return Buffer.from(value, "utf8").toString("base64url");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Fixed-order v1 recovery handle. Every value is unpadded base64url-encoded
|
|
348
|
+
* UTF-8, so valid POSIX and Windows paths remain unambiguous and transport-safe.
|
|
349
|
+
* An empty socket value means setup failed before a socket root was created.
|
|
350
|
+
*/
|
|
351
|
+
export function browserRecoveryHandle(input: {
|
|
352
|
+
readonly session: string;
|
|
353
|
+
readonly configPath: string;
|
|
354
|
+
readonly socketDirectory: string | null;
|
|
355
|
+
readonly artifactsDirectory: string;
|
|
356
|
+
}): string {
|
|
357
|
+
return [
|
|
358
|
+
"v1",
|
|
359
|
+
`session=${recoveryHandleComponent(input.session)}`,
|
|
360
|
+
`config=${recoveryHandleComponent(input.configPath)}`,
|
|
361
|
+
`socket=${input.socketDirectory === null
|
|
362
|
+
? ""
|
|
363
|
+
: recoveryHandleComponent(input.socketDirectory)}`,
|
|
364
|
+
`artifacts=${recoveryHandleComponent(input.artifactsDirectory)}`,
|
|
365
|
+
].join(";");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const encodedRecoveryHandleComponentPattern = /^[A-Za-z0-9_-]{1,4096}$/u;
|
|
369
|
+
const browserSessionPattern = /^io-([1-9][0-9]{0,9})-[a-f0-9-]{12}$/u;
|
|
370
|
+
|
|
371
|
+
function decodeRecoveryHandleComponent(
|
|
372
|
+
value: string,
|
|
373
|
+
label: string,
|
|
374
|
+
): string {
|
|
375
|
+
if (!encodedRecoveryHandleComponentPattern.test(value)) {
|
|
376
|
+
throw new Error(`browser recovery ${label} is malformed`);
|
|
377
|
+
}
|
|
378
|
+
const bytes = Buffer.from(value, "base64url");
|
|
379
|
+
if (bytes.toString("base64url") !== value) {
|
|
380
|
+
throw new Error(`browser recovery ${label} is not canonical`);
|
|
381
|
+
}
|
|
382
|
+
let decoded: string;
|
|
383
|
+
try {
|
|
384
|
+
decoded = new TextDecoder("utf-8", {
|
|
385
|
+
fatal: true,
|
|
386
|
+
ignoreBOM: true,
|
|
387
|
+
}).decode(bytes);
|
|
388
|
+
} catch {
|
|
389
|
+
throw new Error(`browser recovery ${label} is not UTF-8`);
|
|
390
|
+
}
|
|
391
|
+
if (decoded === "" || decoded.includes("\0")) {
|
|
392
|
+
throw new Error(`browser recovery ${label} is malformed`);
|
|
393
|
+
}
|
|
394
|
+
return decoded;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function canonicalAbsolutePath(value: string, label: string): string {
|
|
398
|
+
if (!isAbsolute(value) || resolve(value) !== value) {
|
|
399
|
+
throw new Error(`browser recovery ${label} is not canonical`);
|
|
400
|
+
}
|
|
401
|
+
return value;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function parseBrowserRecoveryHandle(
|
|
405
|
+
value: string,
|
|
406
|
+
): {
|
|
407
|
+
readonly session: string;
|
|
408
|
+
readonly configPath: string;
|
|
409
|
+
readonly socketDirectory: string;
|
|
410
|
+
readonly artifactsDirectory: string;
|
|
411
|
+
} {
|
|
412
|
+
if (value.length < 1 || value.length > 16 * 1024) {
|
|
413
|
+
throw new Error("browser recovery handle is malformed");
|
|
414
|
+
}
|
|
415
|
+
const parts = value.split(";");
|
|
416
|
+
if (
|
|
417
|
+
parts.length !== 5
|
|
418
|
+
|| parts[0] !== "v1"
|
|
419
|
+
|| !parts[1]?.startsWith("session=")
|
|
420
|
+
|| !parts[2]?.startsWith("config=")
|
|
421
|
+
|| !parts[3]?.startsWith("socket=")
|
|
422
|
+
|| !parts[4]?.startsWith("artifacts=")
|
|
423
|
+
) {
|
|
424
|
+
throw new Error("browser recovery handle is malformed");
|
|
425
|
+
}
|
|
426
|
+
const session = decodeRecoveryHandleComponent(
|
|
427
|
+
parts[1].slice("session=".length),
|
|
428
|
+
"session",
|
|
429
|
+
);
|
|
430
|
+
if (!browserSessionPattern.test(session)) {
|
|
431
|
+
throw new Error("browser recovery session is malformed");
|
|
432
|
+
}
|
|
433
|
+
const configPath = canonicalAbsolutePath(
|
|
434
|
+
decodeRecoveryHandleComponent(
|
|
435
|
+
parts[2].slice("config=".length),
|
|
436
|
+
"config path",
|
|
437
|
+
),
|
|
438
|
+
"config path",
|
|
439
|
+
);
|
|
440
|
+
const encodedSocket = parts[3].slice("socket=".length);
|
|
441
|
+
if (encodedSocket === "") {
|
|
442
|
+
throw new Error("browser recovery socket directory is unavailable");
|
|
443
|
+
}
|
|
444
|
+
const socketDirectory = canonicalAbsolutePath(
|
|
445
|
+
decodeRecoveryHandleComponent(encodedSocket, "socket directory"),
|
|
446
|
+
"socket directory",
|
|
447
|
+
);
|
|
448
|
+
const artifactsDirectory = canonicalAbsolutePath(
|
|
449
|
+
decodeRecoveryHandleComponent(
|
|
450
|
+
parts[4].slice("artifacts=".length),
|
|
451
|
+
"artifacts directory",
|
|
452
|
+
),
|
|
453
|
+
"artifacts directory",
|
|
454
|
+
);
|
|
455
|
+
if (
|
|
456
|
+
basename(socketDirectory).match(/^io-ab-[A-Za-z0-9_-]{6,64}$/u) === null
|
|
457
|
+
|| dirname(socketDirectory) !== "/tmp"
|
|
458
|
+
|| basename(artifactsDirectory).match(
|
|
459
|
+
/^io-browser-[A-Za-z0-9_-]{6,64}$/u,
|
|
460
|
+
) === null
|
|
461
|
+
|| dirname(artifactsDirectory) !== resolve(tmpdir())
|
|
462
|
+
|| configPath !== join(artifactsDirectory, "agent-browser.json")
|
|
463
|
+
|| dirname(configPath) !== artifactsDirectory
|
|
464
|
+
) {
|
|
465
|
+
throw new Error("browser recovery handle does not bind owned private roots");
|
|
466
|
+
}
|
|
467
|
+
return Object.freeze({
|
|
468
|
+
session,
|
|
469
|
+
configPath,
|
|
470
|
+
socketDirectory,
|
|
471
|
+
artifactsDirectory,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const browserIdentityDecimalPattern = /^(?:0|[1-9][0-9]{0,39})$/u;
|
|
476
|
+
const processIdentityDigestPattern = /^[a-f0-9]{64}$/u;
|
|
477
|
+
const agentBrowserBoundaryNoncePattern = /^[a-f0-9]{32}$/u;
|
|
478
|
+
const agentBrowserLaunchHashPattern = /^(?:0|[1-9][0-9]{0,19})$/u;
|
|
479
|
+
const maximumAgentBrowserControlLaunchHash = (1n << 64n) - 1n;
|
|
480
|
+
|
|
481
|
+
function browserIdentityRecord(value: unknown, label: string): JsonRecord {
|
|
482
|
+
if (
|
|
483
|
+
typeof value !== "object"
|
|
484
|
+
|| value === null
|
|
485
|
+
|| Array.isArray(value)
|
|
486
|
+
|| nodeTypes.isProxy(value)
|
|
487
|
+
) throw new Error(`${label} is malformed`);
|
|
488
|
+
const prototype: unknown = Object.getPrototypeOf(value) as unknown;
|
|
489
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
490
|
+
throw new Error(`${label} is malformed`);
|
|
491
|
+
}
|
|
492
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
493
|
+
const output: JsonRecord = {};
|
|
494
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
495
|
+
if (typeof key !== "string") throw new Error(`${label} is malformed`);
|
|
496
|
+
const descriptor = descriptors[key];
|
|
497
|
+
if (
|
|
498
|
+
descriptor === undefined
|
|
499
|
+
|| !descriptor.enumerable
|
|
500
|
+
|| !("value" in descriptor)
|
|
501
|
+
|| /[\u0000-\u001f\u007f-\u009f]/u.test(key)
|
|
502
|
+
) throw new Error(`${label} is malformed`);
|
|
503
|
+
output[key] = descriptor.value as unknown;
|
|
504
|
+
}
|
|
505
|
+
return output;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function browserIdentityExactKeys(
|
|
509
|
+
value: JsonRecord,
|
|
510
|
+
expected: readonly string[],
|
|
511
|
+
label: string,
|
|
512
|
+
): void {
|
|
513
|
+
const actual = Object.keys(value).sort();
|
|
514
|
+
const wanted = [...expected].sort();
|
|
515
|
+
if (
|
|
516
|
+
actual.length !== wanted.length
|
|
517
|
+
|| actual.some((key, index) => key !== wanted[index])
|
|
518
|
+
) throw new Error(`${label} is malformed`);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function unwrapAgentBrowserIdentityResult(
|
|
522
|
+
value: unknown,
|
|
523
|
+
label: string,
|
|
524
|
+
): {
|
|
525
|
+
readonly data: unknown;
|
|
526
|
+
readonly success: unknown;
|
|
527
|
+
} {
|
|
528
|
+
const root = browserIdentityRecord(value, label);
|
|
529
|
+
const keys = Object.keys(root).sort();
|
|
530
|
+
if (keys.length === 2 && keys[0] === "data" && keys[1] === "success") {
|
|
531
|
+
return Object.freeze({
|
|
532
|
+
data: root.data,
|
|
533
|
+
success: root.success,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
browserIdentityExactKeys(
|
|
537
|
+
root,
|
|
538
|
+
["_boundary", "data", "error", "success"],
|
|
539
|
+
label,
|
|
540
|
+
);
|
|
541
|
+
const boundary = browserIdentityRecord(root._boundary, `${label} boundary`);
|
|
542
|
+
browserIdentityExactKeys(boundary, ["nonce", "origin"], `${label} boundary`);
|
|
543
|
+
if (
|
|
544
|
+
typeof boundary.nonce !== "string"
|
|
545
|
+
|| !agentBrowserBoundaryNoncePattern.test(boundary.nonce)
|
|
546
|
+
|| boundary.origin !== "unknown"
|
|
547
|
+
) throw new Error(`${label} is malformed`);
|
|
548
|
+
return Object.freeze({
|
|
549
|
+
data: root.data,
|
|
550
|
+
success: root.success,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function browserIdentityDecimal(value: unknown, label: string): string {
|
|
555
|
+
if (
|
|
556
|
+
typeof value !== "string"
|
|
557
|
+
|| !browserIdentityDecimalPattern.test(value)
|
|
558
|
+
) throw new Error(`${label} is malformed`);
|
|
559
|
+
return value;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function parseBrowserPrivateDirectoryIdentityV1(
|
|
563
|
+
value: unknown,
|
|
564
|
+
label: string,
|
|
565
|
+
): BrowserPrivateDirectoryIdentityV1 {
|
|
566
|
+
const identity = browserIdentityRecord(value, label);
|
|
567
|
+
browserIdentityExactKeys(identity, ["device", "inode"], label);
|
|
568
|
+
return Object.freeze({
|
|
569
|
+
device: browserIdentityDecimal(identity.device, `${label} device`),
|
|
570
|
+
inode: browserIdentityDecimal(identity.inode, `${label} inode`),
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function parseBrowserPrivateDirectoryIdentityV2(
|
|
575
|
+
value: unknown,
|
|
576
|
+
label: string,
|
|
577
|
+
): BrowserPrivateDirectoryIdentityV2 {
|
|
578
|
+
const identity = browserIdentityRecord(value, label);
|
|
579
|
+
browserIdentityExactKeys(
|
|
580
|
+
identity,
|
|
581
|
+
["birthtimeNs", "device", "inode", "mode", "uid"],
|
|
582
|
+
label,
|
|
583
|
+
);
|
|
584
|
+
const birthtimeNs = browserIdentityDecimal(
|
|
585
|
+
identity.birthtimeNs,
|
|
586
|
+
`${label} birth time`,
|
|
587
|
+
);
|
|
588
|
+
if (birthtimeNs === "0" || identity.mode !== "448") {
|
|
589
|
+
throw new Error(`${label} is malformed`);
|
|
590
|
+
}
|
|
591
|
+
return Object.freeze({
|
|
592
|
+
device: browserIdentityDecimal(identity.device, `${label} device`),
|
|
593
|
+
inode: browserIdentityDecimal(identity.inode, `${label} inode`),
|
|
594
|
+
birthtimeNs,
|
|
595
|
+
mode: "448",
|
|
596
|
+
uid: browserIdentityDecimal(identity.uid, `${label} owner`),
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function parseBrowserProcessOwner(value: unknown): ProcessOwnerIdentity {
|
|
601
|
+
const owner = browserIdentityRecord(value, "browser cleanup daemon owner");
|
|
602
|
+
browserIdentityExactKeys(
|
|
603
|
+
owner,
|
|
604
|
+
["bootId", "pid", "processStartId"],
|
|
605
|
+
"browser cleanup daemon owner",
|
|
606
|
+
);
|
|
607
|
+
if (
|
|
608
|
+
typeof owner.pid !== "number"
|
|
609
|
+
|| !Number.isSafeInteger(owner.pid)
|
|
610
|
+
|| owner.pid < 1
|
|
611
|
+
|| typeof owner.bootId !== "string"
|
|
612
|
+
|| !processIdentityDigestPattern.test(owner.bootId)
|
|
613
|
+
|| typeof owner.processStartId !== "string"
|
|
614
|
+
|| !processIdentityDigestPattern.test(owner.processStartId)
|
|
615
|
+
) throw new Error("browser cleanup daemon owner is malformed");
|
|
616
|
+
return Object.freeze({
|
|
617
|
+
pid: owner.pid,
|
|
618
|
+
bootId: owner.bootId,
|
|
619
|
+
processStartId: owner.processStartId,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function canonicalAgentBrowserLaunchHash(value: unknown): string {
|
|
624
|
+
if (
|
|
625
|
+
typeof value !== "string"
|
|
626
|
+
|| !agentBrowserLaunchHashPattern.test(value)
|
|
627
|
+
|| BigInt(value) > maximumAgentBrowserControlLaunchHash
|
|
628
|
+
) throw new Error("browser cleanup launch identity is malformed");
|
|
629
|
+
return value;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function literalLoopbackAgentBrowserCdpUrl(value: unknown): string {
|
|
633
|
+
let cdpUrl: string;
|
|
634
|
+
try {
|
|
635
|
+
cdpUrl = localBrowserCdpUrl(value);
|
|
636
|
+
} catch {
|
|
637
|
+
throw new Error("browser cleanup control witness is malformed");
|
|
638
|
+
}
|
|
639
|
+
const hostname = new URL(cdpUrl).hostname;
|
|
640
|
+
if (hostname !== "127.0.0.1" && hostname !== "[::1]") {
|
|
641
|
+
throw new Error("browser cleanup control witness is malformed");
|
|
642
|
+
}
|
|
643
|
+
return cdpUrl;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function parseAgentBrowserControlWitness(
|
|
647
|
+
value: unknown,
|
|
648
|
+
): AgentBrowserControlWitnessV1 {
|
|
649
|
+
const control = browserIdentityRecord(
|
|
650
|
+
value,
|
|
651
|
+
"browser cleanup control witness",
|
|
652
|
+
);
|
|
653
|
+
browserIdentityExactKeys(control, [
|
|
654
|
+
"cdpUrl",
|
|
655
|
+
"daemonOwner",
|
|
656
|
+
"engine",
|
|
657
|
+
"kind",
|
|
658
|
+
"launchHash",
|
|
659
|
+
"session",
|
|
660
|
+
"socketDirectory",
|
|
661
|
+
"version",
|
|
662
|
+
], "browser cleanup control witness");
|
|
663
|
+
if (
|
|
664
|
+
control.kind !== "agent-browser-control-v1"
|
|
665
|
+
|| control.version !== "0.32.3"
|
|
666
|
+
|| typeof control.session !== "string"
|
|
667
|
+
|| !browserSessionPattern.test(control.session)
|
|
668
|
+
|| typeof control.socketDirectory !== "string"
|
|
669
|
+
|| control.engine !== "chrome"
|
|
670
|
+
|| typeof control.cdpUrl !== "string"
|
|
671
|
+
) throw new Error("browser cleanup control witness is malformed");
|
|
672
|
+
const cdpUrl = literalLoopbackAgentBrowserCdpUrl(control.cdpUrl);
|
|
673
|
+
if (cdpUrl !== control.cdpUrl) {
|
|
674
|
+
throw new Error("browser cleanup control witness is malformed");
|
|
675
|
+
}
|
|
676
|
+
return Object.freeze({
|
|
677
|
+
kind: "agent-browser-control-v1",
|
|
678
|
+
version: "0.32.3",
|
|
679
|
+
session: control.session,
|
|
680
|
+
socketDirectory: canonicalAbsolutePath(
|
|
681
|
+
control.socketDirectory,
|
|
682
|
+
"control socket directory",
|
|
683
|
+
),
|
|
684
|
+
daemonOwner: parseBrowserProcessOwner(control.daemonOwner),
|
|
685
|
+
engine: "chrome",
|
|
686
|
+
launchHash: canonicalAgentBrowserLaunchHash(control.launchHash),
|
|
687
|
+
cdpUrl,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function browserCleanupResourceBase(value: JsonRecord): {
|
|
692
|
+
readonly recoveryHandle: string;
|
|
693
|
+
readonly session: string;
|
|
694
|
+
readonly socketDirectory: string;
|
|
695
|
+
readonly artifactsDirectory: string;
|
|
696
|
+
} {
|
|
697
|
+
if (
|
|
698
|
+
typeof value.recoveryHandle !== "string"
|
|
699
|
+
|| typeof value.session !== "string"
|
|
700
|
+
|| typeof value.socketDirectory !== "string"
|
|
701
|
+
|| typeof value.artifactsDirectory !== "string"
|
|
702
|
+
) throw new Error("browser cleanup resource identity is malformed");
|
|
703
|
+
const recovery = parseBrowserRecoveryHandle(value.recoveryHandle);
|
|
704
|
+
if (
|
|
705
|
+
recovery.session !== value.session
|
|
706
|
+
|| recovery.socketDirectory !== value.socketDirectory
|
|
707
|
+
|| recovery.artifactsDirectory !== value.artifactsDirectory
|
|
708
|
+
) throw new Error("browser cleanup resource does not match its recovery handle");
|
|
709
|
+
return {
|
|
710
|
+
recoveryHandle: value.recoveryHandle,
|
|
711
|
+
session: recovery.session,
|
|
712
|
+
socketDirectory: recovery.socketDirectory,
|
|
713
|
+
artifactsDirectory: recovery.artifactsDirectory,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export function parseBrowserCleanupResourceIdentity(
|
|
718
|
+
value: unknown,
|
|
719
|
+
): BrowserCleanupResourceIdentity {
|
|
720
|
+
const identity = browserIdentityRecord(
|
|
721
|
+
value,
|
|
722
|
+
"browser cleanup resource identity",
|
|
723
|
+
);
|
|
724
|
+
const commonKeys = [
|
|
725
|
+
"artifactsDirectory",
|
|
726
|
+
"artifactsDirectoryIdentity",
|
|
727
|
+
"kind",
|
|
728
|
+
"recoveryHandle",
|
|
729
|
+
"session",
|
|
730
|
+
"socketDirectory",
|
|
731
|
+
"socketDirectoryIdentity",
|
|
732
|
+
] as const;
|
|
733
|
+
if (identity.kind === "agent-browser-session-v1") {
|
|
734
|
+
browserIdentityExactKeys(identity, commonKeys, "browser cleanup resource identity");
|
|
735
|
+
const base = browserCleanupResourceBase(identity);
|
|
736
|
+
return Object.freeze({
|
|
737
|
+
kind: "agent-browser-session-v1",
|
|
738
|
+
...base,
|
|
739
|
+
socketDirectoryIdentity: parseBrowserPrivateDirectoryIdentityV1(
|
|
740
|
+
identity.socketDirectoryIdentity,
|
|
741
|
+
"browser cleanup socket identity",
|
|
742
|
+
),
|
|
743
|
+
artifactsDirectoryIdentity: parseBrowserPrivateDirectoryIdentityV1(
|
|
744
|
+
identity.artifactsDirectoryIdentity,
|
|
745
|
+
"browser cleanup artifacts identity",
|
|
746
|
+
),
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
if (identity.kind !== "agent-browser-session-v2") {
|
|
750
|
+
throw new Error("browser cleanup resource identity kind is unsupported");
|
|
751
|
+
}
|
|
752
|
+
browserIdentityExactKeys(
|
|
753
|
+
identity,
|
|
754
|
+
[...commonKeys, "control", "phase"],
|
|
755
|
+
"browser cleanup resource identity",
|
|
756
|
+
);
|
|
757
|
+
const base = browserCleanupResourceBase(identity);
|
|
758
|
+
const roots = Object.freeze({
|
|
759
|
+
kind: "agent-browser-session-v2",
|
|
760
|
+
...base,
|
|
761
|
+
socketDirectoryIdentity: parseBrowserPrivateDirectoryIdentityV2(
|
|
762
|
+
identity.socketDirectoryIdentity,
|
|
763
|
+
"browser cleanup socket identity",
|
|
764
|
+
),
|
|
765
|
+
artifactsDirectoryIdentity: parseBrowserPrivateDirectoryIdentityV2(
|
|
766
|
+
identity.artifactsDirectoryIdentity,
|
|
767
|
+
"browser cleanup artifacts identity",
|
|
768
|
+
),
|
|
769
|
+
});
|
|
770
|
+
if (identity.phase === "prepared" || identity.phase === "launch-intent") {
|
|
771
|
+
if (identity.control !== null) {
|
|
772
|
+
throw new Error("browser cleanup phase and control witness are inconsistent");
|
|
773
|
+
}
|
|
774
|
+
return Object.freeze({
|
|
775
|
+
...roots,
|
|
776
|
+
phase: identity.phase,
|
|
777
|
+
control: null,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
if (identity.phase !== "controlled" || identity.control === null) {
|
|
781
|
+
throw new Error("browser cleanup phase is malformed");
|
|
782
|
+
}
|
|
783
|
+
const control = parseAgentBrowserControlWitness(identity.control);
|
|
784
|
+
if (
|
|
785
|
+
control.session !== base.session
|
|
786
|
+
|| control.socketDirectory !== base.socketDirectory
|
|
787
|
+
) throw new Error("browser cleanup control witness changed resource identity");
|
|
788
|
+
return Object.freeze({
|
|
789
|
+
...roots,
|
|
790
|
+
phase: "controlled",
|
|
791
|
+
control,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function privateBrowserDirectoryIdentityV2(
|
|
796
|
+
path: string,
|
|
797
|
+
): BrowserPrivateDirectoryIdentityV2 {
|
|
798
|
+
const stats = lstatSync(path, { bigint: true });
|
|
799
|
+
const currentUid = process.getuid?.();
|
|
800
|
+
if (
|
|
801
|
+
stats.isSymbolicLink()
|
|
802
|
+
|| !stats.isDirectory()
|
|
803
|
+
|| currentUid === undefined
|
|
804
|
+
|| stats.uid !== BigInt(currentUid)
|
|
805
|
+
|| (stats.mode & 0o777n) !== 0o700n
|
|
806
|
+
|| stats.birthtimeNs <= 0n
|
|
807
|
+
) {
|
|
808
|
+
throw new Error("browser cleanup resource is not a recoverable private directory");
|
|
809
|
+
}
|
|
810
|
+
return Object.freeze({
|
|
811
|
+
device: stats.dev.toString(),
|
|
812
|
+
inode: stats.ino.toString(),
|
|
813
|
+
birthtimeNs: stats.birthtimeNs.toString(),
|
|
814
|
+
mode: "448",
|
|
815
|
+
uid: stats.uid.toString(),
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function sameBrowserIdentity(left: unknown, right: unknown): boolean {
|
|
820
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function sameBrowserCleanupResourceBase(
|
|
824
|
+
left: BrowserCleanupResourceIdentity,
|
|
825
|
+
right: BrowserCleanupResourceIdentity,
|
|
826
|
+
): boolean {
|
|
827
|
+
return left.recoveryHandle === right.recoveryHandle
|
|
828
|
+
&& left.session === right.session
|
|
829
|
+
&& left.socketDirectory === right.socketDirectory
|
|
830
|
+
&& left.artifactsDirectory === right.artifactsDirectory;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** True only for equality or one strictly stronger, immutable browser pin. */
|
|
834
|
+
export function browserCleanupResourceExtends(
|
|
835
|
+
current: BrowserCleanupResourceIdentity,
|
|
836
|
+
next: BrowserCleanupResourceIdentity,
|
|
837
|
+
): boolean {
|
|
838
|
+
const left = parseBrowserCleanupResourceIdentity(current);
|
|
839
|
+
const right = parseBrowserCleanupResourceIdentity(next);
|
|
840
|
+
if (!sameBrowserCleanupResourceBase(left, right)) return false;
|
|
841
|
+
if (left.kind === "agent-browser-session-v1") {
|
|
842
|
+
if (right.kind === "agent-browser-session-v1") {
|
|
843
|
+
return sameBrowserIdentity(left, right);
|
|
844
|
+
}
|
|
845
|
+
const matchesLegacyRoots = right.control !== null
|
|
846
|
+
&& left.socketDirectoryIdentity.device
|
|
847
|
+
=== right.socketDirectoryIdentity.device
|
|
848
|
+
&& left.socketDirectoryIdentity.inode
|
|
849
|
+
=== right.socketDirectoryIdentity.inode
|
|
850
|
+
&& left.artifactsDirectoryIdentity.device
|
|
851
|
+
=== right.artifactsDirectoryIdentity.device
|
|
852
|
+
&& left.artifactsDirectoryIdentity.inode
|
|
853
|
+
=== right.artifactsDirectoryIdentity.inode;
|
|
854
|
+
if (!matchesLegacyRoots) return false;
|
|
855
|
+
try {
|
|
856
|
+
assertBrowserCleanupResourceRootsMatch(right);
|
|
857
|
+
return true;
|
|
858
|
+
} catch {
|
|
859
|
+
return false;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if (
|
|
863
|
+
right.kind !== "agent-browser-session-v2"
|
|
864
|
+
|| !sameBrowserIdentity(
|
|
865
|
+
left.socketDirectoryIdentity,
|
|
866
|
+
right.socketDirectoryIdentity,
|
|
867
|
+
)
|
|
868
|
+
|| !sameBrowserIdentity(
|
|
869
|
+
left.artifactsDirectoryIdentity,
|
|
870
|
+
right.artifactsDirectoryIdentity,
|
|
871
|
+
)
|
|
872
|
+
) return false;
|
|
873
|
+
if (left.phase === "prepared") {
|
|
874
|
+
return right.phase === "prepared" || right.phase === "launch-intent";
|
|
875
|
+
}
|
|
876
|
+
if (left.phase === "launch-intent") {
|
|
877
|
+
return right.phase === "launch-intent" || right.phase === "controlled";
|
|
878
|
+
}
|
|
879
|
+
return right.phase === "controlled"
|
|
880
|
+
&& sameBrowserIdentity(left.control, right.control);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
export type BrowserCleanupResourceRoot = "socket" | "artifacts";
|
|
884
|
+
export type BrowserCleanupResourceRootStatus = "match" | "absent" | "conflict";
|
|
885
|
+
|
|
886
|
+
/** Classify one exact private root without treating a replacement as absent. */
|
|
887
|
+
export function browserCleanupResourceRootStatus(
|
|
888
|
+
value: BrowserCleanupResourceIdentityV2,
|
|
889
|
+
root: BrowserCleanupResourceRoot,
|
|
890
|
+
): BrowserCleanupResourceRootStatus {
|
|
891
|
+
const resource = parseBrowserCleanupResourceIdentity(value);
|
|
892
|
+
if (resource.kind !== "agent-browser-session-v2") {
|
|
893
|
+
throw new Error("browser cleanup resource does not have recoverable roots");
|
|
894
|
+
}
|
|
895
|
+
const path = root === "socket"
|
|
896
|
+
? resource.socketDirectory
|
|
897
|
+
: resource.artifactsDirectory;
|
|
898
|
+
const expected = root === "socket"
|
|
899
|
+
? resource.socketDirectoryIdentity
|
|
900
|
+
: resource.artifactsDirectoryIdentity;
|
|
901
|
+
let actual: BrowserPrivateDirectoryIdentityV2;
|
|
902
|
+
try {
|
|
903
|
+
actual = privateBrowserDirectoryIdentityV2(path);
|
|
904
|
+
} catch (error) {
|
|
905
|
+
return (
|
|
906
|
+
typeof error === "object"
|
|
907
|
+
&& error !== null
|
|
908
|
+
&& "code" in error
|
|
909
|
+
&& error.code === "ENOENT"
|
|
910
|
+
) ? "absent" : "conflict";
|
|
911
|
+
}
|
|
912
|
+
return sameBrowserIdentity(actual, expected) ? "match" : "conflict";
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
export function assertBrowserCleanupResourceRootsMatch(
|
|
916
|
+
value: BrowserCleanupResourceIdentityV2,
|
|
917
|
+
): void {
|
|
918
|
+
const resource = parseBrowserCleanupResourceIdentity(value);
|
|
919
|
+
if (resource.kind !== "agent-browser-session-v2") {
|
|
920
|
+
throw new Error("browser cleanup resource does not have recoverable roots");
|
|
921
|
+
}
|
|
922
|
+
if (
|
|
923
|
+
browserCleanupResourceRootStatus(resource, "socket") !== "match"
|
|
924
|
+
|| browserCleanupResourceRootStatus(resource, "artifacts") !== "match"
|
|
925
|
+
) throw new Error("browser cleanup private root identity changed");
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function browserCleanupResourceIdentity(input: {
|
|
929
|
+
readonly recoveryHandle: string;
|
|
930
|
+
readonly session: string;
|
|
931
|
+
readonly socketDirectory: string;
|
|
932
|
+
readonly artifactsDirectory: string;
|
|
933
|
+
}): BrowserCleanupResourceIdentityV2 {
|
|
934
|
+
const parsed = parseBrowserRecoveryHandle(input.recoveryHandle);
|
|
935
|
+
if (
|
|
936
|
+
parsed.session !== input.session
|
|
937
|
+
|| parsed.socketDirectory !== input.socketDirectory
|
|
938
|
+
|| parsed.artifactsDirectory !== input.artifactsDirectory
|
|
939
|
+
) {
|
|
940
|
+
throw new Error("browser cleanup resource does not match its recovery handle");
|
|
941
|
+
}
|
|
942
|
+
return parseBrowserCleanupResourceIdentity({
|
|
943
|
+
kind: "agent-browser-session-v2",
|
|
944
|
+
recoveryHandle: input.recoveryHandle,
|
|
945
|
+
session: parsed.session,
|
|
946
|
+
socketDirectory: parsed.socketDirectory,
|
|
947
|
+
socketDirectoryIdentity: privateBrowserDirectoryIdentityV2(
|
|
948
|
+
parsed.socketDirectory,
|
|
949
|
+
),
|
|
950
|
+
artifactsDirectory: parsed.artifactsDirectory,
|
|
951
|
+
artifactsDirectoryIdentity: privateBrowserDirectoryIdentityV2(
|
|
952
|
+
parsed.artifactsDirectory,
|
|
953
|
+
),
|
|
954
|
+
phase: "prepared",
|
|
955
|
+
control: null,
|
|
956
|
+
}) as BrowserCleanupResourceIdentityV2;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Observe a browser-backed operation until teardown finishes. Ordinary
|
|
961
|
+
* operation failures are already reported by the provider; only an unsafe
|
|
962
|
+
* cleanup rejects this distinct quiescence barrier.
|
|
963
|
+
*/
|
|
964
|
+
export function browserCleanupBarrier(
|
|
965
|
+
operation: Promise<unknown>,
|
|
966
|
+
): Promise<void> {
|
|
967
|
+
return operation.then(
|
|
968
|
+
() => undefined,
|
|
969
|
+
(error: unknown) => {
|
|
970
|
+
if (error instanceof PreservedBrowserArtifactsError) throw error;
|
|
971
|
+
},
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
export function classifyBrowserProcessGroupProbe(
|
|
976
|
+
status: number | null,
|
|
977
|
+
stderr: string,
|
|
978
|
+
): "live" | "gone" | "unknown" {
|
|
979
|
+
if (status === 0) return "live";
|
|
980
|
+
if (status === 1 && stderr.includes("No such process")) return "gone";
|
|
981
|
+
if (status === 1 && stderr.includes("Operation not permitted")) return "live";
|
|
982
|
+
return "unknown";
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const inheritedProxyKeys = new Set([
|
|
986
|
+
"ALL_PROXY",
|
|
987
|
+
"HTTP_PROXY",
|
|
988
|
+
"HTTPS_PROXY",
|
|
989
|
+
"NO_PROXY",
|
|
990
|
+
"all_proxy",
|
|
991
|
+
"http_proxy",
|
|
992
|
+
"https_proxy",
|
|
993
|
+
"no_proxy",
|
|
994
|
+
]);
|
|
995
|
+
|
|
996
|
+
/** Exact policy vocabulary required by pinned agent-browser 0.32.3. */
|
|
997
|
+
export const runtimeBrowserPolicyActions = [
|
|
998
|
+
"launch", "navigate", "snapshot", "click", "fill", "scroll", "wait", "read", "get", "interact", "state",
|
|
999
|
+
"type", "hover", "focus", "press", "url", "inputvalue", "waitfortext", "waitforurl", "cookies_set", "close",
|
|
1000
|
+
"session_info", "cdp_url",
|
|
1001
|
+
"upload", "select", "check", "uncheck", "ischecked",
|
|
1002
|
+
"getbyrole", "getbytext", "getbylabel", "getbyplaceholder", "getbyalttext", "getbytitle", "getbytestid",
|
|
1003
|
+
] as const;
|
|
1004
|
+
|
|
1005
|
+
export function agentBrowserCommand(): readonly string[] {
|
|
1006
|
+
return packageAgentBrowserCommand();
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
export const ownedChromeOnboardingArguments = Object.freeze([
|
|
1010
|
+
"--no-first-run",
|
|
1011
|
+
"--no-default-browser-check",
|
|
1012
|
+
] as const);
|
|
1013
|
+
|
|
1014
|
+
const reviewedOwnedChromeArguments = new Set([
|
|
1015
|
+
"--profile-directory=Default",
|
|
1016
|
+
"--disable-quic",
|
|
1017
|
+
"--disable-dns-prefetch",
|
|
1018
|
+
"--disable-background-networking",
|
|
1019
|
+
"--disable-component-update",
|
|
1020
|
+
"--disable-default-apps",
|
|
1021
|
+
"--disable-sync",
|
|
1022
|
+
"--disable-features=AsyncDns",
|
|
1023
|
+
"--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
|
|
1024
|
+
"--proxy-bypass-list=<-loopback>",
|
|
1025
|
+
]);
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Build one pinned --args value for a Ghostget-owned fresh or cloned Chrome.
|
|
1029
|
+
* Callers supply only code-owned arguments; Ghostget never accepts raw launch
|
|
1030
|
+
* arguments from manifests, operation input, auth locators, or the CLI.
|
|
1031
|
+
*/
|
|
1032
|
+
export function ownedChromeLaunchArguments(
|
|
1033
|
+
additional: readonly string[] = [],
|
|
1034
|
+
): string {
|
|
1035
|
+
if (
|
|
1036
|
+
additional.some((argument) =>
|
|
1037
|
+
!reviewedOwnedChromeArguments.has(argument)
|
|
1038
|
+
|| /[\0\r\n,]/u.test(argument))
|
|
1039
|
+
) {
|
|
1040
|
+
throw new Error("owned Chrome launch arguments are not reviewed");
|
|
1041
|
+
}
|
|
1042
|
+
return [...new Set([
|
|
1043
|
+
...additional,
|
|
1044
|
+
...ownedChromeOnboardingArguments,
|
|
1045
|
+
])].join("\n");
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
export function ownedBrowserProxyArguments(
|
|
1049
|
+
proxyUrl: string,
|
|
1050
|
+
profileDirectory?: "Default",
|
|
1051
|
+
): readonly string[] {
|
|
1052
|
+
const generated = browserProxyArguments(proxyUrl, profileDirectory);
|
|
1053
|
+
if (
|
|
1054
|
+
generated.length !== 4
|
|
1055
|
+
|| generated[0] !== "--proxy"
|
|
1056
|
+
|| generated[1] !== proxyUrl
|
|
1057
|
+
|| generated[2] !== "--args"
|
|
1058
|
+
|| typeof generated[3] !== "string"
|
|
1059
|
+
) {
|
|
1060
|
+
throw new Error("pinned browser proxy arguments are malformed");
|
|
1061
|
+
}
|
|
1062
|
+
return Object.freeze([
|
|
1063
|
+
"--proxy",
|
|
1064
|
+
proxyUrl,
|
|
1065
|
+
"--args",
|
|
1066
|
+
ownedChromeLaunchArguments(generated[3].split("\n")),
|
|
1067
|
+
]);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Add the one Ghostget-owned MV3 guard to the already reviewed proxy launch.
|
|
1072
|
+
* The path is derived from a private derivation root; caller-selected Chrome
|
|
1073
|
+
* arguments remain impossible.
|
|
1074
|
+
*/
|
|
1075
|
+
export function ownedDerivationGuardBrowserArguments(
|
|
1076
|
+
proxyUrl: string,
|
|
1077
|
+
extensionDirectory: string,
|
|
1078
|
+
): readonly string[] {
|
|
1079
|
+
if (
|
|
1080
|
+
!isAbsolute(extensionDirectory)
|
|
1081
|
+
|| basename(extensionDirectory) !== "network-guard-extension"
|
|
1082
|
+
|| /[\0\r\n,]/u.test(extensionDirectory)
|
|
1083
|
+
) throw new Error("owned derivation guard extension path is invalid");
|
|
1084
|
+
const base = ownedBrowserProxyArguments(proxyUrl);
|
|
1085
|
+
if (base.length !== 4 || base[0] !== "--proxy" || base[2] !== "--args") {
|
|
1086
|
+
throw new Error("owned derivation proxy arguments changed shape");
|
|
1087
|
+
}
|
|
1088
|
+
const chromeArguments = base[3];
|
|
1089
|
+
if (typeof chromeArguments !== "string") {
|
|
1090
|
+
throw new Error("owned derivation proxy arguments changed shape");
|
|
1091
|
+
}
|
|
1092
|
+
return Object.freeze([
|
|
1093
|
+
base[0],
|
|
1094
|
+
base[1] as string,
|
|
1095
|
+
base[2],
|
|
1096
|
+
[
|
|
1097
|
+
chromeArguments,
|
|
1098
|
+
`--disable-extensions-except=${extensionDirectory}`,
|
|
1099
|
+
`--load-extension=${extensionDirectory}`,
|
|
1100
|
+
].join("\n"),
|
|
1101
|
+
]);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
export function isolatedEnvironment(
|
|
1105
|
+
socketDirectory: string,
|
|
1106
|
+
inheritedEnvironment: Readonly<Record<string, string | undefined>> = process.env,
|
|
1107
|
+
): Readonly<Record<string, string>> {
|
|
1108
|
+
const output: Record<string, string> = {};
|
|
1109
|
+
for (const [key, value] of Object.entries(inheritedEnvironment)) {
|
|
1110
|
+
if (value === undefined || key.startsWith("AGENT_BROWSER_") || inheritedProxyKeys.has(key)) continue;
|
|
1111
|
+
output[key] = value;
|
|
1112
|
+
}
|
|
1113
|
+
output.AGENT_BROWSER_SOCKET_DIR = socketDirectory;
|
|
1114
|
+
return output;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const BROWSER_COMMAND_RESOURCE_SETTLEMENT_TIMEOUT_MS = 1_000;
|
|
1118
|
+
|
|
1119
|
+
async function promiseSettlesWithin(
|
|
1120
|
+
promise: Promise<unknown>,
|
|
1121
|
+
maximumMs: number,
|
|
1122
|
+
): Promise<boolean> {
|
|
1123
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1124
|
+
try {
|
|
1125
|
+
return await Promise.race([
|
|
1126
|
+
promise.then(
|
|
1127
|
+
() => true as const,
|
|
1128
|
+
() => true as const,
|
|
1129
|
+
),
|
|
1130
|
+
new Promise<false>((resolve) => {
|
|
1131
|
+
timer = setTimeout(() => resolve(false), maximumMs);
|
|
1132
|
+
}),
|
|
1133
|
+
]);
|
|
1134
|
+
} finally {
|
|
1135
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
async function readBoundedStream(
|
|
1140
|
+
stream: ReadableStream<Uint8Array>,
|
|
1141
|
+
maxBytes: number,
|
|
1142
|
+
cancellation: AbortSignal,
|
|
1143
|
+
observation: BrowserCommandStreamObservation,
|
|
1144
|
+
): Promise<string> {
|
|
1145
|
+
const bytes = new BoundedByteBuffer(maxBytes);
|
|
1146
|
+
const reader = stream.getReader();
|
|
1147
|
+
const cancel = (): void => {
|
|
1148
|
+
void reader.cancel("browser command was terminated").catch(() => undefined);
|
|
1149
|
+
};
|
|
1150
|
+
cancellation.addEventListener("abort", cancel, { once: true });
|
|
1151
|
+
try {
|
|
1152
|
+
if (cancellation.aborted) cancel();
|
|
1153
|
+
for (;;) {
|
|
1154
|
+
const result = await reader.read();
|
|
1155
|
+
if (result.done) {
|
|
1156
|
+
// Cancellation may resolve read() with done:true; that is not natural EOF.
|
|
1157
|
+
observation.naturalEof = !cancellation.aborted;
|
|
1158
|
+
break;
|
|
1159
|
+
}
|
|
1160
|
+
observation.bytes = Math.min(Number.MAX_SAFE_INTEGER, observation.bytes + result.value.byteLength);
|
|
1161
|
+
if (!bytes.append(result.value)) {
|
|
1162
|
+
throw new Error(`process output exceeded ${maxBytes} bytes`);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
} finally {
|
|
1166
|
+
cancellation.removeEventListener("abort", cancel);
|
|
1167
|
+
reader.releaseLock();
|
|
1168
|
+
}
|
|
1169
|
+
return new TextDecoder().decode(bytes.toUint8Array());
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
export async function runCommand(
|
|
1173
|
+
command: readonly string[],
|
|
1174
|
+
options: {
|
|
1175
|
+
readonly cwd: string;
|
|
1176
|
+
readonly environment: Readonly<Record<string, string>>;
|
|
1177
|
+
readonly timeoutMs: number;
|
|
1178
|
+
readonly maxOutputBytes: number;
|
|
1179
|
+
readonly stdin?: string;
|
|
1180
|
+
readonly signal?: AbortSignal;
|
|
1181
|
+
},
|
|
1182
|
+
): Promise<CommandResult> {
|
|
1183
|
+
if (process.platform === "win32") {
|
|
1184
|
+
throw new Error(
|
|
1185
|
+
"contained browser commands require process-tree containment that is unavailable on Windows",
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
const isCancelled = (): boolean => options.signal?.aborted === true;
|
|
1189
|
+
if (isCancelled()) {
|
|
1190
|
+
throw new Error("agent-browser command was cancelled");
|
|
1191
|
+
}
|
|
1192
|
+
const startedAt = performance.now();
|
|
1193
|
+
const ownsProcessGroup = true;
|
|
1194
|
+
const child = Bun.spawn([...command], {
|
|
1195
|
+
cwd: options.cwd,
|
|
1196
|
+
detached: ownsProcessGroup,
|
|
1197
|
+
env: options.environment,
|
|
1198
|
+
stdin: options.stdin === undefined ? "ignore" : new Blob([options.stdin]),
|
|
1199
|
+
stdout: "pipe",
|
|
1200
|
+
stderr: "pipe",
|
|
1201
|
+
});
|
|
1202
|
+
let childExited = false;
|
|
1203
|
+
let wrapperExitCode: number | null = null;
|
|
1204
|
+
const exited = child.exited.then((exitCode) => {
|
|
1205
|
+
childExited = true;
|
|
1206
|
+
wrapperExitCode = exitCode;
|
|
1207
|
+
return exitCode;
|
|
1208
|
+
});
|
|
1209
|
+
const stdoutObservation: BrowserCommandStreamObservation = { bytes: 0, naturalEof: false, failed: false };
|
|
1210
|
+
const stderrObservation: BrowserCommandStreamObservation = { bytes: 0, naturalEof: false, failed: false };
|
|
1211
|
+
const observe = (): BrowserCommandObservation => Object.freeze({
|
|
1212
|
+
elapsedMs: Math.max(0, performance.now() - startedAt),
|
|
1213
|
+
wrapperExited: childExited,
|
|
1214
|
+
wrapperExitCode,
|
|
1215
|
+
stdout: Object.freeze({ ...stdoutObservation }),
|
|
1216
|
+
stderr: Object.freeze({ ...stderrObservation }),
|
|
1217
|
+
});
|
|
1218
|
+
let firstStop: { readonly reason: BrowserCommandLifecycle["stopReason"]; readonly observation: BrowserCommandObservation } | null = null;
|
|
1219
|
+
const diagnosed = <E extends Error>(error: E, originalGroupTerminationSucceeded: boolean, resourcesSettled: boolean): E => {
|
|
1220
|
+
try {
|
|
1221
|
+
if (firstStop !== null) browserCommandObservations.set(error, Object.freeze({
|
|
1222
|
+
schemaVersion: 1,
|
|
1223
|
+
stopReason: firstStop.reason,
|
|
1224
|
+
beforeStop: firstStop.observation,
|
|
1225
|
+
terminal: Object.freeze({ ...observe(), originalGroupTerminationSucceeded, resourcesSettled }),
|
|
1226
|
+
}));
|
|
1227
|
+
} catch { /* Best-effort observation must never replace the selected failure. */ }
|
|
1228
|
+
return error;
|
|
1229
|
+
};
|
|
1230
|
+
const outputCancellation = new AbortController();
|
|
1231
|
+
const signalProcessTree = (signal: "SIGTERM" | "SIGKILL"): void => {
|
|
1232
|
+
if (ownsProcessGroup) {
|
|
1233
|
+
try {
|
|
1234
|
+
process.kill(-child.pid, signal);
|
|
1235
|
+
return;
|
|
1236
|
+
} catch {
|
|
1237
|
+
// The group may have exited between inspection and signaling.
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
try {
|
|
1241
|
+
child.kill(signal);
|
|
1242
|
+
} catch {
|
|
1243
|
+
// Killing an already-exited direct child is harmless.
|
|
1244
|
+
}
|
|
1245
|
+
};
|
|
1246
|
+
const processTreeIsLive = (): boolean => {
|
|
1247
|
+
if (!ownsProcessGroup) return !childExited;
|
|
1248
|
+
try {
|
|
1249
|
+
process.kill(-child.pid, 0);
|
|
1250
|
+
return true;
|
|
1251
|
+
} catch (error) {
|
|
1252
|
+
if (
|
|
1253
|
+
typeof error === "object"
|
|
1254
|
+
&& error !== null
|
|
1255
|
+
&& "code" in error
|
|
1256
|
+
&& error.code === "ESRCH"
|
|
1257
|
+
) return false;
|
|
1258
|
+
if (
|
|
1259
|
+
typeof error === "object"
|
|
1260
|
+
&& error !== null
|
|
1261
|
+
&& "code" in error
|
|
1262
|
+
&& error.code === "EPERM"
|
|
1263
|
+
) {
|
|
1264
|
+
const probe = spawnSync(
|
|
1265
|
+
"/bin/kill",
|
|
1266
|
+
["-0", `-${child.pid}`],
|
|
1267
|
+
{
|
|
1268
|
+
encoding: "utf8",
|
|
1269
|
+
env: {
|
|
1270
|
+
LANG: "C",
|
|
1271
|
+
LC_ALL: "C",
|
|
1272
|
+
NODE_ENV: "production",
|
|
1273
|
+
PATH: "/usr/bin:/bin",
|
|
1274
|
+
},
|
|
1275
|
+
},
|
|
1276
|
+
);
|
|
1277
|
+
if (probe.error === undefined) {
|
|
1278
|
+
const result = classifyBrowserProcessGroupProbe(
|
|
1279
|
+
probe.status,
|
|
1280
|
+
probe.stderr,
|
|
1281
|
+
);
|
|
1282
|
+
if (result === "live") return true;
|
|
1283
|
+
if (result === "gone") return false;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
// An ambiguous process-group probe must never be treated as safe exit.
|
|
1287
|
+
return true;
|
|
1288
|
+
}
|
|
1289
|
+
};
|
|
1290
|
+
const waitUntilStopped = async (maximumMs: number): Promise<boolean> => {
|
|
1291
|
+
const deadline = performance.now() + maximumMs;
|
|
1292
|
+
for (;;) {
|
|
1293
|
+
if (!processTreeIsLive()) return true;
|
|
1294
|
+
if (performance.now() >= deadline) return false;
|
|
1295
|
+
await new Promise<void>((resolve) => {
|
|
1296
|
+
setTimeout(resolve, 10);
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
};
|
|
1300
|
+
const terminate = async (): Promise<void> => {
|
|
1301
|
+
signalProcessTree("SIGTERM");
|
|
1302
|
+
if (!await waitUntilStopped(1_000)) {
|
|
1303
|
+
signalProcessTree("SIGKILL");
|
|
1304
|
+
if (!await waitUntilStopped(1_000)) {
|
|
1305
|
+
throw new BrowserCommandCleanupError(
|
|
1306
|
+
"agent-browser process group survived forced termination",
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1311
|
+
const state: {
|
|
1312
|
+
failure: Error | null;
|
|
1313
|
+
termination: Promise<void> | null;
|
|
1314
|
+
} = {
|
|
1315
|
+
failure: null,
|
|
1316
|
+
termination: null,
|
|
1317
|
+
};
|
|
1318
|
+
let rejectForStop: ((error: Error) => void) | undefined;
|
|
1319
|
+
const stopped = new Promise<never>((_resolve, reject) => {
|
|
1320
|
+
rejectForStop = reject;
|
|
1321
|
+
});
|
|
1322
|
+
const requestStop = (error: Error, reason: BrowserCommandLifecycle["stopReason"] = "command-error"): void => {
|
|
1323
|
+
if (state.failure !== null) return;
|
|
1324
|
+
state.failure = error;
|
|
1325
|
+
try { firstStop = { reason, observation: observe() }; } catch { /* Preserve failure and cleanup. */ }
|
|
1326
|
+
outputCancellation.abort();
|
|
1327
|
+
state.termination = terminate();
|
|
1328
|
+
void state.termination.catch(() => undefined);
|
|
1329
|
+
rejectForStop?.(error);
|
|
1330
|
+
};
|
|
1331
|
+
const onAbort = (): void => {
|
|
1332
|
+
requestStop(new Error("agent-browser command was cancelled"), "cancelled");
|
|
1333
|
+
};
|
|
1334
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
1335
|
+
if (isCancelled()) onAbort();
|
|
1336
|
+
const timeout = setTimeout(() => {
|
|
1337
|
+
requestStop(new Error(`agent-browser timed out after ${options.timeoutMs}ms`), "timeout");
|
|
1338
|
+
}, options.timeoutMs);
|
|
1339
|
+
const stdout = readBoundedStream(
|
|
1340
|
+
child.stdout,
|
|
1341
|
+
options.maxOutputBytes,
|
|
1342
|
+
outputCancellation.signal,
|
|
1343
|
+
stdoutObservation,
|
|
1344
|
+
).catch((error: unknown) => {
|
|
1345
|
+
stdoutObservation.failed = true;
|
|
1346
|
+
requestStop(error instanceof Error ? error : new Error(String(error)), "stdout-error");
|
|
1347
|
+
throw error;
|
|
1348
|
+
});
|
|
1349
|
+
const stderr = readBoundedStream(
|
|
1350
|
+
child.stderr,
|
|
1351
|
+
Math.min(options.maxOutputBytes, 2 * 1024 * 1024),
|
|
1352
|
+
outputCancellation.signal,
|
|
1353
|
+
stderrObservation,
|
|
1354
|
+
).catch((error: unknown) => {
|
|
1355
|
+
stderrObservation.failed = true;
|
|
1356
|
+
requestStop(error instanceof Error ? error : new Error(String(error)), "stderr-error");
|
|
1357
|
+
throw error;
|
|
1358
|
+
});
|
|
1359
|
+
const completed = Promise.all([stdout, stderr, exited]);
|
|
1360
|
+
try {
|
|
1361
|
+
const [stdoutText, stderrText, exitCode] = await Promise.race([
|
|
1362
|
+
completed,
|
|
1363
|
+
stopped,
|
|
1364
|
+
]);
|
|
1365
|
+
if (state.failure !== null) throw state.failure;
|
|
1366
|
+
return { stdout: stdoutText, stderr: stderrText, exitCode };
|
|
1367
|
+
} catch (error) {
|
|
1368
|
+
requestStop(error instanceof Error ? error : new Error(String(error)));
|
|
1369
|
+
let terminationFailure: unknown;
|
|
1370
|
+
let originalGroupTerminationSucceeded = false;
|
|
1371
|
+
try {
|
|
1372
|
+
await state.termination;
|
|
1373
|
+
originalGroupTerminationSucceeded = true;
|
|
1374
|
+
} catch (terminationError) {
|
|
1375
|
+
terminationFailure = terminationError;
|
|
1376
|
+
}
|
|
1377
|
+
if (terminationFailure !== undefined) child.unref();
|
|
1378
|
+
const resourcesSettled = await promiseSettlesWithin(
|
|
1379
|
+
Promise.allSettled([stdout, stderr, exited]),
|
|
1380
|
+
BROWSER_COMMAND_RESOURCE_SETTLEMENT_TIMEOUT_MS,
|
|
1381
|
+
);
|
|
1382
|
+
if (!resourcesSettled) child.unref();
|
|
1383
|
+
if (terminationFailure !== undefined) {
|
|
1384
|
+
throw diagnosed(terminationFailure instanceof BrowserCommandCleanupError
|
|
1385
|
+
? terminationFailure
|
|
1386
|
+
: new BrowserCommandCleanupError(
|
|
1387
|
+
"agent-browser termination failed",
|
|
1388
|
+
terminationFailure,
|
|
1389
|
+
), originalGroupTerminationSucceeded, resourcesSettled);
|
|
1390
|
+
}
|
|
1391
|
+
if (!resourcesSettled) {
|
|
1392
|
+
throw diagnosed(new BrowserCommandCleanupError(
|
|
1393
|
+
"agent-browser resources did not settle after process termination",
|
|
1394
|
+
), originalGroupTerminationSucceeded, resourcesSettled);
|
|
1395
|
+
}
|
|
1396
|
+
const failure = state.failure ?? error;
|
|
1397
|
+
const normalizedFailure = failure instanceof Error
|
|
1398
|
+
? failure
|
|
1399
|
+
: new Error(String(failure));
|
|
1400
|
+
if (normalizedFailure instanceof BrowserCommandCleanupError) {
|
|
1401
|
+
throw diagnosed(normalizedFailure, originalGroupTerminationSucceeded, resourcesSettled);
|
|
1402
|
+
}
|
|
1403
|
+
// A POSIX process group cannot prove that a descendant did not call
|
|
1404
|
+
// setsid(2) and become reparented before termination began. Any command
|
|
1405
|
+
// that required forced stopping therefore has an unprovable containment
|
|
1406
|
+
// boundary even when its original process group and streams are gone.
|
|
1407
|
+
throw diagnosed(new BrowserCommandCleanupError(
|
|
1408
|
+
`${normalizedFailure.message}; descendant process cleanup could not be verified`,
|
|
1409
|
+
normalizedFailure,
|
|
1410
|
+
), originalGroupTerminationSucceeded, resourcesSettled);
|
|
1411
|
+
} finally {
|
|
1412
|
+
clearTimeout(timeout);
|
|
1413
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
export function parseLastJson(output: string): unknown {
|
|
1418
|
+
let lineEnd = output.length;
|
|
1419
|
+
while (lineEnd >= 0) {
|
|
1420
|
+
const newline = output.lastIndexOf("\n", lineEnd - 1);
|
|
1421
|
+
const line = output.slice(newline + 1, lineEnd).trim();
|
|
1422
|
+
lineEnd = newline;
|
|
1423
|
+
if (line.startsWith("[") || line.startsWith("{")) {
|
|
1424
|
+
try {
|
|
1425
|
+
return JSON.parse(line) as unknown;
|
|
1426
|
+
} catch {
|
|
1427
|
+
// Continue past diagnostics emitted before the JSON result.
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
throw new Error("agent-browser did not return JSON");
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
const maximumAgentBrowserLaunchHash = (1n << 64n) - 1n;
|
|
1435
|
+
const maximumAgentBrowserJsonNesting = 256;
|
|
1436
|
+
|
|
1437
|
+
class AgentBrowserLaunchHashParseError extends Error {}
|
|
1438
|
+
|
|
1439
|
+
type JsonTextReplacement = {
|
|
1440
|
+
readonly start: number;
|
|
1441
|
+
readonly end: number;
|
|
1442
|
+
readonly value: string;
|
|
1443
|
+
};
|
|
1444
|
+
|
|
1445
|
+
/**
|
|
1446
|
+
* Rewrites only object values whose decoded key is exactly `launchHash`.
|
|
1447
|
+
* The surrounding JSON is parsed normally after every u64 token has become
|
|
1448
|
+
* a quoted canonical decimal string, avoiding JavaScript Number rounding.
|
|
1449
|
+
*/
|
|
1450
|
+
class AgentBrowserLaunchHashRewriter {
|
|
1451
|
+
readonly #input: string;
|
|
1452
|
+
readonly #replacements: JsonTextReplacement[] = [];
|
|
1453
|
+
#index = 0;
|
|
1454
|
+
|
|
1455
|
+
constructor(input: string) {
|
|
1456
|
+
this.#input = input;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
rewrite(): string {
|
|
1460
|
+
this.#parseValue(false, 0);
|
|
1461
|
+
this.#skipWhitespace();
|
|
1462
|
+
if (this.#index !== this.#input.length) {
|
|
1463
|
+
throw new AgentBrowserLaunchHashParseError("agent-browser JSON is malformed");
|
|
1464
|
+
}
|
|
1465
|
+
let rewritten = this.#input;
|
|
1466
|
+
for (const replacement of this.#replacements.toReversed()) {
|
|
1467
|
+
rewritten = `${rewritten.slice(0, replacement.start)}${replacement.value}${rewritten.slice(replacement.end)}`;
|
|
1468
|
+
}
|
|
1469
|
+
return rewritten;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
#parseValue(launchHash: boolean, depth: number): void {
|
|
1473
|
+
this.#skipWhitespace();
|
|
1474
|
+
if (launchHash) {
|
|
1475
|
+
if (this.#input.startsWith("null", this.#index)) {
|
|
1476
|
+
this.#parseLiteral("null");
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
this.#parseLaunchHash();
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
const token = this.#input[this.#index];
|
|
1483
|
+
if (token === "{") {
|
|
1484
|
+
this.#parseObject(depth + 1);
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
if (token === "[") {
|
|
1488
|
+
this.#parseArray(depth + 1);
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
if (token === '"') {
|
|
1492
|
+
this.#parseString();
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
if (token === "t") {
|
|
1496
|
+
this.#parseLiteral("true");
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
if (token === "f") {
|
|
1500
|
+
this.#parseLiteral("false");
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
if (token === "n") {
|
|
1504
|
+
this.#parseLiteral("null");
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
this.#parseNumber();
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
#parseObject(depth: number): void {
|
|
1511
|
+
this.#assertNesting(depth);
|
|
1512
|
+
let sawLaunchHash = false;
|
|
1513
|
+
this.#index += 1;
|
|
1514
|
+
this.#skipWhitespace();
|
|
1515
|
+
if (this.#input[this.#index] === "}") {
|
|
1516
|
+
this.#index += 1;
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
for (;;) {
|
|
1520
|
+
this.#skipWhitespace();
|
|
1521
|
+
const key = this.#parseString();
|
|
1522
|
+
if (key === "launchHash") {
|
|
1523
|
+
if (sawLaunchHash) {
|
|
1524
|
+
throw new AgentBrowserLaunchHashParseError(
|
|
1525
|
+
"agent-browser JSON contains a duplicate launchHash field",
|
|
1526
|
+
);
|
|
1527
|
+
}
|
|
1528
|
+
sawLaunchHash = true;
|
|
1529
|
+
}
|
|
1530
|
+
this.#skipWhitespace();
|
|
1531
|
+
if (this.#input[this.#index] !== ":") {
|
|
1532
|
+
throw new AgentBrowserLaunchHashParseError("agent-browser JSON is malformed");
|
|
1533
|
+
}
|
|
1534
|
+
this.#index += 1;
|
|
1535
|
+
this.#parseValue(key === "launchHash", depth);
|
|
1536
|
+
this.#skipWhitespace();
|
|
1537
|
+
const separator = this.#input[this.#index];
|
|
1538
|
+
if (separator === "}") {
|
|
1539
|
+
this.#index += 1;
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
if (separator !== ",") {
|
|
1543
|
+
throw new AgentBrowserLaunchHashParseError("agent-browser JSON is malformed");
|
|
1544
|
+
}
|
|
1545
|
+
this.#index += 1;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
#parseArray(depth: number): void {
|
|
1550
|
+
this.#assertNesting(depth);
|
|
1551
|
+
this.#index += 1;
|
|
1552
|
+
this.#skipWhitespace();
|
|
1553
|
+
if (this.#input[this.#index] === "]") {
|
|
1554
|
+
this.#index += 1;
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
for (;;) {
|
|
1558
|
+
this.#parseValue(false, depth);
|
|
1559
|
+
this.#skipWhitespace();
|
|
1560
|
+
const separator = this.#input[this.#index];
|
|
1561
|
+
if (separator === "]") {
|
|
1562
|
+
this.#index += 1;
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
if (separator !== ",") {
|
|
1566
|
+
throw new AgentBrowserLaunchHashParseError("agent-browser JSON is malformed");
|
|
1567
|
+
}
|
|
1568
|
+
this.#index += 1;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
#parseString(): string {
|
|
1573
|
+
const start = this.#index;
|
|
1574
|
+
if (this.#input[this.#index] !== '"') {
|
|
1575
|
+
throw new AgentBrowserLaunchHashParseError("agent-browser JSON is malformed");
|
|
1576
|
+
}
|
|
1577
|
+
this.#index += 1;
|
|
1578
|
+
for (;;) {
|
|
1579
|
+
const token = this.#input[this.#index];
|
|
1580
|
+
if (token === undefined) {
|
|
1581
|
+
throw new AgentBrowserLaunchHashParseError("agent-browser JSON is malformed");
|
|
1582
|
+
}
|
|
1583
|
+
if (token === '"') {
|
|
1584
|
+
this.#index += 1;
|
|
1585
|
+
return JSON.parse(this.#input.slice(start, this.#index)) as string;
|
|
1586
|
+
}
|
|
1587
|
+
if (token === "\\") {
|
|
1588
|
+
this.#index += 2;
|
|
1589
|
+
} else {
|
|
1590
|
+
this.#index += 1;
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
#parseLiteral(literal: "true" | "false" | "null"): void {
|
|
1596
|
+
if (!this.#input.startsWith(literal, this.#index)) {
|
|
1597
|
+
throw new AgentBrowserLaunchHashParseError("agent-browser JSON is malformed");
|
|
1598
|
+
}
|
|
1599
|
+
this.#index += literal.length;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
#parseNumber(): void {
|
|
1603
|
+
const match = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/u.exec(
|
|
1604
|
+
this.#input.slice(this.#index),
|
|
1605
|
+
);
|
|
1606
|
+
if (match === null) {
|
|
1607
|
+
throw new AgentBrowserLaunchHashParseError("agent-browser JSON is malformed");
|
|
1608
|
+
}
|
|
1609
|
+
this.#index += match[0].length;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
#parseLaunchHash(): void {
|
|
1613
|
+
const start = this.#index;
|
|
1614
|
+
const match = /^(?:0|[1-9][0-9]*)/u.exec(this.#input.slice(start));
|
|
1615
|
+
if (match === null) {
|
|
1616
|
+
throw new AgentBrowserLaunchHashParseError(
|
|
1617
|
+
"agent-browser launchHash is not an unsigned 64-bit integer",
|
|
1618
|
+
);
|
|
1619
|
+
}
|
|
1620
|
+
const value = match[0];
|
|
1621
|
+
this.#index += value.length;
|
|
1622
|
+
const following = this.#input[this.#index];
|
|
1623
|
+
if (
|
|
1624
|
+
following !== undefined
|
|
1625
|
+
&& following !== ","
|
|
1626
|
+
&& following !== "}"
|
|
1627
|
+
&& following !== "]"
|
|
1628
|
+
&& following !== " "
|
|
1629
|
+
&& following !== "\t"
|
|
1630
|
+
&& following !== "\r"
|
|
1631
|
+
&& following !== "\n"
|
|
1632
|
+
) {
|
|
1633
|
+
throw new AgentBrowserLaunchHashParseError(
|
|
1634
|
+
"agent-browser launchHash is not an unsigned 64-bit integer",
|
|
1635
|
+
);
|
|
1636
|
+
}
|
|
1637
|
+
if (BigInt(value) > maximumAgentBrowserLaunchHash) {
|
|
1638
|
+
throw new AgentBrowserLaunchHashParseError(
|
|
1639
|
+
"agent-browser launchHash exceeds an unsigned 64-bit integer",
|
|
1640
|
+
);
|
|
1641
|
+
}
|
|
1642
|
+
this.#replacements.push({
|
|
1643
|
+
start,
|
|
1644
|
+
end: this.#index,
|
|
1645
|
+
value: JSON.stringify(value),
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
#skipWhitespace(): void {
|
|
1650
|
+
while (
|
|
1651
|
+
this.#input[this.#index] === " "
|
|
1652
|
+
|| this.#input[this.#index] === "\t"
|
|
1653
|
+
|| this.#input[this.#index] === "\r"
|
|
1654
|
+
|| this.#input[this.#index] === "\n"
|
|
1655
|
+
) this.#index += 1;
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
#assertNesting(depth: number): void {
|
|
1659
|
+
if (depth > maximumAgentBrowserJsonNesting) {
|
|
1660
|
+
throw new AgentBrowserLaunchHashParseError(
|
|
1661
|
+
"agent-browser JSON exceeds its nesting bound",
|
|
1662
|
+
);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
/**
|
|
1668
|
+
* Parse the last valid JSON output line without rounding agent-browser's u64
|
|
1669
|
+
* launch identities. Every object field decoded as `launchHash` is returned
|
|
1670
|
+
* as its exact canonical decimal string, while a disconnected browser's
|
|
1671
|
+
* `launchHash: null` remains null; every other JSON value is unchanged.
|
|
1672
|
+
*/
|
|
1673
|
+
export function parseLastJsonWithExactLaunchHashes(output: string): unknown {
|
|
1674
|
+
let lineEnd = output.length;
|
|
1675
|
+
while (lineEnd >= 0) {
|
|
1676
|
+
const newline = output.lastIndexOf("\n", lineEnd - 1);
|
|
1677
|
+
const line = output.slice(newline + 1, lineEnd).trim();
|
|
1678
|
+
lineEnd = newline;
|
|
1679
|
+
if (line.startsWith("[") || line.startsWith("{")) {
|
|
1680
|
+
try {
|
|
1681
|
+
JSON.parse(line);
|
|
1682
|
+
} catch {
|
|
1683
|
+
// Continue past diagnostics emitted before the JSON result.
|
|
1684
|
+
continue;
|
|
1685
|
+
}
|
|
1686
|
+
const rewritten = new AgentBrowserLaunchHashRewriter(line).rewrite();
|
|
1687
|
+
return JSON.parse(rewritten) as unknown;
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
throw new Error("agent-browser did not return JSON");
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
type AgentBrowserSessionStateBase = {
|
|
1694
|
+
readonly state: "active";
|
|
1695
|
+
readonly pid: number;
|
|
1696
|
+
};
|
|
1697
|
+
|
|
1698
|
+
type LaunchedAgentBrowserSessionState = AgentBrowserSessionStateBase & {
|
|
1699
|
+
readonly browserLaunched: true;
|
|
1700
|
+
readonly engine: "chrome";
|
|
1701
|
+
readonly launchHash: string;
|
|
1702
|
+
};
|
|
1703
|
+
|
|
1704
|
+
type ClosedAgentBrowserDaemonState = AgentBrowserSessionStateBase & {
|
|
1705
|
+
readonly browserLaunched: false;
|
|
1706
|
+
readonly engine: "chrome";
|
|
1707
|
+
readonly launchHash: null;
|
|
1708
|
+
};
|
|
1709
|
+
|
|
1710
|
+
type ActiveAgentBrowserSessionState =
|
|
1711
|
+
| LaunchedAgentBrowserSessionState
|
|
1712
|
+
| ClosedAgentBrowserDaemonState;
|
|
1713
|
+
|
|
1714
|
+
type InactiveAgentBrowserSessionState = {
|
|
1715
|
+
readonly state: "inactive";
|
|
1716
|
+
};
|
|
1717
|
+
|
|
1718
|
+
type AgentBrowserSessionState =
|
|
1719
|
+
| ActiveAgentBrowserSessionState
|
|
1720
|
+
| InactiveAgentBrowserSessionState;
|
|
1721
|
+
|
|
1722
|
+
type AgentBrowserLifecycleRunCommand = (
|
|
1723
|
+
command: readonly string[],
|
|
1724
|
+
options: {
|
|
1725
|
+
readonly cwd: string;
|
|
1726
|
+
readonly environment: Readonly<Record<string, string>>;
|
|
1727
|
+
readonly timeoutMs: number;
|
|
1728
|
+
readonly maxOutputBytes: number;
|
|
1729
|
+
readonly signal?: AbortSignal;
|
|
1730
|
+
},
|
|
1731
|
+
) => Promise<CommandResult>;
|
|
1732
|
+
|
|
1733
|
+
export type AgentBrowserLifecycleDependencies = {
|
|
1734
|
+
readonly runCommand?: AgentBrowserLifecycleRunCommand;
|
|
1735
|
+
readonly captureOwner?: (pid: number) => ProcessOwnerIdentity;
|
|
1736
|
+
readonly ownerStatus?: (owner: ProcessOwnerIdentity) => ProcessOwnerStatus;
|
|
1737
|
+
readonly terminateOwner?: (owner: ProcessOwnerIdentity) => void;
|
|
1738
|
+
readonly cdpEndpointStatus?: (
|
|
1739
|
+
cdpUrl: string,
|
|
1740
|
+
) => Promise<"available" | "unavailable" | "indeterminate">;
|
|
1741
|
+
readonly sleep?: (milliseconds: number) => Promise<void>;
|
|
1742
|
+
readonly now?: () => number;
|
|
1743
|
+
/** Active-session setup only: borrow the kernel-owned cancellation signal. */
|
|
1744
|
+
readonly commandSignal?: AbortSignal;
|
|
1745
|
+
/** Recomputed before each setup command so no subcommand resets the budget. */
|
|
1746
|
+
readonly commandTimeoutMs?: () => number;
|
|
1747
|
+
};
|
|
1748
|
+
|
|
1749
|
+
function boundedAgentBrowserText(value: unknown): boolean {
|
|
1750
|
+
return value === null
|
|
1751
|
+
|| (
|
|
1752
|
+
typeof value === "string"
|
|
1753
|
+
&& value.length <= 64 * 1024
|
|
1754
|
+
&& !/[\0\r\n]/u.test(value)
|
|
1755
|
+
);
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
function parseAgentBrowserEffectiveLaunch(
|
|
1759
|
+
value: unknown,
|
|
1760
|
+
label: string,
|
|
1761
|
+
):
|
|
1762
|
+
| {
|
|
1763
|
+
readonly browserLaunched: true;
|
|
1764
|
+
readonly engine: "chrome";
|
|
1765
|
+
readonly launchHash: string;
|
|
1766
|
+
}
|
|
1767
|
+
| {
|
|
1768
|
+
readonly browserLaunched: false;
|
|
1769
|
+
readonly engine: "chrome";
|
|
1770
|
+
readonly launchHash: null;
|
|
1771
|
+
} {
|
|
1772
|
+
const launch = browserIdentityRecord(value, label);
|
|
1773
|
+
browserIdentityExactKeys(
|
|
1774
|
+
launch,
|
|
1775
|
+
["browserLaunched", "engine", "launchHash"],
|
|
1776
|
+
label,
|
|
1777
|
+
);
|
|
1778
|
+
if (launch.engine !== "chrome") {
|
|
1779
|
+
throw new Error("agent-browser control identity changed");
|
|
1780
|
+
}
|
|
1781
|
+
if (launch.browserLaunched === false) {
|
|
1782
|
+
if (launch.launchHash !== null) {
|
|
1783
|
+
throw new Error("agent-browser control identity changed");
|
|
1784
|
+
}
|
|
1785
|
+
return Object.freeze({
|
|
1786
|
+
browserLaunched: false,
|
|
1787
|
+
engine: "chrome",
|
|
1788
|
+
launchHash: null,
|
|
1789
|
+
});
|
|
1790
|
+
}
|
|
1791
|
+
if (launch.browserLaunched !== true) {
|
|
1792
|
+
throw new Error("agent-browser control identity changed");
|
|
1793
|
+
}
|
|
1794
|
+
return Object.freeze({
|
|
1795
|
+
browserLaunched: true,
|
|
1796
|
+
engine: "chrome",
|
|
1797
|
+
launchHash: canonicalAgentBrowserLaunchHash(launch.launchHash),
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
function parseAgentBrowserLifecycle(
|
|
1802
|
+
value: unknown,
|
|
1803
|
+
requireNonMutatingCdpSemantics = false,
|
|
1804
|
+
): ReturnType<typeof parseAgentBrowserEffectiveLaunch> {
|
|
1805
|
+
const lifecycle = browserIdentityRecord(
|
|
1806
|
+
value,
|
|
1807
|
+
"agent-browser lifecycle",
|
|
1808
|
+
);
|
|
1809
|
+
browserIdentityExactKeys(lifecycle, [
|
|
1810
|
+
"effectiveLaunch",
|
|
1811
|
+
"launched",
|
|
1812
|
+
"relaunchedBrowser",
|
|
1813
|
+
"restartedBackground",
|
|
1814
|
+
"restoreStatus",
|
|
1815
|
+
"reused",
|
|
1816
|
+
"saveStatus",
|
|
1817
|
+
], "agent-browser lifecycle");
|
|
1818
|
+
if (
|
|
1819
|
+
typeof lifecycle.launched !== "boolean"
|
|
1820
|
+
|| typeof lifecycle.relaunchedBrowser !== "boolean"
|
|
1821
|
+
|| typeof lifecycle.restartedBackground !== "boolean"
|
|
1822
|
+
|| typeof lifecycle.reused !== "boolean"
|
|
1823
|
+
|| typeof lifecycle.restoreStatus !== "string"
|
|
1824
|
+
|| lifecycle.restoreStatus.length < 1
|
|
1825
|
+
|| lifecycle.restoreStatus.length > 256
|
|
1826
|
+
|| typeof lifecycle.saveStatus !== "string"
|
|
1827
|
+
|| lifecycle.saveStatus.length < 1
|
|
1828
|
+
|| lifecycle.saveStatus.length > 256
|
|
1829
|
+
|| (
|
|
1830
|
+
requireNonMutatingCdpSemantics
|
|
1831
|
+
&& (
|
|
1832
|
+
lifecycle.launched !== false
|
|
1833
|
+
|| lifecycle.relaunchedBrowser !== false
|
|
1834
|
+
|| lifecycle.restartedBackground !== false
|
|
1835
|
+
|| lifecycle.restoreStatus !== "not_configured"
|
|
1836
|
+
|| lifecycle.saveStatus !== "not_attempted"
|
|
1837
|
+
)
|
|
1838
|
+
)
|
|
1839
|
+
) throw new Error("agent-browser lifecycle changed shape");
|
|
1840
|
+
return parseAgentBrowserEffectiveLaunch(
|
|
1841
|
+
lifecycle.effectiveLaunch,
|
|
1842
|
+
"agent-browser lifecycle launch",
|
|
1843
|
+
);
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
function parseAgentBrowserSessionState(
|
|
1847
|
+
value: unknown,
|
|
1848
|
+
resource: BrowserCleanupResourceIdentity,
|
|
1849
|
+
): AgentBrowserSessionState {
|
|
1850
|
+
const root = unwrapAgentBrowserIdentityResult(
|
|
1851
|
+
value,
|
|
1852
|
+
"agent-browser session result",
|
|
1853
|
+
);
|
|
1854
|
+
const data = browserIdentityRecord(root.data, "agent-browser session data");
|
|
1855
|
+
browserIdentityExactKeys(data, [
|
|
1856
|
+
"active",
|
|
1857
|
+
"namespace",
|
|
1858
|
+
"pid",
|
|
1859
|
+
"runtime",
|
|
1860
|
+
"runtimeError",
|
|
1861
|
+
"session",
|
|
1862
|
+
"socketDir",
|
|
1863
|
+
"version",
|
|
1864
|
+
], "agent-browser session data");
|
|
1865
|
+
if (
|
|
1866
|
+
root.success !== true
|
|
1867
|
+
|| data.namespace !== null
|
|
1868
|
+
|| data.runtimeError !== null
|
|
1869
|
+
|| data.session !== resource.session
|
|
1870
|
+
|| data.socketDir !== resource.socketDirectory
|
|
1871
|
+
) throw new Error("agent-browser session identity changed");
|
|
1872
|
+
if (data.active === false) {
|
|
1873
|
+
if (
|
|
1874
|
+
data.pid !== null
|
|
1875
|
+
|| data.runtime !== null
|
|
1876
|
+
|| data.version !== null
|
|
1877
|
+
) throw new Error("agent-browser inactive session changed shape");
|
|
1878
|
+
return Object.freeze({ state: "inactive" });
|
|
1879
|
+
}
|
|
1880
|
+
if (
|
|
1881
|
+
data.active !== true
|
|
1882
|
+
|| typeof data.pid !== "number"
|
|
1883
|
+
|| !Number.isSafeInteger(data.pid)
|
|
1884
|
+
|| data.pid < 1
|
|
1885
|
+
|| data.version !== "0.32.3"
|
|
1886
|
+
) throw new Error("agent-browser active session changed shape");
|
|
1887
|
+
const runtime = browserIdentityRecord(
|
|
1888
|
+
data.runtime,
|
|
1889
|
+
"agent-browser session runtime",
|
|
1890
|
+
);
|
|
1891
|
+
browserIdentityExactKeys(runtime, [
|
|
1892
|
+
"backgroundPid",
|
|
1893
|
+
"browserLaunched",
|
|
1894
|
+
"compatibilityStatus",
|
|
1895
|
+
"effectiveLaunch",
|
|
1896
|
+
"engine",
|
|
1897
|
+
"launchHash",
|
|
1898
|
+
"lifecycle",
|
|
1899
|
+
"namespace",
|
|
1900
|
+
"pageCount",
|
|
1901
|
+
"restoreCheckFn",
|
|
1902
|
+
"restoreCheckText",
|
|
1903
|
+
"restoreCheckUrl",
|
|
1904
|
+
"restoreKey",
|
|
1905
|
+
"restoreLoadedPath",
|
|
1906
|
+
"restoreSave",
|
|
1907
|
+
"restoreSavedPath",
|
|
1908
|
+
"restoreStatus",
|
|
1909
|
+
"restoreStatusDetail",
|
|
1910
|
+
"restoreValidationPending",
|
|
1911
|
+
"saveStatus",
|
|
1912
|
+
"session",
|
|
1913
|
+
"socketDir",
|
|
1914
|
+
], "agent-browser session runtime");
|
|
1915
|
+
const launch = parseAgentBrowserEffectiveLaunch(
|
|
1916
|
+
runtime.effectiveLaunch,
|
|
1917
|
+
"agent-browser session effective launch",
|
|
1918
|
+
);
|
|
1919
|
+
const lifecycle = parseAgentBrowserLifecycle(runtime.lifecycle, true);
|
|
1920
|
+
const browserLaunched = launch.browserLaunched;
|
|
1921
|
+
if (
|
|
1922
|
+
runtime.backgroundPid !== data.pid
|
|
1923
|
+
|| runtime.browserLaunched !== browserLaunched
|
|
1924
|
+
|| runtime.compatibilityStatus !== "current"
|
|
1925
|
+
|| runtime.engine !== "chrome"
|
|
1926
|
+
|| runtime.launchHash !== launch.launchHash
|
|
1927
|
+
|| runtime.namespace !== null
|
|
1928
|
+
|| typeof runtime.pageCount !== "number"
|
|
1929
|
+
|| !Number.isSafeInteger(runtime.pageCount)
|
|
1930
|
+
|| runtime.pageCount < 0
|
|
1931
|
+
|| runtime.pageCount > 100
|
|
1932
|
+
|| !boundedAgentBrowserText(runtime.restoreCheckFn)
|
|
1933
|
+
|| !boundedAgentBrowserText(runtime.restoreCheckText)
|
|
1934
|
+
|| !boundedAgentBrowserText(runtime.restoreCheckUrl)
|
|
1935
|
+
|| !boundedAgentBrowserText(runtime.restoreKey)
|
|
1936
|
+
|| !boundedAgentBrowserText(runtime.restoreLoadedPath)
|
|
1937
|
+
|| !["auto", "always", "never"].includes(
|
|
1938
|
+
typeof runtime.restoreSave === "string" ? runtime.restoreSave : "",
|
|
1939
|
+
)
|
|
1940
|
+
|| !boundedAgentBrowserText(runtime.restoreSavedPath)
|
|
1941
|
+
|| typeof runtime.restoreStatus !== "string"
|
|
1942
|
+
|| runtime.restoreStatus.length < 1
|
|
1943
|
+
|| runtime.restoreStatus.length > 256
|
|
1944
|
+
|| !boundedAgentBrowserText(runtime.restoreStatusDetail)
|
|
1945
|
+
|| typeof runtime.restoreValidationPending !== "boolean"
|
|
1946
|
+
|| typeof runtime.saveStatus !== "string"
|
|
1947
|
+
|| runtime.saveStatus.length < 1
|
|
1948
|
+
|| runtime.saveStatus.length > 256
|
|
1949
|
+
|| runtime.session !== resource.session
|
|
1950
|
+
|| runtime.socketDir !== resource.socketDirectory
|
|
1951
|
+
|| lifecycle.browserLaunched !== browserLaunched
|
|
1952
|
+
|| lifecycle.engine !== launch.engine
|
|
1953
|
+
|| lifecycle.launchHash !== launch.launchHash
|
|
1954
|
+
|| (!browserLaunched && runtime.pageCount !== 0)
|
|
1955
|
+
) throw new Error("agent-browser active session identity changed");
|
|
1956
|
+
if (launch.browserLaunched) {
|
|
1957
|
+
return Object.freeze({
|
|
1958
|
+
state: "active" as const,
|
|
1959
|
+
pid: data.pid,
|
|
1960
|
+
browserLaunched: true as const,
|
|
1961
|
+
engine: "chrome" as const,
|
|
1962
|
+
launchHash: launch.launchHash,
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
return Object.freeze({
|
|
1966
|
+
state: "active" as const,
|
|
1967
|
+
pid: data.pid,
|
|
1968
|
+
browserLaunched: false as const,
|
|
1969
|
+
engine: "chrome" as const,
|
|
1970
|
+
launchHash: null,
|
|
1971
|
+
});
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
function parseAgentBrowserCdpControl(
|
|
1975
|
+
value: unknown,
|
|
1976
|
+
): {
|
|
1977
|
+
readonly cdpUrl: string;
|
|
1978
|
+
readonly engine: "chrome";
|
|
1979
|
+
readonly launchHash: string;
|
|
1980
|
+
} {
|
|
1981
|
+
const root = unwrapAgentBrowserIdentityResult(
|
|
1982
|
+
value,
|
|
1983
|
+
"agent-browser CDP result",
|
|
1984
|
+
);
|
|
1985
|
+
const data = browserIdentityRecord(root.data, "agent-browser CDP data");
|
|
1986
|
+
browserIdentityExactKeys(data, ["cdpUrl", "lifecycle"], "agent-browser CDP data");
|
|
1987
|
+
if (root.success !== true || typeof data.cdpUrl !== "string") {
|
|
1988
|
+
throw new Error("agent-browser CDP identity changed");
|
|
1989
|
+
}
|
|
1990
|
+
const lifecycle = parseAgentBrowserLifecycle(data.lifecycle, true);
|
|
1991
|
+
if (!lifecycle.browserLaunched) {
|
|
1992
|
+
throw new Error("agent-browser CDP identity changed");
|
|
1993
|
+
}
|
|
1994
|
+
return Object.freeze({
|
|
1995
|
+
cdpUrl: literalLoopbackAgentBrowserCdpUrl(data.cdpUrl),
|
|
1996
|
+
engine: lifecycle.engine,
|
|
1997
|
+
launchHash: lifecycle.launchHash,
|
|
1998
|
+
});
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
function browserLifecycleCommandContext(
|
|
2002
|
+
resource: BrowserCleanupResourceIdentity,
|
|
2003
|
+
dependencies: AgentBrowserLifecycleDependencies,
|
|
2004
|
+
): {
|
|
2005
|
+
readonly inspectSession: () => Promise<unknown>;
|
|
2006
|
+
readonly inspectCdp: () => Promise<unknown>;
|
|
2007
|
+
readonly close: () => Promise<boolean>;
|
|
2008
|
+
} {
|
|
2009
|
+
const recovery = parseBrowserRecoveryHandle(resource.recoveryHandle);
|
|
2010
|
+
const runner = dependencies.runCommand ?? runCommand;
|
|
2011
|
+
const environment = isolatedEnvironment(resource.socketDirectory);
|
|
2012
|
+
const common = [
|
|
2013
|
+
...agentBrowserCommand(),
|
|
2014
|
+
"--config",
|
|
2015
|
+
recovery.configPath,
|
|
2016
|
+
"--session",
|
|
2017
|
+
resource.session,
|
|
2018
|
+
"--content-boundaries",
|
|
2019
|
+
"--max-output",
|
|
2020
|
+
"1048576",
|
|
2021
|
+
] as const;
|
|
2022
|
+
const invoke = async (
|
|
2023
|
+
suffix: readonly string[],
|
|
2024
|
+
label: string,
|
|
2025
|
+
): Promise<CommandResult> => {
|
|
2026
|
+
try {
|
|
2027
|
+
return await runner([...common, ...suffix], {
|
|
2028
|
+
cwd: resource.artifactsDirectory,
|
|
2029
|
+
environment,
|
|
2030
|
+
timeoutMs: dependencies.commandTimeoutMs?.() ?? 10_000,
|
|
2031
|
+
maxOutputBytes: 1024 * 1024,
|
|
2032
|
+
...(dependencies.commandSignal === undefined
|
|
2033
|
+
? {}
|
|
2034
|
+
: { signal: dependencies.commandSignal }),
|
|
2035
|
+
});
|
|
2036
|
+
} catch (error) {
|
|
2037
|
+
if (error instanceof BrowserCommandCleanupError) throw error;
|
|
2038
|
+
throw new AgentBrowserLifecycleCommandUnavailableError(label);
|
|
2039
|
+
}
|
|
2040
|
+
};
|
|
2041
|
+
return Object.freeze({
|
|
2042
|
+
inspectSession: async () => {
|
|
2043
|
+
const result = await invoke(
|
|
2044
|
+
["session", "info", "--json"],
|
|
2045
|
+
"session inspection",
|
|
2046
|
+
);
|
|
2047
|
+
if (result.exitCode !== 0) {
|
|
2048
|
+
throw new AgentBrowserLifecycleCommandUnavailableError(
|
|
2049
|
+
"session inspection",
|
|
2050
|
+
);
|
|
2051
|
+
}
|
|
2052
|
+
try {
|
|
2053
|
+
return parseLastJsonWithExactLaunchHashes(result.stdout);
|
|
2054
|
+
} catch {
|
|
2055
|
+
throw new Error("agent-browser session inspection changed shape");
|
|
2056
|
+
}
|
|
2057
|
+
},
|
|
2058
|
+
inspectCdp: async () => {
|
|
2059
|
+
const result = await invoke(
|
|
2060
|
+
[
|
|
2061
|
+
"--action-policy",
|
|
2062
|
+
join(resource.artifactsDirectory, "action-policy.json"),
|
|
2063
|
+
"--json",
|
|
2064
|
+
"get",
|
|
2065
|
+
"cdp-url",
|
|
2066
|
+
],
|
|
2067
|
+
"control inspection",
|
|
2068
|
+
);
|
|
2069
|
+
if (result.exitCode !== 0) {
|
|
2070
|
+
throw new AgentBrowserLifecycleCommandUnavailableError(
|
|
2071
|
+
"control inspection",
|
|
2072
|
+
);
|
|
2073
|
+
}
|
|
2074
|
+
try {
|
|
2075
|
+
return parseLastJsonWithExactLaunchHashes(result.stdout);
|
|
2076
|
+
} catch {
|
|
2077
|
+
throw new Error("agent-browser control inspection changed shape");
|
|
2078
|
+
}
|
|
2079
|
+
},
|
|
2080
|
+
close: async () => {
|
|
2081
|
+
const result = await invoke(["close", "--json"], "graceful close");
|
|
2082
|
+
return result.exitCode === 0;
|
|
2083
|
+
},
|
|
2084
|
+
});
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
/**
|
|
2088
|
+
* Prove that prepared roots never crossed the durable launch-intent boundary.
|
|
2089
|
+
* Two exact inactive-session reads bracket an unchanged-root observation.
|
|
2090
|
+
*/
|
|
2091
|
+
export async function provePreparedAgentBrowserCleanupResourceQuiescent(
|
|
2092
|
+
value: BrowserCleanupResourceIdentityV2,
|
|
2093
|
+
dependencies: AgentBrowserLifecycleDependencies = {},
|
|
2094
|
+
): Promise<BrowserCleanupResourceIdentityV2> {
|
|
2095
|
+
const resource = parseBrowserCleanupResourceIdentity(value);
|
|
2096
|
+
if (
|
|
2097
|
+
resource.kind !== "agent-browser-session-v2"
|
|
2098
|
+
|| resource.phase !== "prepared"
|
|
2099
|
+
) {
|
|
2100
|
+
throw new Error("browser cleanup resource is not prepared");
|
|
2101
|
+
}
|
|
2102
|
+
const lifecycle = browserLifecycleCommandContext(resource, dependencies);
|
|
2103
|
+
assertBrowserCleanupResourceRootsMatch(resource);
|
|
2104
|
+
const first = parseAgentBrowserSessionState(
|
|
2105
|
+
await lifecycle.inspectSession(),
|
|
2106
|
+
resource,
|
|
2107
|
+
);
|
|
2108
|
+
if (first.state !== "inactive") {
|
|
2109
|
+
throw new Error("prepared browser cleanup session became active");
|
|
2110
|
+
}
|
|
2111
|
+
assertBrowserCleanupResourceRootsMatch(resource);
|
|
2112
|
+
const second = parseAgentBrowserSessionState(
|
|
2113
|
+
await lifecycle.inspectSession(),
|
|
2114
|
+
resource,
|
|
2115
|
+
);
|
|
2116
|
+
if (second.state !== "inactive") {
|
|
2117
|
+
throw new Error("prepared browser cleanup session state changed");
|
|
2118
|
+
}
|
|
2119
|
+
assertBrowserCleanupResourceRootsMatch(resource);
|
|
2120
|
+
return resource;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
function exactActiveAgentBrowserControl(
|
|
2124
|
+
state: AgentBrowserSessionState,
|
|
2125
|
+
control: AgentBrowserControlWitnessV1,
|
|
2126
|
+
): LaunchedAgentBrowserSessionState {
|
|
2127
|
+
if (
|
|
2128
|
+
state.state !== "active"
|
|
2129
|
+
|| !state.browserLaunched
|
|
2130
|
+
|| state.pid !== control.daemonOwner.pid
|
|
2131
|
+
|| state.engine !== control.engine
|
|
2132
|
+
|| state.launchHash !== control.launchHash
|
|
2133
|
+
) throw new Error("agent-browser control identity changed");
|
|
2134
|
+
return state;
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
function exactClosedAgentBrowserDaemon(
|
|
2138
|
+
state: AgentBrowserSessionState,
|
|
2139
|
+
control: AgentBrowserControlWitnessV1,
|
|
2140
|
+
): ClosedAgentBrowserDaemonState {
|
|
2141
|
+
if (
|
|
2142
|
+
state.state !== "active"
|
|
2143
|
+
|| state.browserLaunched
|
|
2144
|
+
|| state.pid !== control.daemonOwner.pid
|
|
2145
|
+
|| state.engine !== control.engine
|
|
2146
|
+
|| state.launchHash !== null
|
|
2147
|
+
) throw new Error("agent-browser control identity changed");
|
|
2148
|
+
return state;
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
function exactPinnedAgentBrowserCdpControl(
|
|
2152
|
+
value: unknown,
|
|
2153
|
+
control: AgentBrowserControlWitnessV1,
|
|
2154
|
+
): void {
|
|
2155
|
+
const current = parseAgentBrowserCdpControl(value);
|
|
2156
|
+
if (
|
|
2157
|
+
current.cdpUrl !== control.cdpUrl
|
|
2158
|
+
|| current.engine !== control.engine
|
|
2159
|
+
|| current.launchHash !== control.launchHash
|
|
2160
|
+
) throw new Error("agent-browser CDP control identity changed");
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
function promoteLegacyBrowserCleanupResource(
|
|
2164
|
+
value: BrowserCleanupResourceIdentityV1,
|
|
2165
|
+
): BrowserCleanupResourceIdentityV2 {
|
|
2166
|
+
const legacy = parseBrowserCleanupResourceIdentity(value);
|
|
2167
|
+
if (legacy.kind !== "agent-browser-session-v1") {
|
|
2168
|
+
throw new Error("legacy browser cleanup resource is malformed");
|
|
2169
|
+
}
|
|
2170
|
+
const promoted = parseBrowserCleanupResourceIdentity({
|
|
2171
|
+
...legacy,
|
|
2172
|
+
kind: "agent-browser-session-v2",
|
|
2173
|
+
socketDirectoryIdentity: privateBrowserDirectoryIdentityV2(
|
|
2174
|
+
legacy.socketDirectory,
|
|
2175
|
+
),
|
|
2176
|
+
artifactsDirectoryIdentity: privateBrowserDirectoryIdentityV2(
|
|
2177
|
+
legacy.artifactsDirectory,
|
|
2178
|
+
),
|
|
2179
|
+
phase: "launch-intent",
|
|
2180
|
+
control: null,
|
|
2181
|
+
});
|
|
2182
|
+
if (
|
|
2183
|
+
promoted.kind !== "agent-browser-session-v2"
|
|
2184
|
+
|| legacy.socketDirectoryIdentity.device
|
|
2185
|
+
!== promoted.socketDirectoryIdentity.device
|
|
2186
|
+
|| legacy.socketDirectoryIdentity.inode
|
|
2187
|
+
!== promoted.socketDirectoryIdentity.inode
|
|
2188
|
+
|| legacy.artifactsDirectoryIdentity.device
|
|
2189
|
+
!== promoted.artifactsDirectoryIdentity.device
|
|
2190
|
+
|| legacy.artifactsDirectoryIdentity.inode
|
|
2191
|
+
!== promoted.artifactsDirectoryIdentity.inode
|
|
2192
|
+
) throw new Error("legacy browser cleanup private root identity changed");
|
|
2193
|
+
return promoted;
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
/**
|
|
2197
|
+
* Bind one running private agent-browser session to a durable daemon-start,
|
|
2198
|
+
* browser-launch, and CDP witness. Three stable session reads bracket two
|
|
2199
|
+
* identical CDP reads and process-owner capture; any drift leaves the prior
|
|
2200
|
+
* launch-intent identity intact.
|
|
2201
|
+
*/
|
|
2202
|
+
export async function bindLiveAgentBrowserCleanupResource(
|
|
2203
|
+
value: BrowserCleanupResourceIdentityV2,
|
|
2204
|
+
dependencies: AgentBrowserLifecycleDependencies = {},
|
|
2205
|
+
): Promise<BrowserCleanupResourceIdentityV2> {
|
|
2206
|
+
const resource = parseBrowserCleanupResourceIdentity(value);
|
|
2207
|
+
if (
|
|
2208
|
+
resource.kind !== "agent-browser-session-v2"
|
|
2209
|
+
|| resource.phase !== "launch-intent"
|
|
2210
|
+
) {
|
|
2211
|
+
throw new Error("browser cleanup resource cannot accept a control witness");
|
|
2212
|
+
}
|
|
2213
|
+
assertBrowserCleanupResourceRootsMatch(resource);
|
|
2214
|
+
const lifecycle = browserLifecycleCommandContext(resource, dependencies);
|
|
2215
|
+
const first = parseAgentBrowserSessionState(
|
|
2216
|
+
await lifecycle.inspectSession(),
|
|
2217
|
+
resource,
|
|
2218
|
+
);
|
|
2219
|
+
if (first.state !== "active" || !first.browserLaunched) {
|
|
2220
|
+
throw new AgentBrowserLiveControlUnavailableError();
|
|
2221
|
+
}
|
|
2222
|
+
const captureOwner = dependencies.captureOwner
|
|
2223
|
+
?? captureProcessOwnerIdentity;
|
|
2224
|
+
let owner: ProcessOwnerIdentity;
|
|
2225
|
+
try {
|
|
2226
|
+
owner = captureOwner(first.pid);
|
|
2227
|
+
} catch {
|
|
2228
|
+
throw new Error("agent-browser live control identity is unavailable");
|
|
2229
|
+
}
|
|
2230
|
+
const cdp = parseAgentBrowserCdpControl(await lifecycle.inspectCdp());
|
|
2231
|
+
const second = parseAgentBrowserSessionState(
|
|
2232
|
+
await lifecycle.inspectSession(),
|
|
2233
|
+
resource,
|
|
2234
|
+
);
|
|
2235
|
+
const repeatedCdp = parseAgentBrowserCdpControl(
|
|
2236
|
+
await lifecycle.inspectCdp(),
|
|
2237
|
+
);
|
|
2238
|
+
const third = parseAgentBrowserSessionState(
|
|
2239
|
+
await lifecycle.inspectSession(),
|
|
2240
|
+
resource,
|
|
2241
|
+
);
|
|
2242
|
+
const inspectOwner = dependencies.ownerStatus ?? processOwnerStatus;
|
|
2243
|
+
if (
|
|
2244
|
+
second.state !== "active"
|
|
2245
|
+
|| !second.browserLaunched
|
|
2246
|
+
|| second.pid !== first.pid
|
|
2247
|
+
|| second.engine !== first.engine
|
|
2248
|
+
|| second.launchHash !== first.launchHash
|
|
2249
|
+
|| cdp.engine !== first.engine
|
|
2250
|
+
|| cdp.launchHash !== first.launchHash
|
|
2251
|
+
|| repeatedCdp.cdpUrl !== cdp.cdpUrl
|
|
2252
|
+
|| repeatedCdp.engine !== cdp.engine
|
|
2253
|
+
|| repeatedCdp.launchHash !== cdp.launchHash
|
|
2254
|
+
|| third.state !== "active"
|
|
2255
|
+
|| !third.browserLaunched
|
|
2256
|
+
|| third.pid !== first.pid
|
|
2257
|
+
|| third.engine !== first.engine
|
|
2258
|
+
|| third.launchHash !== first.launchHash
|
|
2259
|
+
|| owner.pid !== first.pid
|
|
2260
|
+
|| inspectOwner(owner) !== "exact-live-owner"
|
|
2261
|
+
) throw new Error("agent-browser control identity changed while it was bound");
|
|
2262
|
+
assertBrowserCleanupResourceRootsMatch(resource);
|
|
2263
|
+
const next = parseBrowserCleanupResourceIdentity({
|
|
2264
|
+
...resource,
|
|
2265
|
+
phase: "controlled",
|
|
2266
|
+
control: {
|
|
2267
|
+
kind: "agent-browser-control-v1",
|
|
2268
|
+
version: "0.32.3",
|
|
2269
|
+
session: resource.session,
|
|
2270
|
+
socketDirectory: resource.socketDirectory,
|
|
2271
|
+
daemonOwner: owner,
|
|
2272
|
+
engine: "chrome",
|
|
2273
|
+
launchHash: first.launchHash,
|
|
2274
|
+
cdpUrl: cdp.cdpUrl,
|
|
2275
|
+
},
|
|
2276
|
+
});
|
|
2277
|
+
if (
|
|
2278
|
+
next.kind !== "agent-browser-session-v2"
|
|
2279
|
+
|| !browserCleanupResourceExtends(resource, next)
|
|
2280
|
+
) throw new Error("browser cleanup control identity is not monotonic");
|
|
2281
|
+
return next;
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
/** Existing v1 claims may be upgraded only while their exact daemon is live. */
|
|
2285
|
+
export async function adoptLiveLegacyBrowserCleanupResource(
|
|
2286
|
+
value: BrowserCleanupResourceIdentityV1,
|
|
2287
|
+
dependencies: AgentBrowserLifecycleDependencies = {},
|
|
2288
|
+
): Promise<BrowserCleanupResourceIdentityV2> {
|
|
2289
|
+
const legacy = parseBrowserCleanupResourceIdentity(value);
|
|
2290
|
+
if (legacy.kind !== "agent-browser-session-v1") {
|
|
2291
|
+
throw new Error("legacy browser cleanup resource is malformed");
|
|
2292
|
+
}
|
|
2293
|
+
const promoted = promoteLegacyBrowserCleanupResource(legacy);
|
|
2294
|
+
const pinned = await bindLiveAgentBrowserCleanupResource(
|
|
2295
|
+
promoted,
|
|
2296
|
+
dependencies,
|
|
2297
|
+
);
|
|
2298
|
+
if (!browserCleanupResourceExtends(legacy, pinned)) {
|
|
2299
|
+
throw new Error("legacy browser cleanup resource could not be adopted");
|
|
2300
|
+
}
|
|
2301
|
+
return pinned;
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
export async function exactAgentBrowserCdpEndpointStatus(
|
|
2305
|
+
cdpUrl: string,
|
|
2306
|
+
): Promise<"available" | "unavailable" | "indeterminate"> {
|
|
2307
|
+
let endpoint: string;
|
|
2308
|
+
try {
|
|
2309
|
+
endpoint = literalLoopbackAgentBrowserCdpUrl(cdpUrl);
|
|
2310
|
+
} catch {
|
|
2311
|
+
return "indeterminate";
|
|
2312
|
+
}
|
|
2313
|
+
const url = new URL(endpoint);
|
|
2314
|
+
const port = Number(url.port);
|
|
2315
|
+
const host = url.hostname === "[::1]" ? "::1" : url.hostname;
|
|
2316
|
+
if (host !== "127.0.0.1" && host !== "::1") {
|
|
2317
|
+
return "indeterminate";
|
|
2318
|
+
}
|
|
2319
|
+
return new Promise((resolveStatus) => {
|
|
2320
|
+
const socket = createConnection({ host, port });
|
|
2321
|
+
let settled = false;
|
|
2322
|
+
const finish = (
|
|
2323
|
+
status: "available" | "unavailable" | "indeterminate",
|
|
2324
|
+
): void => {
|
|
2325
|
+
if (settled) return;
|
|
2326
|
+
settled = true;
|
|
2327
|
+
clearTimeout(timer);
|
|
2328
|
+
socket.destroy();
|
|
2329
|
+
resolveStatus(status);
|
|
2330
|
+
};
|
|
2331
|
+
const timer = setTimeout(() => finish("indeterminate"), 250);
|
|
2332
|
+
socket.once("connect", () => finish("available"));
|
|
2333
|
+
socket.once("error", (error: NodeJS.ErrnoException) => {
|
|
2334
|
+
finish(error.code === "ECONNREFUSED" ? "unavailable" : "indeterminate");
|
|
2335
|
+
});
|
|
2336
|
+
socket.once("close", () => finish("indeterminate"));
|
|
2337
|
+
});
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
/**
|
|
2341
|
+
* Prove a pinned browser already quiescent without closing or signaling it.
|
|
2342
|
+
* Two exact inactive-session observations and unchanged private roots precede
|
|
2343
|
+
* three endpoint refusals. A final dead-owner, inactive-session, and root
|
|
2344
|
+
* reproof closes the race at the return boundary.
|
|
2345
|
+
*/
|
|
2346
|
+
async function provePinnedAgentBrowserCleanupResourceQuiescentWithoutEffects(
|
|
2347
|
+
value: BrowserCleanupResourceIdentityV2,
|
|
2348
|
+
dependencies: AgentBrowserLifecycleDependencies = {},
|
|
2349
|
+
): Promise<BrowserCleanupResourceIdentityV2> {
|
|
2350
|
+
const resource = parseBrowserCleanupResourceIdentity(value);
|
|
2351
|
+
if (
|
|
2352
|
+
resource.kind !== "agent-browser-session-v2"
|
|
2353
|
+
|| resource.phase !== "controlled"
|
|
2354
|
+
) {
|
|
2355
|
+
throw new Error("browser cleanup resource does not have an exact control witness");
|
|
2356
|
+
}
|
|
2357
|
+
const control = resource.control;
|
|
2358
|
+
const lifecycle = browserLifecycleCommandContext(resource, dependencies);
|
|
2359
|
+
const inspectOwner = dependencies.ownerStatus ?? processOwnerStatus;
|
|
2360
|
+
const sleep = dependencies.sleep
|
|
2361
|
+
?? ((milliseconds: number) => Bun.sleep(milliseconds));
|
|
2362
|
+
const endpointStatus = dependencies.cdpEndpointStatus
|
|
2363
|
+
?? exactAgentBrowserCdpEndpointStatus;
|
|
2364
|
+
assertBrowserCleanupResourceRootsMatch(resource);
|
|
2365
|
+
let ownerStatus = inspectOwner(control.daemonOwner);
|
|
2366
|
+
if (ownerStatus === "unknown") {
|
|
2367
|
+
throw new Error("browser cleanup daemon state is indeterminate");
|
|
2368
|
+
}
|
|
2369
|
+
if (ownerStatus === "exact-live-owner") {
|
|
2370
|
+
let transitionUnavailable: AgentBrowserLifecycleCommandUnavailableError
|
|
2371
|
+
| null = null;
|
|
2372
|
+
try {
|
|
2373
|
+
const transition = parseAgentBrowserSessionState(
|
|
2374
|
+
await lifecycle.inspectSession(),
|
|
2375
|
+
resource,
|
|
2376
|
+
);
|
|
2377
|
+
if (transition.state === "active") {
|
|
2378
|
+
if (transition.browserLaunched) {
|
|
2379
|
+
exactActiveAgentBrowserControl(transition, control);
|
|
2380
|
+
} else {
|
|
2381
|
+
exactClosedAgentBrowserDaemon(transition, control);
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
} catch (error) {
|
|
2385
|
+
if (!(error instanceof AgentBrowserLifecycleCommandUnavailableError)) {
|
|
2386
|
+
throw error;
|
|
2387
|
+
}
|
|
2388
|
+
transitionUnavailable = error;
|
|
2389
|
+
}
|
|
2390
|
+
ownerStatus = inspectOwner(control.daemonOwner);
|
|
2391
|
+
if (ownerStatus === "unknown") {
|
|
2392
|
+
throw new Error("browser cleanup daemon state is indeterminate");
|
|
2393
|
+
}
|
|
2394
|
+
if (ownerStatus === "exact-live-owner") {
|
|
2395
|
+
assertBrowserCleanupResourceRootsMatch(resource);
|
|
2396
|
+
if (transitionUnavailable !== null) throw transitionUnavailable;
|
|
2397
|
+
throw new AgentBrowserCleanupOwnerStillLiveError();
|
|
2398
|
+
}
|
|
2399
|
+
assertBrowserCleanupResourceRootsMatch(resource);
|
|
2400
|
+
}
|
|
2401
|
+
for (let read = 0; read < 2; read += 1) {
|
|
2402
|
+
const inactive = parseAgentBrowserSessionState(
|
|
2403
|
+
await lifecycle.inspectSession(),
|
|
2404
|
+
resource,
|
|
2405
|
+
);
|
|
2406
|
+
if (inactive.state !== "inactive") {
|
|
2407
|
+
throw new Error(read === 0
|
|
2408
|
+
? "browser cleanup session remained active"
|
|
2409
|
+
: "browser cleanup session quiescence changed");
|
|
2410
|
+
}
|
|
2411
|
+
assertBrowserCleanupResourceRootsMatch(resource);
|
|
2412
|
+
}
|
|
2413
|
+
for (let refusal = 0; refusal < 3; refusal += 1) {
|
|
2414
|
+
const status = await endpointStatus(control.cdpUrl);
|
|
2415
|
+
if (status === "indeterminate") {
|
|
2416
|
+
throw new Error("browser cleanup endpoint state is indeterminate");
|
|
2417
|
+
}
|
|
2418
|
+
if (status !== "unavailable") {
|
|
2419
|
+
throw new Error("browser cleanup endpoint remained available");
|
|
2420
|
+
}
|
|
2421
|
+
assertBrowserCleanupResourceRootsMatch(resource);
|
|
2422
|
+
if (refusal < 2) await sleep(25);
|
|
2423
|
+
}
|
|
2424
|
+
if (inspectOwner(control.daemonOwner) !== "different-or-dead") {
|
|
2425
|
+
throw new Error("browser cleanup daemon quiescence changed");
|
|
2426
|
+
}
|
|
2427
|
+
const finalInactive = parseAgentBrowserSessionState(
|
|
2428
|
+
await lifecycle.inspectSession(),
|
|
2429
|
+
resource,
|
|
2430
|
+
);
|
|
2431
|
+
if (finalInactive.state !== "inactive") {
|
|
2432
|
+
throw new Error("browser cleanup session quiescence changed");
|
|
2433
|
+
}
|
|
2434
|
+
assertBrowserCleanupResourceRootsMatch(resource);
|
|
2435
|
+
return resource;
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
type PinnedAgentBrowserCleanupRecoveryMode =
|
|
2439
|
+
| "may-close-browser"
|
|
2440
|
+
| "close-already-attempted";
|
|
2441
|
+
|
|
2442
|
+
/**
|
|
2443
|
+
* Prove one pinned daemon and browser endpoint quiescent within fixed bounds.
|
|
2444
|
+
* The only signal permitted for the exact pinned owner is SIGTERM. This
|
|
2445
|
+
* function never deletes roots; its caller must CAS the durable claim and
|
|
2446
|
+
* recheck the same identities immediately before deletion.
|
|
2447
|
+
*/
|
|
2448
|
+
async function recoverPinnedAgentBrowserCleanupResourceCore(
|
|
2449
|
+
value: BrowserCleanupResourceIdentityV2,
|
|
2450
|
+
dependencies: AgentBrowserLifecycleDependencies,
|
|
2451
|
+
mode: PinnedAgentBrowserCleanupRecoveryMode,
|
|
2452
|
+
postCloseEffectDeadline: number | null = null,
|
|
2453
|
+
admitPostCloseTermination: (() => void) | null = null,
|
|
2454
|
+
): Promise<BrowserCleanupResourceIdentityV2> {
|
|
2455
|
+
const resource = parseBrowserCleanupResourceIdentity(value);
|
|
2456
|
+
if (
|
|
2457
|
+
resource.kind !== "agent-browser-session-v2"
|
|
2458
|
+
|| resource.phase !== "controlled"
|
|
2459
|
+
) {
|
|
2460
|
+
throw new Error("browser cleanup resource does not have an exact control witness");
|
|
2461
|
+
}
|
|
2462
|
+
const control = resource.control;
|
|
2463
|
+
const postClose = mode === "close-already-attempted";
|
|
2464
|
+
const lifecycle = browserLifecycleCommandContext(resource, dependencies);
|
|
2465
|
+
const inspectOwner = dependencies.ownerStatus ?? processOwnerStatus;
|
|
2466
|
+
const sleep = dependencies.sleep ?? ((milliseconds: number) => Bun.sleep(milliseconds));
|
|
2467
|
+
const now = dependencies.now ?? Date.now;
|
|
2468
|
+
const endpointStatus = dependencies.cdpEndpointStatus
|
|
2469
|
+
?? exactAgentBrowserCdpEndpointStatus;
|
|
2470
|
+
const terminateOwner = dependencies.terminateOwner
|
|
2471
|
+
?? ((owner: ProcessOwnerIdentity): void => {
|
|
2472
|
+
process.kill(owner.pid, "SIGTERM");
|
|
2473
|
+
});
|
|
2474
|
+
const proveNaturalExitAtBoundary = async (
|
|
2475
|
+
boundaryFailure: unknown,
|
|
2476
|
+
): Promise<BrowserCleanupResourceIdentityV2> => {
|
|
2477
|
+
const status = inspectOwner(control.daemonOwner);
|
|
2478
|
+
if (status === "unknown") {
|
|
2479
|
+
throw new Error("browser cleanup daemon state became indeterminate");
|
|
2480
|
+
}
|
|
2481
|
+
if (status === "exact-live-owner") {
|
|
2482
|
+
assertBrowserCleanupResourceRootsMatch(resource);
|
|
2483
|
+
if (
|
|
2484
|
+
postClose
|
|
2485
|
+
&& boundaryFailure instanceof AgentBrowserLifecycleCommandUnavailableError
|
|
2486
|
+
) {
|
|
2487
|
+
throw new AgentBrowserPostCloseTransitionStillSettlingError();
|
|
2488
|
+
}
|
|
2489
|
+
throw boundaryFailure;
|
|
2490
|
+
}
|
|
2491
|
+
return provePinnedAgentBrowserCleanupResourceQuiescentWithoutEffects(
|
|
2492
|
+
resource,
|
|
2493
|
+
dependencies,
|
|
2494
|
+
);
|
|
2495
|
+
};
|
|
2496
|
+
const observeSessionAtBoundary = async (): Promise<
|
|
2497
|
+
| {
|
|
2498
|
+
readonly kind: "session";
|
|
2499
|
+
readonly value: AgentBrowserSessionState;
|
|
2500
|
+
}
|
|
2501
|
+
| {
|
|
2502
|
+
readonly kind: "quiescent";
|
|
2503
|
+
readonly resource: BrowserCleanupResourceIdentityV2;
|
|
2504
|
+
}
|
|
2505
|
+
> => {
|
|
2506
|
+
try {
|
|
2507
|
+
return Object.freeze({
|
|
2508
|
+
kind: "session" as const,
|
|
2509
|
+
value: parseAgentBrowserSessionState(
|
|
2510
|
+
await lifecycle.inspectSession(),
|
|
2511
|
+
resource,
|
|
2512
|
+
),
|
|
2513
|
+
});
|
|
2514
|
+
} catch (error) {
|
|
2515
|
+
if (error instanceof AgentBrowserLifecycleCommandUnavailableError) {
|
|
2516
|
+
return Object.freeze({
|
|
2517
|
+
kind: "quiescent" as const,
|
|
2518
|
+
resource: await proveNaturalExitAtBoundary(error),
|
|
2519
|
+
});
|
|
2520
|
+
}
|
|
2521
|
+
throw error;
|
|
2522
|
+
}
|
|
2523
|
+
};
|
|
2524
|
+
const observePinnedCdpAtBoundary = async (): Promise<
|
|
2525
|
+
| { readonly kind: "control" }
|
|
2526
|
+
| {
|
|
2527
|
+
readonly kind: "quiescent";
|
|
2528
|
+
readonly resource: BrowserCleanupResourceIdentityV2;
|
|
2529
|
+
}
|
|
2530
|
+
> => {
|
|
2531
|
+
let observed: unknown;
|
|
2532
|
+
try {
|
|
2533
|
+
observed = await lifecycle.inspectCdp();
|
|
2534
|
+
} catch (error) {
|
|
2535
|
+
if (error instanceof AgentBrowserLifecycleCommandUnavailableError) {
|
|
2536
|
+
return Object.freeze({
|
|
2537
|
+
kind: "quiescent" as const,
|
|
2538
|
+
resource: await proveNaturalExitAtBoundary(error),
|
|
2539
|
+
});
|
|
2540
|
+
}
|
|
2541
|
+
throw error;
|
|
2542
|
+
}
|
|
2543
|
+
exactPinnedAgentBrowserCdpControl(observed, control);
|
|
2544
|
+
return Object.freeze({ kind: "control" as const });
|
|
2545
|
+
};
|
|
2546
|
+
assertBrowserCleanupResourceRootsMatch(resource);
|
|
2547
|
+
const initialOwnerStatus = inspectOwner(control.daemonOwner);
|
|
2548
|
+
if (initialOwnerStatus === "unknown") {
|
|
2549
|
+
throw new Error("browser cleanup daemon state is indeterminate");
|
|
2550
|
+
}
|
|
2551
|
+
if (initialOwnerStatus === "different-or-dead") {
|
|
2552
|
+
return provePinnedAgentBrowserCleanupResourceQuiescentWithoutEffects(
|
|
2553
|
+
resource,
|
|
2554
|
+
dependencies,
|
|
2555
|
+
);
|
|
2556
|
+
}
|
|
2557
|
+
if (initialOwnerStatus === "exact-live-owner") {
|
|
2558
|
+
const initialObservation = await observeSessionAtBoundary();
|
|
2559
|
+
if (initialObservation.kind === "quiescent") {
|
|
2560
|
+
return initialObservation.resource;
|
|
2561
|
+
}
|
|
2562
|
+
const initialSession = initialObservation.value;
|
|
2563
|
+
if (initialSession.state === "inactive") {
|
|
2564
|
+
if (postClose) {
|
|
2565
|
+
const transitionOwnerStatus = inspectOwner(control.daemonOwner);
|
|
2566
|
+
if (transitionOwnerStatus === "unknown") {
|
|
2567
|
+
throw new Error("browser cleanup daemon state became indeterminate");
|
|
2568
|
+
}
|
|
2569
|
+
if (transitionOwnerStatus === "exact-live-owner") {
|
|
2570
|
+
assertBrowserCleanupResourceRootsMatch(resource);
|
|
2571
|
+
throw new AgentBrowserPostCloseTransitionStillSettlingError();
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
return proveNaturalExitAtBoundary(
|
|
2575
|
+
new Error("browser cleanup daemon and session identity disagree"),
|
|
2576
|
+
);
|
|
2577
|
+
} else if (
|
|
2578
|
+
initialSession.browserLaunched
|
|
2579
|
+
&& postClose
|
|
2580
|
+
) {
|
|
2581
|
+
exactActiveAgentBrowserControl(initialSession, control);
|
|
2582
|
+
assertBrowserCleanupResourceRootsMatch(resource);
|
|
2583
|
+
throw new AgentBrowserPostCloseTransitionStillSettlingError();
|
|
2584
|
+
} else if (initialSession.browserLaunched) {
|
|
2585
|
+
exactActiveAgentBrowserControl(initialSession, control);
|
|
2586
|
+
const firstCdp = await observePinnedCdpAtBoundary();
|
|
2587
|
+
if (firstCdp.kind === "quiescent") return firstCdp.resource;
|
|
2588
|
+
const repeatedActiveObservation = await observeSessionAtBoundary();
|
|
2589
|
+
if (repeatedActiveObservation.kind === "quiescent") {
|
|
2590
|
+
return repeatedActiveObservation.resource;
|
|
2591
|
+
}
|
|
2592
|
+
const repeatedActive = repeatedActiveObservation.value;
|
|
2593
|
+
if (repeatedActive.state === "inactive") {
|
|
2594
|
+
return proveNaturalExitAtBoundary(
|
|
2595
|
+
new Error("browser cleanup daemon identity changed before close"),
|
|
2596
|
+
);
|
|
2597
|
+
}
|
|
2598
|
+
exactActiveAgentBrowserControl(repeatedActive, control);
|
|
2599
|
+
const beforeCloseStatus = inspectOwner(control.daemonOwner);
|
|
2600
|
+
if (beforeCloseStatus === "unknown") {
|
|
2601
|
+
throw new Error("browser cleanup daemon state became indeterminate");
|
|
2602
|
+
}
|
|
2603
|
+
if (beforeCloseStatus === "different-or-dead") {
|
|
2604
|
+
return provePinnedAgentBrowserCleanupResourceQuiescentWithoutEffects(
|
|
2605
|
+
resource,
|
|
2606
|
+
dependencies,
|
|
2607
|
+
);
|
|
2608
|
+
}
|
|
2609
|
+
assertBrowserCleanupResourceRootsMatch(resource);
|
|
2610
|
+
let closeSucceeded = false;
|
|
2611
|
+
try {
|
|
2612
|
+
closeSucceeded = await lifecycle.close();
|
|
2613
|
+
} catch (error) {
|
|
2614
|
+
if (error instanceof AgentBrowserLifecycleCommandUnavailableError) {
|
|
2615
|
+
return proveNaturalExitAtBoundary(error);
|
|
2616
|
+
}
|
|
2617
|
+
throw error;
|
|
2618
|
+
}
|
|
2619
|
+
const afterCloseObservation = await observeSessionAtBoundary();
|
|
2620
|
+
if (afterCloseObservation.kind === "quiescent") {
|
|
2621
|
+
return afterCloseObservation.resource;
|
|
2622
|
+
}
|
|
2623
|
+
const afterClose = afterCloseObservation.value;
|
|
2624
|
+
if (afterClose.state === "active") {
|
|
2625
|
+
if (afterClose.browserLaunched) {
|
|
2626
|
+
exactActiveAgentBrowserControl(afterClose, control);
|
|
2627
|
+
} else {
|
|
2628
|
+
exactClosedAgentBrowserDaemon(afterClose, control);
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
const afterCloseOwnerStatus = inspectOwner(control.daemonOwner);
|
|
2632
|
+
if (afterCloseOwnerStatus === "unknown") {
|
|
2633
|
+
throw new Error("browser cleanup daemon state became indeterminate");
|
|
2634
|
+
}
|
|
2635
|
+
if (afterCloseOwnerStatus === "different-or-dead") {
|
|
2636
|
+
return provePinnedAgentBrowserCleanupResourceQuiescentWithoutEffects(
|
|
2637
|
+
resource,
|
|
2638
|
+
dependencies,
|
|
2639
|
+
);
|
|
2640
|
+
}
|
|
2641
|
+
if (afterCloseOwnerStatus === "exact-live-owner") {
|
|
2642
|
+
if (afterClose.state === "active" && afterClose.browserLaunched) {
|
|
2643
|
+
const afterCloseCdp = await observePinnedCdpAtBoundary();
|
|
2644
|
+
if (afterCloseCdp.kind === "quiescent") {
|
|
2645
|
+
return afterCloseCdp.resource;
|
|
2646
|
+
}
|
|
2647
|
+
const repeatedAfterCloseObservation = await observeSessionAtBoundary();
|
|
2648
|
+
if (repeatedAfterCloseObservation.kind === "quiescent") {
|
|
2649
|
+
return repeatedAfterCloseObservation.resource;
|
|
2650
|
+
}
|
|
2651
|
+
const repeatedAfterClose = repeatedAfterCloseObservation.value;
|
|
2652
|
+
if (repeatedAfterClose.state === "inactive") {
|
|
2653
|
+
return proveNaturalExitAtBoundary(
|
|
2654
|
+
new Error("browser cleanup control identity changed"),
|
|
2655
|
+
);
|
|
2656
|
+
}
|
|
2657
|
+
exactActiveAgentBrowserControl(repeatedAfterClose, control);
|
|
2658
|
+
} else if (afterClose.state === "active") {
|
|
2659
|
+
if (!closeSucceeded) {
|
|
2660
|
+
throw new Error("agent-browser graceful close could not be verified");
|
|
2661
|
+
}
|
|
2662
|
+
const repeatedClosedObservation = await observeSessionAtBoundary();
|
|
2663
|
+
if (repeatedClosedObservation.kind === "quiescent") {
|
|
2664
|
+
return repeatedClosedObservation.resource;
|
|
2665
|
+
}
|
|
2666
|
+
const repeatedClosed = repeatedClosedObservation.value;
|
|
2667
|
+
if (repeatedClosed.state === "inactive") {
|
|
2668
|
+
return proveNaturalExitAtBoundary(
|
|
2669
|
+
new Error("browser cleanup control identity changed"),
|
|
2670
|
+
);
|
|
2671
|
+
}
|
|
2672
|
+
exactClosedAgentBrowserDaemon(repeatedClosed, control);
|
|
2673
|
+
} else {
|
|
2674
|
+
const repeatedInactiveObservation = await observeSessionAtBoundary();
|
|
2675
|
+
if (repeatedInactiveObservation.kind === "quiescent") {
|
|
2676
|
+
return repeatedInactiveObservation.resource;
|
|
2677
|
+
}
|
|
2678
|
+
const repeatedInactive = repeatedInactiveObservation.value;
|
|
2679
|
+
if (repeatedInactive.state !== "inactive") {
|
|
2680
|
+
throw new Error("browser cleanup session state changed before termination");
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
} else {
|
|
2685
|
+
exactClosedAgentBrowserDaemon(initialSession, control);
|
|
2686
|
+
const repeatedClosedObservation = await observeSessionAtBoundary();
|
|
2687
|
+
if (repeatedClosedObservation.kind === "quiescent") {
|
|
2688
|
+
return repeatedClosedObservation.resource;
|
|
2689
|
+
}
|
|
2690
|
+
const repeatedClosed = repeatedClosedObservation.value;
|
|
2691
|
+
if (repeatedClosed.state === "inactive") {
|
|
2692
|
+
return proveNaturalExitAtBoundary(
|
|
2693
|
+
new Error("browser cleanup control identity changed"),
|
|
2694
|
+
);
|
|
2695
|
+
}
|
|
2696
|
+
exactClosedAgentBrowserDaemon(repeatedClosed, control);
|
|
2697
|
+
}
|
|
2698
|
+
if (initialSession.state === "active") {
|
|
2699
|
+
const beforeTermination = inspectOwner(control.daemonOwner);
|
|
2700
|
+
if (beforeTermination === "unknown") {
|
|
2701
|
+
throw new Error("browser cleanup daemon state became indeterminate");
|
|
2702
|
+
}
|
|
2703
|
+
if (beforeTermination === "exact-live-owner") {
|
|
2704
|
+
assertBrowserCleanupResourceRootsMatch(resource);
|
|
2705
|
+
if (
|
|
2706
|
+
postClose
|
|
2707
|
+
&& postCloseEffectDeadline !== null
|
|
2708
|
+
&& now() >= postCloseEffectDeadline
|
|
2709
|
+
) {
|
|
2710
|
+
throw new Error(
|
|
2711
|
+
"browser cleanup post-close convergence deadline expired",
|
|
2712
|
+
);
|
|
2713
|
+
}
|
|
2714
|
+
admitPostCloseTermination?.();
|
|
2715
|
+
try {
|
|
2716
|
+
terminateOwner(control.daemonOwner);
|
|
2717
|
+
} catch {
|
|
2718
|
+
if (inspectOwner(control.daemonOwner) === "exact-live-owner") {
|
|
2719
|
+
throw new Error("browser cleanup daemon did not accept graceful termination");
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
const ownerDeadline = postClose
|
|
2723
|
+
&& postCloseEffectDeadline !== null
|
|
2724
|
+
? Math.min(now() + 5_000, postCloseEffectDeadline)
|
|
2725
|
+
: now() + 5_000;
|
|
2726
|
+
let ownerSleeps = 0;
|
|
2727
|
+
for (;;) {
|
|
2728
|
+
const status = inspectOwner(control.daemonOwner);
|
|
2729
|
+
if (status === "unknown") {
|
|
2730
|
+
throw new Error("browser cleanup daemon state became indeterminate");
|
|
2731
|
+
}
|
|
2732
|
+
if (status === "different-or-dead") break;
|
|
2733
|
+
if (
|
|
2734
|
+
ownerSleeps >= (postClose ? 80 : 200)
|
|
2735
|
+
|| now() >= ownerDeadline
|
|
2736
|
+
) {
|
|
2737
|
+
throw new Error("browser cleanup daemon did not stop after SIGTERM");
|
|
2738
|
+
}
|
|
2739
|
+
ownerSleeps += 1;
|
|
2740
|
+
await sleep(25);
|
|
2741
|
+
}
|
|
2742
|
+
} else {
|
|
2743
|
+
return provePinnedAgentBrowserCleanupResourceQuiescentWithoutEffects(
|
|
2744
|
+
resource,
|
|
2745
|
+
dependencies,
|
|
2746
|
+
);
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
const finalOwnerStatus = inspectOwner(control.daemonOwner);
|
|
2751
|
+
if (finalOwnerStatus !== "different-or-dead") {
|
|
2752
|
+
throw new Error("browser cleanup daemon quiescence is unproved");
|
|
2753
|
+
}
|
|
2754
|
+
const inactive = parseAgentBrowserSessionState(
|
|
2755
|
+
await lifecycle.inspectSession(),
|
|
2756
|
+
resource,
|
|
2757
|
+
);
|
|
2758
|
+
if (inactive.state !== "inactive") {
|
|
2759
|
+
throw new Error("browser cleanup session remained active");
|
|
2760
|
+
}
|
|
2761
|
+
const endpointDeadline = postClose
|
|
2762
|
+
&& postCloseEffectDeadline !== null
|
|
2763
|
+
? Math.min(now() + 5_000, postCloseEffectDeadline)
|
|
2764
|
+
: now() + 5_000;
|
|
2765
|
+
let consecutiveRefusals = 0;
|
|
2766
|
+
let endpointSleeps = 0;
|
|
2767
|
+
while (consecutiveRefusals < 3) {
|
|
2768
|
+
const status = await endpointStatus(control.cdpUrl);
|
|
2769
|
+
if (status === "indeterminate") {
|
|
2770
|
+
throw new Error("browser cleanup endpoint state is indeterminate");
|
|
2771
|
+
}
|
|
2772
|
+
consecutiveRefusals = status === "unavailable"
|
|
2773
|
+
? consecutiveRefusals + 1
|
|
2774
|
+
: 0;
|
|
2775
|
+
if (consecutiveRefusals >= 3) break;
|
|
2776
|
+
if (
|
|
2777
|
+
endpointSleeps >= (postClose ? 80 : 200)
|
|
2778
|
+
|| now() >= endpointDeadline
|
|
2779
|
+
) {
|
|
2780
|
+
throw new Error("browser cleanup endpoint remained available");
|
|
2781
|
+
}
|
|
2782
|
+
endpointSleeps += 1;
|
|
2783
|
+
await sleep(25);
|
|
2784
|
+
}
|
|
2785
|
+
if (inspectOwner(control.daemonOwner) !== "different-or-dead") {
|
|
2786
|
+
throw new Error("browser cleanup daemon quiescence changed");
|
|
2787
|
+
}
|
|
2788
|
+
const finalInactive = parseAgentBrowserSessionState(
|
|
2789
|
+
await lifecycle.inspectSession(),
|
|
2790
|
+
resource,
|
|
2791
|
+
);
|
|
2792
|
+
if (finalInactive.state !== "inactive") {
|
|
2793
|
+
throw new Error("browser cleanup session quiescence changed");
|
|
2794
|
+
}
|
|
2795
|
+
assertBrowserCleanupResourceRootsMatch(resource);
|
|
2796
|
+
return resource;
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
export async function recoverPinnedAgentBrowserCleanupResource(
|
|
2800
|
+
value: BrowserCleanupResourceIdentityV2,
|
|
2801
|
+
dependencies: AgentBrowserLifecycleDependencies = {},
|
|
2802
|
+
): Promise<BrowserCleanupResourceIdentityV2> {
|
|
2803
|
+
return recoverPinnedAgentBrowserCleanupResourceCore(
|
|
2804
|
+
value,
|
|
2805
|
+
dependencies,
|
|
2806
|
+
"may-close-browser",
|
|
2807
|
+
);
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
/**
|
|
2811
|
+
* Resume only the cleanup half of a browser close whose acknowledgement was
|
|
2812
|
+
* lost. A still-launched browser is observed again within the existing
|
|
2813
|
+
* teardown bound, but is never closed or signaled by this path. Only two exact
|
|
2814
|
+
* browser-closed observations can reach the pinned-owner SIGTERM recovery.
|
|
2815
|
+
*/
|
|
2816
|
+
async function recoverPinnedAgentBrowserCleanupResourceAfterCloseAttempt(
|
|
2817
|
+
value: BrowserCleanupResourceIdentityV2,
|
|
2818
|
+
dependencies: AgentBrowserLifecycleDependencies = {},
|
|
2819
|
+
): Promise<BrowserCleanupResourceIdentityV2> {
|
|
2820
|
+
const sleep = dependencies.sleep
|
|
2821
|
+
?? ((milliseconds: number) => Bun.sleep(milliseconds));
|
|
2822
|
+
const suppliedNow = dependencies.now ?? (() => performance.now());
|
|
2823
|
+
let lastNow = Number.NEGATIVE_INFINITY;
|
|
2824
|
+
const now = (): number => {
|
|
2825
|
+
const value = suppliedNow();
|
|
2826
|
+
if (!Number.isFinite(value) || value < lastNow) {
|
|
2827
|
+
throw new Error("browser cleanup monotonic clock is invalid");
|
|
2828
|
+
}
|
|
2829
|
+
lastNow = value;
|
|
2830
|
+
return value;
|
|
2831
|
+
};
|
|
2832
|
+
const deadline = now() + BROWSER_RESOURCE_TEARDOWN_TIMEOUT_MS;
|
|
2833
|
+
const retryIntervalMs = 25;
|
|
2834
|
+
const maximumAttempts = Math.ceil(
|
|
2835
|
+
BROWSER_RESOURCE_TEARDOWN_TIMEOUT_MS / retryIntervalMs,
|
|
2836
|
+
) + 1;
|
|
2837
|
+
let attempts = 0;
|
|
2838
|
+
let lastTransitionFailure: unknown = new Error(
|
|
2839
|
+
"browser cleanup post-close convergence did not start",
|
|
2840
|
+
);
|
|
2841
|
+
let terminationAttempted = false;
|
|
2842
|
+
const originalCommandTimeoutMs = dependencies.commandTimeoutMs;
|
|
2843
|
+
const boundedDependencies: AgentBrowserLifecycleDependencies = {
|
|
2844
|
+
...dependencies,
|
|
2845
|
+
now,
|
|
2846
|
+
commandTimeoutMs: () => Math.max(
|
|
2847
|
+
1,
|
|
2848
|
+
Math.min(
|
|
2849
|
+
originalCommandTimeoutMs?.() ?? 10_000,
|
|
2850
|
+
Math.max(0, deadline - now()),
|
|
2851
|
+
),
|
|
2852
|
+
),
|
|
2853
|
+
};
|
|
2854
|
+
while (
|
|
2855
|
+
attempts < maximumAttempts
|
|
2856
|
+
&& (attempts === 0 || now() < deadline)
|
|
2857
|
+
) {
|
|
2858
|
+
attempts += 1;
|
|
2859
|
+
try {
|
|
2860
|
+
return await recoverPinnedAgentBrowserCleanupResourceCore(
|
|
2861
|
+
value,
|
|
2862
|
+
boundedDependencies,
|
|
2863
|
+
"close-already-attempted",
|
|
2864
|
+
deadline,
|
|
2865
|
+
() => {
|
|
2866
|
+
if (terminationAttempted) {
|
|
2867
|
+
throw new Error(
|
|
2868
|
+
"browser cleanup post-close termination was already attempted",
|
|
2869
|
+
);
|
|
2870
|
+
}
|
|
2871
|
+
terminationAttempted = true;
|
|
2872
|
+
},
|
|
2873
|
+
);
|
|
2874
|
+
} catch (error) {
|
|
2875
|
+
if (terminationAttempted) throw error;
|
|
2876
|
+
let retryable = error
|
|
2877
|
+
instanceof AgentBrowserPostCloseTransitionStillSettlingError;
|
|
2878
|
+
if (error instanceof AgentBrowserLifecycleCommandUnavailableError) {
|
|
2879
|
+
const resource = parseBrowserCleanupResourceIdentity(value);
|
|
2880
|
+
if (
|
|
2881
|
+
resource.kind !== "agent-browser-session-v2"
|
|
2882
|
+
|| resource.phase !== "controlled"
|
|
2883
|
+
) throw error;
|
|
2884
|
+
assertBrowserCleanupResourceRootsMatch(resource);
|
|
2885
|
+
const inspectOwner = dependencies.ownerStatus ?? processOwnerStatus;
|
|
2886
|
+
const ownerStatus = inspectOwner(resource.control.daemonOwner);
|
|
2887
|
+
if (ownerStatus === "unknown") {
|
|
2888
|
+
throw new Error("browser cleanup daemon state became indeterminate");
|
|
2889
|
+
}
|
|
2890
|
+
retryable = true;
|
|
2891
|
+
}
|
|
2892
|
+
if (!retryable) throw error;
|
|
2893
|
+
lastTransitionFailure = error;
|
|
2894
|
+
if (attempts >= maximumAttempts || now() >= deadline) throw error;
|
|
2895
|
+
await sleep(retryIntervalMs);
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
throw lastTransitionFailure;
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
/**
|
|
2902
|
+
* Refresh full-root quiescence without weakening a launch-intent boundary.
|
|
2903
|
+
* Prepared roots require only their exact inactive proof; controlled roots
|
|
2904
|
+
* use the pinned close/TERM/CDP recovery protocol.
|
|
2905
|
+
*/
|
|
2906
|
+
export async function refreshBrowserCleanupResourceQuiescence(
|
|
2907
|
+
value: BrowserCleanupResourceIdentityV2,
|
|
2908
|
+
dependencies: AgentBrowserLifecycleDependencies = {},
|
|
2909
|
+
): Promise<BrowserCleanupResourceIdentityV2> {
|
|
2910
|
+
const resource = parseBrowserCleanupResourceIdentity(value);
|
|
2911
|
+
if (resource.kind !== "agent-browser-session-v2") {
|
|
2912
|
+
throw new Error("browser cleanup resource is not recoverable");
|
|
2913
|
+
}
|
|
2914
|
+
if (resource.phase === "prepared") {
|
|
2915
|
+
return provePreparedAgentBrowserCleanupResourceQuiescent(
|
|
2916
|
+
resource,
|
|
2917
|
+
dependencies,
|
|
2918
|
+
);
|
|
2919
|
+
}
|
|
2920
|
+
if (resource.phase === "controlled") {
|
|
2921
|
+
return recoverPinnedAgentBrowserCleanupResource(resource, dependencies);
|
|
2922
|
+
}
|
|
2923
|
+
throw new Error("browser cleanup launch intent is not durably controlled");
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
function assertBrowserDeletionBoundaryRoots(
|
|
2927
|
+
resource: BrowserCleanupResourceIdentityV2,
|
|
2928
|
+
): void {
|
|
2929
|
+
if (
|
|
2930
|
+
browserCleanupResourceRootStatus(resource, "socket") !== "match"
|
|
2931
|
+
|| browserCleanupResourceRootStatus(resource, "artifacts") !== "absent"
|
|
2932
|
+
) {
|
|
2933
|
+
throw new Error("browser cleanup deletion-boundary roots changed");
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
async function proveControlledDeletionBoundary(
|
|
2938
|
+
resource: BrowserCleanupResourceIdentityV2,
|
|
2939
|
+
dependencies: AgentBrowserLifecycleDependencies,
|
|
2940
|
+
): Promise<void> {
|
|
2941
|
+
if (resource.phase !== "controlled") {
|
|
2942
|
+
throw new Error("browser cleanup resource is not durably controlled");
|
|
2943
|
+
}
|
|
2944
|
+
const inspectOwner = dependencies.ownerStatus ?? processOwnerStatus;
|
|
2945
|
+
if (inspectOwner(resource.control.daemonOwner) !== "different-or-dead") {
|
|
2946
|
+
throw new Error("browser cleanup pinned owner is not quiescent");
|
|
2947
|
+
}
|
|
2948
|
+
const endpointStatus = dependencies.cdpEndpointStatus
|
|
2949
|
+
?? exactAgentBrowserCdpEndpointStatus;
|
|
2950
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
2951
|
+
if (await endpointStatus(resource.control.cdpUrl) !== "unavailable") {
|
|
2952
|
+
throw new Error("browser cleanup endpoint refusal is unproved");
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
if (inspectOwner(resource.control.daemonOwner) !== "different-or-dead") {
|
|
2956
|
+
throw new Error("browser cleanup pinned owner quiescence changed");
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
/**
|
|
2961
|
+
* Re-prove deletion safety after the artifacts root has already been removed.
|
|
2962
|
+
* This helper performs session-info reads only. It never launches, closes, or
|
|
2963
|
+
* signals a browser process, and its temporary config remains inside the exact
|
|
2964
|
+
* socket root that is already scheduled for deletion.
|
|
2965
|
+
*/
|
|
2966
|
+
export async function reproveBrowserCleanupAfterArtifactsRemoval(
|
|
2967
|
+
value: BrowserCleanupResourceIdentityV2,
|
|
2968
|
+
dependencies: AgentBrowserLifecycleDependencies = {},
|
|
2969
|
+
): Promise<BrowserCleanupResourceIdentityV2> {
|
|
2970
|
+
const resource = parseBrowserCleanupResourceIdentity(value);
|
|
2971
|
+
if (
|
|
2972
|
+
resource.kind !== "agent-browser-session-v2"
|
|
2973
|
+
|| resource.phase === "launch-intent"
|
|
2974
|
+
) {
|
|
2975
|
+
throw new Error("browser cleanup resource is ineligible for deletion reproof");
|
|
2976
|
+
}
|
|
2977
|
+
assertBrowserDeletionBoundaryRoots(resource);
|
|
2978
|
+
if (resource.phase === "controlled") {
|
|
2979
|
+
await proveControlledDeletionBoundary(resource, dependencies);
|
|
2980
|
+
}
|
|
2981
|
+
const temporaryConfig = join(
|
|
2982
|
+
resource.socketDirectory,
|
|
2983
|
+
`.wrench-cleanup-${crypto.randomUUID()}.json`,
|
|
2984
|
+
);
|
|
2985
|
+
try {
|
|
2986
|
+
try {
|
|
2987
|
+
writeFileSync(temporaryConfig, "{}\n", {
|
|
2988
|
+
flag: "wx",
|
|
2989
|
+
mode: 0o600,
|
|
2990
|
+
});
|
|
2991
|
+
const stats = lstatSync(temporaryConfig, { bigint: true });
|
|
2992
|
+
const currentUid = process.getuid?.();
|
|
2993
|
+
if (
|
|
2994
|
+
stats.isSymbolicLink()
|
|
2995
|
+
|| !stats.isFile()
|
|
2996
|
+
|| currentUid === undefined
|
|
2997
|
+
|| stats.uid !== BigInt(currentUid)
|
|
2998
|
+
|| (stats.mode & 0o777n) !== 0o600n
|
|
2999
|
+
) throw new Error("temporary cleanup config identity changed");
|
|
3000
|
+
} catch {
|
|
3001
|
+
throw new Error("browser cleanup session inspection could not be prepared");
|
|
3002
|
+
}
|
|
3003
|
+
assertBrowserDeletionBoundaryRoots(resource);
|
|
3004
|
+
const runner = dependencies.runCommand ?? runCommand;
|
|
3005
|
+
const environment = isolatedEnvironment(resource.socketDirectory);
|
|
3006
|
+
const inspectSession = async (): Promise<AgentBrowserSessionState> => {
|
|
3007
|
+
let result: CommandResult;
|
|
3008
|
+
try {
|
|
3009
|
+
result = await runner([
|
|
3010
|
+
...agentBrowserCommand(),
|
|
3011
|
+
"--config",
|
|
3012
|
+
temporaryConfig,
|
|
3013
|
+
"--session",
|
|
3014
|
+
resource.session,
|
|
3015
|
+
"--content-boundaries",
|
|
3016
|
+
"--max-output",
|
|
3017
|
+
"1048576",
|
|
3018
|
+
"session",
|
|
3019
|
+
"info",
|
|
3020
|
+
"--json",
|
|
3021
|
+
], {
|
|
3022
|
+
cwd: resource.socketDirectory,
|
|
3023
|
+
environment,
|
|
3024
|
+
timeoutMs: dependencies.commandTimeoutMs?.() ?? 10_000,
|
|
3025
|
+
maxOutputBytes: 1024 * 1024,
|
|
3026
|
+
...(dependencies.commandSignal === undefined
|
|
3027
|
+
? {}
|
|
3028
|
+
: { signal: dependencies.commandSignal }),
|
|
3029
|
+
});
|
|
3030
|
+
} catch {
|
|
3031
|
+
throw new Error("browser cleanup session inspection could not be verified");
|
|
3032
|
+
}
|
|
3033
|
+
if (result.exitCode !== 0) {
|
|
3034
|
+
throw new Error("browser cleanup session inspection could not be verified");
|
|
3035
|
+
}
|
|
3036
|
+
let parsed: unknown;
|
|
3037
|
+
try {
|
|
3038
|
+
parsed = parseLastJsonWithExactLaunchHashes(result.stdout);
|
|
3039
|
+
} catch {
|
|
3040
|
+
throw new Error("browser cleanup session inspection changed shape");
|
|
3041
|
+
}
|
|
3042
|
+
return parseAgentBrowserSessionState(parsed, resource);
|
|
3043
|
+
};
|
|
3044
|
+
const first = await inspectSession();
|
|
3045
|
+
if (first.state !== "inactive") {
|
|
3046
|
+
throw new Error("browser cleanup session remained active");
|
|
3047
|
+
}
|
|
3048
|
+
assertBrowserDeletionBoundaryRoots(resource);
|
|
3049
|
+
const second = await inspectSession();
|
|
3050
|
+
if (second.state !== "inactive") {
|
|
3051
|
+
throw new Error("browser cleanup session quiescence changed");
|
|
3052
|
+
}
|
|
3053
|
+
assertBrowserDeletionBoundaryRoots(resource);
|
|
3054
|
+
if (resource.phase === "controlled") {
|
|
3055
|
+
await proveControlledDeletionBoundary(resource, dependencies);
|
|
3056
|
+
}
|
|
3057
|
+
} finally {
|
|
3058
|
+
try {
|
|
3059
|
+
rmSync(temporaryConfig, { force: true });
|
|
3060
|
+
} catch {
|
|
3061
|
+
// A retained mode-0600 config remains inside the root being deleted.
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
assertBrowserDeletionBoundaryRoots(resource);
|
|
3065
|
+
return resource;
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
export function agentBrowserFailure(result: CommandResult, context: string): Error {
|
|
3069
|
+
let detail: string | null = null;
|
|
3070
|
+
try {
|
|
3071
|
+
const parsed = parseLastJson(result.stdout);
|
|
3072
|
+
const failures = Array.isArray(parsed) ? parsed : [parsed];
|
|
3073
|
+
for (const failure of failures) {
|
|
3074
|
+
if (typeof failure !== "object" || failure === null || Array.isArray(failure)) continue;
|
|
3075
|
+
const record = failure as Record<string, unknown>;
|
|
3076
|
+
if (record.success === false && typeof record.error === "string") {
|
|
3077
|
+
detail = sanitizeTerminalLine(redactSensitiveText(record.error)).slice(0, 1_000);
|
|
3078
|
+
break;
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
} catch {
|
|
3082
|
+
// A structured error is optional; never echo arbitrary stdout or stderr.
|
|
3083
|
+
}
|
|
3084
|
+
return new Error(`${context} failed with exit code ${result.exitCode}${detail === null || detail === "" ? "" : `: ${detail}`}`);
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
function parsedBatch(output: string, expected: number): readonly JsonRecord[] {
|
|
3088
|
+
const parsed = parseLastJson(output);
|
|
3089
|
+
if (!Array.isArray(parsed) || parsed.length !== expected) throw new Error("agent-browser returned a malformed batch result");
|
|
3090
|
+
const records: JsonRecord[] = [];
|
|
3091
|
+
for (const value of parsed) {
|
|
3092
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("agent-browser returned a malformed batch entry");
|
|
3093
|
+
records.push(value as JsonRecord);
|
|
3094
|
+
}
|
|
3095
|
+
return records;
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
export {
|
|
3099
|
+
cloneBrowserProfile,
|
|
3100
|
+
cloneProfile,
|
|
3101
|
+
copyBoundedLocalState,
|
|
3102
|
+
isSafeNamedProfile,
|
|
3103
|
+
profilePath,
|
|
3104
|
+
};
|
|
3105
|
+
export type { ClonedBrowserProfile };
|
|
3106
|
+
|
|
3107
|
+
export function browserResultData(record: JsonRecord): unknown {
|
|
3108
|
+
if (record.success !== true) {
|
|
3109
|
+
const error = typeof record.error === "string" ? record.error : "agent-browser command failed";
|
|
3110
|
+
throw new Error(error);
|
|
3111
|
+
}
|
|
3112
|
+
if (Object.hasOwn(record, "result")) return record.result;
|
|
3113
|
+
if (Object.hasOwn(record, "data")) return record.data;
|
|
3114
|
+
throw new Error("agent-browser command omitted its result");
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
const BROWSER_SETUP_DEADLINE_LABEL = "browser session setup";
|
|
3118
|
+
const BROWSER_CLOSE_TEARDOWN_TIMEOUT_MS = 17_500;
|
|
3119
|
+
const BROWSER_RESOURCE_TEARDOWN_TIMEOUT_MS = 2_000;
|
|
3120
|
+
const BROWSER_ACTIVE_BATCH_SETTLEMENT_TIMEOUT_MS = 2_500;
|
|
3121
|
+
|
|
3122
|
+
function guardBrowserSetup(deadline: BrowserOperationDeadline | undefined): void {
|
|
3123
|
+
deadline?.throwIfUnavailable(BROWSER_SETUP_DEADLINE_LABEL);
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
function remainingBrowserSetupTime(
|
|
3127
|
+
requestedTimeoutMs: number,
|
|
3128
|
+
deadline: BrowserOperationDeadline | undefined,
|
|
3129
|
+
): number {
|
|
3130
|
+
if (deadline === undefined) return requestedTimeoutMs;
|
|
3131
|
+
deadline.throwIfUnavailable(BROWSER_SETUP_DEADLINE_LABEL);
|
|
3132
|
+
const remaining = Math.min(requestedTimeoutMs, deadline.remainingTimeMs());
|
|
3133
|
+
if (remaining < 1) {
|
|
3134
|
+
deadline.throwIfUnavailable(BROWSER_SETUP_DEADLINE_LABEL);
|
|
3135
|
+
throw new Error(`${BROWSER_SETUP_DEADLINE_LABEL} timed out`);
|
|
3136
|
+
}
|
|
3137
|
+
return remaining;
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
function runBrowserSetupStep<T>(
|
|
3141
|
+
deadline: BrowserOperationDeadline | undefined,
|
|
3142
|
+
work: () => Promise<T>,
|
|
3143
|
+
): Promise<T> {
|
|
3144
|
+
if (deadline === undefined) return work();
|
|
3145
|
+
deadline.throwIfUnavailable(BROWSER_SETUP_DEADLINE_LABEL);
|
|
3146
|
+
return deadline.run(
|
|
3147
|
+
() => work(),
|
|
3148
|
+
BROWSER_SETUP_DEADLINE_LABEL,
|
|
3149
|
+
);
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
async function teardownCompletesWithin(
|
|
3153
|
+
teardown: Promise<unknown>,
|
|
3154
|
+
maximumMs: number,
|
|
3155
|
+
): Promise<boolean> {
|
|
3156
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
3157
|
+
const observed = teardown.then(
|
|
3158
|
+
() => true as const,
|
|
3159
|
+
() => false as const,
|
|
3160
|
+
);
|
|
3161
|
+
try {
|
|
3162
|
+
return await Promise.race([
|
|
3163
|
+
observed,
|
|
3164
|
+
new Promise<false>((resolve) => {
|
|
3165
|
+
timer = setTimeout(() => resolve(false), maximumMs);
|
|
3166
|
+
}),
|
|
3167
|
+
]);
|
|
3168
|
+
} finally {
|
|
3169
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
function removePrivateArtifacts(
|
|
3174
|
+
paths: readonly (string | null)[],
|
|
3175
|
+
remove: BrowserPrivateArtifactRemover,
|
|
3176
|
+
): readonly unknown[] {
|
|
3177
|
+
const failures: unknown[] = [];
|
|
3178
|
+
for (const path of paths) {
|
|
3179
|
+
if (path === null) continue;
|
|
3180
|
+
try {
|
|
3181
|
+
remove(path);
|
|
3182
|
+
} catch (error) {
|
|
3183
|
+
failures.push(error);
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
return failures;
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
function cleanupFailureCause(
|
|
3190
|
+
operationFailure: unknown,
|
|
3191
|
+
cleanupFailures: readonly unknown[],
|
|
3192
|
+
): unknown {
|
|
3193
|
+
if (cleanupFailures.length === 0) return operationFailure;
|
|
3194
|
+
return new AggregateError(
|
|
3195
|
+
[operationFailure, ...cleanupFailures],
|
|
3196
|
+
"browser private-artifact cleanup failed",
|
|
3197
|
+
);
|
|
3198
|
+
}
|
|
3199
|
+
|
|
3200
|
+
export async function createBrowserSession(
|
|
3201
|
+
manifest: GhostgetManifest,
|
|
3202
|
+
auth: GhostgetAuth,
|
|
3203
|
+
options: CreateBrowserSessionOptions,
|
|
3204
|
+
): Promise<BrowserSession> {
|
|
3205
|
+
if (process.platform === "win32") {
|
|
3206
|
+
throw new Error(
|
|
3207
|
+
"contained browser sessions require process-tree containment that is unavailable on Windows",
|
|
3208
|
+
);
|
|
3209
|
+
}
|
|
3210
|
+
const operationDeadline = options.operationDeadline;
|
|
3211
|
+
guardBrowserSetup(operationDeadline);
|
|
3212
|
+
const runBrowserCommand = options.dependencies?.runCommand ?? runCommand;
|
|
3213
|
+
const createNetworkProxy = options.dependencies?.startNetworkProxy ?? startNetworkProxy;
|
|
3214
|
+
const readCookies = options.dependencies?.acquireCookieRecords ?? acquireCookieRecords;
|
|
3215
|
+
const removePrivateArtifact = options.dependencies?.removePrivateArtifact
|
|
3216
|
+
?? ((path: string): void => rmSync(path, { recursive: true, force: true }));
|
|
3217
|
+
guardBrowserSetup(operationDeadline);
|
|
3218
|
+
const session = `io-${process.pid}-${crypto.randomUUID().slice(0, 12)}`;
|
|
3219
|
+
const directory = mkdtempSync(join(tmpdir(), "io-browser-"));
|
|
3220
|
+
const configPath = join(directory, "agent-browser.json");
|
|
3221
|
+
const policyPath = join(directory, "action-policy.json");
|
|
3222
|
+
const globalArguments: string[] = [];
|
|
3223
|
+
let selectedProfileDirectory: "Default" | null = null;
|
|
3224
|
+
let socketDirectory: string | null = null;
|
|
3225
|
+
let cleanupResourceIdentity: BrowserCleanupResourceIdentityV2 | null = null;
|
|
3226
|
+
let cleanupResourcePublication:
|
|
3227
|
+
| "unpublished"
|
|
3228
|
+
| "uncertain"
|
|
3229
|
+
| "published" = "unpublished";
|
|
3230
|
+
const recoveryHandle = (): string => browserRecoveryHandle({
|
|
3231
|
+
session,
|
|
3232
|
+
configPath,
|
|
3233
|
+
socketDirectory,
|
|
3234
|
+
artifactsDirectory: directory,
|
|
3235
|
+
});
|
|
3236
|
+
const failInitialization = (
|
|
3237
|
+
error: unknown,
|
|
3238
|
+
cleanupEvidenceResource: BrowserCleanupResourceIdentity | null =
|
|
3239
|
+
cleanupResourceIdentity,
|
|
3240
|
+
): never => {
|
|
3241
|
+
if (cleanupResourcePublication !== "unpublished") {
|
|
3242
|
+
throw new PreservedBrowserArtifactsError(
|
|
3243
|
+
"browser session initialization failed after its cleanup roots may have been durably published",
|
|
3244
|
+
recoveryHandle(),
|
|
3245
|
+
error,
|
|
3246
|
+
cleanupEvidenceResource === null
|
|
3247
|
+
? undefined
|
|
3248
|
+
: Object.freeze({
|
|
3249
|
+
kind: "agent-browser-closed-artifacts-v1" as const,
|
|
3250
|
+
resource: cleanupEvidenceResource,
|
|
3251
|
+
}),
|
|
3252
|
+
);
|
|
3253
|
+
}
|
|
3254
|
+
const cleanupFailures = removePrivateArtifacts(
|
|
3255
|
+
[socketDirectory, directory],
|
|
3256
|
+
removePrivateArtifact,
|
|
3257
|
+
);
|
|
3258
|
+
if (cleanupFailures.length > 0) {
|
|
3259
|
+
throw new PreservedBrowserArtifactsError(
|
|
3260
|
+
"browser session initialization failed and private artifacts were preserved because rollback was incomplete",
|
|
3261
|
+
recoveryHandle(),
|
|
3262
|
+
cleanupFailureCause(error, cleanupFailures),
|
|
3263
|
+
);
|
|
3264
|
+
}
|
|
3265
|
+
throw error;
|
|
3266
|
+
};
|
|
3267
|
+
try {
|
|
3268
|
+
globalArguments.push(
|
|
3269
|
+
"--config",
|
|
3270
|
+
configPath,
|
|
3271
|
+
"--session",
|
|
3272
|
+
session,
|
|
3273
|
+
"--content-boundaries",
|
|
3274
|
+
"--max-output",
|
|
3275
|
+
String(options.maxOutputBytes),
|
|
3276
|
+
"--action-policy",
|
|
3277
|
+
policyPath,
|
|
3278
|
+
...(auth.kind === "browser-profile" && auth.browserExecutable !== undefined
|
|
3279
|
+
? ["--executable-path", auth.browserExecutable]
|
|
3280
|
+
: []),
|
|
3281
|
+
...(options.headed ? ["--headed"] : []),
|
|
3282
|
+
);
|
|
3283
|
+
if (auth.kind !== "browser-profile") {
|
|
3284
|
+
globalArguments.push("--allowed-domains", manifest.browserDomains.join(","));
|
|
3285
|
+
}
|
|
3286
|
+
guardBrowserSetup(operationDeadline);
|
|
3287
|
+
chmodSync(directory, 0o700);
|
|
3288
|
+
guardBrowserSetup(operationDeadline);
|
|
3289
|
+
socketDirectory = mkdtempSync(join("/tmp", "io-ab-"));
|
|
3290
|
+
guardBrowserSetup(operationDeadline);
|
|
3291
|
+
chmodSync(socketDirectory, 0o700);
|
|
3292
|
+
guardBrowserSetup(operationDeadline);
|
|
3293
|
+
writeFileSync(configPath, "{}\n", { mode: 0o600, flag: "wx" });
|
|
3294
|
+
guardBrowserSetup(operationDeadline);
|
|
3295
|
+
writeFileSync(policyPath, `${JSON.stringify({
|
|
3296
|
+
default: "deny",
|
|
3297
|
+
// Pinned agent-browser 0.32.3 checks both documented categories and
|
|
3298
|
+
// concrete commands such as getbyrole, inputvalue, and waitfortext.
|
|
3299
|
+
allow: [
|
|
3300
|
+
...runtimeBrowserPolicyActions,
|
|
3301
|
+
...(options.allowCodeOwnedEvaluation === true ? ["evaluate"] : []),
|
|
3302
|
+
...(options.allowCodeOwnedNetworkObservation === true ? ["network", "requests", "request"] : []),
|
|
3303
|
+
],
|
|
3304
|
+
})}\n`, { mode: 0o600, flag: "wx" });
|
|
3305
|
+
} catch (error) {
|
|
3306
|
+
failInitialization(error);
|
|
3307
|
+
}
|
|
3308
|
+
const initializedSocketDirectory = socketDirectory
|
|
3309
|
+
?? failInitialization(new Error("browser socket directory was not initialized"));
|
|
3310
|
+
cleanupResourceIdentity = (() => {
|
|
3311
|
+
let identity: BrowserCleanupResourceIdentityV2 | null = null;
|
|
3312
|
+
try {
|
|
3313
|
+
identity = browserCleanupResourceIdentity({
|
|
3314
|
+
recoveryHandle: recoveryHandle(),
|
|
3315
|
+
session,
|
|
3316
|
+
socketDirectory: initializedSocketDirectory,
|
|
3317
|
+
artifactsDirectory: directory,
|
|
3318
|
+
});
|
|
3319
|
+
// A throwing publisher may have committed before reporting failure. The
|
|
3320
|
+
// durable registrar normally reconciles that exact commit, but custom or
|
|
3321
|
+
// interrupted publishers cannot disprove it. Preserve both roots.
|
|
3322
|
+
cleanupResourcePublication = options.publishCleanupResource === undefined
|
|
3323
|
+
? "unpublished"
|
|
3324
|
+
: "uncertain";
|
|
3325
|
+
options.publishCleanupResource?.(identity);
|
|
3326
|
+
cleanupResourcePublication = options.publishCleanupResource === undefined
|
|
3327
|
+
? "unpublished"
|
|
3328
|
+
: "published";
|
|
3329
|
+
return identity;
|
|
3330
|
+
} catch (error) {
|
|
3331
|
+
return failInitialization(error, identity);
|
|
3332
|
+
}
|
|
3333
|
+
})();
|
|
3334
|
+
try {
|
|
3335
|
+
guardBrowserSetup(operationDeadline);
|
|
3336
|
+
const sourceProfile = auth.kind === "browser-profile"
|
|
3337
|
+
? profilePath(auth.profile)
|
|
3338
|
+
: null;
|
|
3339
|
+
if (sourceProfile !== null) {
|
|
3340
|
+
guardBrowserSetup(operationDeadline);
|
|
3341
|
+
const clonedProfile = cloneBrowserProfile(sourceProfile, directory);
|
|
3342
|
+
guardBrowserSetup(operationDeadline);
|
|
3343
|
+
globalArguments.push("--profile", clonedProfile.userDataPath);
|
|
3344
|
+
selectedProfileDirectory = clonedProfile.profileDirectory ?? null;
|
|
3345
|
+
} else if (auth.kind === "browser-profile") {
|
|
3346
|
+
guardBrowserSetup(operationDeadline);
|
|
3347
|
+
globalArguments.push("--profile", auth.profile);
|
|
3348
|
+
}
|
|
3349
|
+
} catch (error) {
|
|
3350
|
+
failInitialization(error);
|
|
3351
|
+
}
|
|
3352
|
+
if (cleanupResourceIdentity === null) {
|
|
3353
|
+
failInitialization(new Error("browser cleanup resource was not initialized"));
|
|
3354
|
+
}
|
|
3355
|
+
const publishCleanupResourceExtension = (
|
|
3356
|
+
next: BrowserCleanupResourceIdentityV2,
|
|
3357
|
+
): void => {
|
|
3358
|
+
const current = cleanupResourceIdentity;
|
|
3359
|
+
if (current === null || !browserCleanupResourceExtends(current, next)) {
|
|
3360
|
+
throw new Error("browser cleanup resource extension is not monotonic");
|
|
3361
|
+
}
|
|
3362
|
+
if (options.publishCleanupResource !== undefined) {
|
|
3363
|
+
cleanupResourcePublication = "uncertain";
|
|
3364
|
+
options.publishCleanupResource(next);
|
|
3365
|
+
cleanupResourcePublication = "published";
|
|
3366
|
+
}
|
|
3367
|
+
cleanupResourceIdentity = next;
|
|
3368
|
+
};
|
|
3369
|
+
let environment: Readonly<Record<string, string>>;
|
|
3370
|
+
try {
|
|
3371
|
+
environment = isolatedEnvironment(initializedSocketDirectory);
|
|
3372
|
+
} catch (error) {
|
|
3373
|
+
failInitialization(error);
|
|
3374
|
+
}
|
|
3375
|
+
let networkProxy: LocalNetworkProxy | null = null;
|
|
3376
|
+
const networkProxyCreation: {
|
|
3377
|
+
pending: Promise<LocalNetworkProxy> | null;
|
|
3378
|
+
} = { pending: null };
|
|
3379
|
+
let closeDisposition:
|
|
3380
|
+
| "open"
|
|
3381
|
+
| "acknowledged"
|
|
3382
|
+
| "independently-proved-quiescent" = "open";
|
|
3383
|
+
let closeRecoveryDisposition:
|
|
3384
|
+
| "ineligible"
|
|
3385
|
+
| "retry-after-close-attempt" = "ineligible";
|
|
3386
|
+
let cleanupConvergenceAttempted = false;
|
|
3387
|
+
const sessionIsClosed = (): boolean => closeDisposition !== "open";
|
|
3388
|
+
let closeOperation: Promise<void> | null = null;
|
|
3389
|
+
let launchAttempted = false;
|
|
3390
|
+
const activeBatches = new Set<Promise<readonly JsonRecord[]>>();
|
|
3391
|
+
let unsafeCommandCleanup: BrowserCommandCleanupError | null = null;
|
|
3392
|
+
const runBatch = async (
|
|
3393
|
+
commands: readonly (readonly string[])[],
|
|
3394
|
+
timeoutMs: number,
|
|
3395
|
+
maxOutputBytes: number,
|
|
3396
|
+
): Promise<readonly JsonRecord[]> => {
|
|
3397
|
+
if (sessionIsClosed() || closeOperation !== null) {
|
|
3398
|
+
throw new Error("browser session is closed");
|
|
3399
|
+
}
|
|
3400
|
+
return runBrowserSetupStep(
|
|
3401
|
+
operationDeadline,
|
|
3402
|
+
() => {
|
|
3403
|
+
const batch = (async (): Promise<readonly JsonRecord[]> => {
|
|
3404
|
+
const result = await runBrowserCommand(
|
|
3405
|
+
[...agentBrowserCommand(), ...globalArguments, "batch", "--bail", "--json"],
|
|
3406
|
+
{
|
|
3407
|
+
cwd: directory,
|
|
3408
|
+
environment,
|
|
3409
|
+
timeoutMs: remainingBrowserSetupTime(timeoutMs, operationDeadline),
|
|
3410
|
+
maxOutputBytes,
|
|
3411
|
+
stdin: JSON.stringify(commands),
|
|
3412
|
+
...(operationDeadline === undefined
|
|
3413
|
+
? {}
|
|
3414
|
+
: { signal: operationDeadline.signal }),
|
|
3415
|
+
},
|
|
3416
|
+
);
|
|
3417
|
+
if (result.exitCode !== 0) throw agentBrowserFailure(result, "agent-browser batch");
|
|
3418
|
+
const entries = parsedBatch(result.stdout, commands.length);
|
|
3419
|
+
for (const entry of entries) browserResultData(entry);
|
|
3420
|
+
return entries;
|
|
3421
|
+
})();
|
|
3422
|
+
activeBatches.add(batch);
|
|
3423
|
+
void batch.then(
|
|
3424
|
+
() => activeBatches.delete(batch),
|
|
3425
|
+
(error: unknown) => {
|
|
3426
|
+
if (error instanceof BrowserCommandCleanupError) {
|
|
3427
|
+
unsafeCommandCleanup = error;
|
|
3428
|
+
}
|
|
3429
|
+
activeBatches.delete(batch);
|
|
3430
|
+
},
|
|
3431
|
+
);
|
|
3432
|
+
return batch;
|
|
3433
|
+
},
|
|
3434
|
+
);
|
|
3435
|
+
};
|
|
3436
|
+
const close = (): Promise<void> => {
|
|
3437
|
+
if (sessionIsClosed()) return Promise.resolve();
|
|
3438
|
+
if (closeOperation !== null) return closeOperation;
|
|
3439
|
+
closeOperation = (async () => {
|
|
3440
|
+
if (activeBatches.size > 0) {
|
|
3441
|
+
const settled = await teardownCompletesWithin(
|
|
3442
|
+
Promise.allSettled([...activeBatches]),
|
|
3443
|
+
BROWSER_ACTIVE_BATCH_SETTLEMENT_TIMEOUT_MS,
|
|
3444
|
+
);
|
|
3445
|
+
if (!settled || activeBatches.size > 0) {
|
|
3446
|
+
throw new Error(
|
|
3447
|
+
"refusing to close the browser while a batch command remains active",
|
|
3448
|
+
);
|
|
3449
|
+
}
|
|
3450
|
+
}
|
|
3451
|
+
if (unsafeCommandCleanup !== null) {
|
|
3452
|
+
throw unsafeCommandCleanup;
|
|
3453
|
+
}
|
|
3454
|
+
let closeFailure: unknown;
|
|
3455
|
+
try {
|
|
3456
|
+
const result = await runBrowserCommand(
|
|
3457
|
+
[...agentBrowserCommand(), ...globalArguments, "close", "--json"],
|
|
3458
|
+
{ cwd: directory, environment, timeoutMs: 15_000, maxOutputBytes: 1024 * 1024 },
|
|
3459
|
+
);
|
|
3460
|
+
if (result.exitCode === 0) {
|
|
3461
|
+
closeDisposition = "acknowledged";
|
|
3462
|
+
return;
|
|
3463
|
+
}
|
|
3464
|
+
closeFailure = agentBrowserFailure(result, "agent-browser close");
|
|
3465
|
+
} catch (error) {
|
|
3466
|
+
if (error instanceof BrowserCommandCleanupError) throw error;
|
|
3467
|
+
closeFailure = error;
|
|
3468
|
+
}
|
|
3469
|
+
const resource = cleanupResourceIdentity;
|
|
3470
|
+
if (
|
|
3471
|
+
resource === null
|
|
3472
|
+
|| resource.kind !== "agent-browser-session-v2"
|
|
3473
|
+
|| resource.phase !== "controlled"
|
|
3474
|
+
) throw closeFailure;
|
|
3475
|
+
try {
|
|
3476
|
+
await provePinnedAgentBrowserCleanupResourceQuiescentWithoutEffects(
|
|
3477
|
+
resource,
|
|
3478
|
+
{
|
|
3479
|
+
...options.dependencies?.cleanupLifecycle,
|
|
3480
|
+
runCommand: runBrowserCommand,
|
|
3481
|
+
},
|
|
3482
|
+
);
|
|
3483
|
+
} catch (quiescenceFailure) {
|
|
3484
|
+
if (
|
|
3485
|
+
quiescenceFailure instanceof AgentBrowserCleanupOwnerStillLiveError
|
|
3486
|
+
|| quiescenceFailure
|
|
3487
|
+
instanceof AgentBrowserLifecycleCommandUnavailableError
|
|
3488
|
+
) {
|
|
3489
|
+
closeRecoveryDisposition = "retry-after-close-attempt";
|
|
3490
|
+
}
|
|
3491
|
+
throw new AggregateError(
|
|
3492
|
+
[closeFailure, quiescenceFailure],
|
|
3493
|
+
"browser session close was neither acknowledged nor independently proved quiescent",
|
|
3494
|
+
);
|
|
3495
|
+
}
|
|
3496
|
+
closeDisposition = "independently-proved-quiescent";
|
|
3497
|
+
})();
|
|
3498
|
+
return closeOperation;
|
|
3499
|
+
};
|
|
3500
|
+
const cleanup = async (): Promise<void> => {
|
|
3501
|
+
if (!sessionIsClosed() && closeOperation === null) {
|
|
3502
|
+
throw new Error("refusing to delete private browser artifacts before the session closes");
|
|
3503
|
+
}
|
|
3504
|
+
const failures: unknown[] = [];
|
|
3505
|
+
if (!sessionIsClosed() && closeOperation !== null) {
|
|
3506
|
+
try {
|
|
3507
|
+
await closeOperation;
|
|
3508
|
+
} catch {
|
|
3509
|
+
// close() retains and reports its exact failure. cleanup() may only
|
|
3510
|
+
// wait for exact post-close convergence and may terminate only a
|
|
3511
|
+
// twice-observed browser-closed pinned daemon.
|
|
3512
|
+
}
|
|
3513
|
+
if (
|
|
3514
|
+
!sessionIsClosed()
|
|
3515
|
+
&& closeRecoveryDisposition === "retry-after-close-attempt"
|
|
3516
|
+
&& !cleanupConvergenceAttempted
|
|
3517
|
+
&& unsafeCommandCleanup === null
|
|
3518
|
+
&& activeBatches.size === 0
|
|
3519
|
+
) {
|
|
3520
|
+
cleanupConvergenceAttempted = true;
|
|
3521
|
+
const resource = cleanupResourceIdentity;
|
|
3522
|
+
if (
|
|
3523
|
+
resource === null
|
|
3524
|
+
|| resource.kind !== "agent-browser-session-v2"
|
|
3525
|
+
|| resource.phase !== "controlled"
|
|
3526
|
+
) {
|
|
3527
|
+
failures.push(new Error(
|
|
3528
|
+
"browser cleanup resource identity is unavailable for close convergence",
|
|
3529
|
+
));
|
|
3530
|
+
} else {
|
|
3531
|
+
try {
|
|
3532
|
+
await recoverPinnedAgentBrowserCleanupResourceAfterCloseAttempt(
|
|
3533
|
+
resource,
|
|
3534
|
+
{
|
|
3535
|
+
...options.dependencies?.cleanupLifecycle,
|
|
3536
|
+
runCommand: runBrowserCommand,
|
|
3537
|
+
},
|
|
3538
|
+
);
|
|
3539
|
+
closeDisposition = "independently-proved-quiescent";
|
|
3540
|
+
} catch (error) {
|
|
3541
|
+
failures.push(error);
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
3544
|
+
}
|
|
3545
|
+
}
|
|
3546
|
+
let resourcesQuiescent = true;
|
|
3547
|
+
if (networkProxy !== null) {
|
|
3548
|
+
const proxy = networkProxy;
|
|
3549
|
+
if (await teardownCompletesWithin(
|
|
3550
|
+
Promise.resolve().then(() => proxy.close()),
|
|
3551
|
+
BROWSER_RESOURCE_TEARDOWN_TIMEOUT_MS,
|
|
3552
|
+
)) {
|
|
3553
|
+
if (networkProxy === proxy) networkProxy = null;
|
|
3554
|
+
} else {
|
|
3555
|
+
resourcesQuiescent = false;
|
|
3556
|
+
failures.push(
|
|
3557
|
+
new Error("browser network proxy cleanup did not settle safely"),
|
|
3558
|
+
);
|
|
3559
|
+
}
|
|
3560
|
+
}
|
|
3561
|
+
const rootsAreUnused = sessionIsClosed() && activeBatches.size === 0;
|
|
3562
|
+
if (!rootsAreUnused) {
|
|
3563
|
+
failures.push(
|
|
3564
|
+
new Error(
|
|
3565
|
+
"refusing to delete private browser artifacts before the session closes",
|
|
3566
|
+
),
|
|
3567
|
+
);
|
|
3568
|
+
}
|
|
3569
|
+
if (resourcesQuiescent && rootsAreUnused) {
|
|
3570
|
+
const resource = cleanupResourceIdentity;
|
|
3571
|
+
const publisher = options.publishCleanupResource;
|
|
3572
|
+
const markQuiescent = publisher?.markBrowserCleanupQuiescent;
|
|
3573
|
+
const markRootRemoved = publisher?.markBrowserCleanupRootRemoved;
|
|
3574
|
+
if (resource === null) {
|
|
3575
|
+
failures.push(new Error("browser cleanup resource identity is unavailable"));
|
|
3576
|
+
} else if (publisher !== undefined) {
|
|
3577
|
+
if (
|
|
3578
|
+
markQuiescent === undefined
|
|
3579
|
+
|| markRootRemoved === undefined
|
|
3580
|
+
) {
|
|
3581
|
+
failures.push(new Error(
|
|
3582
|
+
"durable browser cleanup journaling is unavailable",
|
|
3583
|
+
));
|
|
3584
|
+
} else {
|
|
3585
|
+
try {
|
|
3586
|
+
// This transition must commit while both roots still match. Once
|
|
3587
|
+
// durable, recovery may accept an absent root as a crash between
|
|
3588
|
+
// its removal and the following journal CAS.
|
|
3589
|
+
await refreshBrowserCleanupResourceQuiescence(
|
|
3590
|
+
resource,
|
|
3591
|
+
{
|
|
3592
|
+
...options.dependencies?.cleanupLifecycle,
|
|
3593
|
+
runCommand: runBrowserCommand,
|
|
3594
|
+
},
|
|
3595
|
+
);
|
|
3596
|
+
markQuiescent(resource);
|
|
3597
|
+
} catch (error) {
|
|
3598
|
+
failures.push(error);
|
|
3599
|
+
}
|
|
3600
|
+
}
|
|
3601
|
+
}
|
|
3602
|
+
if (failures.length === 0 && resource !== null) {
|
|
3603
|
+
for (const [rootName, path] of [
|
|
3604
|
+
["artifacts", directory],
|
|
3605
|
+
["socket", initializedSocketDirectory],
|
|
3606
|
+
] as const) {
|
|
3607
|
+
try {
|
|
3608
|
+
removePrivateArtifact(path);
|
|
3609
|
+
if (browserCleanupResourceRootStatus(resource, rootName) !== "absent") {
|
|
3610
|
+
throw new Error(
|
|
3611
|
+
"browser cleanup root removal could not be verified",
|
|
3612
|
+
);
|
|
3613
|
+
}
|
|
3614
|
+
} catch (error) {
|
|
3615
|
+
failures.push(error);
|
|
3616
|
+
break;
|
|
3617
|
+
}
|
|
3618
|
+
if (publisher !== undefined && markRootRemoved !== undefined) {
|
|
3619
|
+
try {
|
|
3620
|
+
markRootRemoved(resource, rootName);
|
|
3621
|
+
} catch (error) {
|
|
3622
|
+
failures.push(error);
|
|
3623
|
+
break;
|
|
3624
|
+
}
|
|
3625
|
+
}
|
|
3626
|
+
if (rootName === "artifacts" && publisher !== undefined) {
|
|
3627
|
+
try {
|
|
3628
|
+
await reproveBrowserCleanupAfterArtifactsRemoval(
|
|
3629
|
+
resource,
|
|
3630
|
+
{
|
|
3631
|
+
...options.dependencies?.cleanupLifecycle,
|
|
3632
|
+
runCommand: runBrowserCommand,
|
|
3633
|
+
},
|
|
3634
|
+
);
|
|
3635
|
+
} catch (error) {
|
|
3636
|
+
failures.push(error);
|
|
3637
|
+
break;
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3643
|
+
if (failures.length > 0) {
|
|
3644
|
+
throw new AggregateError(
|
|
3645
|
+
failures,
|
|
3646
|
+
"browser session cleanup did not remove every private resource",
|
|
3647
|
+
);
|
|
3648
|
+
}
|
|
3649
|
+
};
|
|
3650
|
+
try {
|
|
3651
|
+
networkProxy = await runBrowserSetupStep(operationDeadline, () => {
|
|
3652
|
+
const creation = createNetworkProxy({
|
|
3653
|
+
allowPrivateNetwork: false,
|
|
3654
|
+
timeoutMs: remainingBrowserSetupTime(options.timeoutMs, operationDeadline),
|
|
3655
|
+
maxTransferredBytes: 1024 * 1024 * 1024,
|
|
3656
|
+
});
|
|
3657
|
+
networkProxyCreation.pending = creation;
|
|
3658
|
+
return creation;
|
|
3659
|
+
});
|
|
3660
|
+
networkProxyCreation.pending = null;
|
|
3661
|
+
guardBrowserSetup(operationDeadline);
|
|
3662
|
+
const proxyArguments = ownedBrowserProxyArguments(
|
|
3663
|
+
networkProxy.url,
|
|
3664
|
+
selectedProfileDirectory ?? undefined,
|
|
3665
|
+
);
|
|
3666
|
+
guardBrowserSetup(operationDeadline);
|
|
3667
|
+
globalArguments.push(...proxyArguments);
|
|
3668
|
+
const launchUrl = auth.kind === "browser-profile"
|
|
3669
|
+
? "about:blank"
|
|
3670
|
+
: manifest.origins[0];
|
|
3671
|
+
if (launchUrl === undefined) throw new Error("contained browser session requires one reviewed origin");
|
|
3672
|
+
guardBrowserSetup(operationDeadline);
|
|
3673
|
+
const preparedResource = cleanupResourceIdentity;
|
|
3674
|
+
if (preparedResource === null || preparedResource.phase !== "prepared") {
|
|
3675
|
+
throw new Error("browser cleanup resource is not prepared for launch");
|
|
3676
|
+
}
|
|
3677
|
+
const launchIntentResource = parseBrowserCleanupResourceIdentity({
|
|
3678
|
+
...preparedResource,
|
|
3679
|
+
phase: "launch-intent",
|
|
3680
|
+
control: null,
|
|
3681
|
+
});
|
|
3682
|
+
if (launchIntentResource.kind !== "agent-browser-session-v2") {
|
|
3683
|
+
throw new Error("browser cleanup launch intent is malformed");
|
|
3684
|
+
}
|
|
3685
|
+
publishCleanupResourceExtension(launchIntentResource);
|
|
3686
|
+
launchAttempted = true;
|
|
3687
|
+
await runBatch(
|
|
3688
|
+
[["open", launchUrl]],
|
|
3689
|
+
remainingBrowserSetupTime(options.timeoutMs, operationDeadline),
|
|
3690
|
+
options.maxOutputBytes,
|
|
3691
|
+
);
|
|
3692
|
+
if (options.publishCleanupResource !== undefined) {
|
|
3693
|
+
guardBrowserSetup(operationDeadline);
|
|
3694
|
+
const pinnedCleanupResource = await runBrowserSetupStep(
|
|
3695
|
+
operationDeadline,
|
|
3696
|
+
() => bindLiveAgentBrowserCleanupResource(
|
|
3697
|
+
launchIntentResource,
|
|
3698
|
+
{
|
|
3699
|
+
runCommand: runBrowserCommand,
|
|
3700
|
+
...(operationDeadline === undefined
|
|
3701
|
+
? {}
|
|
3702
|
+
: {
|
|
3703
|
+
commandSignal: operationDeadline.signal,
|
|
3704
|
+
commandTimeoutMs: () => remainingBrowserSetupTime(
|
|
3705
|
+
options.timeoutMs,
|
|
3706
|
+
operationDeadline,
|
|
3707
|
+
),
|
|
3708
|
+
}),
|
|
3709
|
+
},
|
|
3710
|
+
),
|
|
3711
|
+
);
|
|
3712
|
+
guardBrowserSetup(operationDeadline);
|
|
3713
|
+
publishCleanupResourceExtension(pinnedCleanupResource);
|
|
3714
|
+
}
|
|
3715
|
+
if (
|
|
3716
|
+
auth.kind === "cookie-source"
|
|
3717
|
+
|| auth.kind === "cookies-file"
|
|
3718
|
+
|| (auth.kind === "browser-profile" && auth.cookieSource !== undefined)
|
|
3719
|
+
) {
|
|
3720
|
+
for (const origin of manifest.origins) {
|
|
3721
|
+
guardBrowserSetup(operationDeadline);
|
|
3722
|
+
const target = new URL(origin);
|
|
3723
|
+
const cookieResult = await runBrowserSetupStep(
|
|
3724
|
+
operationDeadline,
|
|
3725
|
+
() => readCookies({
|
|
3726
|
+
cookieSources: auth.kind === "cookie-source"
|
|
3727
|
+
? [auth.source]
|
|
3728
|
+
: auth.kind === "browser-profile" && auth.cookieSource !== undefined
|
|
3729
|
+
? [auth.cookieSource]
|
|
3730
|
+
: [],
|
|
3731
|
+
cookieProfile: auth.kind === "cookie-source"
|
|
3732
|
+
? auth.profile
|
|
3733
|
+
: auth.kind === "browser-profile"
|
|
3734
|
+
? auth.cookieProfile
|
|
3735
|
+
: undefined,
|
|
3736
|
+
cookiesFile: auth.kind === "cookies-file" ? auth.path : undefined,
|
|
3737
|
+
timeoutMs: remainingBrowserSetupTime(options.timeoutMs, operationDeadline),
|
|
3738
|
+
requireExplicitCookieScope: true,
|
|
3739
|
+
}, target),
|
|
3740
|
+
);
|
|
3741
|
+
guardBrowserSetup(operationDeadline);
|
|
3742
|
+
const cookieCommands = browserCookieCommands(cookieResult.cookies, target);
|
|
3743
|
+
guardBrowserSetup(operationDeadline);
|
|
3744
|
+
await runBatch(
|
|
3745
|
+
cookieCommands,
|
|
3746
|
+
remainingBrowserSetupTime(options.timeoutMs, operationDeadline),
|
|
3747
|
+
options.maxOutputBytes,
|
|
3748
|
+
);
|
|
3749
|
+
}
|
|
3750
|
+
}
|
|
3751
|
+
guardBrowserSetup(operationDeadline);
|
|
3752
|
+
return {
|
|
3753
|
+
runBatch,
|
|
3754
|
+
close,
|
|
3755
|
+
cleanup,
|
|
3756
|
+
recoveryHandle: recoveryHandle(),
|
|
3757
|
+
...(options.publishCleanupResource === undefined
|
|
3758
|
+
? {}
|
|
3759
|
+
: {
|
|
3760
|
+
cleanupResourceIdentity: cleanupResourceIdentity
|
|
3761
|
+
?? failInitialization(
|
|
3762
|
+
new Error("browser cleanup resource identity is unavailable"),
|
|
3763
|
+
),
|
|
3764
|
+
}),
|
|
3765
|
+
};
|
|
3766
|
+
} catch (error) {
|
|
3767
|
+
const cleanupFailures: unknown[] = [];
|
|
3768
|
+
let resourcesQuiescent = true;
|
|
3769
|
+
let commandCleanupUnsafe = error instanceof BrowserCommandCleanupError;
|
|
3770
|
+
if (networkProxyCreation.pending !== null) {
|
|
3771
|
+
const closeLateProxy = networkProxyCreation.pending.then(
|
|
3772
|
+
(proxy) => proxy.close(),
|
|
3773
|
+
() => undefined,
|
|
3774
|
+
);
|
|
3775
|
+
if (!await teardownCompletesWithin(
|
|
3776
|
+
closeLateProxy,
|
|
3777
|
+
BROWSER_RESOURCE_TEARDOWN_TIMEOUT_MS,
|
|
3778
|
+
)) {
|
|
3779
|
+
resourcesQuiescent = false;
|
|
3780
|
+
cleanupFailures.push(
|
|
3781
|
+
new Error("browser setup proxy creation did not settle safely"),
|
|
3782
|
+
);
|
|
3783
|
+
}
|
|
3784
|
+
networkProxyCreation.pending = null;
|
|
3785
|
+
}
|
|
3786
|
+
if (!launchAttempted) closeDisposition = "acknowledged";
|
|
3787
|
+
if (launchAttempted && !commandCleanupUnsafe) {
|
|
3788
|
+
if (!await teardownCompletesWithin(
|
|
3789
|
+
close(),
|
|
3790
|
+
BROWSER_CLOSE_TEARDOWN_TIMEOUT_MS,
|
|
3791
|
+
)) {
|
|
3792
|
+
cleanupFailures.push(
|
|
3793
|
+
new Error("browser close could not be verified after setup failure"),
|
|
3794
|
+
);
|
|
3795
|
+
}
|
|
3796
|
+
if (unsafeCommandCleanup !== null) {
|
|
3797
|
+
commandCleanupUnsafe = true;
|
|
3798
|
+
cleanupFailures.push(
|
|
3799
|
+
new Error("a late browser command cleanup failure was preserved"),
|
|
3800
|
+
);
|
|
3801
|
+
}
|
|
3802
|
+
} else if (commandCleanupUnsafe) {
|
|
3803
|
+
cleanupFailures.push(
|
|
3804
|
+
new Error("browser command cleanup could not be verified"),
|
|
3805
|
+
);
|
|
3806
|
+
}
|
|
3807
|
+
if (networkProxy !== null) {
|
|
3808
|
+
const proxy = networkProxy;
|
|
3809
|
+
if (await teardownCompletesWithin(
|
|
3810
|
+
Promise.resolve().then(() => proxy.close()),
|
|
3811
|
+
BROWSER_RESOURCE_TEARDOWN_TIMEOUT_MS,
|
|
3812
|
+
)) {
|
|
3813
|
+
if (networkProxy === proxy) networkProxy = null;
|
|
3814
|
+
} else {
|
|
3815
|
+
resourcesQuiescent = false;
|
|
3816
|
+
cleanupFailures.push(
|
|
3817
|
+
new Error("browser network proxy could not be closed safely"),
|
|
3818
|
+
);
|
|
3819
|
+
}
|
|
3820
|
+
}
|
|
3821
|
+
if (cleanupResourcePublication !== "unpublished") {
|
|
3822
|
+
cleanupFailures.push(
|
|
3823
|
+
new Error("durably published browser roots require exact recovery"),
|
|
3824
|
+
);
|
|
3825
|
+
} else if (
|
|
3826
|
+
resourcesQuiescent
|
|
3827
|
+
&& sessionIsClosed()
|
|
3828
|
+
&& activeBatches.size === 0
|
|
3829
|
+
&& !commandCleanupUnsafe
|
|
3830
|
+
) {
|
|
3831
|
+
cleanupFailures.push(...removePrivateArtifacts(
|
|
3832
|
+
[initializedSocketDirectory, directory],
|
|
3833
|
+
removePrivateArtifact,
|
|
3834
|
+
));
|
|
3835
|
+
} else {
|
|
3836
|
+
cleanupFailures.push(
|
|
3837
|
+
new Error("browser private roots remain in use by an unclosed session"),
|
|
3838
|
+
);
|
|
3839
|
+
}
|
|
3840
|
+
if (cleanupFailures.length > 0) {
|
|
3841
|
+
throw new PreservedBrowserArtifactsError(
|
|
3842
|
+
"browser session setup failed and private artifacts were preserved because cleanup could not be verified",
|
|
3843
|
+
recoveryHandle(),
|
|
3844
|
+
cleanupFailureCause(error, cleanupFailures),
|
|
3845
|
+
);
|
|
3846
|
+
}
|
|
3847
|
+
throw error;
|
|
3848
|
+
}
|
|
3849
|
+
}
|
|
3850
|
+
|
|
3851
|
+
/** Retired DOM-action boundary. Browsers remain capture/bootstrap-only. */
|
|
3852
|
+
export function executeBrowserRecipe(
|
|
3853
|
+
manifest: GhostgetManifest,
|
|
3854
|
+
recipe: BrowserRecipe,
|
|
3855
|
+
input: OperationInput,
|
|
3856
|
+
auth: GhostgetAuth,
|
|
3857
|
+
options: {
|
|
3858
|
+
readonly headed: boolean;
|
|
3859
|
+
readonly createSession?: typeof createBrowserSession;
|
|
3860
|
+
readonly fileResolver?: BrowserFileResolver;
|
|
3861
|
+
/** Called after origin validation and before a dispatch command can start. */
|
|
3862
|
+
readonly beforeDispatch?: (event: BrowserDispatchEvent) => Promise<void>;
|
|
3863
|
+
/** Called only after every observation in the matching verification group succeeds. */
|
|
3864
|
+
readonly afterDispatchVerified?: (event: BrowserDispatchEvent) => Promise<void>;
|
|
3865
|
+
},
|
|
3866
|
+
): Promise<BrowserExecution> {
|
|
3867
|
+
void manifest;
|
|
3868
|
+
void recipe;
|
|
3869
|
+
void input;
|
|
3870
|
+
void auth;
|
|
3871
|
+
void options;
|
|
3872
|
+
return Promise.reject(new Error(DOM_ACTION_TRANSPORT_DISABLED_MESSAGE));
|
|
3873
|
+
}
|