@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/derive.ts
ADDED
|
@@ -0,0 +1,4754 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
constants,
|
|
4
|
+
existsSync,
|
|
5
|
+
fstatSync,
|
|
6
|
+
futimesSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
openSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
realpathSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { createConnection, isIP } from "node:net";
|
|
15
|
+
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
acquireCookieRecords,
|
|
20
|
+
browserCookieCommands,
|
|
21
|
+
type CookieRecordReader,
|
|
22
|
+
} from "@hraness/kb/clip/acquire";
|
|
23
|
+
import { isPrivateAddress, isPrivateHostname } from "@hraness/kb/clip/network";
|
|
24
|
+
import type { StrictCookie } from "@hraness/kb/clip/cookies";
|
|
25
|
+
import type { GhostgetAuth } from "./auth";
|
|
26
|
+
import {
|
|
27
|
+
agentBrowserFailure,
|
|
28
|
+
browserResultData,
|
|
29
|
+
isSafeNamedProfile,
|
|
30
|
+
ownedChromeLaunchArguments,
|
|
31
|
+
ownedDerivationGuardBrowserArguments,
|
|
32
|
+
parseLastJsonWithExactLaunchHashes,
|
|
33
|
+
profilePath,
|
|
34
|
+
runCommand,
|
|
35
|
+
} from "./browser";
|
|
36
|
+
import {
|
|
37
|
+
adoptDerivationNetworkBoundary,
|
|
38
|
+
closeDerivationNetworkBoundary,
|
|
39
|
+
closeInterruptedDerivationNetworkBoundary,
|
|
40
|
+
createDerivationNetworkBoundary,
|
|
41
|
+
UnsafeDerivationNetworkBoundaryCleanupError,
|
|
42
|
+
verifyDerivationNetworkBoundary,
|
|
43
|
+
} from "./derivation-network-boundary";
|
|
44
|
+
import {
|
|
45
|
+
DERIVATION_GUARD_EXTENSION_DIRECTORY,
|
|
46
|
+
derivationGuardControlSocketPath,
|
|
47
|
+
parseDerivationNetworkGuard,
|
|
48
|
+
type DerivationNetworkGuard,
|
|
49
|
+
} from "./derivation-network-guard";
|
|
50
|
+
import {
|
|
51
|
+
browserDomainsCover,
|
|
52
|
+
validateDerivationBrowserDomains,
|
|
53
|
+
} from "./derivation-network-proxy";
|
|
54
|
+
import {
|
|
55
|
+
initializeDerivationDnrReadiness,
|
|
56
|
+
verifyDerivationDnrReadiness,
|
|
57
|
+
} from "./derivation-dnr-readiness";
|
|
58
|
+
import {
|
|
59
|
+
analyzeHarValue,
|
|
60
|
+
analyzeHarFile,
|
|
61
|
+
assertBrowserDerivationTargetAllowed,
|
|
62
|
+
assertScaffoldOutput,
|
|
63
|
+
MAX_HAR_BYTES,
|
|
64
|
+
writeDerivationScaffold,
|
|
65
|
+
type HarAnalysis,
|
|
66
|
+
} from "./har";
|
|
67
|
+
import { analyzeInternalHarFile, analyzeInternalHarValue, type InternalHarEvidence } from "./har-internal";
|
|
68
|
+
import {
|
|
69
|
+
parseDerivationReviewFieldNames,
|
|
70
|
+
reviewDerivationHarText,
|
|
71
|
+
type DerivationReviewResult,
|
|
72
|
+
type DerivationReviewSelection,
|
|
73
|
+
} from "./derive-review";
|
|
74
|
+
import {
|
|
75
|
+
assertDerivationFixtureFile,
|
|
76
|
+
parseDerivationFixtures,
|
|
77
|
+
stageDerivationFixtures,
|
|
78
|
+
type DerivationFixture,
|
|
79
|
+
} from "./derive-fixtures";
|
|
80
|
+
import { localBrowserCdpUrl, uploadThroughInterceptedFileChooser } from "./derivation-file-chooser";
|
|
81
|
+
import { sha256 } from "./canonical-json";
|
|
82
|
+
import type { PlatformSurfaceId } from "./platform-catalog";
|
|
83
|
+
import type { ProviderPluginRegistry } from "./provider-plugin-registry";
|
|
84
|
+
import {
|
|
85
|
+
captureProcessOwnerIdentity,
|
|
86
|
+
processOwnerStatus,
|
|
87
|
+
type ProcessOwnerIdentity,
|
|
88
|
+
} from "./process-identity";
|
|
89
|
+
import {
|
|
90
|
+
createPrivateJsonIfAbsent,
|
|
91
|
+
createPrivateStateDirectory,
|
|
92
|
+
ensurePrivateDirectory,
|
|
93
|
+
ensurePrivateStateDirectory,
|
|
94
|
+
listPrivateStateDirectory,
|
|
95
|
+
readPrivateStateFileIfPresent,
|
|
96
|
+
readRegularFile,
|
|
97
|
+
removePrivateDirectoryTree,
|
|
98
|
+
removePrivateEmptyStateDirectory,
|
|
99
|
+
removePrivateStateFile,
|
|
100
|
+
removePrivateStateDirectoryTree,
|
|
101
|
+
ghostgetStateHome,
|
|
102
|
+
writePrivateJson,
|
|
103
|
+
} from "./storage";
|
|
104
|
+
|
|
105
|
+
type DirectoryIdentity = { readonly device: string; readonly inode: string };
|
|
106
|
+
|
|
107
|
+
type SealedDerivationReview = {
|
|
108
|
+
readonly schemaVersion: 1;
|
|
109
|
+
readonly state: "sealed";
|
|
110
|
+
readonly har: {
|
|
111
|
+
readonly device: string;
|
|
112
|
+
readonly inode: string;
|
|
113
|
+
readonly byteLength: number;
|
|
114
|
+
readonly sha256: string;
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const derivationReviewMarkerName = "review.json";
|
|
119
|
+
const derivationReadyMarkerName = "ready.json";
|
|
120
|
+
const derivationPhaseMarkerName = "phase.json";
|
|
121
|
+
const derivationInitializationMarkerName = "initializing.json";
|
|
122
|
+
const derivationLifecycleOwnerMarkerName = "owner.json";
|
|
123
|
+
const derivationContainedBrowserOwnerMarkerName = "browser-owner.json";
|
|
124
|
+
const derivationContainedBrowserPinMarkerName = "browser-pin.json";
|
|
125
|
+
export const DERIVATION_LIFECYCLE_ORPHAN_GRACE_MS = 15 * 60_000;
|
|
126
|
+
|
|
127
|
+
type PrivateFileEvidence = {
|
|
128
|
+
readonly device: string;
|
|
129
|
+
readonly inode: string;
|
|
130
|
+
readonly byteLength: number;
|
|
131
|
+
readonly sha256: string;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
type ReadyDerivation = {
|
|
135
|
+
readonly schemaVersion: 1;
|
|
136
|
+
readonly state: "ready";
|
|
137
|
+
readonly metadata: PrivateFileEvidence;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
type DerivationInitialization = {
|
|
141
|
+
readonly schemaVersion: 1;
|
|
142
|
+
readonly kind: "io-derivation-initialization";
|
|
143
|
+
readonly derivationId: string;
|
|
144
|
+
readonly directoryIdentity: DirectoryIdentity;
|
|
145
|
+
readonly socketDirectory: string;
|
|
146
|
+
readonly socketIdentity: DirectoryIdentity;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
type DerivationDirectoryPhase = {
|
|
150
|
+
readonly schemaVersion: 1;
|
|
151
|
+
readonly kind: "io-derivation-directory-phase";
|
|
152
|
+
readonly derivationId: string;
|
|
153
|
+
readonly directoryIdentity: DirectoryIdentity;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
type DerivationLifecycleOwner = {
|
|
157
|
+
readonly schemaVersion: 1;
|
|
158
|
+
readonly kind: "io-derivation-lifecycle";
|
|
159
|
+
readonly derivationId: string;
|
|
160
|
+
readonly pid: number;
|
|
161
|
+
readonly createdAtMs: number;
|
|
162
|
+
readonly bootId: string;
|
|
163
|
+
readonly processStartId: string;
|
|
164
|
+
readonly nonce: string;
|
|
165
|
+
readonly directoryIdentity: DirectoryIdentity;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
type ContainedDerivationBrowserMarker = {
|
|
169
|
+
readonly schemaVersion: 1;
|
|
170
|
+
readonly kind: "io-derivation-contained-browser-owner" | "io-derivation-contained-browser-pin";
|
|
171
|
+
readonly derivationId: string;
|
|
172
|
+
readonly sessionName: string;
|
|
173
|
+
readonly cdpUrl: string;
|
|
174
|
+
readonly browserIdentity: DerivationGuardBrowserIdentity;
|
|
175
|
+
readonly daemonOwner: ProcessOwnerIdentity;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export type DerivationLifecycleGate = {
|
|
179
|
+
readonly path: string;
|
|
180
|
+
readonly identity: DirectoryIdentity;
|
|
181
|
+
release: () => void;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const derivationLifecycleHeartbeatMs = 30_000;
|
|
185
|
+
|
|
186
|
+
const deriveCommandHelperPath = join(dirname(fileURLToPath(import.meta.url)), "derive-command-helper.ts");
|
|
187
|
+
const profileCloneHelperPath = join(dirname(fileURLToPath(import.meta.url)), "profile-clone-helper.ts");
|
|
188
|
+
const trustedBunConfigPath = join(dirname(fileURLToPath(import.meta.url)), "state-helper.bunfig.toml");
|
|
189
|
+
export const derivationDnrPolicyFileName = "network-readiness-policy.json";
|
|
190
|
+
export const derivationDnrPolicyActions = Object.freeze([
|
|
191
|
+
"launch",
|
|
192
|
+
"cdp_url",
|
|
193
|
+
"url",
|
|
194
|
+
] as const);
|
|
195
|
+
const derivationIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
|
196
|
+
const singleFileInputReference = "@single-file-input";
|
|
197
|
+
const singleFileInputSelector = "input[type=file]";
|
|
198
|
+
const singleImageInputReference = "@single-image-input";
|
|
199
|
+
const singleImageInputSelector = "input[type=file][accept*='image']";
|
|
200
|
+
const singleVideoInputReference = "@single-video-input";
|
|
201
|
+
const singleVideoInputSelector = "input[type=file][accept*='video']";
|
|
202
|
+
const uploadSettlingDelayMs = 5_000;
|
|
203
|
+
|
|
204
|
+
export type HarContentMode = "none" | "text";
|
|
205
|
+
|
|
206
|
+
export type DerivationSession = {
|
|
207
|
+
readonly schemaVersion: 1 | 2;
|
|
208
|
+
readonly id: string;
|
|
209
|
+
readonly adapterId: string;
|
|
210
|
+
readonly targetUrl: string;
|
|
211
|
+
readonly targetOrigin: string;
|
|
212
|
+
readonly createdAt: string;
|
|
213
|
+
readonly allowRemoteActions: boolean;
|
|
214
|
+
readonly contentMode: HarContentMode;
|
|
215
|
+
readonly browserDomains: readonly string[];
|
|
216
|
+
readonly fixtures: readonly DerivationFixture[];
|
|
217
|
+
readonly headed: boolean;
|
|
218
|
+
readonly sessionName: string;
|
|
219
|
+
readonly directory: string;
|
|
220
|
+
readonly directoryIdentity: DirectoryIdentity;
|
|
221
|
+
readonly socketDirectory: string;
|
|
222
|
+
readonly socketIdentity: DirectoryIdentity;
|
|
223
|
+
readonly configPath: string;
|
|
224
|
+
readonly policyPath: string;
|
|
225
|
+
readonly profilePath: string | null;
|
|
226
|
+
/** Schema v2 binds contained sessions to their exact DNR/proxy boundary. */
|
|
227
|
+
readonly networkGuard?: DerivationNetworkGuard | null;
|
|
228
|
+
readonly browserExecutable?: string;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
function ownedByCurrentUser(uid: number | bigint): boolean {
|
|
232
|
+
const currentUid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
233
|
+
return currentUid === undefined || uid === (typeof uid === "bigint" ? BigInt(currentUid) : currentUid);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function inspectDirectoryIdentity(path: string): DirectoryIdentity {
|
|
237
|
+
const stats = lstatSync(path, { bigint: true });
|
|
238
|
+
if (
|
|
239
|
+
!stats.isDirectory()
|
|
240
|
+
|| stats.isSymbolicLink()
|
|
241
|
+
|| !ownedByCurrentUser(stats.uid)
|
|
242
|
+
|| (stats.mode & 0o777n) !== 0o700n
|
|
243
|
+
) throw new Error("derivation session directory is unsafe");
|
|
244
|
+
return { device: stats.dev.toString(), inode: stats.ino.toString() };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function parseDirectoryIdentity(value: unknown): DirectoryIdentity {
|
|
248
|
+
if (
|
|
249
|
+
typeof value !== "object"
|
|
250
|
+
|| value === null
|
|
251
|
+
|| Array.isArray(value)
|
|
252
|
+
|| Object.keys(value).sort().join(",") !== "device,inode"
|
|
253
|
+
|| !("device" in value)
|
|
254
|
+
|| typeof value.device !== "string"
|
|
255
|
+
|| !/^\d{1,40}$/u.test(value.device)
|
|
256
|
+
|| !("inode" in value)
|
|
257
|
+
|| typeof value.inode !== "string"
|
|
258
|
+
|| !/^\d{1,40}$/u.test(value.inode)
|
|
259
|
+
) throw new Error("derivation directory identity is malformed");
|
|
260
|
+
return { device: value.device, inode: value.inode };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function sameDirectoryIdentity(left: DirectoryIdentity, right: DirectoryIdentity): boolean {
|
|
264
|
+
return left.device === right.device && left.inode === right.inode;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
type BoundProfileClone = {
|
|
268
|
+
readonly profile: "profile" | "profile-user-data";
|
|
269
|
+
readonly profileDirectory: "Default" | null;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const activeChromiumProfileMessage =
|
|
273
|
+
"Chromium profile is active or retains a stale process lock; fully quit the browser and retry";
|
|
274
|
+
|
|
275
|
+
async function cloneProfileBound(source: string, directory: string, expected: DirectoryIdentity): Promise<BoundProfileClone> {
|
|
276
|
+
const result = await runCommand(
|
|
277
|
+
[
|
|
278
|
+
process.execPath,
|
|
279
|
+
"--no-env-file",
|
|
280
|
+
"--no-install",
|
|
281
|
+
"--no-macros",
|
|
282
|
+
"--no-addons",
|
|
283
|
+
`--config=${trustedBunConfigPath}`,
|
|
284
|
+
profileCloneHelperPath,
|
|
285
|
+
],
|
|
286
|
+
{
|
|
287
|
+
cwd: directory,
|
|
288
|
+
environment: { NODE_ENV: "production" },
|
|
289
|
+
timeoutMs: 120_000,
|
|
290
|
+
maxOutputBytes: 1024 * 1024,
|
|
291
|
+
stdin: JSON.stringify({
|
|
292
|
+
schemaVersion: 1,
|
|
293
|
+
requestId: crypto.randomUUID(),
|
|
294
|
+
expectedDirectory: expected,
|
|
295
|
+
source,
|
|
296
|
+
}),
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
if (result.exitCode !== 0) {
|
|
300
|
+
const reason = result.stderr.includes(activeChromiumProfileMessage)
|
|
301
|
+
? `: ${activeChromiumProfileMessage}`
|
|
302
|
+
: result.stderr.includes("ENOENT") ? " (ENOENT)" : "";
|
|
303
|
+
throw new Error(`bound browser-profile clone failed${reason}`);
|
|
304
|
+
}
|
|
305
|
+
let response: unknown;
|
|
306
|
+
try {
|
|
307
|
+
response = JSON.parse(result.stdout) as unknown;
|
|
308
|
+
} catch (error) {
|
|
309
|
+
throw new Error("bound browser-profile clone returned invalid JSON", { cause: error });
|
|
310
|
+
}
|
|
311
|
+
if (
|
|
312
|
+
typeof response !== "object"
|
|
313
|
+
|| response === null
|
|
314
|
+
|| Array.isArray(response)
|
|
315
|
+
|| Object.keys(response).sort().join(",") !== "ok,profile,profileDirectory"
|
|
316
|
+
|| !("ok" in response)
|
|
317
|
+
|| response.ok !== true
|
|
318
|
+
|| !("profile" in response)
|
|
319
|
+
|| (response.profile !== "profile" && response.profile !== "profile-user-data")
|
|
320
|
+
|| !("profileDirectory" in response)
|
|
321
|
+
|| (response.profileDirectory !== null && response.profileDirectory !== "Default")
|
|
322
|
+
|| (response.profile === "profile" && response.profileDirectory !== null)
|
|
323
|
+
|| (response.profile === "profile-user-data" && response.profileDirectory !== "Default")
|
|
324
|
+
) throw new Error("bound browser-profile clone returned an invalid response");
|
|
325
|
+
return {
|
|
326
|
+
profile: response.profile,
|
|
327
|
+
profileDirectory: response.profileDirectory,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function derivationPolicyActions(
|
|
332
|
+
allowRemoteActions: boolean,
|
|
333
|
+
allowFixtureUpload = false,
|
|
334
|
+
): readonly string[] {
|
|
335
|
+
const actions = [
|
|
336
|
+
"launch", "navigate", "snapshot", "scroll", "wait", "get", "network", "state",
|
|
337
|
+
"back", "forward", "reload", "scrollintoview", "url", "title", "text", "textcontent", "html", "innerhtml", "value", "inputvalue", "attr", "getattribute",
|
|
338
|
+
"getbyrole", "getbytext", "getbylabel", "getbyplaceholder", "getbyalttext", "getbytitle", "getbytestid",
|
|
339
|
+
"waitfortext", "waitforurl", "har_start", "har_stop", "requests", "cookies_set", "close",
|
|
340
|
+
// These remain code-owned. The public derivation grammar never exposes
|
|
341
|
+
// CDP endpoint reads or agent-browser's pending-action protocol.
|
|
342
|
+
"cdp_url", "confirm", "session_info",
|
|
343
|
+
];
|
|
344
|
+
if (allowRemoteActions) {
|
|
345
|
+
actions.push("click", "dblclick", "fill", "type", "hover", "focus", "press", "check", "uncheck", "select", "interact");
|
|
346
|
+
if (allowFixtureUpload) {
|
|
347
|
+
actions.push("upload", "count");
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return actions;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export const derivationConfirmedPolicyActions = Object.freeze([
|
|
354
|
+
"click",
|
|
355
|
+
"dblclick",
|
|
356
|
+
"fill",
|
|
357
|
+
"type",
|
|
358
|
+
"hover",
|
|
359
|
+
"focus",
|
|
360
|
+
"press",
|
|
361
|
+
"check",
|
|
362
|
+
"uncheck",
|
|
363
|
+
"select",
|
|
364
|
+
"interact",
|
|
365
|
+
"upload",
|
|
366
|
+
"getbyrole",
|
|
367
|
+
"getbytext",
|
|
368
|
+
"getbylabel",
|
|
369
|
+
"getbyplaceholder",
|
|
370
|
+
"getbyalttext",
|
|
371
|
+
"getbytitle",
|
|
372
|
+
"getbytestid",
|
|
373
|
+
] as const);
|
|
374
|
+
|
|
375
|
+
export function derivationActionPolicy(
|
|
376
|
+
allowRemoteActions: boolean,
|
|
377
|
+
allowFixtureUpload = false,
|
|
378
|
+
): {
|
|
379
|
+
readonly allow: readonly string[];
|
|
380
|
+
readonly confirm: readonly string[];
|
|
381
|
+
readonly default: "deny";
|
|
382
|
+
readonly deny: readonly string[];
|
|
383
|
+
} {
|
|
384
|
+
const allow = derivationPolicyActions(allowRemoteActions, allowFixtureUpload);
|
|
385
|
+
const allowed = new Set(allow);
|
|
386
|
+
return {
|
|
387
|
+
allow,
|
|
388
|
+
confirm: allowRemoteActions
|
|
389
|
+
? derivationConfirmedPolicyActions.filter((action) => allowed.has(action))
|
|
390
|
+
: [],
|
|
391
|
+
default: "deny",
|
|
392
|
+
deny: ["plugin:wrench-contained-relaunch-tripwire:launch.mutate"],
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export const derivationRelaunchTripwirePluginName = "wrench-contained-relaunch-tripwire";
|
|
397
|
+
|
|
398
|
+
export function derivationBrowserConfig(
|
|
399
|
+
relaunchTripwire: boolean,
|
|
400
|
+
): Readonly<Record<string, unknown>> {
|
|
401
|
+
return relaunchTripwire
|
|
402
|
+
? {
|
|
403
|
+
plugins: [{
|
|
404
|
+
args: [],
|
|
405
|
+
capabilities: ["launch.mutate"],
|
|
406
|
+
command: "wrench-contained-browser-relaunch-is-forbidden",
|
|
407
|
+
name: derivationRelaunchTripwirePluginName,
|
|
408
|
+
}],
|
|
409
|
+
}
|
|
410
|
+
: {};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const allowedBrowserCommands = new Set([
|
|
414
|
+
"open",
|
|
415
|
+
"back",
|
|
416
|
+
"forward",
|
|
417
|
+
"reload",
|
|
418
|
+
"snapshot",
|
|
419
|
+
"get",
|
|
420
|
+
"network",
|
|
421
|
+
"wait",
|
|
422
|
+
"scroll",
|
|
423
|
+
"scrollintoview",
|
|
424
|
+
"find",
|
|
425
|
+
"click",
|
|
426
|
+
"dblclick",
|
|
427
|
+
"focus",
|
|
428
|
+
"fill",
|
|
429
|
+
"cleartext",
|
|
430
|
+
"type",
|
|
431
|
+
"press",
|
|
432
|
+
"hover",
|
|
433
|
+
"check",
|
|
434
|
+
"uncheck",
|
|
435
|
+
"select",
|
|
436
|
+
"close",
|
|
437
|
+
"upload",
|
|
438
|
+
"upload-and-seal",
|
|
439
|
+
"choose-upload",
|
|
440
|
+
]);
|
|
441
|
+
|
|
442
|
+
const mutatingBrowserTokens = new Set(["click", "dblclick", "fill", "cleartext", "type", "press", "hover", "focus", "check", "uncheck", "select", "upload", "upload-and-seal", "choose-upload"]);
|
|
443
|
+
|
|
444
|
+
function commandCanMutate(command: readonly string[]): boolean {
|
|
445
|
+
const action = command[0] ?? "";
|
|
446
|
+
if (mutatingBrowserTokens.has(action)) return true;
|
|
447
|
+
return action === "find" && mutatingBrowserTokens.has(command[3] ?? "");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function derivationDirectory(id: string, environment: Readonly<Record<string, string | undefined>>): string {
|
|
451
|
+
if (!derivationIdPattern.test(id)) throw new Error("derivation ID is invalid");
|
|
452
|
+
return join(ghostgetStateHome(environment), "derivations", id);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function metadataPath(id: string, environment: Readonly<Record<string, string | undefined>>): string {
|
|
456
|
+
return join(derivationDirectory(id, environment), "session.json");
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function derivationLifecycleLockPath(
|
|
460
|
+
id: string,
|
|
461
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
462
|
+
): string {
|
|
463
|
+
if (!derivationIdPattern.test(id)) throw new Error("derivation ID is invalid");
|
|
464
|
+
return join(ghostgetStateHome(environment), "derivations", `.lifecycle-${id}`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
type ProcessIdentityInspection =
|
|
468
|
+
| { readonly status: "alive"; readonly processStartId: string }
|
|
469
|
+
| { readonly status: "missing" }
|
|
470
|
+
| { readonly status: "unknown" };
|
|
471
|
+
|
|
472
|
+
let cachedBootId: string | null = null;
|
|
473
|
+
let cachedCurrentProcessStartId: string | null = null;
|
|
474
|
+
|
|
475
|
+
function identityDigest(kind: string, value: string): string {
|
|
476
|
+
return sha256(`${kind}\u0000${value}`);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function commandText(command: string, arguments_: readonly string[]): string {
|
|
480
|
+
const environment: NodeJS.ProcessEnv = process.platform === "win32"
|
|
481
|
+
? {
|
|
482
|
+
NODE_ENV: "production",
|
|
483
|
+
SystemRoot: "C:\\Windows",
|
|
484
|
+
WINDIR: "C:\\Windows",
|
|
485
|
+
PATH: "C:\\Windows\\System32",
|
|
486
|
+
}
|
|
487
|
+
: { LANG: "C", LC_ALL: "C", NODE_ENV: "production", TZ: "UTC" };
|
|
488
|
+
return execFileSync(command, arguments_, {
|
|
489
|
+
encoding: "utf8",
|
|
490
|
+
env: environment,
|
|
491
|
+
timeout: 2_000,
|
|
492
|
+
windowsHide: true,
|
|
493
|
+
}).trim();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function windowsPowerShellPath(): string {
|
|
497
|
+
return "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function currentBootId(): string {
|
|
501
|
+
if (cachedBootId !== null) return cachedBootId;
|
|
502
|
+
let raw: string;
|
|
503
|
+
if (process.platform === "linux") {
|
|
504
|
+
raw = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
505
|
+
if (!/^[a-f0-9-]{36}$/iu.test(raw)) throw new Error("system boot identity is malformed");
|
|
506
|
+
} else if (process.platform === "darwin") {
|
|
507
|
+
const value = commandText("/usr/sbin/sysctl", ["-n", "kern.boottime"]);
|
|
508
|
+
const match = /sec\s*=\s*(\d+)\s*,\s*usec\s*=\s*(\d+)/u.exec(value);
|
|
509
|
+
if (match === null) throw new Error("system boot identity is malformed");
|
|
510
|
+
raw = `${match[1]}:${match[2]}`;
|
|
511
|
+
} else if (process.platform === "win32") {
|
|
512
|
+
raw = commandText(windowsPowerShellPath(), [
|
|
513
|
+
"-NoProfile",
|
|
514
|
+
"-NonInteractive",
|
|
515
|
+
"-Command",
|
|
516
|
+
"(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime().Ticks",
|
|
517
|
+
]);
|
|
518
|
+
if (!/^\d{10,30}$/u.test(raw)) throw new Error("system boot identity is malformed");
|
|
519
|
+
} else throw new Error(`system boot identity is unsupported on ${process.platform}`);
|
|
520
|
+
cachedBootId = identityDigest("io-boot", raw);
|
|
521
|
+
return cachedBootId;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function processIsDefinitelyMissing(pid: number): boolean {
|
|
525
|
+
try {
|
|
526
|
+
process.kill(pid, 0);
|
|
527
|
+
return false;
|
|
528
|
+
} catch (error) {
|
|
529
|
+
return (
|
|
530
|
+
typeof error === "object"
|
|
531
|
+
&& error !== null
|
|
532
|
+
&& "code" in error
|
|
533
|
+
&& (error as { readonly code?: unknown }).code === "ESRCH"
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function inspectProcessStartId(pid: number, bootId: string): ProcessIdentityInspection {
|
|
539
|
+
if (pid === process.pid && cachedCurrentProcessStartId !== null) {
|
|
540
|
+
return { status: "alive", processStartId: cachedCurrentProcessStartId };
|
|
541
|
+
}
|
|
542
|
+
try {
|
|
543
|
+
let raw: string;
|
|
544
|
+
if (process.platform === "linux") {
|
|
545
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8").trim();
|
|
546
|
+
const close = stat.lastIndexOf(")");
|
|
547
|
+
if (close < 1) return { status: "unknown" };
|
|
548
|
+
const fields = stat.slice(close + 1).trim().split(/\s+/u);
|
|
549
|
+
const startTicks = fields[19];
|
|
550
|
+
if (startTicks === undefined || !/^\d+$/u.test(startTicks)) return { status: "unknown" };
|
|
551
|
+
raw = startTicks;
|
|
552
|
+
} else if (process.platform === "win32") {
|
|
553
|
+
raw = commandText(windowsPowerShellPath(), [
|
|
554
|
+
"-NoProfile",
|
|
555
|
+
"-NonInteractive",
|
|
556
|
+
"-Command",
|
|
557
|
+
"$p=[int]$args[0]; (Get-Process -Id $p -ErrorAction Stop).StartTime.ToUniversalTime().Ticks",
|
|
558
|
+
String(pid),
|
|
559
|
+
]);
|
|
560
|
+
if (!/^\d{10,30}$/u.test(raw)) return { status: "unknown" };
|
|
561
|
+
} else if (process.platform === "darwin") {
|
|
562
|
+
raw = commandText("/bin/ps", ["-o", "lstart=", "-p", String(pid)]);
|
|
563
|
+
if (raw === "") return { status: "missing" };
|
|
564
|
+
} else return { status: "unknown" };
|
|
565
|
+
const processStartId = identityDigest("io-process-start", `${bootId}\u0000${raw}`);
|
|
566
|
+
if (pid === process.pid) cachedCurrentProcessStartId = processStartId;
|
|
567
|
+
return { status: "alive", processStartId };
|
|
568
|
+
} catch (error) {
|
|
569
|
+
if (
|
|
570
|
+
processIsDefinitelyMissing(pid)
|
|
571
|
+
|| (
|
|
572
|
+
process.platform === "linux"
|
|
573
|
+
&& typeof error === "object"
|
|
574
|
+
&& error !== null
|
|
575
|
+
&& "code" in error
|
|
576
|
+
&& (error as { readonly code?: unknown }).code === "ENOENT"
|
|
577
|
+
)
|
|
578
|
+
) return { status: "missing" };
|
|
579
|
+
return { status: "unknown" };
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function currentLifecycleProcessIdentity(): {
|
|
584
|
+
readonly bootId: string;
|
|
585
|
+
readonly processStartId: string;
|
|
586
|
+
} {
|
|
587
|
+
const bootId = currentBootId();
|
|
588
|
+
const processIdentity = inspectProcessStartId(process.pid, bootId);
|
|
589
|
+
if (processIdentity.status !== "alive") {
|
|
590
|
+
throw new Error("current process start identity is unavailable");
|
|
591
|
+
}
|
|
592
|
+
return { bootId, processStartId: processIdentity.processStartId };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function lifecycleOwnerStatus(
|
|
596
|
+
owner: DerivationLifecycleOwner,
|
|
597
|
+
): "exact-live-owner" | "different-or-dead" | "unknown" {
|
|
598
|
+
const bootId = currentBootId();
|
|
599
|
+
if (bootId !== owner.bootId) return "different-or-dead";
|
|
600
|
+
const processIdentity = inspectProcessStartId(owner.pid, bootId);
|
|
601
|
+
if (processIdentity.status === "unknown") return "unknown";
|
|
602
|
+
if (processIdentity.status === "missing") return "different-or-dead";
|
|
603
|
+
return processIdentity.processStartId === owner.processStartId
|
|
604
|
+
? "exact-live-owner"
|
|
605
|
+
: "different-or-dead";
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function parseDerivationLifecycleOwner(
|
|
609
|
+
value: unknown,
|
|
610
|
+
id: string,
|
|
611
|
+
identity: DirectoryIdentity,
|
|
612
|
+
): DerivationLifecycleOwner {
|
|
613
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
614
|
+
throw new Error("derivation lifecycle owner is malformed");
|
|
615
|
+
}
|
|
616
|
+
const record = value as Record<string, unknown>;
|
|
617
|
+
if (
|
|
618
|
+
Object.keys(record).sort().join(",")
|
|
619
|
+
!== "bootId,createdAtMs,derivationId,directoryIdentity,kind,nonce,pid,processStartId,schemaVersion"
|
|
620
|
+
|| record.schemaVersion !== 1
|
|
621
|
+
|| record.kind !== "io-derivation-lifecycle"
|
|
622
|
+
|| record.derivationId !== id
|
|
623
|
+
|| !Number.isSafeInteger(record.pid)
|
|
624
|
+
|| (record.pid as number) < 1
|
|
625
|
+
|| !Number.isSafeInteger(record.createdAtMs)
|
|
626
|
+
|| (record.createdAtMs as number) < 0
|
|
627
|
+
|| typeof record.bootId !== "string"
|
|
628
|
+
|| !/^[a-f0-9]{64}$/u.test(record.bootId)
|
|
629
|
+
|| typeof record.processStartId !== "string"
|
|
630
|
+
|| !/^[a-f0-9]{64}$/u.test(record.processStartId)
|
|
631
|
+
|| typeof record.nonce !== "string"
|
|
632
|
+
|| !/^[a-f0-9]{32}$/u.test(record.nonce)
|
|
633
|
+
) throw new Error("derivation lifecycle owner is malformed");
|
|
634
|
+
const directoryIdentity = parseDirectoryIdentity(record.directoryIdentity);
|
|
635
|
+
if (!sameDirectoryIdentity(directoryIdentity, identity)) {
|
|
636
|
+
throw new Error("derivation lifecycle owner does not match its lock directory");
|
|
637
|
+
}
|
|
638
|
+
return {
|
|
639
|
+
schemaVersion: 1,
|
|
640
|
+
kind: "io-derivation-lifecycle",
|
|
641
|
+
derivationId: id,
|
|
642
|
+
pid: record.pid as number,
|
|
643
|
+
createdAtMs: record.createdAtMs as number,
|
|
644
|
+
bootId: record.bootId,
|
|
645
|
+
processStartId: record.processStartId,
|
|
646
|
+
nonce: record.nonce,
|
|
647
|
+
directoryIdentity,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function lifecycleLockIsPastRecoveryGrace(
|
|
652
|
+
path: string,
|
|
653
|
+
expected: DirectoryIdentity,
|
|
654
|
+
nowMs: number,
|
|
655
|
+
): boolean {
|
|
656
|
+
const stats = lstatSync(path, { bigint: true });
|
|
657
|
+
if (
|
|
658
|
+
!stats.isDirectory()
|
|
659
|
+
|| stats.isSymbolicLink()
|
|
660
|
+
|| !ownedByCurrentUser(stats.uid)
|
|
661
|
+
|| (stats.mode & 0o777n) !== 0o700n
|
|
662
|
+
) throw new Error("derivation lifecycle lock is unsafe");
|
|
663
|
+
const actual = { device: stats.dev.toString(), inode: stats.ino.toString() };
|
|
664
|
+
if (!sameDirectoryIdentity(actual, expected)) {
|
|
665
|
+
throw new Error("derivation lifecycle lock changed identity");
|
|
666
|
+
}
|
|
667
|
+
const age = nowMs - Number(stats.mtimeMs);
|
|
668
|
+
if (!Number.isFinite(age) || age < 0) throw new Error("derivation lifecycle lock timestamp is invalid");
|
|
669
|
+
return age >= DERIVATION_LIFECYCLE_ORPHAN_GRACE_MS;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Claim one derivation from preflight through its final state transition.
|
|
674
|
+
* The fixed, identity-bound directory is the atomic claim; the owner marker
|
|
675
|
+
* and heartbeat allow conservative crash recovery without overlapping a
|
|
676
|
+
* helper process that outlived its CLI parent.
|
|
677
|
+
*/
|
|
678
|
+
export function acquireDerivationLifecycleGate(
|
|
679
|
+
id: string,
|
|
680
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
681
|
+
options: {
|
|
682
|
+
/** Deterministic crash-recovery test seams. Production callers leave these unset. */
|
|
683
|
+
readonly nowMs?: number;
|
|
684
|
+
} = {},
|
|
685
|
+
): DerivationLifecycleGate {
|
|
686
|
+
if (!derivationIdPattern.test(id)) throw new Error("derivation ID is invalid");
|
|
687
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
688
|
+
const ownerPid = process.pid;
|
|
689
|
+
if (!Number.isSafeInteger(nowMs) || nowMs < 0 || !Number.isSafeInteger(ownerPid) || ownerPid < 1) {
|
|
690
|
+
throw new Error("derivation lifecycle owner is invalid");
|
|
691
|
+
}
|
|
692
|
+
const processIdentity = currentLifecycleProcessIdentity();
|
|
693
|
+
const root = join(ghostgetStateHome(environment), "derivations");
|
|
694
|
+
const rootIdentity = ensurePrivateStateDirectory(root, environment);
|
|
695
|
+
const path = derivationLifecycleLockPath(id, environment);
|
|
696
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
697
|
+
let identity: DirectoryIdentity | null = null;
|
|
698
|
+
let descriptor: number | null = null;
|
|
699
|
+
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
|
700
|
+
try {
|
|
701
|
+
identity = createPrivateStateDirectory(path, environment, rootIdentity);
|
|
702
|
+
const acquiredIdentity = identity;
|
|
703
|
+
const owner: DerivationLifecycleOwner = {
|
|
704
|
+
schemaVersion: 1,
|
|
705
|
+
kind: "io-derivation-lifecycle",
|
|
706
|
+
derivationId: id,
|
|
707
|
+
pid: ownerPid,
|
|
708
|
+
createdAtMs: nowMs,
|
|
709
|
+
bootId: processIdentity.bootId,
|
|
710
|
+
processStartId: processIdentity.processStartId,
|
|
711
|
+
nonce: crypto.randomUUID().replaceAll("-", ""),
|
|
712
|
+
directoryIdentity: acquiredIdentity,
|
|
713
|
+
};
|
|
714
|
+
const marker = createPrivateJsonIfAbsent(
|
|
715
|
+
join(path, derivationLifecycleOwnerMarkerName),
|
|
716
|
+
owner,
|
|
717
|
+
{
|
|
718
|
+
environment,
|
|
719
|
+
expectedStateDirectories: [rootIdentity, acquiredIdentity],
|
|
720
|
+
},
|
|
721
|
+
);
|
|
722
|
+
if (!marker.created) throw new Error("derivation lifecycle owner marker already exists");
|
|
723
|
+
descriptor = openSync(
|
|
724
|
+
path,
|
|
725
|
+
constants.O_RDONLY
|
|
726
|
+
| ("O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0)
|
|
727
|
+
| ("O_DIRECTORY" in constants ? constants.O_DIRECTORY : 0),
|
|
728
|
+
);
|
|
729
|
+
const descriptorStats = fstatSync(descriptor, { bigint: true });
|
|
730
|
+
const descriptorIdentity = {
|
|
731
|
+
device: descriptorStats.dev.toString(),
|
|
732
|
+
inode: descriptorStats.ino.toString(),
|
|
733
|
+
};
|
|
734
|
+
if (
|
|
735
|
+
!descriptorStats.isDirectory()
|
|
736
|
+
|| !ownedByCurrentUser(descriptorStats.uid)
|
|
737
|
+
|| (descriptorStats.mode & 0o777n) !== 0o700n
|
|
738
|
+
|| !sameDirectoryIdentity(descriptorIdentity, acquiredIdentity)
|
|
739
|
+
) throw new Error("derivation lifecycle lock changed identity during acquisition");
|
|
740
|
+
heartbeat = setInterval(() => {
|
|
741
|
+
if (descriptor === null || heartbeat === null) return;
|
|
742
|
+
try {
|
|
743
|
+
const timestamp = new Date();
|
|
744
|
+
futimesSync(descriptor, timestamp, timestamp);
|
|
745
|
+
} catch {
|
|
746
|
+
// A live owner PID still prevents reclamation. Stopping the failed
|
|
747
|
+
// heartbeat only delays crash recovery; it cannot permit overlap.
|
|
748
|
+
clearInterval(heartbeat);
|
|
749
|
+
heartbeat = null;
|
|
750
|
+
}
|
|
751
|
+
}, derivationLifecycleHeartbeatMs);
|
|
752
|
+
heartbeat.unref();
|
|
753
|
+
let released = false;
|
|
754
|
+
return {
|
|
755
|
+
path,
|
|
756
|
+
identity: acquiredIdentity,
|
|
757
|
+
release: (): void => {
|
|
758
|
+
if (released) return;
|
|
759
|
+
if (heartbeat !== null) {
|
|
760
|
+
clearInterval(heartbeat);
|
|
761
|
+
heartbeat = null;
|
|
762
|
+
}
|
|
763
|
+
if (descriptor !== null) {
|
|
764
|
+
closeSync(descriptor);
|
|
765
|
+
descriptor = null;
|
|
766
|
+
}
|
|
767
|
+
try {
|
|
768
|
+
if (!removePrivateStateDirectoryTree(path, environment, acquiredIdentity, rootIdentity)) {
|
|
769
|
+
throw new Error("derivation lifecycle gate ownership was lost");
|
|
770
|
+
}
|
|
771
|
+
} catch (error) {
|
|
772
|
+
if (error instanceof Error && error.message === "derivation lifecycle gate ownership was lost") {
|
|
773
|
+
throw error;
|
|
774
|
+
}
|
|
775
|
+
throw new Error("derivation lifecycle gate ownership was lost", { cause: error });
|
|
776
|
+
}
|
|
777
|
+
released = true;
|
|
778
|
+
},
|
|
779
|
+
};
|
|
780
|
+
} catch (creationError) {
|
|
781
|
+
if (heartbeat !== null) {
|
|
782
|
+
clearInterval(heartbeat);
|
|
783
|
+
heartbeat = null;
|
|
784
|
+
}
|
|
785
|
+
if (descriptor !== null) {
|
|
786
|
+
closeSync(descriptor);
|
|
787
|
+
descriptor = null;
|
|
788
|
+
}
|
|
789
|
+
if (identity !== null) {
|
|
790
|
+
try {
|
|
791
|
+
removePrivateStateDirectoryTree(path, environment, identity, rootIdentity);
|
|
792
|
+
} catch (cleanupError) {
|
|
793
|
+
throw new AggregateError(
|
|
794
|
+
[creationError, cleanupError],
|
|
795
|
+
"derivation lifecycle gate setup failed and cleanup was incomplete",
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
throw creationError;
|
|
799
|
+
}
|
|
800
|
+
const entry = listPrivateStateDirectory(root, environment, rootIdentity)
|
|
801
|
+
.find((candidate) => candidate.name === `.lifecycle-${id}`);
|
|
802
|
+
if (entry === undefined) {
|
|
803
|
+
if (attempt < 3) continue;
|
|
804
|
+
throw creationError;
|
|
805
|
+
}
|
|
806
|
+
if (entry.kind !== "directory" || entry.identity === undefined) {
|
|
807
|
+
throw new Error("derivation lifecycle lock is unsafe", { cause: creationError });
|
|
808
|
+
}
|
|
809
|
+
const markerText = readPrivateStateFileIfPresent(
|
|
810
|
+
join(path, derivationLifecycleOwnerMarkerName),
|
|
811
|
+
4 * 1024,
|
|
812
|
+
"derivation lifecycle owner",
|
|
813
|
+
environment,
|
|
814
|
+
[rootIdentity, entry.identity],
|
|
815
|
+
);
|
|
816
|
+
if (markerText === null) {
|
|
817
|
+
if (!lifecycleLockIsPastRecoveryGrace(path, entry.identity, nowMs)) {
|
|
818
|
+
throw new Error(`derivation ${id} lifecycle is busy; retry the command`);
|
|
819
|
+
}
|
|
820
|
+
} else {
|
|
821
|
+
let markerValue: unknown;
|
|
822
|
+
try {
|
|
823
|
+
markerValue = JSON.parse(markerText) as unknown;
|
|
824
|
+
} catch (error) {
|
|
825
|
+
throw new Error("derivation lifecycle owner is malformed", { cause: error });
|
|
826
|
+
}
|
|
827
|
+
const owner = parseDerivationLifecycleOwner(markerValue, id, entry.identity);
|
|
828
|
+
if (owner.createdAtMs > nowMs + 60_000) {
|
|
829
|
+
throw new Error("derivation lifecycle owner timestamp is from the future");
|
|
830
|
+
}
|
|
831
|
+
const pastRecoveryGrace = lifecycleLockIsPastRecoveryGrace(path, entry.identity, nowMs);
|
|
832
|
+
const ownerStatus = lifecycleOwnerStatus(owner);
|
|
833
|
+
if (
|
|
834
|
+
ownerStatus === "exact-live-owner"
|
|
835
|
+
|| ownerStatus === "unknown"
|
|
836
|
+
|| !pastRecoveryGrace
|
|
837
|
+
) {
|
|
838
|
+
throw new Error(`derivation ${id} lifecycle is busy; retry the command`);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
if (!removePrivateStateDirectoryTree(path, environment, entry.identity, rootIdentity)) {
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
throw new Error(`derivation ${id} lifecycle is busy; retry the command`);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
async function withDerivationLifecycleGate<T>(
|
|
850
|
+
id: string,
|
|
851
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
852
|
+
operation: () => Promise<T>,
|
|
853
|
+
): Promise<T> {
|
|
854
|
+
const gate = acquireDerivationLifecycleGate(id, environment);
|
|
855
|
+
try {
|
|
856
|
+
return await operation();
|
|
857
|
+
} finally {
|
|
858
|
+
gate.release();
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function parseSession(value: unknown): DerivationSession {
|
|
863
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("derivation session is malformed");
|
|
864
|
+
const record = value as Record<string, unknown>;
|
|
865
|
+
const id = record.id;
|
|
866
|
+
const adapterId = record.adapterId;
|
|
867
|
+
const targetUrl = record.targetUrl;
|
|
868
|
+
const targetOrigin = record.targetOrigin;
|
|
869
|
+
const createdAt = record.createdAt;
|
|
870
|
+
const sessionName = record.sessionName;
|
|
871
|
+
const directory = record.directory;
|
|
872
|
+
const directoryIdentity = parseDirectoryIdentity(record.directoryIdentity);
|
|
873
|
+
const socketDirectory = record.socketDirectory;
|
|
874
|
+
const socketIdentity = parseDirectoryIdentity(record.socketIdentity);
|
|
875
|
+
const configPath = record.configPath;
|
|
876
|
+
const policyPath = record.policyPath;
|
|
877
|
+
const fixtures = parseDerivationFixtures(record.fixtures);
|
|
878
|
+
const networkGuard = record.schemaVersion === 2
|
|
879
|
+
? record.networkGuard === null
|
|
880
|
+
? null
|
|
881
|
+
: parseDerivationNetworkGuard(record.networkGuard)
|
|
882
|
+
: undefined;
|
|
883
|
+
const expectedKeys = [
|
|
884
|
+
"schemaVersion", "id", "adapterId", "targetUrl", "targetOrigin", "createdAt", "allowRemoteActions", "contentMode",
|
|
885
|
+
"browserDomains", "headed", "sessionName", "directory", "directoryIdentity", "socketDirectory", "socketIdentity", "configPath", "policyPath", "profilePath",
|
|
886
|
+
...(record.fixtures === undefined ? [] : ["fixtures"]),
|
|
887
|
+
...(record.schemaVersion === 2 ? ["networkGuard"] : []),
|
|
888
|
+
...(record.browserExecutable === undefined ? [] : ["browserExecutable"]),
|
|
889
|
+
].sort();
|
|
890
|
+
const actualKeys = Object.keys(record).sort();
|
|
891
|
+
if (
|
|
892
|
+
(record.schemaVersion !== 1 && record.schemaVersion !== 2)
|
|
893
|
+
|| typeof id !== "string"
|
|
894
|
+
|| typeof adapterId !== "string"
|
|
895
|
+
|| typeof targetUrl !== "string"
|
|
896
|
+
|| typeof targetOrigin !== "string"
|
|
897
|
+
|| typeof createdAt !== "string"
|
|
898
|
+
|| typeof sessionName !== "string"
|
|
899
|
+
|| typeof directory !== "string"
|
|
900
|
+
|| typeof socketDirectory !== "string"
|
|
901
|
+
|| typeof configPath !== "string"
|
|
902
|
+
|| typeof policyPath !== "string"
|
|
903
|
+
|| !Array.isArray(record.browserDomains)
|
|
904
|
+
|| record.browserDomains.some((domain) => typeof domain !== "string")
|
|
905
|
+
|| actualKeys.length !== expectedKeys.length
|
|
906
|
+
|| actualKeys.some((key, index) => key !== expectedKeys[index])
|
|
907
|
+
) {
|
|
908
|
+
throw new Error("derivation session is malformed");
|
|
909
|
+
}
|
|
910
|
+
if (typeof record.allowRemoteActions !== "boolean" || typeof record.headed !== "boolean" || (record.contentMode !== "none" && record.contentMode !== "text")) {
|
|
911
|
+
throw new Error("derivation session is malformed");
|
|
912
|
+
}
|
|
913
|
+
if (record.profilePath !== null && typeof record.profilePath !== "string") throw new Error("derivation session is malformed");
|
|
914
|
+
if (
|
|
915
|
+
record.schemaVersion === 2
|
|
916
|
+
&& (
|
|
917
|
+
(record.profilePath === null && networkGuard === null)
|
|
918
|
+
|| (record.profilePath !== null && networkGuard !== null)
|
|
919
|
+
)
|
|
920
|
+
) throw new Error("derivation session network boundary is malformed");
|
|
921
|
+
if (
|
|
922
|
+
record.browserExecutable !== undefined
|
|
923
|
+
&& (
|
|
924
|
+
typeof record.browserExecutable !== "string"
|
|
925
|
+
|| !isAbsolute(record.browserExecutable)
|
|
926
|
+
|| record.browserExecutable.length < 1
|
|
927
|
+
|| record.browserExecutable.length > 4_096
|
|
928
|
+
|| record.browserExecutable.includes("\u0000")
|
|
929
|
+
)
|
|
930
|
+
) throw new Error("derivation session browser executable is malformed");
|
|
931
|
+
if (
|
|
932
|
+
!derivationIdPattern.test(id)
|
|
933
|
+
|| !/^[a-z][a-z0-9-]{0,47}$/u.test(adapterId)
|
|
934
|
+
|| !Number.isFinite(Date.parse(createdAt))
|
|
935
|
+
|| !/^io-derive-[a-f0-9]{12}$/u.test(sessionName)
|
|
936
|
+
) throw new Error("derivation session metadata is malformed");
|
|
937
|
+
const parsedTarget = validateTarget(targetUrl);
|
|
938
|
+
if (parsedTarget.origin !== targetOrigin) throw new Error("derivation target origin is malformed");
|
|
939
|
+
const browserDomains = validateDerivationBrowserDomains(record.browserDomains.map((domain) => String(domain)), parsedTarget.hostname.toLowerCase());
|
|
940
|
+
return {
|
|
941
|
+
schemaVersion: record.schemaVersion,
|
|
942
|
+
id,
|
|
943
|
+
adapterId,
|
|
944
|
+
targetUrl,
|
|
945
|
+
targetOrigin,
|
|
946
|
+
createdAt,
|
|
947
|
+
allowRemoteActions: record.allowRemoteActions,
|
|
948
|
+
contentMode: record.contentMode,
|
|
949
|
+
browserDomains,
|
|
950
|
+
fixtures,
|
|
951
|
+
headed: record.headed,
|
|
952
|
+
sessionName,
|
|
953
|
+
directory,
|
|
954
|
+
directoryIdentity,
|
|
955
|
+
socketDirectory,
|
|
956
|
+
socketIdentity,
|
|
957
|
+
configPath,
|
|
958
|
+
policyPath,
|
|
959
|
+
profilePath: record.profilePath,
|
|
960
|
+
...(record.schemaVersion === 2 ? { networkGuard: networkGuard ?? null } : {}),
|
|
961
|
+
...(typeof record.browserExecutable === "string"
|
|
962
|
+
? { browserExecutable: record.browserExecutable }
|
|
963
|
+
: {}),
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function expectedSocketDirectory(id: string): string {
|
|
968
|
+
const root = process.platform === "win32" ? tmpdir() : "/tmp";
|
|
969
|
+
return join(root, `io-derive-ab-${id}`);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function parseDerivationDirectoryPhase(
|
|
973
|
+
value: unknown,
|
|
974
|
+
id: string,
|
|
975
|
+
directoryIdentity: DirectoryIdentity,
|
|
976
|
+
): DerivationDirectoryPhase {
|
|
977
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
978
|
+
throw new Error("derivation directory phase marker is malformed");
|
|
979
|
+
}
|
|
980
|
+
const record = value as Record<string, unknown>;
|
|
981
|
+
if (
|
|
982
|
+
Object.keys(record).sort().join(",")
|
|
983
|
+
!== "derivationId,directoryIdentity,kind,schemaVersion"
|
|
984
|
+
|| record.schemaVersion !== 1
|
|
985
|
+
|| record.kind !== "io-derivation-directory-phase"
|
|
986
|
+
|| record.derivationId !== id
|
|
987
|
+
) throw new Error("derivation directory phase marker is malformed");
|
|
988
|
+
const recordedDirectoryIdentity = parseDirectoryIdentity(record.directoryIdentity);
|
|
989
|
+
if (!sameDirectoryIdentity(recordedDirectoryIdentity, directoryIdentity)) {
|
|
990
|
+
throw new Error("derivation directory phase marker does not match its directory");
|
|
991
|
+
}
|
|
992
|
+
return {
|
|
993
|
+
schemaVersion: 1,
|
|
994
|
+
kind: "io-derivation-directory-phase",
|
|
995
|
+
derivationId: id,
|
|
996
|
+
directoryIdentity: recordedDirectoryIdentity,
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function readDerivationDirectoryPhase(
|
|
1001
|
+
id: string,
|
|
1002
|
+
directory: string,
|
|
1003
|
+
directoryIdentity: DirectoryIdentity,
|
|
1004
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
1005
|
+
): DerivationDirectoryPhase {
|
|
1006
|
+
const entry = listPrivateStateDirectory(directory, environment, directoryIdentity)
|
|
1007
|
+
.find((candidate) => candidate.name === derivationPhaseMarkerName);
|
|
1008
|
+
if (entry?.kind !== "file") throw new Error("derivation directory phase marker is unavailable or unsafe");
|
|
1009
|
+
let value: unknown;
|
|
1010
|
+
try {
|
|
1011
|
+
value = JSON.parse(readRegularFile(
|
|
1012
|
+
phaseMarkerPath(directory),
|
|
1013
|
+
64 * 1024,
|
|
1014
|
+
"derivation directory phase marker",
|
|
1015
|
+
directoryIdentity,
|
|
1016
|
+
)) as unknown;
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
if (error instanceof SyntaxError) {
|
|
1019
|
+
throw new Error("derivation directory phase marker is malformed", { cause: error });
|
|
1020
|
+
}
|
|
1021
|
+
throw error;
|
|
1022
|
+
}
|
|
1023
|
+
return parseDerivationDirectoryPhase(value, id, directoryIdentity);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function parseDerivationInitialization(
|
|
1027
|
+
value: unknown,
|
|
1028
|
+
id: string,
|
|
1029
|
+
directoryIdentity: DirectoryIdentity,
|
|
1030
|
+
): DerivationInitialization {
|
|
1031
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1032
|
+
throw new Error("derivation initialization marker is malformed");
|
|
1033
|
+
}
|
|
1034
|
+
const record = value as Record<string, unknown>;
|
|
1035
|
+
if (
|
|
1036
|
+
Object.keys(record).sort().join(",")
|
|
1037
|
+
!== "derivationId,directoryIdentity,kind,schemaVersion,socketDirectory,socketIdentity"
|
|
1038
|
+
|| record.schemaVersion !== 1
|
|
1039
|
+
|| record.kind !== "io-derivation-initialization"
|
|
1040
|
+
|| record.derivationId !== id
|
|
1041
|
+
|| record.socketDirectory !== expectedSocketDirectory(id)
|
|
1042
|
+
) throw new Error("derivation initialization marker is malformed");
|
|
1043
|
+
const recordedDirectoryIdentity = parseDirectoryIdentity(record.directoryIdentity);
|
|
1044
|
+
const socketIdentity = parseDirectoryIdentity(record.socketIdentity);
|
|
1045
|
+
if (!sameDirectoryIdentity(recordedDirectoryIdentity, directoryIdentity)) {
|
|
1046
|
+
throw new Error("derivation initialization marker does not match its directory");
|
|
1047
|
+
}
|
|
1048
|
+
return {
|
|
1049
|
+
schemaVersion: 1,
|
|
1050
|
+
kind: "io-derivation-initialization",
|
|
1051
|
+
derivationId: id,
|
|
1052
|
+
directoryIdentity: recordedDirectoryIdentity,
|
|
1053
|
+
socketDirectory: record.socketDirectory,
|
|
1054
|
+
socketIdentity,
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function readDerivationInitialization(
|
|
1059
|
+
id: string,
|
|
1060
|
+
directory: string,
|
|
1061
|
+
directoryIdentity: DirectoryIdentity,
|
|
1062
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
1063
|
+
): DerivationInitialization {
|
|
1064
|
+
const entry = listPrivateStateDirectory(directory, environment, directoryIdentity)
|
|
1065
|
+
.find((candidate) => candidate.name === derivationInitializationMarkerName);
|
|
1066
|
+
if (entry?.kind !== "file") throw new Error("derivation initialization marker is unavailable or unsafe");
|
|
1067
|
+
let value: unknown;
|
|
1068
|
+
try {
|
|
1069
|
+
value = JSON.parse(readRegularFile(
|
|
1070
|
+
initializationMarkerPath(directory),
|
|
1071
|
+
64 * 1024,
|
|
1072
|
+
"derivation initialization marker",
|
|
1073
|
+
directoryIdentity,
|
|
1074
|
+
)) as unknown;
|
|
1075
|
+
} catch (error) {
|
|
1076
|
+
if (error instanceof SyntaxError) {
|
|
1077
|
+
throw new Error("derivation initialization marker is malformed", { cause: error });
|
|
1078
|
+
}
|
|
1079
|
+
throw error;
|
|
1080
|
+
}
|
|
1081
|
+
return parseDerivationInitialization(value, id, directoryIdentity);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function initializationFromSession(session: DerivationSession): DerivationInitialization {
|
|
1085
|
+
return {
|
|
1086
|
+
schemaVersion: 1,
|
|
1087
|
+
kind: "io-derivation-initialization",
|
|
1088
|
+
derivationId: session.id,
|
|
1089
|
+
directoryIdentity: session.directoryIdentity,
|
|
1090
|
+
socketDirectory: session.socketDirectory,
|
|
1091
|
+
socketIdentity: session.socketIdentity,
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function hasErrorCode(error: unknown, code: string): boolean {
|
|
1096
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function inspectPrivateFile(
|
|
1100
|
+
path: string,
|
|
1101
|
+
maximumBytes: number,
|
|
1102
|
+
label: string,
|
|
1103
|
+
): Omit<PrivateFileEvidence, "sha256"> {
|
|
1104
|
+
const stats = (() => {
|
|
1105
|
+
try {
|
|
1106
|
+
return lstatSync(path, { bigint: true });
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
throw new Error(`${label} is unavailable or unsafe`, { cause: error });
|
|
1109
|
+
}
|
|
1110
|
+
})();
|
|
1111
|
+
if (
|
|
1112
|
+
!stats.isFile()
|
|
1113
|
+
|| stats.isSymbolicLink()
|
|
1114
|
+
|| !ownedByCurrentUser(stats.uid)
|
|
1115
|
+
|| (stats.mode & 0o077n) !== 0n
|
|
1116
|
+
|| stats.size < 1n
|
|
1117
|
+
|| stats.size > BigInt(maximumBytes)
|
|
1118
|
+
) throw new Error(`${label} is unavailable or unsafe`);
|
|
1119
|
+
return {
|
|
1120
|
+
device: stats.dev.toString(),
|
|
1121
|
+
inode: stats.ino.toString(),
|
|
1122
|
+
byteLength: Number(stats.size),
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function samePrivateFile(
|
|
1127
|
+
left: Pick<PrivateFileEvidence, "device" | "inode" | "byteLength">,
|
|
1128
|
+
right: Pick<PrivateFileEvidence, "device" | "inode" | "byteLength">,
|
|
1129
|
+
): boolean {
|
|
1130
|
+
return left.device === right.device
|
|
1131
|
+
&& left.inode === right.inode
|
|
1132
|
+
&& left.byteLength === right.byteLength;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function readStablePrivateFileEvidence(
|
|
1136
|
+
path: string,
|
|
1137
|
+
maximumBytes: number,
|
|
1138
|
+
label: string,
|
|
1139
|
+
parentIdentity: DirectoryIdentity,
|
|
1140
|
+
): { readonly text: string; readonly evidence: PrivateFileEvidence } {
|
|
1141
|
+
const before = inspectPrivateFile(path, maximumBytes, label);
|
|
1142
|
+
const text = readRegularFile(path, maximumBytes, label, parentIdentity);
|
|
1143
|
+
const after = inspectPrivateFile(path, maximumBytes, label);
|
|
1144
|
+
if (!samePrivateFile(before, after) || Buffer.byteLength(text, "utf8") !== before.byteLength) {
|
|
1145
|
+
throw new Error(`${label} changed while it was being read`);
|
|
1146
|
+
}
|
|
1147
|
+
return { text, evidence: { ...before, sha256: sha256(text) } };
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function readyMarkerPath(session: DerivationSession): string {
|
|
1151
|
+
return join(session.directory, derivationReadyMarkerName);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function initializationMarkerPath(directory: string): string {
|
|
1155
|
+
return join(directory, derivationInitializationMarkerName);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function phaseMarkerPath(directory: string): string {
|
|
1159
|
+
return join(directory, derivationPhaseMarkerName);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function parseReadyDerivation(value: unknown): ReadyDerivation {
|
|
1163
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1164
|
+
throw new Error("derivation ready marker is malformed");
|
|
1165
|
+
}
|
|
1166
|
+
const record = value as Record<string, unknown>;
|
|
1167
|
+
const metadata = record.metadata;
|
|
1168
|
+
if (
|
|
1169
|
+
Object.keys(record).sort().join(",") !== "metadata,schemaVersion,state"
|
|
1170
|
+
|| record.schemaVersion !== 1
|
|
1171
|
+
|| record.state !== "ready"
|
|
1172
|
+
|| typeof metadata !== "object"
|
|
1173
|
+
|| metadata === null
|
|
1174
|
+
|| Array.isArray(metadata)
|
|
1175
|
+
) throw new Error("derivation ready marker is malformed");
|
|
1176
|
+
const evidence = metadata as Record<string, unknown>;
|
|
1177
|
+
if (
|
|
1178
|
+
Object.keys(evidence).sort().join(",") !== "byteLength,device,inode,sha256"
|
|
1179
|
+
|| typeof evidence.device !== "string"
|
|
1180
|
+
|| !/^\d{1,40}$/u.test(evidence.device)
|
|
1181
|
+
|| typeof evidence.inode !== "string"
|
|
1182
|
+
|| !/^\d{1,40}$/u.test(evidence.inode)
|
|
1183
|
+
|| !Number.isSafeInteger(evidence.byteLength)
|
|
1184
|
+
|| (evidence.byteLength as number) < 1
|
|
1185
|
+
|| (evidence.byteLength as number) > 64 * 1024
|
|
1186
|
+
|| typeof evidence.sha256 !== "string"
|
|
1187
|
+
|| !/^[a-f0-9]{64}$/u.test(evidence.sha256)
|
|
1188
|
+
) throw new Error("derivation ready marker is malformed");
|
|
1189
|
+
return {
|
|
1190
|
+
schemaVersion: 1,
|
|
1191
|
+
state: "ready",
|
|
1192
|
+
metadata: {
|
|
1193
|
+
device: evidence.device,
|
|
1194
|
+
inode: evidence.inode,
|
|
1195
|
+
byteLength: evidence.byteLength as number,
|
|
1196
|
+
sha256: evidence.sha256,
|
|
1197
|
+
},
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function readDerivationReady(
|
|
1202
|
+
session: DerivationSession,
|
|
1203
|
+
metadataEvidence: PrivateFileEvidence,
|
|
1204
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
1205
|
+
): boolean {
|
|
1206
|
+
const entry = sessionEntry(session, derivationReadyMarkerName, environment);
|
|
1207
|
+
if (entry === undefined) return false;
|
|
1208
|
+
if (entry.kind !== "file") throw new Error("derivation ready marker is not a regular file");
|
|
1209
|
+
const text = readRegularFile(
|
|
1210
|
+
readyMarkerPath(session),
|
|
1211
|
+
64 * 1024,
|
|
1212
|
+
"derivation ready marker",
|
|
1213
|
+
session.directoryIdentity,
|
|
1214
|
+
);
|
|
1215
|
+
let marker: ReadyDerivation;
|
|
1216
|
+
try {
|
|
1217
|
+
marker = parseReadyDerivation(JSON.parse(text) as unknown);
|
|
1218
|
+
} catch (error) {
|
|
1219
|
+
if (error instanceof SyntaxError) throw new Error("derivation ready marker is malformed", { cause: error });
|
|
1220
|
+
throw error;
|
|
1221
|
+
}
|
|
1222
|
+
if (
|
|
1223
|
+
!samePrivateFile(marker.metadata, metadataEvidence)
|
|
1224
|
+
|| marker.metadata.sha256 !== metadataEvidence.sha256
|
|
1225
|
+
) throw new Error("derivation ready marker does not match its final session metadata");
|
|
1226
|
+
const currentMetadata = inspectPrivateFile(metadataPath(session.id, environment), 64 * 1024, "derivation session metadata");
|
|
1227
|
+
if (!samePrivateFile(currentMetadata, metadataEvidence)) {
|
|
1228
|
+
throw new Error("derivation session metadata changed after readiness was checked");
|
|
1229
|
+
}
|
|
1230
|
+
return true;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function inspectSessionSocket(
|
|
1234
|
+
session: DerivationSession,
|
|
1235
|
+
policy: "required" | "allow-missing",
|
|
1236
|
+
): boolean {
|
|
1237
|
+
let identity: DirectoryIdentity;
|
|
1238
|
+
try {
|
|
1239
|
+
identity = inspectDirectoryIdentity(session.socketDirectory);
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
if (hasErrorCode(error, "ENOENT")) {
|
|
1242
|
+
if (policy === "allow-missing") return false;
|
|
1243
|
+
throw new Error("derivation socket directory is unavailable; only list or discard can recover this session", { cause: error });
|
|
1244
|
+
}
|
|
1245
|
+
throw error;
|
|
1246
|
+
}
|
|
1247
|
+
if (!sameDirectoryIdentity(identity, session.socketIdentity)) {
|
|
1248
|
+
throw new Error("derivation socket directory changed identity");
|
|
1249
|
+
}
|
|
1250
|
+
return true;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function loadSessionWithSocketPolicy(
|
|
1254
|
+
id: string,
|
|
1255
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
1256
|
+
socketPolicy: "required" | "allow-missing",
|
|
1257
|
+
readyPolicy: "required" | "allow-missing" = "required",
|
|
1258
|
+
): {
|
|
1259
|
+
readonly session: DerivationSession;
|
|
1260
|
+
readonly socketAvailable: boolean;
|
|
1261
|
+
readonly ready: boolean;
|
|
1262
|
+
readonly metadataEvidence: PrivateFileEvidence;
|
|
1263
|
+
} {
|
|
1264
|
+
const directory = derivationDirectory(id, environment);
|
|
1265
|
+
const directoryIdentity = inspectDirectoryIdentity(directory);
|
|
1266
|
+
const metadata = readStablePrivateFileEvidence(
|
|
1267
|
+
metadataPath(id, environment),
|
|
1268
|
+
64 * 1024,
|
|
1269
|
+
"derivation session metadata",
|
|
1270
|
+
directoryIdentity,
|
|
1271
|
+
);
|
|
1272
|
+
let metadataValue: unknown;
|
|
1273
|
+
try {
|
|
1274
|
+
metadataValue = JSON.parse(metadata.text) as unknown;
|
|
1275
|
+
} catch (error) {
|
|
1276
|
+
throw new Error("derivation session metadata is malformed", { cause: error });
|
|
1277
|
+
}
|
|
1278
|
+
const session = parseSession(metadataValue);
|
|
1279
|
+
const expectedProfiles = new Set([join(directory, "profile"), join(directory, "profile-user-data")]);
|
|
1280
|
+
const namedProfile = session.profilePath !== null && isSafeNamedProfile(session.profilePath);
|
|
1281
|
+
if (
|
|
1282
|
+
session.id !== id
|
|
1283
|
+
|| session.directory !== directory
|
|
1284
|
+
|| session.socketDirectory !== expectedSocketDirectory(id)
|
|
1285
|
+
|| session.configPath !== join(directory, "agent-browser.json")
|
|
1286
|
+
|| session.policyPath !== join(directory, "action-policy.json")
|
|
1287
|
+
|| (session.profilePath !== null && !expectedProfiles.has(session.profilePath) && !namedProfile)
|
|
1288
|
+
) {
|
|
1289
|
+
throw new Error("derivation session paths do not match its ID");
|
|
1290
|
+
}
|
|
1291
|
+
if (!sameDirectoryIdentity(directoryIdentity, session.directoryIdentity)) {
|
|
1292
|
+
throw new Error("derivation session directory changed identity");
|
|
1293
|
+
}
|
|
1294
|
+
const ready = readDerivationReady(session, metadata.evidence, environment);
|
|
1295
|
+
if (!ready && readyPolicy === "required") {
|
|
1296
|
+
throw new Error("derivation is not ready; only list or discard can recover this interrupted session");
|
|
1297
|
+
}
|
|
1298
|
+
if (ready) {
|
|
1299
|
+
const phase = readDerivationDirectoryPhase(id, directory, directoryIdentity, environment);
|
|
1300
|
+
const initialization = readDerivationInitialization(id, directory, directoryIdentity, environment);
|
|
1301
|
+
if (
|
|
1302
|
+
!sameDirectoryIdentity(phase.directoryIdentity, session.directoryIdentity)
|
|
1303
|
+
|| !sameDirectoryIdentity(initialization.directoryIdentity, session.directoryIdentity)
|
|
1304
|
+
|| !sameDirectoryIdentity(initialization.socketIdentity, session.socketIdentity)
|
|
1305
|
+
|| initialization.socketDirectory !== session.socketDirectory
|
|
1306
|
+
) throw new Error("ready derivation does not match its initialization boundary");
|
|
1307
|
+
}
|
|
1308
|
+
const socketAvailable = inspectSessionSocket(session, socketPolicy);
|
|
1309
|
+
readRegularFile(session.configPath, 64 * 1024, "derivation browser config", session.directoryIdentity);
|
|
1310
|
+
readRegularFile(session.policyPath, 64 * 1024, "derivation action policy", session.directoryIdentity);
|
|
1311
|
+
if (session.profilePath === null && session.schemaVersion === 2) {
|
|
1312
|
+
readRegularFile(
|
|
1313
|
+
join(session.directory, derivationDnrPolicyFileName),
|
|
1314
|
+
64 * 1024,
|
|
1315
|
+
"derivation DNR readiness policy",
|
|
1316
|
+
session.directoryIdentity,
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
return { session, socketAvailable, ready, metadataEvidence: metadata.evidence };
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function loadSession(id: string, environment: Readonly<Record<string, string | undefined>>): DerivationSession {
|
|
1323
|
+
const session = loadSessionWithSocketPolicy(id, environment, "required", "required").session;
|
|
1324
|
+
if (
|
|
1325
|
+
session.profilePath === null
|
|
1326
|
+
&& (session.schemaVersion !== 2 || session.networkGuard === null || session.networkGuard === undefined)
|
|
1327
|
+
) {
|
|
1328
|
+
throw new Error(
|
|
1329
|
+
"legacy contained derivation lacks the bound network guard; only list or discard may recover it",
|
|
1330
|
+
);
|
|
1331
|
+
}
|
|
1332
|
+
return session;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function containedNetworkGuard(session: DerivationSession): DerivationNetworkGuard | null {
|
|
1336
|
+
if (session.profilePath !== null) return null;
|
|
1337
|
+
if (
|
|
1338
|
+
session.schemaVersion !== 2
|
|
1339
|
+
|| session.networkGuard === null
|
|
1340
|
+
|| session.networkGuard === undefined
|
|
1341
|
+
) throw new Error("contained derivation network guard is unavailable");
|
|
1342
|
+
return session.networkGuard;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
function publishDerivationReady(
|
|
1346
|
+
id: string,
|
|
1347
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
1348
|
+
): DerivationSession {
|
|
1349
|
+
const prepared = loadSessionWithSocketPolicy(id, environment, "required", "allow-missing");
|
|
1350
|
+
if (prepared.ready) throw new Error("derivation ready marker already exists");
|
|
1351
|
+
const marker: ReadyDerivation = {
|
|
1352
|
+
schemaVersion: 1,
|
|
1353
|
+
state: "ready",
|
|
1354
|
+
metadata: prepared.metadataEvidence,
|
|
1355
|
+
};
|
|
1356
|
+
const publication = createPrivateJsonIfAbsent(readyMarkerPath(prepared.session), marker, {
|
|
1357
|
+
environment,
|
|
1358
|
+
expectedStateParent: prepared.session.directoryIdentity,
|
|
1359
|
+
});
|
|
1360
|
+
if (!publication.created) throw new Error("derivation ready marker already exists");
|
|
1361
|
+
return loadSession(id, environment);
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
function reviewMarkerPath(session: DerivationSession): string {
|
|
1365
|
+
return join(session.directory, derivationReviewMarkerName);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function captureHarPath(session: DerivationSession): string {
|
|
1369
|
+
return join(session.directory, "capture.har");
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function sessionEntry(
|
|
1373
|
+
session: DerivationSession,
|
|
1374
|
+
name: string,
|
|
1375
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
1376
|
+
) {
|
|
1377
|
+
return listPrivateStateDirectory(session.directory, environment, session.directoryIdentity)
|
|
1378
|
+
.find((candidate) => candidate.name === name);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
function parseSealedDerivationReview(value: unknown): SealedDerivationReview {
|
|
1382
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1383
|
+
throw new Error("derivation review seal is malformed");
|
|
1384
|
+
}
|
|
1385
|
+
const record = value as Record<string, unknown>;
|
|
1386
|
+
const har = record.har;
|
|
1387
|
+
if (
|
|
1388
|
+
Object.keys(record).sort().join(",") !== "har,schemaVersion,state"
|
|
1389
|
+
|| record.schemaVersion !== 1
|
|
1390
|
+
|| record.state !== "sealed"
|
|
1391
|
+
|| typeof har !== "object"
|
|
1392
|
+
|| har === null
|
|
1393
|
+
|| Array.isArray(har)
|
|
1394
|
+
) throw new Error("derivation review seal is malformed");
|
|
1395
|
+
const harRecord = har as Record<string, unknown>;
|
|
1396
|
+
if (
|
|
1397
|
+
Object.keys(harRecord).sort().join(",") !== "byteLength,device,inode,sha256"
|
|
1398
|
+
|| typeof harRecord.device !== "string"
|
|
1399
|
+
|| !/^\d{1,40}$/u.test(harRecord.device)
|
|
1400
|
+
|| typeof harRecord.inode !== "string"
|
|
1401
|
+
|| !/^\d{1,40}$/u.test(harRecord.inode)
|
|
1402
|
+
|| !Number.isSafeInteger(harRecord.byteLength)
|
|
1403
|
+
|| (harRecord.byteLength as number) < 1
|
|
1404
|
+
|| (harRecord.byteLength as number) > MAX_HAR_BYTES
|
|
1405
|
+
|| typeof harRecord.sha256 !== "string"
|
|
1406
|
+
|| !/^[a-f0-9]{64}$/u.test(harRecord.sha256)
|
|
1407
|
+
) throw new Error("derivation review seal is malformed");
|
|
1408
|
+
return {
|
|
1409
|
+
schemaVersion: 1,
|
|
1410
|
+
state: "sealed",
|
|
1411
|
+
har: {
|
|
1412
|
+
device: harRecord.device,
|
|
1413
|
+
inode: harRecord.inode,
|
|
1414
|
+
byteLength: harRecord.byteLength as number,
|
|
1415
|
+
sha256: harRecord.sha256,
|
|
1416
|
+
},
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
function readReviewSeal(
|
|
1421
|
+
session: DerivationSession,
|
|
1422
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
1423
|
+
): SealedDerivationReview | null {
|
|
1424
|
+
const marker = sessionEntry(session, derivationReviewMarkerName, environment);
|
|
1425
|
+
if (marker === undefined) return null;
|
|
1426
|
+
if (marker.kind !== "file") throw new Error("derivation review seal is not a regular file");
|
|
1427
|
+
let value: unknown;
|
|
1428
|
+
try {
|
|
1429
|
+
value = JSON.parse(readRegularFile(
|
|
1430
|
+
reviewMarkerPath(session),
|
|
1431
|
+
64 * 1024,
|
|
1432
|
+
"derivation review seal",
|
|
1433
|
+
session.directoryIdentity,
|
|
1434
|
+
)) as unknown;
|
|
1435
|
+
} catch (error) {
|
|
1436
|
+
if (error instanceof SyntaxError) throw new Error("derivation review seal is malformed", { cause: error });
|
|
1437
|
+
throw error;
|
|
1438
|
+
}
|
|
1439
|
+
return parseSealedDerivationReview(value);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
function inspectCapturedHar(path: string): {
|
|
1443
|
+
readonly device: string;
|
|
1444
|
+
readonly inode: string;
|
|
1445
|
+
readonly byteLength: number;
|
|
1446
|
+
} {
|
|
1447
|
+
const stats = lstatSync(path, { bigint: true });
|
|
1448
|
+
if (
|
|
1449
|
+
!stats.isFile()
|
|
1450
|
+
|| stats.isSymbolicLink()
|
|
1451
|
+
|| !ownedByCurrentUser(stats.uid)
|
|
1452
|
+
|| stats.size < 1n
|
|
1453
|
+
|| stats.size > BigInt(MAX_HAR_BYTES)
|
|
1454
|
+
) throw new Error("derivation review HAR is unavailable or unsafe");
|
|
1455
|
+
return {
|
|
1456
|
+
device: stats.dev.toString(),
|
|
1457
|
+
inode: stats.ino.toString(),
|
|
1458
|
+
byteLength: Number(stats.size),
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function sameCapturedHar(
|
|
1463
|
+
left: { readonly device: string; readonly inode: string; readonly byteLength: number },
|
|
1464
|
+
right: { readonly device: string; readonly inode: string; readonly byteLength: number },
|
|
1465
|
+
): boolean {
|
|
1466
|
+
return left.device === right.device && left.inode === right.inode && left.byteLength === right.byteLength;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function readSealedReviewHar(
|
|
1470
|
+
session: DerivationSession,
|
|
1471
|
+
seal: SealedDerivationReview,
|
|
1472
|
+
): string {
|
|
1473
|
+
const path = captureHarPath(session);
|
|
1474
|
+
const before = inspectCapturedHar(path);
|
|
1475
|
+
if (!sameCapturedHar(before, seal.har)) throw new Error("sealed derivation review HAR changed identity or size");
|
|
1476
|
+
const text = readRegularFile(path, MAX_HAR_BYTES, "sealed derivation review HAR", session.directoryIdentity);
|
|
1477
|
+
const after = inspectCapturedHar(path);
|
|
1478
|
+
if (!sameCapturedHar(before, after) || Buffer.byteLength(text, "utf8") !== seal.har.byteLength || sha256(text) !== seal.har.sha256) {
|
|
1479
|
+
throw new Error("sealed derivation review HAR changed after it was sealed");
|
|
1480
|
+
}
|
|
1481
|
+
return text;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
function hasCapturedHar(
|
|
1485
|
+
session: DerivationSession,
|
|
1486
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
1487
|
+
): boolean {
|
|
1488
|
+
const entry = sessionEntry(session, "capture.har", environment);
|
|
1489
|
+
if (entry === undefined) return false;
|
|
1490
|
+
if (entry.kind !== "file") throw new Error("derivation capture HAR is not a regular file");
|
|
1491
|
+
return true;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
export type DerivationSummary = {
|
|
1495
|
+
readonly id: string;
|
|
1496
|
+
readonly adapterId?: string;
|
|
1497
|
+
readonly targetOrigin?: string;
|
|
1498
|
+
readonly createdAt?: string;
|
|
1499
|
+
readonly allowRemoteActions?: boolean;
|
|
1500
|
+
readonly contentMode?: HarContentMode;
|
|
1501
|
+
readonly headed?: boolean;
|
|
1502
|
+
readonly browserDomains?: readonly string[];
|
|
1503
|
+
readonly rawHarPresent?: boolean;
|
|
1504
|
+
readonly reviewSealed?: boolean;
|
|
1505
|
+
readonly socketAvailable?: boolean;
|
|
1506
|
+
readonly ready?: boolean;
|
|
1507
|
+
readonly recoverable?: boolean;
|
|
1508
|
+
readonly invalid?: true;
|
|
1509
|
+
};
|
|
1510
|
+
|
|
1511
|
+
export function listDerivations(
|
|
1512
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
1513
|
+
): readonly DerivationSummary[] {
|
|
1514
|
+
const directory = join(ghostgetStateHome(environment), "derivations");
|
|
1515
|
+
return listPrivateStateDirectory(directory, environment)
|
|
1516
|
+
.filter((entry) => entry.kind === "directory" && derivationIdPattern.test(entry.name))
|
|
1517
|
+
.map((entry): DerivationSummary => {
|
|
1518
|
+
try {
|
|
1519
|
+
const loaded = loadSessionWithSocketPolicy(entry.name, environment, "allow-missing", "allow-missing");
|
|
1520
|
+
const { session } = loaded;
|
|
1521
|
+
const hasBoundControlOwner = session.profilePath === null
|
|
1522
|
+
&& session.schemaVersion === 2
|
|
1523
|
+
&& session.networkGuard != null;
|
|
1524
|
+
if (!hasBoundControlOwner && !derivationControlEndpointIsAbsent(entry.name)) {
|
|
1525
|
+
return { id: entry.name, invalid: true };
|
|
1526
|
+
}
|
|
1527
|
+
return {
|
|
1528
|
+
id: session.id,
|
|
1529
|
+
adapterId: session.adapterId,
|
|
1530
|
+
targetOrigin: session.targetOrigin,
|
|
1531
|
+
createdAt: session.createdAt,
|
|
1532
|
+
allowRemoteActions: session.allowRemoteActions,
|
|
1533
|
+
contentMode: session.contentMode,
|
|
1534
|
+
headed: session.headed,
|
|
1535
|
+
browserDomains: session.browserDomains,
|
|
1536
|
+
rawHarPresent: listPrivateStateDirectory(session.directory, environment, session.directoryIdentity)
|
|
1537
|
+
.some((candidate) => candidate.kind === "file" && candidate.name === "capture.har"),
|
|
1538
|
+
reviewSealed: readReviewSeal(session, environment) !== null,
|
|
1539
|
+
socketAvailable: loaded.socketAvailable,
|
|
1540
|
+
ready: loaded.ready,
|
|
1541
|
+
...(!loaded.ready ? { recoverable: true } : {}),
|
|
1542
|
+
};
|
|
1543
|
+
} catch {
|
|
1544
|
+
try {
|
|
1545
|
+
if (entry.identity === undefined) throw new Error("derivation directory identity is unavailable");
|
|
1546
|
+
const sessionDirectory = join(directory, entry.name);
|
|
1547
|
+
const entries = listPrivateStateDirectory(
|
|
1548
|
+
sessionDirectory,
|
|
1549
|
+
environment,
|
|
1550
|
+
entry.identity,
|
|
1551
|
+
);
|
|
1552
|
+
const readyEntry = entries.find((candidate) => candidate.name === derivationReadyMarkerName);
|
|
1553
|
+
if (readyEntry !== undefined) throw new Error("ready derivation is malformed");
|
|
1554
|
+
const initializationEntry = entries
|
|
1555
|
+
.find((candidate) => candidate.name === derivationInitializationMarkerName);
|
|
1556
|
+
if (initializationEntry !== undefined) {
|
|
1557
|
+
const initialization = readDerivationInitialization(
|
|
1558
|
+
entry.name,
|
|
1559
|
+
sessionDirectory,
|
|
1560
|
+
entry.identity,
|
|
1561
|
+
environment,
|
|
1562
|
+
);
|
|
1563
|
+
assertNoUnboundDerivationControl(entry.name);
|
|
1564
|
+
return {
|
|
1565
|
+
id: entry.name,
|
|
1566
|
+
ready: false,
|
|
1567
|
+
recoverable: true,
|
|
1568
|
+
socketAvailable: inspectInitializationSocket(initialization),
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
const phaseEntry = entries.find((candidate) => candidate.name === derivationPhaseMarkerName);
|
|
1572
|
+
if (phaseEntry === undefined) {
|
|
1573
|
+
if (
|
|
1574
|
+
entries.length !== 0
|
|
1575
|
+
|| inspectUnknownInitializationSocket(entry.name)
|
|
1576
|
+
|| !derivationControlEndpointIsAbsent(entry.name)
|
|
1577
|
+
) {
|
|
1578
|
+
throw new Error("markerless derivation state is not safely recoverable");
|
|
1579
|
+
}
|
|
1580
|
+
return {
|
|
1581
|
+
id: entry.name,
|
|
1582
|
+
ready: false,
|
|
1583
|
+
recoverable: true,
|
|
1584
|
+
socketAvailable: false,
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
readDerivationDirectoryPhase(
|
|
1588
|
+
entry.name,
|
|
1589
|
+
sessionDirectory,
|
|
1590
|
+
entry.identity,
|
|
1591
|
+
environment,
|
|
1592
|
+
);
|
|
1593
|
+
assertNoUnboundDerivationControl(entry.name);
|
|
1594
|
+
return {
|
|
1595
|
+
id: entry.name,
|
|
1596
|
+
ready: false,
|
|
1597
|
+
recoverable: true,
|
|
1598
|
+
socketAvailable: inspectUnknownInitializationSocket(entry.name),
|
|
1599
|
+
};
|
|
1600
|
+
} catch {
|
|
1601
|
+
return { id: entry.name, invalid: true };
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
export function derivationGlobalArguments(session: DerivationSession): readonly string[] {
|
|
1608
|
+
const genericProfile = join(session.directory, "profile");
|
|
1609
|
+
const chromiumProfile = join(session.directory, "profile-user-data");
|
|
1610
|
+
const profile = session.profilePath === genericProfile
|
|
1611
|
+
? "./profile"
|
|
1612
|
+
: session.profilePath === chromiumProfile ? "./profile-user-data" : session.profilePath;
|
|
1613
|
+
const ownsChrome = session.profilePath === null
|
|
1614
|
+
|| session.profilePath === genericProfile
|
|
1615
|
+
|| session.profilePath === chromiumProfile;
|
|
1616
|
+
const guard = session.profilePath === null
|
|
1617
|
+
&& session.schemaVersion === 2
|
|
1618
|
+
&& session.networkGuard != null
|
|
1619
|
+
? session.networkGuard
|
|
1620
|
+
: null;
|
|
1621
|
+
const guardArguments = guard === null
|
|
1622
|
+
? []
|
|
1623
|
+
: ownedDerivationGuardBrowserArguments(
|
|
1624
|
+
`http://127.0.0.1:${guard.proxy.port}`,
|
|
1625
|
+
join(session.directory, DERIVATION_GUARD_EXTENSION_DIRECTORY),
|
|
1626
|
+
);
|
|
1627
|
+
return [
|
|
1628
|
+
"--session",
|
|
1629
|
+
session.sessionName,
|
|
1630
|
+
"--content-boundaries",
|
|
1631
|
+
"--max-output",
|
|
1632
|
+
String(5 * 1024 * 1024),
|
|
1633
|
+
...(session.headed ? ["--headed"] : []),
|
|
1634
|
+
...(session.browserExecutable === undefined
|
|
1635
|
+
? []
|
|
1636
|
+
: ["--executable-path", session.browserExecutable]),
|
|
1637
|
+
...(session.profilePath === null && guard === null
|
|
1638
|
+
? ["--allowed-domains", session.browserDomains.join(",")]
|
|
1639
|
+
: []),
|
|
1640
|
+
...guardArguments,
|
|
1641
|
+
...(profile === null ? [] : ["--profile", profile]),
|
|
1642
|
+
...(ownsChrome && guard === null
|
|
1643
|
+
? [
|
|
1644
|
+
"--args",
|
|
1645
|
+
ownedChromeLaunchArguments(
|
|
1646
|
+
session.profilePath === chromiumProfile
|
|
1647
|
+
? ["--profile-directory=Default"]
|
|
1648
|
+
: [],
|
|
1649
|
+
),
|
|
1650
|
+
]
|
|
1651
|
+
: []),
|
|
1652
|
+
];
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
export function derivationPinSessionName(session: Pick<DerivationSession, "id" | "sessionName">): string {
|
|
1656
|
+
const expectedOwner = `io-derive-${session.id.replaceAll("-", "").slice(0, 12)}`;
|
|
1657
|
+
if (session.sessionName !== expectedOwner) throw new Error("derivation browser owner session name is invalid");
|
|
1658
|
+
return `io-derive-pin-${session.id.replaceAll("-", "").slice(0, 12)}`;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
export function derivationExistingOwnerContextArguments(
|
|
1662
|
+
session: Pick<DerivationSession, "sessionName">,
|
|
1663
|
+
): readonly string[] {
|
|
1664
|
+
if (!/^io-derive-[a-f0-9]{12}$/u.test(session.sessionName)) {
|
|
1665
|
+
throw new Error("derivation browser owner session name is invalid");
|
|
1666
|
+
}
|
|
1667
|
+
return [
|
|
1668
|
+
"--session",
|
|
1669
|
+
session.sessionName,
|
|
1670
|
+
"--content-boundaries",
|
|
1671
|
+
"--max-output",
|
|
1672
|
+
String(5 * 1024 * 1024),
|
|
1673
|
+
"batch",
|
|
1674
|
+
"--bail",
|
|
1675
|
+
"--json",
|
|
1676
|
+
];
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
type DerivationBrowserPin = {
|
|
1680
|
+
readonly sessionName: string;
|
|
1681
|
+
readonly cdpUrl: string;
|
|
1682
|
+
readonly browserIdentity: DerivationGuardBrowserIdentity | null;
|
|
1683
|
+
readonly confirmationAction: string | null;
|
|
1684
|
+
readonly daemonOwner: ProcessOwnerIdentity | null;
|
|
1685
|
+
};
|
|
1686
|
+
|
|
1687
|
+
function containedBrowserMarkerPath(
|
|
1688
|
+
session: DerivationSession,
|
|
1689
|
+
kind: ContainedDerivationBrowserMarker["kind"],
|
|
1690
|
+
): string {
|
|
1691
|
+
return join(
|
|
1692
|
+
session.directory,
|
|
1693
|
+
kind === "io-derivation-contained-browser-owner"
|
|
1694
|
+
? derivationContainedBrowserOwnerMarkerName
|
|
1695
|
+
: derivationContainedBrowserPinMarkerName,
|
|
1696
|
+
);
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
function parseContainedBrowserProcessOwner(value: unknown): ProcessOwnerIdentity {
|
|
1700
|
+
const record = guardBrowserRecord(value, "contained derivation browser process owner");
|
|
1701
|
+
exactGuardBrowserKeys(
|
|
1702
|
+
record,
|
|
1703
|
+
["bootId", "pid", "processStartId"],
|
|
1704
|
+
"contained derivation browser process owner",
|
|
1705
|
+
);
|
|
1706
|
+
if (
|
|
1707
|
+
!Number.isSafeInteger(record.pid)
|
|
1708
|
+
|| (record.pid as number) < 1
|
|
1709
|
+
|| typeof record.bootId !== "string"
|
|
1710
|
+
|| !/^[a-f0-9]{64}$/u.test(record.bootId)
|
|
1711
|
+
|| typeof record.processStartId !== "string"
|
|
1712
|
+
|| !/^[a-f0-9]{64}$/u.test(record.processStartId)
|
|
1713
|
+
) throw new Error("contained derivation browser process owner is malformed");
|
|
1714
|
+
return {
|
|
1715
|
+
pid: record.pid as number,
|
|
1716
|
+
bootId: record.bootId,
|
|
1717
|
+
processStartId: record.processStartId,
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
function parseContainedBrowserMarker(
|
|
1722
|
+
value: unknown,
|
|
1723
|
+
session: DerivationSession,
|
|
1724
|
+
kind: ContainedDerivationBrowserMarker["kind"],
|
|
1725
|
+
): ContainedDerivationBrowserMarker {
|
|
1726
|
+
const record = guardBrowserRecord(value, "contained derivation browser marker");
|
|
1727
|
+
exactGuardBrowserKeys(record, [
|
|
1728
|
+
"browserIdentity",
|
|
1729
|
+
"cdpUrl",
|
|
1730
|
+
"daemonOwner",
|
|
1731
|
+
"derivationId",
|
|
1732
|
+
"kind",
|
|
1733
|
+
"schemaVersion",
|
|
1734
|
+
"sessionName",
|
|
1735
|
+
], "contained derivation browser marker");
|
|
1736
|
+
const expectedSessionName = kind === "io-derivation-contained-browser-owner"
|
|
1737
|
+
? session.sessionName
|
|
1738
|
+
: derivationPinSessionName(session);
|
|
1739
|
+
const browserIdentity = guardBrowserRecord(
|
|
1740
|
+
record.browserIdentity,
|
|
1741
|
+
"contained derivation browser identity",
|
|
1742
|
+
);
|
|
1743
|
+
exactGuardBrowserKeys(
|
|
1744
|
+
browserIdentity,
|
|
1745
|
+
["engine", "launchHash"],
|
|
1746
|
+
"contained derivation browser identity",
|
|
1747
|
+
);
|
|
1748
|
+
const parsedIdentity = guardBrowserLifecycle({
|
|
1749
|
+
effectiveLaunch: {
|
|
1750
|
+
browserLaunched: true,
|
|
1751
|
+
engine: browserIdentity.engine,
|
|
1752
|
+
launchHash: browserIdentity.launchHash,
|
|
1753
|
+
},
|
|
1754
|
+
launched: false,
|
|
1755
|
+
relaunchedBrowser: false,
|
|
1756
|
+
restartedBackground: false,
|
|
1757
|
+
restoreStatus: "not_configured",
|
|
1758
|
+
reused: true,
|
|
1759
|
+
saveStatus: "not_attempted",
|
|
1760
|
+
});
|
|
1761
|
+
if (
|
|
1762
|
+
record.schemaVersion !== 1
|
|
1763
|
+
|| record.kind !== kind
|
|
1764
|
+
|| record.derivationId !== session.id
|
|
1765
|
+
|| record.sessionName !== expectedSessionName
|
|
1766
|
+
) throw new Error("contained derivation browser marker is malformed");
|
|
1767
|
+
return {
|
|
1768
|
+
schemaVersion: 1,
|
|
1769
|
+
kind,
|
|
1770
|
+
derivationId: session.id,
|
|
1771
|
+
sessionName: expectedSessionName,
|
|
1772
|
+
cdpUrl: localBrowserCdpUrl(record.cdpUrl),
|
|
1773
|
+
browserIdentity: parsedIdentity,
|
|
1774
|
+
daemonOwner: parseContainedBrowserProcessOwner(record.daemonOwner),
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
function publishContainedBrowserMarker(
|
|
1779
|
+
session: DerivationSession,
|
|
1780
|
+
marker: ContainedDerivationBrowserMarker,
|
|
1781
|
+
): void {
|
|
1782
|
+
const path = containedBrowserMarkerPath(session, marker.kind);
|
|
1783
|
+
const publication = createPrivateJsonIfAbsent(path, marker, {
|
|
1784
|
+
expectedStateParent: session.directoryIdentity,
|
|
1785
|
+
});
|
|
1786
|
+
if (publication.created) return;
|
|
1787
|
+
const existing = parseContainedBrowserMarker(
|
|
1788
|
+
JSON.parse(readRegularFile(
|
|
1789
|
+
path,
|
|
1790
|
+
64 * 1024,
|
|
1791
|
+
"contained derivation browser marker",
|
|
1792
|
+
session.directoryIdentity,
|
|
1793
|
+
)) as unknown,
|
|
1794
|
+
session,
|
|
1795
|
+
marker.kind,
|
|
1796
|
+
);
|
|
1797
|
+
if (
|
|
1798
|
+
existing.cdpUrl !== marker.cdpUrl
|
|
1799
|
+
|| !sameGuardBrowserIdentity(existing.browserIdentity, marker.browserIdentity)
|
|
1800
|
+
|| existing.daemonOwner.pid !== marker.daemonOwner.pid
|
|
1801
|
+
|| existing.daemonOwner.bootId !== marker.daemonOwner.bootId
|
|
1802
|
+
|| existing.daemonOwner.processStartId !== marker.daemonOwner.processStartId
|
|
1803
|
+
) throw new Error("contained derivation browser marker changed identity");
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
async function runBoundAgentBrowser(
|
|
1807
|
+
session: DerivationSession,
|
|
1808
|
+
arguments_: readonly string[],
|
|
1809
|
+
options: {
|
|
1810
|
+
readonly timeoutMs: number;
|
|
1811
|
+
readonly maxOutputBytes: number;
|
|
1812
|
+
readonly stdin?: string;
|
|
1813
|
+
readonly codeOwnedBrowserRequest?:
|
|
1814
|
+
| { readonly kind: "initial-contained-launch" }
|
|
1815
|
+
| { readonly kind: "readiness-context" }
|
|
1816
|
+
| { readonly kind: "pin-context"; readonly pin: DerivationBrowserPin }
|
|
1817
|
+
| { readonly kind: "pinned-batch"; readonly pin: DerivationBrowserPin }
|
|
1818
|
+
| { readonly kind: "pinned-confirm"; readonly pin: DerivationBrowserPin }
|
|
1819
|
+
| { readonly kind: "profile-confirm" };
|
|
1820
|
+
},
|
|
1821
|
+
) {
|
|
1822
|
+
return runCommand(
|
|
1823
|
+
[
|
|
1824
|
+
process.execPath,
|
|
1825
|
+
"--no-env-file",
|
|
1826
|
+
"--no-install",
|
|
1827
|
+
"--no-macros",
|
|
1828
|
+
"--no-addons",
|
|
1829
|
+
`--config=${trustedBunConfigPath}`,
|
|
1830
|
+
deriveCommandHelperPath,
|
|
1831
|
+
],
|
|
1832
|
+
{
|
|
1833
|
+
cwd: session.directory,
|
|
1834
|
+
environment: { NODE_ENV: "production" },
|
|
1835
|
+
timeoutMs: options.timeoutMs + 5_000,
|
|
1836
|
+
maxOutputBytes: options.maxOutputBytes,
|
|
1837
|
+
stdin: JSON.stringify({
|
|
1838
|
+
schemaVersion: 1,
|
|
1839
|
+
requestId: crypto.randomUUID(),
|
|
1840
|
+
expectedDirectory: session.directoryIdentity,
|
|
1841
|
+
socketDirectory: session.socketDirectory,
|
|
1842
|
+
expectedSocketDirectory: session.socketIdentity,
|
|
1843
|
+
ownerSessionName: session.sessionName,
|
|
1844
|
+
allowRemoteActions: session.allowRemoteActions,
|
|
1845
|
+
allowFixtureUpload: session.fixtures.length > 0,
|
|
1846
|
+
guardedBrowserConfig: session.profilePath === null
|
|
1847
|
+
&& session.schemaVersion === 2
|
|
1848
|
+
&& session.networkGuard != null
|
|
1849
|
+
&& options.codeOwnedBrowserRequest?.kind !== "initial-contained-launch",
|
|
1850
|
+
codeOwnedBrowserRequest: options.codeOwnedBrowserRequest?.kind ?? "none",
|
|
1851
|
+
browserPin: options.codeOwnedBrowserRequest !== undefined && "pin" in options.codeOwnedBrowserRequest
|
|
1852
|
+
? options.codeOwnedBrowserRequest.pin
|
|
1853
|
+
: null,
|
|
1854
|
+
timeoutMs: options.timeoutMs,
|
|
1855
|
+
maxOutputBytes: options.maxOutputBytes,
|
|
1856
|
+
arguments: arguments_,
|
|
1857
|
+
browserStdin: options.stdin ?? null,
|
|
1858
|
+
}),
|
|
1859
|
+
},
|
|
1860
|
+
);
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
async function rawBatch(
|
|
1864
|
+
session: DerivationSession,
|
|
1865
|
+
commands: readonly (readonly string[])[],
|
|
1866
|
+
initialContainedLaunch = false,
|
|
1867
|
+
): Promise<readonly Record<string, unknown>[]> {
|
|
1868
|
+
if (
|
|
1869
|
+
initialContainedLaunch
|
|
1870
|
+
&& JSON.stringify(commands) !== JSON.stringify([["open", "about:blank"]])
|
|
1871
|
+
) throw new Error("initial contained derivation launch command changed shape");
|
|
1872
|
+
const result = await runBoundAgentBrowser(
|
|
1873
|
+
session,
|
|
1874
|
+
[...derivationGlobalArguments(session), "batch", "--bail", "--json"],
|
|
1875
|
+
{
|
|
1876
|
+
timeoutMs: 120_000,
|
|
1877
|
+
maxOutputBytes: 10 * 1024 * 1024,
|
|
1878
|
+
stdin: JSON.stringify(commands),
|
|
1879
|
+
...(initialContainedLaunch
|
|
1880
|
+
? { codeOwnedBrowserRequest: { kind: "initial-contained-launch" } as const }
|
|
1881
|
+
: {}),
|
|
1882
|
+
},
|
|
1883
|
+
);
|
|
1884
|
+
if (result.exitCode !== 0) throw agentBrowserFailure(result, "agent-browser batch");
|
|
1885
|
+
const parsed = parseLastJsonWithExactLaunchHashes(result.stdout);
|
|
1886
|
+
if (!Array.isArray(parsed) || parsed.length !== commands.length) throw new Error("agent-browser returned a malformed batch result");
|
|
1887
|
+
return parsed.map((entry) => {
|
|
1888
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new Error("agent-browser returned a malformed batch entry");
|
|
1889
|
+
const record = entry as Record<string, unknown>;
|
|
1890
|
+
if (record.success !== true) throw new Error(typeof record.error === "string" ? record.error : "agent-browser command failed");
|
|
1891
|
+
return record;
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
export class IndeterminateDerivationConfirmationError extends Error {
|
|
1896
|
+
constructor(kind: "pinned" | "profile" = "pinned") {
|
|
1897
|
+
super(
|
|
1898
|
+
`${kind} derivation browser confirmation is indeterminate: the mutation may have applied; do not retry it and reconcile the exact page state before any further mutation`,
|
|
1899
|
+
);
|
|
1900
|
+
this.name = "IndeterminateDerivationConfirmationError";
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
async function runProfileDerivationConfirmationOnce<T>(
|
|
1905
|
+
attempt: () => Promise<T>,
|
|
1906
|
+
): Promise<T> {
|
|
1907
|
+
try {
|
|
1908
|
+
return await attempt();
|
|
1909
|
+
} catch {
|
|
1910
|
+
throw new IndeterminateDerivationConfirmationError("profile");
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
export async function runPinnedDerivationConfirmationOnce<T>(
|
|
1915
|
+
attempt: () => Promise<T>,
|
|
1916
|
+
): Promise<T> {
|
|
1917
|
+
try {
|
|
1918
|
+
return await attempt();
|
|
1919
|
+
} catch {
|
|
1920
|
+
// From the instant the confirmation helper is invoked, an ordinary
|
|
1921
|
+
// failure cannot prove whether the pending mutation ran. Never retry the
|
|
1922
|
+
// helper here or expose its ambiguous failure as a safe pre-dispatch error.
|
|
1923
|
+
throw new IndeterminateDerivationConfirmationError();
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
export type ProfileDerivationConfirmation = {
|
|
1928
|
+
readonly action: string;
|
|
1929
|
+
readonly confirmationId: string;
|
|
1930
|
+
};
|
|
1931
|
+
|
|
1932
|
+
function profileDerivationExpectedConfirmationAction(
|
|
1933
|
+
command: readonly string[],
|
|
1934
|
+
): string {
|
|
1935
|
+
const action = command[0] === "find" ? command[3] : command[0];
|
|
1936
|
+
if (
|
|
1937
|
+
typeof action !== "string"
|
|
1938
|
+
|| !(derivationConfirmedPolicyActions as readonly string[]).includes(action)
|
|
1939
|
+
) {
|
|
1940
|
+
throw new Error("profile derivation browser confirmation changed shape");
|
|
1941
|
+
}
|
|
1942
|
+
return action;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
/**
|
|
1946
|
+
* Parse the one pending action returned by an uncontained profile session.
|
|
1947
|
+
* The public derivation grammar cannot issue `confirm`; only this exact,
|
|
1948
|
+
* code-owned continuation can consume the returned identifier.
|
|
1949
|
+
*/
|
|
1950
|
+
export function parseProfileDerivationConfirmation(
|
|
1951
|
+
value: unknown,
|
|
1952
|
+
command: readonly string[],
|
|
1953
|
+
): ProfileDerivationConfirmation {
|
|
1954
|
+
const expectedAction = profileDerivationExpectedConfirmationAction(command);
|
|
1955
|
+
if (!Array.isArray(value) || value.length !== 1) {
|
|
1956
|
+
throw new Error("profile derivation browser confirmation changed shape");
|
|
1957
|
+
}
|
|
1958
|
+
const record = guardBrowserRecord(value[0], "profile derivation browser result");
|
|
1959
|
+
exactGuardBrowserKeys(
|
|
1960
|
+
record,
|
|
1961
|
+
["command", "error", "result", "success"],
|
|
1962
|
+
"profile derivation browser result",
|
|
1963
|
+
);
|
|
1964
|
+
if (
|
|
1965
|
+
JSON.stringify(record.command) !== JSON.stringify(command)
|
|
1966
|
+
|| record.success !== true
|
|
1967
|
+
|| record.error !== null
|
|
1968
|
+
) throw new Error("profile derivation browser confirmation changed shape");
|
|
1969
|
+
const data = guardBrowserRecord(
|
|
1970
|
+
record.result,
|
|
1971
|
+
"profile derivation browser confirmation",
|
|
1972
|
+
);
|
|
1973
|
+
exactGuardBrowserKeys(
|
|
1974
|
+
data,
|
|
1975
|
+
["action", "confirmation_id", "confirmation_required"],
|
|
1976
|
+
"profile derivation browser confirmation",
|
|
1977
|
+
);
|
|
1978
|
+
if (
|
|
1979
|
+
data.confirmation_required !== true
|
|
1980
|
+
|| typeof data.confirmation_id !== "string"
|
|
1981
|
+
|| !/^r\d{1,6}$/u.test(data.confirmation_id)
|
|
1982
|
+
|| typeof data.action !== "string"
|
|
1983
|
+
|| data.action !== expectedAction
|
|
1984
|
+
) throw new Error("profile derivation browser confirmation changed shape");
|
|
1985
|
+
return {
|
|
1986
|
+
action: data.action,
|
|
1987
|
+
confirmationId: data.confirmation_id,
|
|
1988
|
+
};
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
export function parseProfileDerivationConfirmationResult(
|
|
1992
|
+
value: unknown,
|
|
1993
|
+
confirmation: ProfileDerivationConfirmation,
|
|
1994
|
+
): Record<string, unknown> {
|
|
1995
|
+
const root = guardBrowserRecord(
|
|
1996
|
+
value,
|
|
1997
|
+
"profile derivation browser confirmation result",
|
|
1998
|
+
);
|
|
1999
|
+
exactGuardBrowserKeys(
|
|
2000
|
+
root,
|
|
2001
|
+
["_boundary", "data", "error", "success"],
|
|
2002
|
+
"profile derivation browser confirmation result",
|
|
2003
|
+
);
|
|
2004
|
+
const boundary = guardBrowserRecord(
|
|
2005
|
+
root._boundary,
|
|
2006
|
+
"profile derivation browser confirmation boundary",
|
|
2007
|
+
);
|
|
2008
|
+
exactGuardBrowserKeys(
|
|
2009
|
+
boundary,
|
|
2010
|
+
["nonce", "origin"],
|
|
2011
|
+
"profile derivation browser confirmation boundary",
|
|
2012
|
+
);
|
|
2013
|
+
const data = guardBrowserRecord(
|
|
2014
|
+
root.data,
|
|
2015
|
+
"profile derivation browser confirmation data",
|
|
2016
|
+
);
|
|
2017
|
+
exactGuardBrowserKeys(
|
|
2018
|
+
data,
|
|
2019
|
+
["action", "confirmed", "lifecycle", "result"],
|
|
2020
|
+
"profile derivation browser confirmation data",
|
|
2021
|
+
);
|
|
2022
|
+
const result = guardBrowserRecord(
|
|
2023
|
+
data.result,
|
|
2024
|
+
"profile derivation browser confirmed result",
|
|
2025
|
+
);
|
|
2026
|
+
exactGuardBrowserKeys(
|
|
2027
|
+
result,
|
|
2028
|
+
["data", "id", "success"],
|
|
2029
|
+
"profile derivation browser confirmed result",
|
|
2030
|
+
);
|
|
2031
|
+
const confirmedData = guardBrowserRecord(
|
|
2032
|
+
result.data,
|
|
2033
|
+
"profile derivation browser confirmed data",
|
|
2034
|
+
);
|
|
2035
|
+
if (
|
|
2036
|
+
root.success !== true
|
|
2037
|
+
|| root.error !== null
|
|
2038
|
+
|| typeof boundary.nonce !== "string"
|
|
2039
|
+
|| !/^[a-f0-9]{32}$/u.test(boundary.nonce)
|
|
2040
|
+
|| boundary.origin !== "unknown"
|
|
2041
|
+
|| data.confirmed !== true
|
|
2042
|
+
|| data.action !== confirmation.action
|
|
2043
|
+
|| result.id !== confirmation.confirmationId
|
|
2044
|
+
|| result.success !== true
|
|
2045
|
+
|| !("lifecycle" in confirmedData)
|
|
2046
|
+
|| ["confirmation_required", "confirmation_id", "confirmed", "denied"].some(
|
|
2047
|
+
(key) => key in confirmedData,
|
|
2048
|
+
)
|
|
2049
|
+
) throw new Error("profile derivation browser confirmation did not complete exactly");
|
|
2050
|
+
try {
|
|
2051
|
+
exactGuardBrowserIdentity([
|
|
2052
|
+
guardBrowserLifecycle(data.lifecycle, false),
|
|
2053
|
+
guardBrowserLifecycle(confirmedData.lifecycle, false),
|
|
2054
|
+
]);
|
|
2055
|
+
} catch (error) {
|
|
2056
|
+
throw new Error(
|
|
2057
|
+
"profile derivation browser confirmation lifecycle changed identity",
|
|
2058
|
+
{ cause: error },
|
|
2059
|
+
);
|
|
2060
|
+
}
|
|
2061
|
+
return confirmedData;
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
export async function confirmProfileDerivationMutationOnce(
|
|
2065
|
+
pendingValue: unknown,
|
|
2066
|
+
command: readonly string[],
|
|
2067
|
+
invoke: (confirmation: ProfileDerivationConfirmation) => Promise<unknown>,
|
|
2068
|
+
): Promise<Record<string, unknown>> {
|
|
2069
|
+
const confirmation = parseProfileDerivationConfirmation(pendingValue, command);
|
|
2070
|
+
return runProfileDerivationConfirmationOnce(async () => parseProfileDerivationConfirmationResult(
|
|
2071
|
+
await invoke(confirmation),
|
|
2072
|
+
confirmation,
|
|
2073
|
+
));
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
async function profileRawBatch(
|
|
2077
|
+
session: DerivationSession,
|
|
2078
|
+
commands: readonly (readonly string[])[],
|
|
2079
|
+
onCommandCompleted?: (command: readonly string[]) => void,
|
|
2080
|
+
): Promise<readonly Record<string, unknown>[]> {
|
|
2081
|
+
const completed: Record<string, unknown>[] = [];
|
|
2082
|
+
for (const command of commands) {
|
|
2083
|
+
const records = await rawBatch(session, [command]);
|
|
2084
|
+
const record = records[0];
|
|
2085
|
+
if (record === undefined) {
|
|
2086
|
+
throw new Error("profile derivation browser returned a malformed command result");
|
|
2087
|
+
}
|
|
2088
|
+
completed.push(await completeProfileDerivationBatchCommand(
|
|
2089
|
+
record,
|
|
2090
|
+
command,
|
|
2091
|
+
async (confirmation) => {
|
|
2092
|
+
const result = await runBoundAgentBrowser(
|
|
2093
|
+
session,
|
|
2094
|
+
["confirm", confirmation.confirmationId, "--json"],
|
|
2095
|
+
{
|
|
2096
|
+
timeoutMs: 120_000,
|
|
2097
|
+
maxOutputBytes: 10 * 1024 * 1024,
|
|
2098
|
+
codeOwnedBrowserRequest: { kind: "profile-confirm" },
|
|
2099
|
+
},
|
|
2100
|
+
);
|
|
2101
|
+
if (result.exitCode !== 0) {
|
|
2102
|
+
throw agentBrowserFailure(result, "profile agent-browser confirmation");
|
|
2103
|
+
}
|
|
2104
|
+
return parseLastJsonWithExactLaunchHashes(result.stdout);
|
|
2105
|
+
},
|
|
2106
|
+
));
|
|
2107
|
+
onCommandCompleted?.(command);
|
|
2108
|
+
}
|
|
2109
|
+
return completed;
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
/** Deterministic profile-result seam used by the command runner and its safety tests. */
|
|
2113
|
+
export async function completeProfileDerivationBatchCommand(
|
|
2114
|
+
record: unknown,
|
|
2115
|
+
command: readonly string[],
|
|
2116
|
+
invokeConfirmation: (confirmation: ProfileDerivationConfirmation) => Promise<unknown>,
|
|
2117
|
+
): Promise<Record<string, unknown>> {
|
|
2118
|
+
const data = guardBrowserResult(record, command);
|
|
2119
|
+
const requiresConfirmation = commandCanMutate(command);
|
|
2120
|
+
if ("lifecycle" in data) {
|
|
2121
|
+
if (requiresConfirmation) {
|
|
2122
|
+
// A completed mutation means the remote effect may already exist even
|
|
2123
|
+
// though the required confirmation protocol was bypassed. Classify it
|
|
2124
|
+
// as one-shot indeterminate so no caller can safely retry it.
|
|
2125
|
+
throw new IndeterminateDerivationConfirmationError("profile");
|
|
2126
|
+
}
|
|
2127
|
+
guardBrowserLifecycle(data.lifecycle, false);
|
|
2128
|
+
return record as Record<string, unknown>;
|
|
2129
|
+
}
|
|
2130
|
+
if (!requiresConfirmation) {
|
|
2131
|
+
throw new Error("read-only profile derivation browser command requested confirmation");
|
|
2132
|
+
}
|
|
2133
|
+
const confirmedData = await confirmProfileDerivationMutationOnce(
|
|
2134
|
+
[record],
|
|
2135
|
+
command,
|
|
2136
|
+
invokeConfirmation,
|
|
2137
|
+
);
|
|
2138
|
+
return { command, error: null, result: confirmedData, success: true };
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
async function pinnedRawBatch(
|
|
2142
|
+
session: DerivationSession,
|
|
2143
|
+
commands: readonly (readonly string[])[],
|
|
2144
|
+
pin: DerivationBrowserPin & { readonly browserIdentity: DerivationGuardBrowserIdentity },
|
|
2145
|
+
expectedPageUrl: string,
|
|
2146
|
+
onCommandCompleted?: (command: readonly string[]) => void,
|
|
2147
|
+
): Promise<readonly Record<string, unknown>[]> {
|
|
2148
|
+
const completed: Record<string, unknown>[] = [];
|
|
2149
|
+
for (const command of commands) {
|
|
2150
|
+
const requestPin: DerivationBrowserPin = {
|
|
2151
|
+
...pin,
|
|
2152
|
+
confirmationAction: null,
|
|
2153
|
+
daemonOwner: null,
|
|
2154
|
+
};
|
|
2155
|
+
const result = await runBoundAgentBrowser(
|
|
2156
|
+
session,
|
|
2157
|
+
["batch", "--bail", "--json"],
|
|
2158
|
+
{
|
|
2159
|
+
timeoutMs: 120_000,
|
|
2160
|
+
maxOutputBytes: 10 * 1024 * 1024,
|
|
2161
|
+
stdin: JSON.stringify([command]),
|
|
2162
|
+
codeOwnedBrowserRequest: { kind: "pinned-batch", pin: requestPin },
|
|
2163
|
+
},
|
|
2164
|
+
);
|
|
2165
|
+
if (result.exitCode !== 0) throw agentBrowserFailure(result, "pinned agent-browser command");
|
|
2166
|
+
const parsed = parseLastJsonWithExactLaunchHashes(result.stdout);
|
|
2167
|
+
if (!Array.isArray(parsed) || parsed.length !== 1) {
|
|
2168
|
+
throw new Error("pinned agent-browser returned a malformed command result");
|
|
2169
|
+
}
|
|
2170
|
+
const record = guardBrowserRecord(parsed[0], "pinned derivation browser result");
|
|
2171
|
+
exactGuardBrowserKeys(record, ["command", "error", "result", "success"], "pinned derivation browser result");
|
|
2172
|
+
if (
|
|
2173
|
+
JSON.stringify(record.command) !== JSON.stringify(command)
|
|
2174
|
+
|| record.success !== true
|
|
2175
|
+
|| record.error !== null
|
|
2176
|
+
) throw new Error("pinned derivation browser command did not succeed exactly");
|
|
2177
|
+
const data = guardBrowserRecord(record.result, "pinned derivation browser command result");
|
|
2178
|
+
const requiresConfirmation = commandCanMutate(command);
|
|
2179
|
+
if (!("lifecycle" in data)) {
|
|
2180
|
+
if (!requiresConfirmation) {
|
|
2181
|
+
throw new Error("read-only pinned derivation browser command requested confirmation");
|
|
2182
|
+
}
|
|
2183
|
+
exactGuardBrowserKeys(
|
|
2184
|
+
data,
|
|
2185
|
+
["action", "confirmation_id", "confirmation_required"],
|
|
2186
|
+
"pinned derivation browser confirmation",
|
|
2187
|
+
);
|
|
2188
|
+
if (
|
|
2189
|
+
data.confirmation_required !== true
|
|
2190
|
+
|| typeof data.confirmation_id !== "string"
|
|
2191
|
+
|| !/^r\d{1,6}$/u.test(data.confirmation_id)
|
|
2192
|
+
|| typeof data.action !== "string"
|
|
2193
|
+
|| !(derivationConfirmedPolicyActions as readonly string[]).includes(data.action)
|
|
2194
|
+
) throw new Error("pinned derivation browser confirmation changed shape");
|
|
2195
|
+
const confirmationId = data.confirmation_id;
|
|
2196
|
+
const confirmationAction = data.action;
|
|
2197
|
+
const daemonOwner = await bindDerivationBrowserDaemon(
|
|
2198
|
+
session,
|
|
2199
|
+
pin.sessionName,
|
|
2200
|
+
pin.browserIdentity,
|
|
2201
|
+
);
|
|
2202
|
+
publishContainedBrowserMarker(session, {
|
|
2203
|
+
schemaVersion: 1,
|
|
2204
|
+
kind: "io-derivation-contained-browser-pin",
|
|
2205
|
+
derivationId: session.id,
|
|
2206
|
+
sessionName: pin.sessionName,
|
|
2207
|
+
cdpUrl: pin.cdpUrl,
|
|
2208
|
+
browserIdentity: pin.browserIdentity,
|
|
2209
|
+
daemonOwner,
|
|
2210
|
+
});
|
|
2211
|
+
await runDerivationDnrReadiness(session, {
|
|
2212
|
+
cdpUrl: pin.cdpUrl,
|
|
2213
|
+
currentUrl: expectedPageUrl,
|
|
2214
|
+
browserIdentity: pin.browserIdentity,
|
|
2215
|
+
}, false);
|
|
2216
|
+
const reboundOwner = await bindDerivationBrowserDaemon(
|
|
2217
|
+
session,
|
|
2218
|
+
pin.sessionName,
|
|
2219
|
+
pin.browserIdentity,
|
|
2220
|
+
);
|
|
2221
|
+
if (
|
|
2222
|
+
reboundOwner.pid !== daemonOwner.pid
|
|
2223
|
+
|| reboundOwner.bootId !== daemonOwner.bootId
|
|
2224
|
+
|| reboundOwner.processStartId !== daemonOwner.processStartId
|
|
2225
|
+
) throw new Error("pinned derivation daemon changed before confirmation");
|
|
2226
|
+
const confirmationPin: DerivationBrowserPin & {
|
|
2227
|
+
readonly browserIdentity: DerivationGuardBrowserIdentity;
|
|
2228
|
+
readonly daemonOwner: ProcessOwnerIdentity;
|
|
2229
|
+
} = {
|
|
2230
|
+
...pin,
|
|
2231
|
+
confirmationAction,
|
|
2232
|
+
daemonOwner,
|
|
2233
|
+
};
|
|
2234
|
+
const confirmedData = await runPinnedDerivationConfirmationOnce(async () => {
|
|
2235
|
+
const confirmationResult = await runBoundAgentBrowser(
|
|
2236
|
+
session,
|
|
2237
|
+
["confirm", confirmationId, "--json"],
|
|
2238
|
+
{
|
|
2239
|
+
timeoutMs: 120_000,
|
|
2240
|
+
maxOutputBytes: 10 * 1024 * 1024,
|
|
2241
|
+
codeOwnedBrowserRequest: { kind: "pinned-confirm", pin: confirmationPin },
|
|
2242
|
+
},
|
|
2243
|
+
);
|
|
2244
|
+
if (confirmationResult.exitCode !== 0) {
|
|
2245
|
+
throw agentBrowserFailure(confirmationResult, "pinned agent-browser confirmation");
|
|
2246
|
+
}
|
|
2247
|
+
const confirmationRoot = guardBrowserRecord(
|
|
2248
|
+
parseLastJsonWithExactLaunchHashes(confirmationResult.stdout),
|
|
2249
|
+
"pinned derivation browser confirmation result",
|
|
2250
|
+
);
|
|
2251
|
+
exactGuardBrowserKeys(
|
|
2252
|
+
confirmationRoot,
|
|
2253
|
+
["_boundary", "data", "error", "success"],
|
|
2254
|
+
"pinned derivation browser confirmation result",
|
|
2255
|
+
);
|
|
2256
|
+
const confirmationBoundary = guardBrowserRecord(
|
|
2257
|
+
confirmationRoot._boundary,
|
|
2258
|
+
"pinned derivation browser confirmation boundary",
|
|
2259
|
+
);
|
|
2260
|
+
exactGuardBrowserKeys(
|
|
2261
|
+
confirmationBoundary,
|
|
2262
|
+
["nonce", "origin"],
|
|
2263
|
+
"pinned derivation browser confirmation boundary",
|
|
2264
|
+
);
|
|
2265
|
+
const confirmationData = guardBrowserRecord(
|
|
2266
|
+
confirmationRoot.data,
|
|
2267
|
+
"pinned derivation browser confirmation data",
|
|
2268
|
+
);
|
|
2269
|
+
exactGuardBrowserKeys(
|
|
2270
|
+
confirmationData,
|
|
2271
|
+
["action", "confirmed", "lifecycle", "result"],
|
|
2272
|
+
"pinned derivation browser confirmation data",
|
|
2273
|
+
);
|
|
2274
|
+
const confirmedResult = guardBrowserRecord(
|
|
2275
|
+
confirmationData.result,
|
|
2276
|
+
"pinned derivation browser confirmed result",
|
|
2277
|
+
);
|
|
2278
|
+
exactGuardBrowserKeys(
|
|
2279
|
+
confirmedResult,
|
|
2280
|
+
["data", "id", "success"],
|
|
2281
|
+
"pinned derivation browser confirmed result",
|
|
2282
|
+
);
|
|
2283
|
+
const exactConfirmedData = guardBrowserRecord(
|
|
2284
|
+
confirmedResult.data,
|
|
2285
|
+
"pinned derivation browser confirmed data",
|
|
2286
|
+
);
|
|
2287
|
+
if (
|
|
2288
|
+
confirmationRoot.success !== true
|
|
2289
|
+
|| confirmationRoot.error !== null
|
|
2290
|
+
|| typeof confirmationBoundary.nonce !== "string"
|
|
2291
|
+
|| !/^[a-f0-9]{32}$/u.test(confirmationBoundary.nonce)
|
|
2292
|
+
|| confirmationBoundary.origin !== "unknown"
|
|
2293
|
+
|| confirmationData.confirmed !== true
|
|
2294
|
+
|| confirmationData.action !== confirmationAction
|
|
2295
|
+
|| confirmedResult.id !== confirmationId
|
|
2296
|
+
|| confirmedResult.success !== true
|
|
2297
|
+
|| !("lifecycle" in exactConfirmedData)
|
|
2298
|
+
|| ["confirmation_required", "confirmation_id", "confirmed", "denied"].some(
|
|
2299
|
+
(key) => key in exactConfirmedData,
|
|
2300
|
+
)
|
|
2301
|
+
) throw new Error("pinned derivation browser confirmation did not complete exactly");
|
|
2302
|
+
exactGuardBrowserIdentity([
|
|
2303
|
+
guardBrowserLifecycle(confirmationData.lifecycle, false),
|
|
2304
|
+
guardBrowserLifecycle(exactConfirmedData.lifecycle, false),
|
|
2305
|
+
], pin.browserIdentity);
|
|
2306
|
+
return exactConfirmedData;
|
|
2307
|
+
});
|
|
2308
|
+
completed.push({
|
|
2309
|
+
command,
|
|
2310
|
+
error: null,
|
|
2311
|
+
result: confirmedData,
|
|
2312
|
+
success: true,
|
|
2313
|
+
});
|
|
2314
|
+
onCommandCompleted?.(command);
|
|
2315
|
+
continue;
|
|
2316
|
+
}
|
|
2317
|
+
if (requiresConfirmation) {
|
|
2318
|
+
throw new IndeterminateDerivationConfirmationError();
|
|
2319
|
+
}
|
|
2320
|
+
exactGuardBrowserIdentity([guardBrowserLifecycle(data.lifecycle, false)], pin.browserIdentity);
|
|
2321
|
+
if (
|
|
2322
|
+
command[0] === "get"
|
|
2323
|
+
&& command[1] === "cdp-url"
|
|
2324
|
+
&& data.cdpUrl !== pin.cdpUrl
|
|
2325
|
+
) throw new Error("pinned derivation browser returned a different private CDP URL");
|
|
2326
|
+
completed.push(record);
|
|
2327
|
+
onCommandCompleted?.(command);
|
|
2328
|
+
}
|
|
2329
|
+
return completed;
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
export type DerivationGuardBrowserContext = {
|
|
2333
|
+
readonly cdpUrl: string;
|
|
2334
|
+
readonly currentUrl: string;
|
|
2335
|
+
readonly browserIdentity: DerivationGuardBrowserIdentity;
|
|
2336
|
+
};
|
|
2337
|
+
|
|
2338
|
+
export type DerivationGuardBrowserIdentity = {
|
|
2339
|
+
readonly engine: "chrome";
|
|
2340
|
+
readonly launchHash: string;
|
|
2341
|
+
};
|
|
2342
|
+
|
|
2343
|
+
export type DerivationPinnedBrowserContext = {
|
|
2344
|
+
readonly cdpUrl: string;
|
|
2345
|
+
readonly currentUrl: string;
|
|
2346
|
+
readonly browserIdentity: DerivationGuardBrowserIdentity;
|
|
2347
|
+
};
|
|
2348
|
+
|
|
2349
|
+
const maximumU64 = (1n << 64n) - 1n;
|
|
2350
|
+
|
|
2351
|
+
function guardBrowserRecord(value: unknown, label: string): Record<string, unknown> {
|
|
2352
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
2353
|
+
throw new Error(`${label} changed shape`);
|
|
2354
|
+
}
|
|
2355
|
+
return value as Record<string, unknown>;
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
function exactGuardBrowserKeys(
|
|
2359
|
+
value: Record<string, unknown>,
|
|
2360
|
+
expected: readonly string[],
|
|
2361
|
+
label: string,
|
|
2362
|
+
): void {
|
|
2363
|
+
const actual = Object.keys(value).sort();
|
|
2364
|
+
const sorted = [...expected].sort();
|
|
2365
|
+
if (actual.length !== sorted.length || actual.some((key, index) => key !== sorted[index])) {
|
|
2366
|
+
throw new Error(`${label} changed shape`);
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
function guardBrowserResult(
|
|
2371
|
+
value: unknown,
|
|
2372
|
+
expectedCommand: readonly string[],
|
|
2373
|
+
): Record<string, unknown> {
|
|
2374
|
+
const record = guardBrowserRecord(value, "derivation guard browser result");
|
|
2375
|
+
exactGuardBrowserKeys(record, ["command", "error", "result", "success"], "derivation guard browser result");
|
|
2376
|
+
if (
|
|
2377
|
+
JSON.stringify(record.command) !== JSON.stringify(expectedCommand)
|
|
2378
|
+
|| record.success !== true
|
|
2379
|
+
|| record.error !== null
|
|
2380
|
+
) throw new Error("derivation guard browser command did not succeed exactly");
|
|
2381
|
+
return guardBrowserRecord(record.result, "derivation guard browser command result");
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
function guardBrowserLifecycle(
|
|
2385
|
+
value: unknown,
|
|
2386
|
+
requireReusable = true,
|
|
2387
|
+
): DerivationGuardBrowserIdentity {
|
|
2388
|
+
const lifecycle = guardBrowserRecord(value, "derivation guard browser lifecycle");
|
|
2389
|
+
exactGuardBrowserKeys(lifecycle, [
|
|
2390
|
+
"effectiveLaunch",
|
|
2391
|
+
"launched",
|
|
2392
|
+
"relaunchedBrowser",
|
|
2393
|
+
"restartedBackground",
|
|
2394
|
+
"restoreStatus",
|
|
2395
|
+
"reused",
|
|
2396
|
+
"saveStatus",
|
|
2397
|
+
], "derivation guard browser lifecycle");
|
|
2398
|
+
const effectiveLaunch = guardBrowserRecord(
|
|
2399
|
+
lifecycle.effectiveLaunch,
|
|
2400
|
+
"derivation guard browser effective launch",
|
|
2401
|
+
);
|
|
2402
|
+
exactGuardBrowserKeys(
|
|
2403
|
+
effectiveLaunch,
|
|
2404
|
+
["browserLaunched", "engine", "launchHash"],
|
|
2405
|
+
"derivation guard browser effective launch",
|
|
2406
|
+
);
|
|
2407
|
+
if (
|
|
2408
|
+
effectiveLaunch.browserLaunched !== true
|
|
2409
|
+
|| effectiveLaunch.engine !== "chrome"
|
|
2410
|
+
|| typeof effectiveLaunch.launchHash !== "string"
|
|
2411
|
+
|| !/^(?:0|[1-9][0-9]{0,19})$/u.test(effectiveLaunch.launchHash)
|
|
2412
|
+
|| BigInt(effectiveLaunch.launchHash) > maximumU64
|
|
2413
|
+
|| lifecycle.launched !== false
|
|
2414
|
+
|| lifecycle.relaunchedBrowser !== false
|
|
2415
|
+
|| lifecycle.restartedBackground !== false
|
|
2416
|
+
|| (requireReusable ? lifecycle.reused !== true : typeof lifecycle.reused !== "boolean")
|
|
2417
|
+
|| lifecycle.restoreStatus !== "not_configured"
|
|
2418
|
+
|| lifecycle.saveStatus !== "not_attempted"
|
|
2419
|
+
) throw new Error("derivation guard browser lifecycle is not reusable");
|
|
2420
|
+
return {
|
|
2421
|
+
engine: "chrome",
|
|
2422
|
+
launchHash: effectiveLaunch.launchHash,
|
|
2423
|
+
};
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
function sameGuardBrowserIdentity(
|
|
2427
|
+
left: DerivationGuardBrowserIdentity,
|
|
2428
|
+
right: DerivationGuardBrowserIdentity,
|
|
2429
|
+
): boolean {
|
|
2430
|
+
return left.engine === right.engine && left.launchHash === right.launchHash;
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
function exactGuardBrowserIdentity(
|
|
2434
|
+
identities: readonly DerivationGuardBrowserIdentity[],
|
|
2435
|
+
expected?: DerivationGuardBrowserIdentity,
|
|
2436
|
+
): DerivationGuardBrowserIdentity {
|
|
2437
|
+
const first = identities[0];
|
|
2438
|
+
if (
|
|
2439
|
+
first === undefined
|
|
2440
|
+
|| identities.some((identity) => !sameGuardBrowserIdentity(identity, first))
|
|
2441
|
+
|| (expected !== undefined && !sameGuardBrowserIdentity(first, expected))
|
|
2442
|
+
) throw new Error("derivation guard browser lifecycle changed identity");
|
|
2443
|
+
return first;
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
function guardBrowserData(
|
|
2447
|
+
value: Record<string, unknown>,
|
|
2448
|
+
expected: readonly string[],
|
|
2449
|
+
label: string,
|
|
2450
|
+
): DerivationGuardBrowserIdentity {
|
|
2451
|
+
exactGuardBrowserKeys(value, [...expected, "lifecycle"], label);
|
|
2452
|
+
return guardBrowserLifecycle(value.lifecycle);
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
function guardBrowserPageUrl(
|
|
2456
|
+
value: unknown,
|
|
2457
|
+
browserDomains: readonly string[],
|
|
2458
|
+
): string {
|
|
2459
|
+
if (
|
|
2460
|
+
typeof value !== "string"
|
|
2461
|
+
|| value.length < 1
|
|
2462
|
+
|| value.length > 64 * 1024
|
|
2463
|
+
|| /[\0\r\n]/u.test(value)
|
|
2464
|
+
) throw new Error("derivation guard browser URL is invalid");
|
|
2465
|
+
if (value === "about:blank") return value;
|
|
2466
|
+
let url: URL;
|
|
2467
|
+
try {
|
|
2468
|
+
url = new URL(value);
|
|
2469
|
+
} catch {
|
|
2470
|
+
throw new Error("derivation guard browser URL is invalid");
|
|
2471
|
+
}
|
|
2472
|
+
if (
|
|
2473
|
+
url.protocol !== "https:"
|
|
2474
|
+
|| url.username !== ""
|
|
2475
|
+
|| url.password !== ""
|
|
2476
|
+
|| !browserDomainsCover(browserDomains, url.hostname.toLowerCase())
|
|
2477
|
+
) throw new Error("derivation guard browser URL is outside its exact policy");
|
|
2478
|
+
return value;
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
export function parseDerivationGuardBrowserContextResult(
|
|
2482
|
+
value: unknown,
|
|
2483
|
+
browserDomains: readonly string[],
|
|
2484
|
+
): DerivationGuardBrowserContext {
|
|
2485
|
+
if (!Array.isArray(value) || value.length !== 2) {
|
|
2486
|
+
throw new Error("derivation guard browser context changed shape");
|
|
2487
|
+
}
|
|
2488
|
+
const cdpData = guardBrowserResult(value[0], ["get", "cdp-url"]);
|
|
2489
|
+
const cdpIdentity = guardBrowserData(
|
|
2490
|
+
cdpData,
|
|
2491
|
+
["cdpUrl"],
|
|
2492
|
+
"derivation guard browser CDP result",
|
|
2493
|
+
);
|
|
2494
|
+
const urlData = guardBrowserResult(value[1], ["get", "url"]);
|
|
2495
|
+
const urlIdentity = guardBrowserData(
|
|
2496
|
+
urlData,
|
|
2497
|
+
["url"],
|
|
2498
|
+
"derivation guard browser URL result",
|
|
2499
|
+
);
|
|
2500
|
+
const currentUrl = guardBrowserPageUrl(urlData.url, browserDomains);
|
|
2501
|
+
const browserIdentity = exactGuardBrowserIdentity([
|
|
2502
|
+
cdpIdentity,
|
|
2503
|
+
urlIdentity,
|
|
2504
|
+
]);
|
|
2505
|
+
return {
|
|
2506
|
+
cdpUrl: localBrowserCdpUrl(cdpData.cdpUrl),
|
|
2507
|
+
currentUrl,
|
|
2508
|
+
browserIdentity,
|
|
2509
|
+
};
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
export function parseDerivationPinnedBrowserContextResult(
|
|
2513
|
+
value: unknown,
|
|
2514
|
+
browserDomains: readonly string[],
|
|
2515
|
+
): DerivationPinnedBrowserContext {
|
|
2516
|
+
if (!Array.isArray(value) || value.length !== 2) {
|
|
2517
|
+
throw new Error("pinned derivation browser context changed shape");
|
|
2518
|
+
}
|
|
2519
|
+
const cdpData = guardBrowserResult(value[0], ["get", "cdp-url"]);
|
|
2520
|
+
const cdpIdentity = guardBrowserData(
|
|
2521
|
+
cdpData,
|
|
2522
|
+
["cdpUrl"],
|
|
2523
|
+
"pinned derivation browser CDP result",
|
|
2524
|
+
);
|
|
2525
|
+
const urlData = guardBrowserResult(value[1], ["get", "url"]);
|
|
2526
|
+
const urlIdentity = guardBrowserData(
|
|
2527
|
+
urlData,
|
|
2528
|
+
["url"],
|
|
2529
|
+
"pinned derivation browser URL result",
|
|
2530
|
+
);
|
|
2531
|
+
return {
|
|
2532
|
+
cdpUrl: localBrowserCdpUrl(cdpData.cdpUrl),
|
|
2533
|
+
currentUrl: guardBrowserPageUrl(urlData.url, browserDomains),
|
|
2534
|
+
browserIdentity: exactGuardBrowserIdentity([cdpIdentity, urlIdentity]),
|
|
2535
|
+
};
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
async function privateDerivationGuardBrowserContext(
|
|
2539
|
+
session: DerivationSession,
|
|
2540
|
+
): Promise<DerivationGuardBrowserContext> {
|
|
2541
|
+
const commands = [["get", "cdp-url"], ["get", "url"]] as const;
|
|
2542
|
+
const result = await runBoundAgentBrowser(
|
|
2543
|
+
session,
|
|
2544
|
+
derivationExistingOwnerContextArguments(session),
|
|
2545
|
+
{
|
|
2546
|
+
timeoutMs: 120_000,
|
|
2547
|
+
maxOutputBytes: 10 * 1024 * 1024,
|
|
2548
|
+
stdin: JSON.stringify(commands),
|
|
2549
|
+
codeOwnedBrowserRequest: { kind: "readiness-context" },
|
|
2550
|
+
},
|
|
2551
|
+
);
|
|
2552
|
+
if (result.exitCode !== 0) throw new Error("derivation guard browser context command failed");
|
|
2553
|
+
return parseDerivationGuardBrowserContextResult(
|
|
2554
|
+
parseLastJsonWithExactLaunchHashes(result.stdout),
|
|
2555
|
+
session.browserDomains,
|
|
2556
|
+
);
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
|
|
2560
|
+
async function privateDerivationPinnedBrowserContext(
|
|
2561
|
+
session: DerivationSession,
|
|
2562
|
+
context: DerivationGuardBrowserContext,
|
|
2563
|
+
): Promise<DerivationPinnedBrowserContext> {
|
|
2564
|
+
const commands = [["get", "cdp-url"], ["get", "url"]] as const;
|
|
2565
|
+
const result = await runBoundAgentBrowser(
|
|
2566
|
+
session,
|
|
2567
|
+
["batch", "--bail", "--json"],
|
|
2568
|
+
{
|
|
2569
|
+
timeoutMs: 120_000,
|
|
2570
|
+
maxOutputBytes: 10 * 1024 * 1024,
|
|
2571
|
+
stdin: JSON.stringify(commands),
|
|
2572
|
+
codeOwnedBrowserRequest: {
|
|
2573
|
+
kind: "pin-context",
|
|
2574
|
+
pin: {
|
|
2575
|
+
sessionName: derivationPinSessionName(session),
|
|
2576
|
+
cdpUrl: context.cdpUrl,
|
|
2577
|
+
browserIdentity: null,
|
|
2578
|
+
confirmationAction: null,
|
|
2579
|
+
daemonOwner: null,
|
|
2580
|
+
},
|
|
2581
|
+
},
|
|
2582
|
+
},
|
|
2583
|
+
);
|
|
2584
|
+
if (result.exitCode !== 0) throw new Error("pinned derivation browser context command failed");
|
|
2585
|
+
const pinned = parseDerivationPinnedBrowserContextResult(
|
|
2586
|
+
parseLastJsonWithExactLaunchHashes(result.stdout),
|
|
2587
|
+
session.browserDomains,
|
|
2588
|
+
);
|
|
2589
|
+
if (pinned.currentUrl !== context.currentUrl) {
|
|
2590
|
+
throw new Error("pinned derivation browser page changed during guard verification");
|
|
2591
|
+
}
|
|
2592
|
+
if (pinned.cdpUrl !== context.cdpUrl) {
|
|
2593
|
+
throw new Error("pinned derivation browser endpoint changed during guard verification");
|
|
2594
|
+
}
|
|
2595
|
+
const daemonOwner = await bindDerivationBrowserDaemon(
|
|
2596
|
+
session,
|
|
2597
|
+
derivationPinSessionName(session),
|
|
2598
|
+
pinned.browserIdentity,
|
|
2599
|
+
);
|
|
2600
|
+
publishContainedBrowserMarker(session, {
|
|
2601
|
+
schemaVersion: 1,
|
|
2602
|
+
kind: "io-derivation-contained-browser-pin",
|
|
2603
|
+
derivationId: session.id,
|
|
2604
|
+
sessionName: derivationPinSessionName(session),
|
|
2605
|
+
cdpUrl: pinned.cdpUrl,
|
|
2606
|
+
browserIdentity: pinned.browserIdentity,
|
|
2607
|
+
daemonOwner,
|
|
2608
|
+
});
|
|
2609
|
+
return pinned;
|
|
2610
|
+
}
|
|
2611
|
+
async function runDerivationDnrReadiness(
|
|
2612
|
+
session: DerivationSession,
|
|
2613
|
+
context: DerivationGuardBrowserContext,
|
|
2614
|
+
initialize: boolean,
|
|
2615
|
+
): Promise<void> {
|
|
2616
|
+
await (initialize ? initializeDerivationDnrReadiness : verifyDerivationDnrReadiness)(
|
|
2617
|
+
context.cdpUrl,
|
|
2618
|
+
context.currentUrl,
|
|
2619
|
+
session.browserDomains,
|
|
2620
|
+
);
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
async function verifyContainedNetworkGuard(
|
|
2624
|
+
session: DerivationSession,
|
|
2625
|
+
): Promise<DerivationGuardBrowserContext | null> {
|
|
2626
|
+
const guard = containedNetworkGuard(session);
|
|
2627
|
+
if (guard === null) return null;
|
|
2628
|
+
await verifyDerivationNetworkBoundary({
|
|
2629
|
+
derivationId: session.id,
|
|
2630
|
+
directory: session.directory,
|
|
2631
|
+
directoryIdentity: session.directoryIdentity,
|
|
2632
|
+
socketDirectory: session.socketDirectory,
|
|
2633
|
+
socketIdentity: session.socketIdentity,
|
|
2634
|
+
browserDomains: session.browserDomains,
|
|
2635
|
+
guard,
|
|
2636
|
+
});
|
|
2637
|
+
const context = await privateDerivationGuardBrowserContext(session);
|
|
2638
|
+
await runDerivationDnrReadiness(session, context, false);
|
|
2639
|
+
return context;
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
async function initializeContainedNetworkGuard(session: DerivationSession): Promise<void> {
|
|
2643
|
+
const guard = containedNetworkGuard(session);
|
|
2644
|
+
if (guard === null) return;
|
|
2645
|
+
await verifyDerivationNetworkBoundary({
|
|
2646
|
+
derivationId: session.id,
|
|
2647
|
+
directory: session.directory,
|
|
2648
|
+
directoryIdentity: session.directoryIdentity,
|
|
2649
|
+
socketDirectory: session.socketDirectory,
|
|
2650
|
+
socketIdentity: session.socketIdentity,
|
|
2651
|
+
browserDomains: session.browserDomains,
|
|
2652
|
+
guard,
|
|
2653
|
+
});
|
|
2654
|
+
await rawBatch(session, [["open", "about:blank"]], true);
|
|
2655
|
+
writePrivateJson(session.configPath, derivationBrowserConfig(true));
|
|
2656
|
+
const context = await privateDerivationGuardBrowserContext(session);
|
|
2657
|
+
const daemonOwner = await bindDerivationBrowserDaemon(
|
|
2658
|
+
session,
|
|
2659
|
+
session.sessionName,
|
|
2660
|
+
context.browserIdentity,
|
|
2661
|
+
);
|
|
2662
|
+
publishContainedBrowserMarker(session, {
|
|
2663
|
+
schemaVersion: 1,
|
|
2664
|
+
kind: "io-derivation-contained-browser-owner",
|
|
2665
|
+
derivationId: session.id,
|
|
2666
|
+
sessionName: session.sessionName,
|
|
2667
|
+
cdpUrl: context.cdpUrl,
|
|
2668
|
+
browserIdentity: context.browserIdentity,
|
|
2669
|
+
daemonOwner,
|
|
2670
|
+
});
|
|
2671
|
+
await runDerivationDnrReadiness(session, context, true);
|
|
2672
|
+
await verifyContainedNetworkGuard(session);
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
async function batch(
|
|
2676
|
+
session: DerivationSession,
|
|
2677
|
+
commands: readonly (readonly string[])[],
|
|
2678
|
+
onCommandCompleted?: (command: readonly string[]) => void,
|
|
2679
|
+
): Promise<readonly Record<string, unknown>[]> {
|
|
2680
|
+
const context = await verifyContainedNetworkGuard(session);
|
|
2681
|
+
if (context === null) {
|
|
2682
|
+
// Unguarded legacy sessions use the same exact pending-confirmation
|
|
2683
|
+
// protocol as profile-backed sessions. Never let a raw mutating batch
|
|
2684
|
+
// bypass that parser merely because it has no selected profile path.
|
|
2685
|
+
return profileRawBatch(session, commands, onCommandCompleted);
|
|
2686
|
+
}
|
|
2687
|
+
const pinContext = await privateDerivationPinnedBrowserContext(session, context);
|
|
2688
|
+
return pinnedRawBatch(session, commands, {
|
|
2689
|
+
sessionName: derivationPinSessionName(session),
|
|
2690
|
+
cdpUrl: context.cdpUrl,
|
|
2691
|
+
browserIdentity: pinContext.browserIdentity,
|
|
2692
|
+
confirmationAction: null,
|
|
2693
|
+
daemonOwner: null,
|
|
2694
|
+
}, pinContext.currentUrl, onCommandCompleted);
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
function parseActiveDerivationBrowserDaemon(
|
|
2698
|
+
value: unknown,
|
|
2699
|
+
session: DerivationSession,
|
|
2700
|
+
sessionName: string,
|
|
2701
|
+
expectedBrowser: DerivationGuardBrowserIdentity,
|
|
2702
|
+
): number {
|
|
2703
|
+
const root = guardBrowserRecord(value, "derivation browser daemon result");
|
|
2704
|
+
exactGuardBrowserKeys(root, ["data", "success"], "derivation browser daemon result");
|
|
2705
|
+
const data = guardBrowserRecord(root.data, "derivation browser daemon data");
|
|
2706
|
+
exactGuardBrowserKeys(data, [
|
|
2707
|
+
"active",
|
|
2708
|
+
"namespace",
|
|
2709
|
+
"pid",
|
|
2710
|
+
"runtime",
|
|
2711
|
+
"runtimeError",
|
|
2712
|
+
"session",
|
|
2713
|
+
"socketDir",
|
|
2714
|
+
"version",
|
|
2715
|
+
], "derivation browser daemon data");
|
|
2716
|
+
const runtime = guardBrowserRecord(data.runtime, "derivation browser daemon runtime");
|
|
2717
|
+
exactGuardBrowserKeys(runtime, [
|
|
2718
|
+
"backgroundPid",
|
|
2719
|
+
"browserLaunched",
|
|
2720
|
+
"compatibilityStatus",
|
|
2721
|
+
"effectiveLaunch",
|
|
2722
|
+
"engine",
|
|
2723
|
+
"launchHash",
|
|
2724
|
+
"lifecycle",
|
|
2725
|
+
"namespace",
|
|
2726
|
+
"pageCount",
|
|
2727
|
+
"restoreCheckFn",
|
|
2728
|
+
"restoreCheckText",
|
|
2729
|
+
"restoreCheckUrl",
|
|
2730
|
+
"restoreKey",
|
|
2731
|
+
"restoreLoadedPath",
|
|
2732
|
+
"restoreSave",
|
|
2733
|
+
"restoreSavedPath",
|
|
2734
|
+
"restoreStatus",
|
|
2735
|
+
"restoreStatusDetail",
|
|
2736
|
+
"restoreValidationPending",
|
|
2737
|
+
"saveStatus",
|
|
2738
|
+
"session",
|
|
2739
|
+
"socketDir",
|
|
2740
|
+
], "derivation browser daemon runtime");
|
|
2741
|
+
const effectiveLaunch = guardBrowserRecord(
|
|
2742
|
+
runtime.effectiveLaunch,
|
|
2743
|
+
"derivation browser daemon effective launch",
|
|
2744
|
+
);
|
|
2745
|
+
exactGuardBrowserKeys(
|
|
2746
|
+
effectiveLaunch,
|
|
2747
|
+
["browserLaunched", "engine", "launchHash"],
|
|
2748
|
+
"derivation browser daemon effective launch",
|
|
2749
|
+
);
|
|
2750
|
+
const lifecycle = guardBrowserRecord(runtime.lifecycle, "derivation browser daemon lifecycle");
|
|
2751
|
+
exactGuardBrowserKeys(lifecycle, [
|
|
2752
|
+
"effectiveLaunch",
|
|
2753
|
+
"launched",
|
|
2754
|
+
"relaunchedBrowser",
|
|
2755
|
+
"restartedBackground",
|
|
2756
|
+
"restoreStatus",
|
|
2757
|
+
"reused",
|
|
2758
|
+
"saveStatus",
|
|
2759
|
+
], "derivation browser daemon lifecycle");
|
|
2760
|
+
const lifecycleLaunch = guardBrowserRecord(
|
|
2761
|
+
lifecycle.effectiveLaunch,
|
|
2762
|
+
"derivation browser daemon lifecycle launch",
|
|
2763
|
+
);
|
|
2764
|
+
exactGuardBrowserKeys(
|
|
2765
|
+
lifecycleLaunch,
|
|
2766
|
+
["browserLaunched", "engine", "launchHash"],
|
|
2767
|
+
"derivation browser daemon lifecycle launch",
|
|
2768
|
+
);
|
|
2769
|
+
if (
|
|
2770
|
+
root.success !== true
|
|
2771
|
+
|| data.active !== true
|
|
2772
|
+
|| data.namespace !== null
|
|
2773
|
+
|| !Number.isSafeInteger(data.pid)
|
|
2774
|
+
|| (data.pid as number) < 1
|
|
2775
|
+
|| data.runtimeError !== null
|
|
2776
|
+
|| data.session !== sessionName
|
|
2777
|
+
|| data.socketDir !== session.socketDirectory
|
|
2778
|
+
|| data.version !== "0.32.3"
|
|
2779
|
+
|| runtime.backgroundPid !== data.pid
|
|
2780
|
+
|| runtime.browserLaunched !== true
|
|
2781
|
+
|| runtime.compatibilityStatus !== "current"
|
|
2782
|
+
|| runtime.engine !== expectedBrowser.engine
|
|
2783
|
+
|| runtime.launchHash !== expectedBrowser.launchHash
|
|
2784
|
+
|| runtime.namespace !== null
|
|
2785
|
+
|| runtime.pageCount !== 1
|
|
2786
|
+
|| runtime.restoreCheckFn !== null
|
|
2787
|
+
|| runtime.restoreCheckText !== null
|
|
2788
|
+
|| runtime.restoreCheckUrl !== null
|
|
2789
|
+
|| runtime.restoreKey !== null
|
|
2790
|
+
|| runtime.restoreLoadedPath !== null
|
|
2791
|
+
|| runtime.restoreSave !== "auto"
|
|
2792
|
+
|| runtime.restoreSavedPath !== null
|
|
2793
|
+
|| runtime.restoreStatus !== "not_configured"
|
|
2794
|
+
|| runtime.restoreStatusDetail !== null
|
|
2795
|
+
|| runtime.restoreValidationPending !== false
|
|
2796
|
+
|| runtime.saveStatus !== "not_attempted"
|
|
2797
|
+
|| runtime.session !== sessionName
|
|
2798
|
+
|| runtime.socketDir !== session.socketDirectory
|
|
2799
|
+
|| effectiveLaunch.browserLaunched !== true
|
|
2800
|
+
|| effectiveLaunch.engine !== expectedBrowser.engine
|
|
2801
|
+
|| effectiveLaunch.launchHash !== expectedBrowser.launchHash
|
|
2802
|
+
|| lifecycleLaunch.browserLaunched !== true
|
|
2803
|
+
|| lifecycleLaunch.engine !== expectedBrowser.engine
|
|
2804
|
+
|| lifecycleLaunch.launchHash !== expectedBrowser.launchHash
|
|
2805
|
+
|| lifecycle.launched !== false
|
|
2806
|
+
|| lifecycle.relaunchedBrowser !== false
|
|
2807
|
+
|| lifecycle.restartedBackground !== false
|
|
2808
|
+
|| lifecycle.restoreStatus !== "not_configured"
|
|
2809
|
+
|| lifecycle.reused !== false
|
|
2810
|
+
|| lifecycle.saveStatus !== "not_attempted"
|
|
2811
|
+
) throw new Error("derivation browser daemon identity changed or was relaunched");
|
|
2812
|
+
return data.pid as number;
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
export function parseMarkedDerivationBrowserDaemonForCleanup(
|
|
2816
|
+
value: unknown,
|
|
2817
|
+
session: DerivationSession,
|
|
2818
|
+
marker: {
|
|
2819
|
+
readonly daemonOwner: ProcessOwnerIdentity;
|
|
2820
|
+
readonly sessionName: string;
|
|
2821
|
+
},
|
|
2822
|
+
): number {
|
|
2823
|
+
const root = guardBrowserRecord(value, "marked derivation browser daemon result");
|
|
2824
|
+
exactGuardBrowserKeys(root, ["data", "success"], "marked derivation browser daemon result");
|
|
2825
|
+
const data = guardBrowserRecord(root.data, "marked derivation browser daemon data");
|
|
2826
|
+
exactGuardBrowserKeys(data, [
|
|
2827
|
+
"active",
|
|
2828
|
+
"namespace",
|
|
2829
|
+
"pid",
|
|
2830
|
+
"runtime",
|
|
2831
|
+
"runtimeError",
|
|
2832
|
+
"session",
|
|
2833
|
+
"socketDir",
|
|
2834
|
+
"version",
|
|
2835
|
+
], "marked derivation browser daemon data");
|
|
2836
|
+
const runtime = guardBrowserRecord(data.runtime, "marked derivation browser daemon runtime");
|
|
2837
|
+
exactGuardBrowserKeys(runtime, [
|
|
2838
|
+
"backgroundPid",
|
|
2839
|
+
"browserLaunched",
|
|
2840
|
+
"compatibilityStatus",
|
|
2841
|
+
"effectiveLaunch",
|
|
2842
|
+
"engine",
|
|
2843
|
+
"launchHash",
|
|
2844
|
+
"lifecycle",
|
|
2845
|
+
"namespace",
|
|
2846
|
+
"pageCount",
|
|
2847
|
+
"restoreCheckFn",
|
|
2848
|
+
"restoreCheckText",
|
|
2849
|
+
"restoreCheckUrl",
|
|
2850
|
+
"restoreKey",
|
|
2851
|
+
"restoreLoadedPath",
|
|
2852
|
+
"restoreSave",
|
|
2853
|
+
"restoreSavedPath",
|
|
2854
|
+
"restoreStatus",
|
|
2855
|
+
"restoreStatusDetail",
|
|
2856
|
+
"restoreValidationPending",
|
|
2857
|
+
"saveStatus",
|
|
2858
|
+
"session",
|
|
2859
|
+
"socketDir",
|
|
2860
|
+
], "marked derivation browser daemon runtime");
|
|
2861
|
+
const effectiveLaunch = guardBrowserRecord(
|
|
2862
|
+
runtime.effectiveLaunch,
|
|
2863
|
+
"marked derivation browser daemon effective launch",
|
|
2864
|
+
);
|
|
2865
|
+
exactGuardBrowserKeys(
|
|
2866
|
+
effectiveLaunch,
|
|
2867
|
+
["browserLaunched", "engine", "launchHash"],
|
|
2868
|
+
"marked derivation browser daemon effective launch",
|
|
2869
|
+
);
|
|
2870
|
+
const lifecycle = guardBrowserRecord(runtime.lifecycle, "marked derivation browser daemon lifecycle");
|
|
2871
|
+
exactGuardBrowserKeys(lifecycle, [
|
|
2872
|
+
"effectiveLaunch",
|
|
2873
|
+
"launched",
|
|
2874
|
+
"relaunchedBrowser",
|
|
2875
|
+
"restartedBackground",
|
|
2876
|
+
"restoreStatus",
|
|
2877
|
+
"reused",
|
|
2878
|
+
"saveStatus",
|
|
2879
|
+
], "marked derivation browser daemon lifecycle");
|
|
2880
|
+
const lifecycleLaunch = guardBrowserRecord(
|
|
2881
|
+
lifecycle.effectiveLaunch,
|
|
2882
|
+
"marked derivation browser daemon lifecycle launch",
|
|
2883
|
+
);
|
|
2884
|
+
exactGuardBrowserKeys(
|
|
2885
|
+
lifecycleLaunch,
|
|
2886
|
+
["browserLaunched", "engine", "launchHash"],
|
|
2887
|
+
"marked derivation browser daemon lifecycle launch",
|
|
2888
|
+
);
|
|
2889
|
+
const validLaunch = (
|
|
2890
|
+
launch: Record<string, unknown>,
|
|
2891
|
+
browserLaunched: boolean,
|
|
2892
|
+
): boolean => (
|
|
2893
|
+
launch.browserLaunched === browserLaunched
|
|
2894
|
+
&& launch.engine === "chrome"
|
|
2895
|
+
&& (
|
|
2896
|
+
browserLaunched
|
|
2897
|
+
? typeof launch.launchHash === "string"
|
|
2898
|
+
&& /^(?:0|[1-9][0-9]{0,19})$/u.test(launch.launchHash)
|
|
2899
|
+
&& BigInt(launch.launchHash) <= maximumU64
|
|
2900
|
+
: launch.launchHash === null
|
|
2901
|
+
)
|
|
2902
|
+
);
|
|
2903
|
+
const boundedNullableText = (candidate: unknown): boolean => (
|
|
2904
|
+
candidate === null
|
|
2905
|
+
|| (
|
|
2906
|
+
typeof candidate === "string"
|
|
2907
|
+
&& candidate.length <= 64 * 1024
|
|
2908
|
+
&& !candidate.includes("\u0000")
|
|
2909
|
+
)
|
|
2910
|
+
);
|
|
2911
|
+
if (
|
|
2912
|
+
root.success !== true
|
|
2913
|
+
|| data.active !== true
|
|
2914
|
+
|| data.namespace !== null
|
|
2915
|
+
|| data.pid !== marker.daemonOwner.pid
|
|
2916
|
+
|| data.runtimeError !== null
|
|
2917
|
+
|| data.session !== marker.sessionName
|
|
2918
|
+
|| data.socketDir !== session.socketDirectory
|
|
2919
|
+
|| data.version !== "0.32.3"
|
|
2920
|
+
|| runtime.backgroundPid !== marker.daemonOwner.pid
|
|
2921
|
+
|| typeof runtime.browserLaunched !== "boolean"
|
|
2922
|
+
|| typeof runtime.compatibilityStatus !== "string"
|
|
2923
|
+
|| runtime.compatibilityStatus.length < 1
|
|
2924
|
+
|| runtime.compatibilityStatus.length > 256
|
|
2925
|
+
|| runtime.engine !== "chrome"
|
|
2926
|
+
|| (
|
|
2927
|
+
runtime.browserLaunched
|
|
2928
|
+
? typeof runtime.launchHash !== "string"
|
|
2929
|
+
|| !/^(?:0|[1-9][0-9]{0,19})$/u.test(runtime.launchHash)
|
|
2930
|
+
|| BigInt(runtime.launchHash) > maximumU64
|
|
2931
|
+
: runtime.launchHash !== null
|
|
2932
|
+
)
|
|
2933
|
+
|| runtime.namespace !== null
|
|
2934
|
+
|| !Number.isSafeInteger(runtime.pageCount)
|
|
2935
|
+
|| (runtime.pageCount as number) < 0
|
|
2936
|
+
|| (runtime.pageCount as number) > 100
|
|
2937
|
+
|| !boundedNullableText(runtime.restoreCheckFn)
|
|
2938
|
+
|| !boundedNullableText(runtime.restoreCheckText)
|
|
2939
|
+
|| !boundedNullableText(runtime.restoreCheckUrl)
|
|
2940
|
+
|| !boundedNullableText(runtime.restoreKey)
|
|
2941
|
+
|| !boundedNullableText(runtime.restoreLoadedPath)
|
|
2942
|
+
|| !["auto", "always", "never"].includes(
|
|
2943
|
+
typeof runtime.restoreSave === "string" ? runtime.restoreSave : "",
|
|
2944
|
+
)
|
|
2945
|
+
|| !boundedNullableText(runtime.restoreSavedPath)
|
|
2946
|
+
|| typeof runtime.restoreStatus !== "string"
|
|
2947
|
+
|| runtime.restoreStatus.length < 1
|
|
2948
|
+
|| runtime.restoreStatus.length > 256
|
|
2949
|
+
|| !boundedNullableText(runtime.restoreStatusDetail)
|
|
2950
|
+
|| typeof runtime.restoreValidationPending !== "boolean"
|
|
2951
|
+
|| typeof runtime.saveStatus !== "string"
|
|
2952
|
+
|| runtime.saveStatus.length < 1
|
|
2953
|
+
|| runtime.saveStatus.length > 256
|
|
2954
|
+
|| runtime.session !== marker.sessionName
|
|
2955
|
+
|| runtime.socketDir !== session.socketDirectory
|
|
2956
|
+
|| !validLaunch(effectiveLaunch, runtime.browserLaunched)
|
|
2957
|
+
|| !validLaunch(lifecycleLaunch, runtime.browserLaunched)
|
|
2958
|
+
|| typeof lifecycle.launched !== "boolean"
|
|
2959
|
+
|| typeof lifecycle.relaunchedBrowser !== "boolean"
|
|
2960
|
+
|| typeof lifecycle.restartedBackground !== "boolean"
|
|
2961
|
+
|| typeof lifecycle.restoreStatus !== "string"
|
|
2962
|
+
|| lifecycle.restoreStatus.length < 1
|
|
2963
|
+
|| lifecycle.restoreStatus.length > 256
|
|
2964
|
+
|| typeof lifecycle.reused !== "boolean"
|
|
2965
|
+
|| typeof lifecycle.saveStatus !== "string"
|
|
2966
|
+
|| lifecycle.saveStatus.length < 1
|
|
2967
|
+
|| lifecycle.saveStatus.length > 256
|
|
2968
|
+
) throw new Error("marked derivation browser daemon identity changed shape");
|
|
2969
|
+
return marker.daemonOwner.pid;
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
function parseInactiveDerivationBrowserDaemon(
|
|
2973
|
+
value: unknown,
|
|
2974
|
+
session: DerivationSession,
|
|
2975
|
+
sessionName: string,
|
|
2976
|
+
): void {
|
|
2977
|
+
const root = guardBrowserRecord(value, "inactive derivation browser daemon result");
|
|
2978
|
+
exactGuardBrowserKeys(root, ["data", "success"], "inactive derivation browser daemon result");
|
|
2979
|
+
const data = guardBrowserRecord(root.data, "inactive derivation browser daemon data");
|
|
2980
|
+
exactGuardBrowserKeys(data, [
|
|
2981
|
+
"active",
|
|
2982
|
+
"namespace",
|
|
2983
|
+
"pid",
|
|
2984
|
+
"runtime",
|
|
2985
|
+
"runtimeError",
|
|
2986
|
+
"session",
|
|
2987
|
+
"socketDir",
|
|
2988
|
+
"version",
|
|
2989
|
+
], "inactive derivation browser daemon data");
|
|
2990
|
+
if (
|
|
2991
|
+
root.success !== true
|
|
2992
|
+
|| data.active !== false
|
|
2993
|
+
|| data.namespace !== null
|
|
2994
|
+
|| data.pid !== null
|
|
2995
|
+
|| data.runtime !== null
|
|
2996
|
+
|| data.runtimeError !== null
|
|
2997
|
+
|| data.session !== sessionName
|
|
2998
|
+
|| data.socketDir !== session.socketDirectory
|
|
2999
|
+
|| data.version !== null
|
|
3000
|
+
) throw new Error("derivation browser daemon did not become inactive exactly");
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
async function derivationBrowserSessionInfo(
|
|
3004
|
+
session: DerivationSession,
|
|
3005
|
+
sessionName: string,
|
|
3006
|
+
): Promise<unknown> {
|
|
3007
|
+
const result = await runBoundAgentBrowser(
|
|
3008
|
+
session,
|
|
3009
|
+
["--session", sessionName, "session", "info", "--json"],
|
|
3010
|
+
{ timeoutMs: 10_000, maxOutputBytes: 1024 * 1024 },
|
|
3011
|
+
);
|
|
3012
|
+
if (result.exitCode !== 0) throw new Error("derivation browser daemon inspection failed");
|
|
3013
|
+
return parseLastJsonWithExactLaunchHashes(result.stdout);
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
async function bindDerivationBrowserDaemon(
|
|
3017
|
+
session: DerivationSession,
|
|
3018
|
+
sessionName: string,
|
|
3019
|
+
expectedBrowser: DerivationGuardBrowserIdentity,
|
|
3020
|
+
): Promise<ProcessOwnerIdentity> {
|
|
3021
|
+
const firstPid = parseActiveDerivationBrowserDaemon(
|
|
3022
|
+
await derivationBrowserSessionInfo(session, sessionName),
|
|
3023
|
+
session,
|
|
3024
|
+
sessionName,
|
|
3025
|
+
expectedBrowser,
|
|
3026
|
+
);
|
|
3027
|
+
const owner = captureProcessOwnerIdentity(firstPid);
|
|
3028
|
+
const secondPid = parseActiveDerivationBrowserDaemon(
|
|
3029
|
+
await derivationBrowserSessionInfo(session, sessionName),
|
|
3030
|
+
session,
|
|
3031
|
+
sessionName,
|
|
3032
|
+
expectedBrowser,
|
|
3033
|
+
);
|
|
3034
|
+
if (secondPid !== owner.pid || processOwnerStatus(owner) !== "exact-live-owner") {
|
|
3035
|
+
throw new Error("derivation browser daemon changed while its process identity was bound");
|
|
3036
|
+
}
|
|
3037
|
+
return owner;
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
export async function exactCdpEndpointStatus(
|
|
3041
|
+
cdpUrl: string,
|
|
3042
|
+
): Promise<"available" | "unavailable" | "indeterminate"> {
|
|
3043
|
+
const endpoint = localBrowserCdpUrl(cdpUrl);
|
|
3044
|
+
const url = new URL(endpoint);
|
|
3045
|
+
const port = Number(url.port);
|
|
3046
|
+
const host = url.hostname === "[::1]" ? "::1" : url.hostname;
|
|
3047
|
+
if (host !== "127.0.0.1" && host !== "::1") return "indeterminate";
|
|
3048
|
+
return new Promise<"available" | "unavailable" | "indeterminate">((resolve) => {
|
|
3049
|
+
const socket = createConnection({
|
|
3050
|
+
host,
|
|
3051
|
+
port,
|
|
3052
|
+
});
|
|
3053
|
+
let settled = false;
|
|
3054
|
+
const finish = (status: "available" | "unavailable" | "indeterminate"): void => {
|
|
3055
|
+
if (settled) return;
|
|
3056
|
+
settled = true;
|
|
3057
|
+
clearTimeout(timer);
|
|
3058
|
+
socket.destroy();
|
|
3059
|
+
resolve(status);
|
|
3060
|
+
};
|
|
3061
|
+
const timer = setTimeout(() => finish("indeterminate"), 250);
|
|
3062
|
+
socket.once("connect", () => finish("available"));
|
|
3063
|
+
socket.once("error", (error: NodeJS.ErrnoException) => {
|
|
3064
|
+
finish(error.code === "ECONNREFUSED" ? "unavailable" : "indeterminate");
|
|
3065
|
+
});
|
|
3066
|
+
socket.once("close", () => finish("indeterminate"));
|
|
3067
|
+
});
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
function readContainedBrowserMarkerIfPresent(
|
|
3071
|
+
session: DerivationSession,
|
|
3072
|
+
kind: ContainedDerivationBrowserMarker["kind"],
|
|
3073
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
3074
|
+
): ContainedDerivationBrowserMarker | null {
|
|
3075
|
+
const name = kind === "io-derivation-contained-browser-owner"
|
|
3076
|
+
? derivationContainedBrowserOwnerMarkerName
|
|
3077
|
+
: derivationContainedBrowserPinMarkerName;
|
|
3078
|
+
const entry = sessionEntry(session, name, environment);
|
|
3079
|
+
if (entry === undefined) return null;
|
|
3080
|
+
if (entry.kind !== "file") throw new Error("contained derivation browser marker is unavailable or unsafe");
|
|
3081
|
+
let value: unknown;
|
|
3082
|
+
try {
|
|
3083
|
+
value = JSON.parse(readRegularFile(
|
|
3084
|
+
containedBrowserMarkerPath(session, kind),
|
|
3085
|
+
64 * 1024,
|
|
3086
|
+
"contained derivation browser marker",
|
|
3087
|
+
session.directoryIdentity,
|
|
3088
|
+
)) as unknown;
|
|
3089
|
+
} catch (error) {
|
|
3090
|
+
if (error instanceof SyntaxError) {
|
|
3091
|
+
throw new Error("contained derivation browser marker is malformed", { cause: error });
|
|
3092
|
+
}
|
|
3093
|
+
throw error;
|
|
3094
|
+
}
|
|
3095
|
+
return parseContainedBrowserMarker(value, session, kind);
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
async function inspectContainedDaemonBeforeCleanup(
|
|
3099
|
+
session: DerivationSession,
|
|
3100
|
+
sessionName: string,
|
|
3101
|
+
marker: ContainedDerivationBrowserMarker | null,
|
|
3102
|
+
): Promise<ProcessOwnerIdentity | null> {
|
|
3103
|
+
const status = marker === null
|
|
3104
|
+
? "different-or-dead"
|
|
3105
|
+
: processOwnerStatus(marker.daemonOwner);
|
|
3106
|
+
if (status === "unknown") {
|
|
3107
|
+
throw new Error("contained derivation browser process identity is indeterminate");
|
|
3108
|
+
}
|
|
3109
|
+
const info = await derivationBrowserSessionInfo(session, sessionName);
|
|
3110
|
+
if (marker === null || status === "different-or-dead") {
|
|
3111
|
+
parseInactiveDerivationBrowserDaemon(info, session, sessionName);
|
|
3112
|
+
return null;
|
|
3113
|
+
}
|
|
3114
|
+
const pid = parseMarkedDerivationBrowserDaemonForCleanup(
|
|
3115
|
+
info,
|
|
3116
|
+
session,
|
|
3117
|
+
marker,
|
|
3118
|
+
);
|
|
3119
|
+
if (
|
|
3120
|
+
pid !== marker.daemonOwner.pid
|
|
3121
|
+
|| processOwnerStatus(marker.daemonOwner) !== "exact-live-owner"
|
|
3122
|
+
) throw new Error("contained derivation browser daemon changed before cleanup");
|
|
3123
|
+
return marker.daemonOwner;
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
async function terminateContainedDerivationBrowsers(
|
|
3127
|
+
session: DerivationSession,
|
|
3128
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
3129
|
+
): Promise<void> {
|
|
3130
|
+
const ownerMarker = readContainedBrowserMarkerIfPresent(
|
|
3131
|
+
session,
|
|
3132
|
+
"io-derivation-contained-browser-owner",
|
|
3133
|
+
environment,
|
|
3134
|
+
);
|
|
3135
|
+
if (ownerMarker === null) {
|
|
3136
|
+
throw new Error("contained derivation browser owner marker is unavailable");
|
|
3137
|
+
}
|
|
3138
|
+
const pinMarker = readContainedBrowserMarkerIfPresent(
|
|
3139
|
+
session,
|
|
3140
|
+
"io-derivation-contained-browser-pin",
|
|
3141
|
+
environment,
|
|
3142
|
+
);
|
|
3143
|
+
if (pinMarker !== null && pinMarker.cdpUrl !== ownerMarker.cdpUrl) {
|
|
3144
|
+
throw new Error("contained derivation browser markers disagree on their exact CDP endpoint");
|
|
3145
|
+
}
|
|
3146
|
+
const pinName = derivationPinSessionName(session);
|
|
3147
|
+
const terminateOne = async (
|
|
3148
|
+
sessionName: string,
|
|
3149
|
+
marker: ContainedDerivationBrowserMarker | null,
|
|
3150
|
+
forbiddenPid: number | null,
|
|
3151
|
+
): Promise<number | null> => {
|
|
3152
|
+
const identity = await inspectContainedDaemonBeforeCleanup(session, sessionName, marker);
|
|
3153
|
+
if (identity === null) return null;
|
|
3154
|
+
if (forbiddenPid !== null && identity.pid === forbiddenPid) {
|
|
3155
|
+
throw new Error("contained derivation browser daemons share an invalid process identity");
|
|
3156
|
+
}
|
|
3157
|
+
if (processOwnerStatus(identity) !== "exact-live-owner") {
|
|
3158
|
+
throw new Error("contained derivation browser daemon changed before termination");
|
|
3159
|
+
}
|
|
3160
|
+
try {
|
|
3161
|
+
process.kill(identity.pid, "SIGTERM");
|
|
3162
|
+
} catch (error) {
|
|
3163
|
+
if (processOwnerStatus(identity) === "exact-live-owner") throw error;
|
|
3164
|
+
}
|
|
3165
|
+
const processDeadline = Date.now() + 5_000;
|
|
3166
|
+
for (;;) {
|
|
3167
|
+
const status = processOwnerStatus(identity);
|
|
3168
|
+
if (status === "unknown") {
|
|
3169
|
+
throw new Error("contained derivation browser termination became indeterminate");
|
|
3170
|
+
}
|
|
3171
|
+
if (status === "different-or-dead") break;
|
|
3172
|
+
if (Date.now() >= processDeadline) {
|
|
3173
|
+
throw new Error("contained derivation browser daemon did not terminate after SIGTERM");
|
|
3174
|
+
}
|
|
3175
|
+
await Bun.sleep(25);
|
|
3176
|
+
}
|
|
3177
|
+
parseInactiveDerivationBrowserDaemon(
|
|
3178
|
+
await derivationBrowserSessionInfo(session, sessionName),
|
|
3179
|
+
session,
|
|
3180
|
+
sessionName,
|
|
3181
|
+
);
|
|
3182
|
+
return identity.pid;
|
|
3183
|
+
};
|
|
3184
|
+
const pinPid = await terminateOne(pinName, pinMarker, null);
|
|
3185
|
+
await terminateOne(session.sessionName, ownerMarker, pinPid);
|
|
3186
|
+
const deadline = Date.now() + 5_000;
|
|
3187
|
+
let consecutiveRefusals = 0;
|
|
3188
|
+
while (consecutiveRefusals < 3) {
|
|
3189
|
+
const endpointStatus = await exactCdpEndpointStatus(ownerMarker.cdpUrl);
|
|
3190
|
+
if (endpointStatus === "indeterminate") {
|
|
3191
|
+
throw new Error("exact derivation browser CDP endpoint state became indeterminate");
|
|
3192
|
+
}
|
|
3193
|
+
consecutiveRefusals = endpointStatus === "unavailable"
|
|
3194
|
+
? consecutiveRefusals + 1
|
|
3195
|
+
: 0;
|
|
3196
|
+
if (consecutiveRefusals >= 3) break;
|
|
3197
|
+
if (Date.now() >= deadline) {
|
|
3198
|
+
throw new Error("exact derivation browser CDP endpoint remained available");
|
|
3199
|
+
}
|
|
3200
|
+
await Bun.sleep(25);
|
|
3201
|
+
}
|
|
3202
|
+
}
|
|
3203
|
+
|
|
3204
|
+
async function closeSession(
|
|
3205
|
+
session: DerivationSession,
|
|
3206
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
3207
|
+
): Promise<boolean> {
|
|
3208
|
+
const guard = session.profilePath === null
|
|
3209
|
+
&& session.schemaVersion === 2
|
|
3210
|
+
&& session.networkGuard != null
|
|
3211
|
+
? session.networkGuard
|
|
3212
|
+
: null;
|
|
3213
|
+
if (guard !== null) {
|
|
3214
|
+
try {
|
|
3215
|
+
await terminateContainedDerivationBrowsers(session, environment);
|
|
3216
|
+
} catch {
|
|
3217
|
+
return false;
|
|
3218
|
+
}
|
|
3219
|
+
try {
|
|
3220
|
+
if (!await closeSessionNetworkBoundary(session)) return false;
|
|
3221
|
+
} catch {
|
|
3222
|
+
return false;
|
|
3223
|
+
}
|
|
3224
|
+
} else {
|
|
3225
|
+
const result = await runBoundAgentBrowser(
|
|
3226
|
+
session,
|
|
3227
|
+
[...derivationGlobalArguments(session), "close", "--json"],
|
|
3228
|
+
{ timeoutMs: 15_000, maxOutputBytes: 1024 * 1024 },
|
|
3229
|
+
).catch(() => null);
|
|
3230
|
+
if (result?.exitCode !== 0) return false;
|
|
3231
|
+
try {
|
|
3232
|
+
assertNoUnboundDerivationControl(session.id);
|
|
3233
|
+
} catch {
|
|
3234
|
+
return false;
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3237
|
+
return true;
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3240
|
+
async function closeSessionNetworkBoundary(session: DerivationSession): Promise<boolean> {
|
|
3241
|
+
const guard = session.profilePath === null
|
|
3242
|
+
&& session.schemaVersion === 2
|
|
3243
|
+
&& session.networkGuard != null
|
|
3244
|
+
? session.networkGuard
|
|
3245
|
+
: null;
|
|
3246
|
+
try {
|
|
3247
|
+
if (guard !== null) {
|
|
3248
|
+
await closeDerivationNetworkBoundary({
|
|
3249
|
+
derivationId: session.id,
|
|
3250
|
+
guard,
|
|
3251
|
+
});
|
|
3252
|
+
}
|
|
3253
|
+
assertNoUnboundDerivationControl(session.id);
|
|
3254
|
+
return true;
|
|
3255
|
+
} catch {
|
|
3256
|
+
return false;
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
function sessionNetworkOwner(session: DerivationSession): ProcessOwnerIdentity | null {
|
|
3261
|
+
return session.profilePath === null
|
|
3262
|
+
&& session.schemaVersion === 2
|
|
3263
|
+
&& session.networkGuard != null
|
|
3264
|
+
? session.networkGuard.proxy.owner
|
|
3265
|
+
: null;
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
function assertRecordedNetworkOwnerQuiescent(owner: ProcessOwnerIdentity | null): void {
|
|
3269
|
+
if (owner === null) return;
|
|
3270
|
+
if (processOwnerStatus(owner) !== "different-or-dead") {
|
|
3271
|
+
throw new Error("derivation network proxy owner could not be proved quiescent");
|
|
3272
|
+
}
|
|
3273
|
+
}
|
|
3274
|
+
|
|
3275
|
+
function assertSessionNetworkBoundaryQuiescent(session: DerivationSession): void {
|
|
3276
|
+
assertRecordedNetworkOwnerQuiescent(sessionNetworkOwner(session));
|
|
3277
|
+
assertNoUnboundDerivationControl(session.id);
|
|
3278
|
+
}
|
|
3279
|
+
|
|
3280
|
+
function removeSessionStateWithoutSocket(
|
|
3281
|
+
session: DerivationSession,
|
|
3282
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
3283
|
+
): void {
|
|
3284
|
+
assertSessionNetworkBoundaryQuiescent(session);
|
|
3285
|
+
assertSessionNetworkBoundaryQuiescent(session);
|
|
3286
|
+
removePrivateStateDirectoryTree(session.directory, environment, session.directoryIdentity);
|
|
3287
|
+
}
|
|
3288
|
+
|
|
3289
|
+
function removeSessionTrees(
|
|
3290
|
+
session: DerivationSession,
|
|
3291
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
3292
|
+
): void {
|
|
3293
|
+
assertSessionNetworkBoundaryQuiescent(session);
|
|
3294
|
+
removePrivateDirectoryTree(session.socketDirectory, session.socketIdentity);
|
|
3295
|
+
assertSessionNetworkBoundaryQuiescent(session);
|
|
3296
|
+
removePrivateStateDirectoryTree(session.directory, environment, session.directoryIdentity);
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
function removeInterruptedSessionTrees(
|
|
3300
|
+
initialization: DerivationInitialization,
|
|
3301
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
3302
|
+
owner: ProcessOwnerIdentity | null,
|
|
3303
|
+
): void {
|
|
3304
|
+
assertRecordedNetworkOwnerQuiescent(owner);
|
|
3305
|
+
assertNoUnboundDerivationControl(initialization.derivationId);
|
|
3306
|
+
const socketPresent = inspectInitializationSocket(initialization);
|
|
3307
|
+
if (socketPresent) {
|
|
3308
|
+
removePrivateDirectoryTree(initialization.socketDirectory, initialization.socketIdentity);
|
|
3309
|
+
}
|
|
3310
|
+
assertRecordedNetworkOwnerQuiescent(owner);
|
|
3311
|
+
assertNoUnboundDerivationControl(initialization.derivationId);
|
|
3312
|
+
removePrivateStateDirectoryTree(
|
|
3313
|
+
derivationDirectory(initialization.derivationId, environment),
|
|
3314
|
+
environment,
|
|
3315
|
+
initialization.directoryIdentity,
|
|
3316
|
+
);
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
function inspectInitializationSocket(initialization: DerivationInitialization): boolean {
|
|
3320
|
+
try {
|
|
3321
|
+
const actualSocketIdentity = inspectDirectoryIdentity(initialization.socketDirectory);
|
|
3322
|
+
if (!sameDirectoryIdentity(actualSocketIdentity, initialization.socketIdentity)) {
|
|
3323
|
+
throw new Error("derivation socket directory changed identity");
|
|
3324
|
+
}
|
|
3325
|
+
return true;
|
|
3326
|
+
} catch (error) {
|
|
3327
|
+
if (!hasErrorCode(error, "ENOENT")) throw error;
|
|
3328
|
+
return false;
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
function inspectUnknownInitializationSocket(id: string): boolean {
|
|
3333
|
+
const socketDirectory = expectedSocketDirectory(id);
|
|
3334
|
+
try {
|
|
3335
|
+
inspectDirectoryIdentity(socketDirectory);
|
|
3336
|
+
return true;
|
|
3337
|
+
} catch (error) {
|
|
3338
|
+
if (!hasErrorCode(error, "ENOENT")) throw error;
|
|
3339
|
+
return false;
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
|
|
3343
|
+
function derivationControlEndpointIsAbsent(id: string): boolean {
|
|
3344
|
+
try {
|
|
3345
|
+
lstatSync(derivationGuardControlSocketPath(id));
|
|
3346
|
+
return false;
|
|
3347
|
+
} catch (error) {
|
|
3348
|
+
if (!hasErrorCode(error, "ENOENT")) throw error;
|
|
3349
|
+
return true;
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
|
|
3353
|
+
function assertNoUnboundDerivationControl(id: string): void {
|
|
3354
|
+
if (!derivationControlEndpointIsAbsent(id)) {
|
|
3355
|
+
throw new Error(
|
|
3356
|
+
`derivation ${id} has an unbound network control endpoint; its private state was preserved`,
|
|
3357
|
+
);
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
|
|
3361
|
+
function removeDirectoryPhaseState(
|
|
3362
|
+
phase: DerivationDirectoryPhase,
|
|
3363
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
3364
|
+
): void {
|
|
3365
|
+
const directory = derivationDirectory(phase.derivationId, environment);
|
|
3366
|
+
const assertNoUnknownSocket = (): void => {
|
|
3367
|
+
const actualIdentity = inspectDirectoryIdentity(directory);
|
|
3368
|
+
if (!sameDirectoryIdentity(actualIdentity, phase.directoryIdentity)) {
|
|
3369
|
+
throw new Error("derivation directory phase changed identity");
|
|
3370
|
+
}
|
|
3371
|
+
if (inspectUnknownInitializationSocket(phase.derivationId)) {
|
|
3372
|
+
throw new Error(
|
|
3373
|
+
`derivation ${phase.derivationId} has an unbound browser socket; its private state was preserved`,
|
|
3374
|
+
);
|
|
3375
|
+
}
|
|
3376
|
+
assertNoUnboundDerivationControl(phase.derivationId);
|
|
3377
|
+
};
|
|
3378
|
+
assertNoUnknownSocket();
|
|
3379
|
+
assertNoUnknownSocket();
|
|
3380
|
+
removePrivateStateDirectoryTree(
|
|
3381
|
+
directory,
|
|
3382
|
+
environment,
|
|
3383
|
+
phase.directoryIdentity,
|
|
3384
|
+
);
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3387
|
+
function removeEmptyMarkerlessDirectoryState(
|
|
3388
|
+
id: string,
|
|
3389
|
+
directoryIdentity: DirectoryIdentity,
|
|
3390
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
3391
|
+
): void {
|
|
3392
|
+
const directory = derivationDirectory(id, environment);
|
|
3393
|
+
const parentIdentity = ensurePrivateStateDirectory(dirname(directory), environment);
|
|
3394
|
+
const assertStillRecoverable = (): void => {
|
|
3395
|
+
const actualIdentity = inspectDirectoryIdentity(directory);
|
|
3396
|
+
if (!sameDirectoryIdentity(actualIdentity, directoryIdentity)) {
|
|
3397
|
+
throw new Error("markerless derivation directory changed identity");
|
|
3398
|
+
}
|
|
3399
|
+
if (listPrivateStateDirectory(directory, environment, directoryIdentity).length !== 0) {
|
|
3400
|
+
throw new Error("markerless derivation directory is not empty; its state was preserved");
|
|
3401
|
+
}
|
|
3402
|
+
if (inspectUnknownInitializationSocket(id)) {
|
|
3403
|
+
throw new Error(`derivation ${id} has an unbound browser socket; its private state was preserved`);
|
|
3404
|
+
}
|
|
3405
|
+
assertNoUnboundDerivationControl(id);
|
|
3406
|
+
};
|
|
3407
|
+
assertStillRecoverable();
|
|
3408
|
+
assertStillRecoverable();
|
|
3409
|
+
if (!removePrivateEmptyStateDirectory(
|
|
3410
|
+
directory,
|
|
3411
|
+
environment,
|
|
3412
|
+
directoryIdentity,
|
|
3413
|
+
parentIdentity,
|
|
3414
|
+
)) {
|
|
3415
|
+
throw new Error("markerless derivation directory was no longer empty; its state was preserved");
|
|
3416
|
+
}
|
|
3417
|
+
}
|
|
3418
|
+
|
|
3419
|
+
async function currentDerivationUrl(session: DerivationSession): Promise<URL> {
|
|
3420
|
+
const [record] = await batch(session, [["get", "url"]]);
|
|
3421
|
+
const data = record === undefined ? undefined : browserResultData(record);
|
|
3422
|
+
if (typeof data !== "object" || data === null || Array.isArray(data) || typeof (data as Record<string, unknown>).url !== "string") {
|
|
3423
|
+
throw new Error("agent-browser returned no current URL for the derivation");
|
|
3424
|
+
}
|
|
3425
|
+
let url: URL;
|
|
3426
|
+
try {
|
|
3427
|
+
url = new URL((data as Record<string, unknown>).url as string);
|
|
3428
|
+
} catch {
|
|
3429
|
+
throw new Error("agent-browser returned an invalid current URL for the derivation");
|
|
3430
|
+
}
|
|
3431
|
+
return url;
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
async function assertDerivationOrigin(session: DerivationSession): Promise<void> {
|
|
3435
|
+
const url = await currentDerivationUrl(session);
|
|
3436
|
+
if (url.origin !== session.targetOrigin) throw new Error(`derivation left its target origin: ${url.origin}`);
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
async function waitForDerivationOrigin(
|
|
3440
|
+
session: DerivationSession,
|
|
3441
|
+
targetUrl: string,
|
|
3442
|
+
timeoutMs = 15_000,
|
|
3443
|
+
): Promise<void> {
|
|
3444
|
+
const deadline = Date.now() + timeoutMs;
|
|
3445
|
+
let nextNavigationAttempt = Date.now() + 1_000;
|
|
3446
|
+
for (;;) {
|
|
3447
|
+
const url = await currentDerivationUrl(session);
|
|
3448
|
+
if (url.origin === session.targetOrigin) return;
|
|
3449
|
+
if (url.href !== "about:blank") throw new Error(`derivation left its target origin: ${url.origin}`);
|
|
3450
|
+
if (Date.now() >= deadline) throw new Error("derivation navigation did not reach its target origin");
|
|
3451
|
+
if (Date.now() >= nextNavigationAttempt) {
|
|
3452
|
+
await batch(session, [["open", targetUrl]]);
|
|
3453
|
+
nextNavigationAttempt = Date.now() + 1_000;
|
|
3454
|
+
}
|
|
3455
|
+
await Bun.sleep(100);
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
function validateTarget(value: string): URL {
|
|
3460
|
+
if (value.length > 64 * 1024) throw new Error("target URL is too long");
|
|
3461
|
+
const url = new URL(value);
|
|
3462
|
+
if (url.protocol !== "https:" || url.username !== "" || url.password !== "") {
|
|
3463
|
+
throw new Error("derivation target must be HTTPS and contain no embedded credentials");
|
|
3464
|
+
}
|
|
3465
|
+
if (isPrivateHostname(url.hostname) || (isIP(url.hostname) !== 0 && isPrivateAddress(url.hostname))) {
|
|
3466
|
+
throw new Error("derivation target cannot use a private network host");
|
|
3467
|
+
}
|
|
3468
|
+
return url;
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
/**
|
|
3472
|
+
* Initialize the contained browser on a harmless allowed origin before
|
|
3473
|
+
* seeding cookies. When explicitly supplied, the first exact cookie origin is
|
|
3474
|
+
* the code-owned bootstrap; this lets redirect-prone application origins use
|
|
3475
|
+
* a stable first-party page without broadening the browser allowlist.
|
|
3476
|
+
*/
|
|
3477
|
+
export function derivationBootstrapUrl(
|
|
3478
|
+
target: URL,
|
|
3479
|
+
cookieOrigins: readonly URL[] = [],
|
|
3480
|
+
): string {
|
|
3481
|
+
const selected = cookieOrigins[0] ?? target;
|
|
3482
|
+
const bootstrap = new URL("/robots.txt", selected.origin);
|
|
3483
|
+
if (bootstrap.origin !== selected.origin) {
|
|
3484
|
+
throw new Error("derivation bootstrap escaped its selected origin");
|
|
3485
|
+
}
|
|
3486
|
+
return bootstrap.href;
|
|
3487
|
+
}
|
|
3488
|
+
|
|
3489
|
+
function validateDerivationCookieOrigins(
|
|
3490
|
+
values: readonly string[],
|
|
3491
|
+
browserDomains: readonly string[],
|
|
3492
|
+
target: URL,
|
|
3493
|
+
auth: GhostgetAuth,
|
|
3494
|
+
): readonly URL[] {
|
|
3495
|
+
if (values.length > 16) {
|
|
3496
|
+
throw new Error("derivation accepts at most 16 cookie origins");
|
|
3497
|
+
}
|
|
3498
|
+
if (
|
|
3499
|
+
values.length > 0
|
|
3500
|
+
&& auth.kind !== "cookie-source"
|
|
3501
|
+
&& auth.kind !== "cookies-file"
|
|
3502
|
+
) {
|
|
3503
|
+
throw new Error(
|
|
3504
|
+
"derivation cookie origins require cookie-source or cookies-file auth so the browser remains domain-contained",
|
|
3505
|
+
);
|
|
3506
|
+
}
|
|
3507
|
+
const origins: URL[] = [];
|
|
3508
|
+
const seen = new Set<string>();
|
|
3509
|
+
for (const value of values) {
|
|
3510
|
+
let origin: URL;
|
|
3511
|
+
try {
|
|
3512
|
+
origin = validateTarget(value);
|
|
3513
|
+
} catch {
|
|
3514
|
+
throw new Error("derivation cookie origins must be exact public HTTPS origins");
|
|
3515
|
+
}
|
|
3516
|
+
if (value !== origin.origin && value !== `${origin.origin}/`) {
|
|
3517
|
+
throw new Error("derivation cookie origins must be exact public HTTPS origins");
|
|
3518
|
+
}
|
|
3519
|
+
if (!browserDomainsCover(browserDomains, origin.hostname.toLowerCase())) {
|
|
3520
|
+
throw new Error("every derivation cookie origin must be covered by browser domains");
|
|
3521
|
+
}
|
|
3522
|
+
if (seen.has(origin.origin)) {
|
|
3523
|
+
throw new Error("derivation cookie origins must be unique");
|
|
3524
|
+
}
|
|
3525
|
+
seen.add(origin.origin);
|
|
3526
|
+
if (origin.origin !== target.origin) origins.push(new URL(origin.origin));
|
|
3527
|
+
}
|
|
3528
|
+
return Object.freeze(origins);
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
function sameCookie(left: StrictCookie, right: StrictCookie): boolean {
|
|
3532
|
+
return left.name === right.name
|
|
3533
|
+
&& left.value === right.value
|
|
3534
|
+
&& left.domain === right.domain
|
|
3535
|
+
&& left.hostOnly === right.hostOnly
|
|
3536
|
+
&& left.path === right.path
|
|
3537
|
+
&& left.secure === right.secure
|
|
3538
|
+
&& left.httpOnly === right.httpOnly
|
|
3539
|
+
&& left.sameSite === right.sameSite
|
|
3540
|
+
&& left.expires === right.expires;
|
|
3541
|
+
}
|
|
3542
|
+
|
|
3543
|
+
/**
|
|
3544
|
+
* Read every explicit origin before emitting any secret-bearing stdin command.
|
|
3545
|
+
* Exact duplicate cookies are injected once; an identity collision with any
|
|
3546
|
+
* differing value or attribute fails before the browser receives a cookie.
|
|
3547
|
+
*/
|
|
3548
|
+
export async function derivationCookieCommands(
|
|
3549
|
+
auth: Extract<GhostgetAuth, { readonly kind: "cookie-source" | "cookies-file" }>,
|
|
3550
|
+
target: URL,
|
|
3551
|
+
cookieOrigins: readonly URL[],
|
|
3552
|
+
reader: CookieRecordReader = acquireCookieRecords,
|
|
3553
|
+
): Promise<readonly (readonly string[])[]> {
|
|
3554
|
+
const targets = [target, ...cookieOrigins];
|
|
3555
|
+
const selected = new Map<
|
|
3556
|
+
string,
|
|
3557
|
+
{ readonly cookie: StrictCookie; readonly target: URL }
|
|
3558
|
+
>();
|
|
3559
|
+
for (const cookieTarget of targets) {
|
|
3560
|
+
const result = await reader({
|
|
3561
|
+
cookieSources: auth.kind === "cookie-source" ? [auth.source] : [],
|
|
3562
|
+
cookieProfile: auth.kind === "cookie-source" ? auth.profile : undefined,
|
|
3563
|
+
cookiesFile: auth.kind === "cookies-file" ? auth.path : undefined,
|
|
3564
|
+
timeoutMs: 60_000,
|
|
3565
|
+
...(cookieOrigins.length === 0 ? {} : { requireExplicitCookieScope: true }),
|
|
3566
|
+
}, cookieTarget);
|
|
3567
|
+
for (const cookie of result.cookies) {
|
|
3568
|
+
const identity = `${cookie.name}\0${cookie.domain}\0${cookie.path}`;
|
|
3569
|
+
const previous = selected.get(identity);
|
|
3570
|
+
if (previous !== undefined) {
|
|
3571
|
+
if (!sameCookie(previous.cookie, cookie)) {
|
|
3572
|
+
throw new Error("cookie origins returned conflicting duplicate cookie identities");
|
|
3573
|
+
}
|
|
3574
|
+
continue;
|
|
3575
|
+
}
|
|
3576
|
+
selected.set(identity, { cookie, target: cookieTarget });
|
|
3577
|
+
}
|
|
3578
|
+
}
|
|
3579
|
+
return Object.freeze([...selected.values()].flatMap(({ cookie, target: cookieTarget }) =>
|
|
3580
|
+
browserCookieCommands([cookie], cookieTarget)));
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
/**
|
|
3584
|
+
* LinkedIn's authenticated app creates execution contexts that the pinned
|
|
3585
|
+
* agent-browser domain-containment bootstrap cannot reliably retain. The
|
|
3586
|
+
* daemon can survive while its Chrome child exits, after which agent-browser
|
|
3587
|
+
* transparently relaunches about:blank and the in-memory HAR is gone.
|
|
3588
|
+
*
|
|
3589
|
+
* A path-backed profile locator is the explicit uncontained-egress boundary
|
|
3590
|
+
* for this case. It still uses a task-private clone and may seed filtered
|
|
3591
|
+
* cookies, but it must have been stored with --trust-profile-egress and an
|
|
3592
|
+
* exact browser executable. Named agent-browser profiles are deliberately
|
|
3593
|
+
* rejected because they would be opened directly instead of cloned.
|
|
3594
|
+
*/
|
|
3595
|
+
export function assertDerivationAuthCompatibility(target: URL, auth: GhostgetAuth): void {
|
|
3596
|
+
const isArcExecutable = (path: string): boolean => {
|
|
3597
|
+
const hasArcExecutableName = (candidate: string): boolean =>
|
|
3598
|
+
["arc", "arc.exe"].includes(basename(candidate).toLowerCase());
|
|
3599
|
+
if (hasArcExecutableName(path)) return true;
|
|
3600
|
+
try {
|
|
3601
|
+
return hasArcExecutableName(realpathSync(path));
|
|
3602
|
+
} catch {
|
|
3603
|
+
// Path validity is owned by auth/execution boundaries. Do not disclose
|
|
3604
|
+
// a local executable path through this provider-specific safety check.
|
|
3605
|
+
return false;
|
|
3606
|
+
}
|
|
3607
|
+
};
|
|
3608
|
+
if (
|
|
3609
|
+
auth.kind === "browser-profile"
|
|
3610
|
+
&& auth.browserExecutable !== undefined
|
|
3611
|
+
&& isArcExecutable(auth.browserExecutable)
|
|
3612
|
+
) {
|
|
3613
|
+
throw new Error(
|
|
3614
|
+
"managed derivation cannot use Arc as --browser-executable because Arc may attach its source user-data root "
|
|
3615
|
+
+ "to task-private launches; keep Arc fully closed as the --browser-profile or --cookie-source and use an exact "
|
|
3616
|
+
+ "Chrome or Chromium executable for the isolated clone",
|
|
3617
|
+
);
|
|
3618
|
+
}
|
|
3619
|
+
const hostname = target.hostname.toLowerCase();
|
|
3620
|
+
const linkedin = hostname === "linkedin.com" || hostname.endsWith(".linkedin.com");
|
|
3621
|
+
if (!linkedin) return;
|
|
3622
|
+
if (auth.kind !== "browser-profile" || auth.browserExecutable === undefined) {
|
|
3623
|
+
throw new Error(
|
|
3624
|
+
"LinkedIn managed derivation requires a path-backed browser-profile auth locator stored with --trust-profile-egress; "
|
|
3625
|
+
+ "the pinned contained cookie-only browser can lose LinkedIn's authenticated execution context and its HAR. "
|
|
3626
|
+
+ "Use a private disposable --browser-profile with --browser-executable and optional --cookie-source, then retry",
|
|
3627
|
+
);
|
|
3628
|
+
}
|
|
3629
|
+
if (profilePath(auth.profile) === null) {
|
|
3630
|
+
throw new Error(
|
|
3631
|
+
"LinkedIn managed derivation requires a path-backed browser-profile auth locator so ghostget can clone it "
|
|
3632
|
+
+ "into the task-private derivation directory; named browser profiles would be opened directly",
|
|
3633
|
+
);
|
|
3634
|
+
}
|
|
3635
|
+
}
|
|
3636
|
+
|
|
3637
|
+
export async function startDerivation(
|
|
3638
|
+
adapterId: string,
|
|
3639
|
+
targetValue: string,
|
|
3640
|
+
auth: GhostgetAuth,
|
|
3641
|
+
options: {
|
|
3642
|
+
readonly allowRemoteActions: boolean;
|
|
3643
|
+
readonly contentMode: HarContentMode;
|
|
3644
|
+
readonly browserDomains: readonly string[];
|
|
3645
|
+
readonly cookieOrigins?: readonly string[];
|
|
3646
|
+
readonly fixtureSources?: readonly string[];
|
|
3647
|
+
readonly headed: boolean;
|
|
3648
|
+
readonly environment?: Readonly<Record<string, string | undefined>>;
|
|
3649
|
+
},
|
|
3650
|
+
): Promise<DerivationSession> {
|
|
3651
|
+
if (!/^[a-z][a-z0-9-]{0,47}$/u.test(adapterId)) throw new Error("adapter ID must be lowercase kebab-case");
|
|
3652
|
+
const environment = options.environment ?? process.env;
|
|
3653
|
+
const target = validateTarget(targetValue);
|
|
3654
|
+
assertBrowserDerivationTargetAllowed(target);
|
|
3655
|
+
assertDerivationAuthCompatibility(target, auth);
|
|
3656
|
+
const browserDomains = validateDerivationBrowserDomains(options.browserDomains, target.hostname.toLowerCase());
|
|
3657
|
+
const cookieOrigins = validateDerivationCookieOrigins(
|
|
3658
|
+
options.cookieOrigins ?? [],
|
|
3659
|
+
browserDomains,
|
|
3660
|
+
target,
|
|
3661
|
+
auth,
|
|
3662
|
+
);
|
|
3663
|
+
const fixtureSources = options.fixtureSources ?? [];
|
|
3664
|
+
if (fixtureSources.length > 0 && !options.allowRemoteActions) {
|
|
3665
|
+
throw new Error("derivation fixtures require --allow-remote-actions because upload changes remote draft state");
|
|
3666
|
+
}
|
|
3667
|
+
const id = crypto.randomUUID();
|
|
3668
|
+
const gate = acquireDerivationLifecycleGate(id, environment);
|
|
3669
|
+
try {
|
|
3670
|
+
const directory = derivationDirectory(id, environment);
|
|
3671
|
+
const derivationsRoot = dirname(directory);
|
|
3672
|
+
const derivationsRootIdentity = ensurePrivateStateDirectory(derivationsRoot, environment);
|
|
3673
|
+
const directoryIdentity = createPrivateStateDirectory(
|
|
3674
|
+
directory,
|
|
3675
|
+
environment,
|
|
3676
|
+
derivationsRootIdentity,
|
|
3677
|
+
);
|
|
3678
|
+
const socketDirectory = expectedSocketDirectory(id);
|
|
3679
|
+
const configPath = join(directory, "agent-browser.json");
|
|
3680
|
+
const policyPath = join(directory, "action-policy.json");
|
|
3681
|
+
let socketCreated = false;
|
|
3682
|
+
let socketIdentity: DirectoryIdentity | null = null;
|
|
3683
|
+
let session: DerivationSession | null = null;
|
|
3684
|
+
let initializedNetworkGuard: DerivationNetworkGuard | null = null;
|
|
3685
|
+
try {
|
|
3686
|
+
const phase: DerivationDirectoryPhase = {
|
|
3687
|
+
schemaVersion: 1,
|
|
3688
|
+
kind: "io-derivation-directory-phase",
|
|
3689
|
+
derivationId: id,
|
|
3690
|
+
directoryIdentity,
|
|
3691
|
+
};
|
|
3692
|
+
const phasePublication = createPrivateJsonIfAbsent(
|
|
3693
|
+
phaseMarkerPath(directory),
|
|
3694
|
+
phase,
|
|
3695
|
+
{ environment, expectedStateParent: directoryIdentity },
|
|
3696
|
+
);
|
|
3697
|
+
if (!phasePublication.created) {
|
|
3698
|
+
throw new Error("derivation directory phase marker already exists");
|
|
3699
|
+
}
|
|
3700
|
+
ensurePrivateDirectory(socketDirectory);
|
|
3701
|
+
socketCreated = true;
|
|
3702
|
+
socketIdentity = inspectDirectoryIdentity(socketDirectory);
|
|
3703
|
+
const initialization: DerivationInitialization = {
|
|
3704
|
+
schemaVersion: 1,
|
|
3705
|
+
kind: "io-derivation-initialization",
|
|
3706
|
+
derivationId: id,
|
|
3707
|
+
directoryIdentity,
|
|
3708
|
+
socketDirectory,
|
|
3709
|
+
socketIdentity,
|
|
3710
|
+
};
|
|
3711
|
+
const initializationPublication = createPrivateJsonIfAbsent(
|
|
3712
|
+
initializationMarkerPath(directory),
|
|
3713
|
+
initialization,
|
|
3714
|
+
{ environment, expectedStateParent: directoryIdentity },
|
|
3715
|
+
);
|
|
3716
|
+
if (!initializationPublication.created) {
|
|
3717
|
+
throw new Error("derivation initialization marker already exists");
|
|
3718
|
+
}
|
|
3719
|
+
const fixtures = stageDerivationFixtures(fixtureSources, directory);
|
|
3720
|
+
writePrivateJson(configPath, derivationBrowserConfig(false));
|
|
3721
|
+
// Pinned agent-browser 0.32.3 checks both documented categories and
|
|
3722
|
+
// concrete commands such as getbyrole, inputvalue, and har_start.
|
|
3723
|
+
writePrivateJson(
|
|
3724
|
+
policyPath,
|
|
3725
|
+
derivationActionPolicy(options.allowRemoteActions, fixtures.length > 0),
|
|
3726
|
+
);
|
|
3727
|
+
let clonedProfile: string | null = null;
|
|
3728
|
+
if (auth.kind === "browser-profile") {
|
|
3729
|
+
const source = profilePath(auth.profile);
|
|
3730
|
+
if (source === null) {
|
|
3731
|
+
clonedProfile = auth.profile;
|
|
3732
|
+
} else {
|
|
3733
|
+
const cloned = await cloneProfileBound(source, directory, directoryIdentity);
|
|
3734
|
+
clonedProfile = join(directory, cloned.profile);
|
|
3735
|
+
}
|
|
3736
|
+
}
|
|
3737
|
+
if (clonedProfile === null) {
|
|
3738
|
+
writePrivateJson(join(directory, derivationDnrPolicyFileName), {
|
|
3739
|
+
default: "deny",
|
|
3740
|
+
allow: derivationDnrPolicyActions,
|
|
3741
|
+
});
|
|
3742
|
+
initializedNetworkGuard = await createDerivationNetworkBoundary({
|
|
3743
|
+
derivationId: id,
|
|
3744
|
+
directory,
|
|
3745
|
+
directoryIdentity,
|
|
3746
|
+
socketDirectory,
|
|
3747
|
+
socketIdentity,
|
|
3748
|
+
browserDomains,
|
|
3749
|
+
});
|
|
3750
|
+
}
|
|
3751
|
+
session = {
|
|
3752
|
+
schemaVersion: 2,
|
|
3753
|
+
id,
|
|
3754
|
+
adapterId,
|
|
3755
|
+
targetUrl: target.href,
|
|
3756
|
+
targetOrigin: target.origin,
|
|
3757
|
+
createdAt: new Date().toISOString(),
|
|
3758
|
+
allowRemoteActions: options.allowRemoteActions,
|
|
3759
|
+
contentMode: options.contentMode,
|
|
3760
|
+
browserDomains,
|
|
3761
|
+
fixtures,
|
|
3762
|
+
headed: options.headed,
|
|
3763
|
+
sessionName: `io-derive-${id.replaceAll("-", "").slice(0, 12)}`,
|
|
3764
|
+
directory,
|
|
3765
|
+
directoryIdentity,
|
|
3766
|
+
socketDirectory,
|
|
3767
|
+
socketIdentity,
|
|
3768
|
+
configPath,
|
|
3769
|
+
policyPath,
|
|
3770
|
+
profilePath: clonedProfile,
|
|
3771
|
+
networkGuard: initializedNetworkGuard,
|
|
3772
|
+
...(auth.kind === "browser-profile" && auth.browserExecutable !== undefined
|
|
3773
|
+
? { browserExecutable: auth.browserExecutable }
|
|
3774
|
+
: {}),
|
|
3775
|
+
};
|
|
3776
|
+
if (!sameDirectoryIdentity(inspectDirectoryIdentity(directory), directoryIdentity)) {
|
|
3777
|
+
throw new Error("derivation session directory changed identity during initialization");
|
|
3778
|
+
}
|
|
3779
|
+
const metadataPublication = createPrivateJsonIfAbsent(metadataPath(id, environment), session, {
|
|
3780
|
+
environment,
|
|
3781
|
+
expectedStateParent: directoryIdentity,
|
|
3782
|
+
});
|
|
3783
|
+
if (!metadataPublication.created) throw new Error("derivation session metadata already exists");
|
|
3784
|
+
if (initializedNetworkGuard !== null) {
|
|
3785
|
+
await adoptDerivationNetworkBoundary({
|
|
3786
|
+
derivationId: id,
|
|
3787
|
+
guard: initializedNetworkGuard,
|
|
3788
|
+
});
|
|
3789
|
+
}
|
|
3790
|
+
} catch (error) {
|
|
3791
|
+
const cleanupErrors: unknown[] = [];
|
|
3792
|
+
let networkBoundaryQuiescent = true;
|
|
3793
|
+
if (error instanceof UnsafeDerivationNetworkBoundaryCleanupError) {
|
|
3794
|
+
throw error;
|
|
3795
|
+
}
|
|
3796
|
+
if (initializedNetworkGuard !== null && socketIdentity !== null) {
|
|
3797
|
+
try {
|
|
3798
|
+
await closeDerivationNetworkBoundary({
|
|
3799
|
+
derivationId: id,
|
|
3800
|
+
guard: initializedNetworkGuard,
|
|
3801
|
+
});
|
|
3802
|
+
} catch (cleanupError) {
|
|
3803
|
+
networkBoundaryQuiescent = false;
|
|
3804
|
+
cleanupErrors.push(cleanupError);
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
3807
|
+
if (!networkBoundaryQuiescent) {
|
|
3808
|
+
throw new AggregateError(
|
|
3809
|
+
[error, ...cleanupErrors],
|
|
3810
|
+
"derivation initialization failed and its live network boundary was preserved",
|
|
3811
|
+
);
|
|
3812
|
+
}
|
|
3813
|
+
if (socketCreated && socketIdentity !== null) {
|
|
3814
|
+
try {
|
|
3815
|
+
removePrivateDirectoryTree(socketDirectory, socketIdentity);
|
|
3816
|
+
} catch (cleanupError) {
|
|
3817
|
+
cleanupErrors.push(cleanupError);
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
try {
|
|
3821
|
+
assertRecordedNetworkOwnerQuiescent(initializedNetworkGuard?.proxy.owner ?? null);
|
|
3822
|
+
assertNoUnboundDerivationControl(id);
|
|
3823
|
+
removePrivateStateDirectoryTree(directory, environment, directoryIdentity);
|
|
3824
|
+
} catch (cleanupError) {
|
|
3825
|
+
cleanupErrors.push(cleanupError);
|
|
3826
|
+
}
|
|
3827
|
+
if (cleanupErrors.length > 0) {
|
|
3828
|
+
throw new AggregateError([error, ...cleanupErrors], "derivation initialization failed and private cleanup was incomplete");
|
|
3829
|
+
}
|
|
3830
|
+
throw error;
|
|
3831
|
+
}
|
|
3832
|
+
if (session === null) throw new Error("derivation session was not initialized");
|
|
3833
|
+
try {
|
|
3834
|
+
// A contained session launches on about:blank, proves the exact extension
|
|
3835
|
+
// and proxy boundary through private CDP/control channels, and only then
|
|
3836
|
+
// permits the first provider navigation. The bootstrap remains unrecorded.
|
|
3837
|
+
await initializeContainedNetworkGuard(session);
|
|
3838
|
+
await batch(session, [["open", derivationBootstrapUrl(target, cookieOrigins)]]);
|
|
3839
|
+
if (auth.kind === "cookie-source" || auth.kind === "cookies-file") {
|
|
3840
|
+
await batch(
|
|
3841
|
+
session,
|
|
3842
|
+
await derivationCookieCommands(auth, target, cookieOrigins),
|
|
3843
|
+
);
|
|
3844
|
+
} else if (auth.kind === "browser-profile" && auth.cookieSource !== undefined) {
|
|
3845
|
+
const cookies = await acquireCookieRecords({
|
|
3846
|
+
cookieSources: [auth.cookieSource],
|
|
3847
|
+
cookieProfile: auth.cookieProfile,
|
|
3848
|
+
cookiesFile: undefined,
|
|
3849
|
+
timeoutMs: 60_000,
|
|
3850
|
+
}, target);
|
|
3851
|
+
await batch(session, browserCookieCommands(cookies.cookies, target));
|
|
3852
|
+
}
|
|
3853
|
+
await batch(session, [["network", "har", "start", "--content", options.contentMode]]);
|
|
3854
|
+
await batch(session, [["open", target.href]]);
|
|
3855
|
+
await waitForDerivationOrigin(session, target.href);
|
|
3856
|
+
await assertDerivationOrigin(session);
|
|
3857
|
+
return publishDerivationReady(id, environment);
|
|
3858
|
+
} catch (error) {
|
|
3859
|
+
if (await closeSession(session, environment)) {
|
|
3860
|
+
removeSessionTrees(session, environment);
|
|
3861
|
+
throw error;
|
|
3862
|
+
}
|
|
3863
|
+
throw new Error(`derivation ${id} failed to start and could not be closed; its private session was preserved for 'ghostget derive discard ${id} --yes'`, { cause: error });
|
|
3864
|
+
}
|
|
3865
|
+
} finally {
|
|
3866
|
+
gate.release();
|
|
3867
|
+
}
|
|
3868
|
+
}
|
|
3869
|
+
|
|
3870
|
+
function isReference(value: string | undefined): boolean {
|
|
3871
|
+
return value !== undefined && /^@e\d+$/u.test(value);
|
|
3872
|
+
}
|
|
3873
|
+
|
|
3874
|
+
function safePlainArgument(value: string | undefined, maximum = 64 * 1024): value is string {
|
|
3875
|
+
return value !== undefined && value.length > 0 && value.length <= maximum && !value.startsWith("--") && !value.includes("\u0000");
|
|
3876
|
+
}
|
|
3877
|
+
|
|
3878
|
+
function safeFillValue(value: string | undefined): value is string {
|
|
3879
|
+
return value !== undefined && value.length <= 64 * 1024 && !value.includes("\u0000");
|
|
3880
|
+
}
|
|
3881
|
+
|
|
3882
|
+
function validateFindCommand(command: readonly string[]): void {
|
|
3883
|
+
const locator = command[1];
|
|
3884
|
+
const locatorValue = command[2];
|
|
3885
|
+
if (!["role", "text", "label", "placeholder", "alt", "title", "testid"].includes(locator ?? "")) {
|
|
3886
|
+
throw new Error("derive browser permits semantic find locators only");
|
|
3887
|
+
}
|
|
3888
|
+
if (!safePlainArgument(locatorValue, 2_000)) throw new Error("derive browser locator is invalid");
|
|
3889
|
+
const action = command[3];
|
|
3890
|
+
// The pinned 0.32.3 help advertises semantic `find ... focus`, but the
|
|
3891
|
+
// implementation rejects it as an unknown subaction. Use `focus @ref`.
|
|
3892
|
+
if (!["click", "fill", "type", "hover"].includes(action ?? "")) throw new Error("derive browser find action is invalid");
|
|
3893
|
+
let cursor = 4;
|
|
3894
|
+
if (action === "fill" || action === "type") {
|
|
3895
|
+
if (action === "fill" ? !safeFillValue(command[cursor]) : !safePlainArgument(command[cursor])) {
|
|
3896
|
+
throw new Error(`derive browser ${action} requires a bounded value`);
|
|
3897
|
+
}
|
|
3898
|
+
cursor += 1;
|
|
3899
|
+
}
|
|
3900
|
+
let sawName = false;
|
|
3901
|
+
let sawExact = false;
|
|
3902
|
+
while (cursor < command.length) {
|
|
3903
|
+
const option = command[cursor];
|
|
3904
|
+
if (option === "--name" && locator === "role" && !sawName) {
|
|
3905
|
+
if (!safePlainArgument(command[cursor + 1], 2_000)) throw new Error("derive browser role name is invalid");
|
|
3906
|
+
sawName = true;
|
|
3907
|
+
cursor += 2;
|
|
3908
|
+
} else if (option === "--exact" && !sawExact) {
|
|
3909
|
+
sawExact = true;
|
|
3910
|
+
cursor += 1;
|
|
3911
|
+
} else throw new Error(`derive browser find option is not allowed: ${option ?? "missing"}`);
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3914
|
+
|
|
3915
|
+
export function validateDerivationBrowserCommand(
|
|
3916
|
+
policy: {
|
|
3917
|
+
readonly allowRemoteActions: boolean;
|
|
3918
|
+
readonly targetOrigin: string;
|
|
3919
|
+
readonly fixtures?: readonly DerivationFixture[];
|
|
3920
|
+
},
|
|
3921
|
+
command: readonly string[],
|
|
3922
|
+
): void {
|
|
3923
|
+
if (command.length < 1 || command.length > 100 || command.some((part) => part.length > 64 * 1024 || part.includes("\u0000"))) {
|
|
3924
|
+
throw new Error("derive browser command is empty or exceeds its argument bounds");
|
|
3925
|
+
}
|
|
3926
|
+
const action = command[0] ?? "";
|
|
3927
|
+
if (!allowedBrowserCommands.has(action)) throw new Error(`derive browser does not allow ${action}`);
|
|
3928
|
+
if (command.some((token) => ["--fn", "--download", "--headers", "--require-md", "--llms", "cdp-url"].includes(token))) {
|
|
3929
|
+
throw new Error("derive browser command requests a disallowed escape or file/network feature");
|
|
3930
|
+
}
|
|
3931
|
+
if (action === "open") {
|
|
3932
|
+
if (command.length !== 2) throw new Error("derive browser open accepts exactly one URL");
|
|
3933
|
+
const target = validateTarget(command[1] ?? "");
|
|
3934
|
+
if (target.origin !== policy.targetOrigin) throw new Error("derive browser navigation must stay on the target origin");
|
|
3935
|
+
}
|
|
3936
|
+
else if (action === "back" || action === "forward" || action === "reload" || action === "close") {
|
|
3937
|
+
if (command.length !== 1) throw new Error(`derive browser ${action} accepts no arguments`);
|
|
3938
|
+
}
|
|
3939
|
+
else if (action === "snapshot") {
|
|
3940
|
+
const options = command.slice(1);
|
|
3941
|
+
if (
|
|
3942
|
+
options.length > 3
|
|
3943
|
+
|| new Set(options).size !== options.length
|
|
3944
|
+
|| options.some((option) => option !== "-i" && option !== "-c" && option !== "-u")
|
|
3945
|
+
) {
|
|
3946
|
+
throw new Error("derive browser snapshot accepts only unique -i, -c, and -u options");
|
|
3947
|
+
}
|
|
3948
|
+
}
|
|
3949
|
+
else if (action === "get") {
|
|
3950
|
+
const kind = command[1];
|
|
3951
|
+
if ((kind === "url" || kind === "title") && command.length === 2) {
|
|
3952
|
+
// Fixed no-target metadata reads.
|
|
3953
|
+
} else if (["text", "html", "value"].includes(kind ?? "") && command.length === 3 && isReference(command[2])) {
|
|
3954
|
+
// Snapshot-bound element reads.
|
|
3955
|
+
} else if (kind === "attr" && command.length === 4 && isReference(command[2]) && /^[A-Za-z_:][-A-Za-z0-9_:.]{0,127}$/u.test(command[3] ?? "")) {
|
|
3956
|
+
// Snapshot-bound attribute reads.
|
|
3957
|
+
} else throw new Error("derive browser get is limited to URL/title or snapshot references");
|
|
3958
|
+
}
|
|
3959
|
+
else if (action === "network") {
|
|
3960
|
+
if (command[1] !== "requests") throw new Error("derive browser network permits request listing only");
|
|
3961
|
+
if (command.length === 2) {
|
|
3962
|
+
// The browser returns request metadata only; raw headers and bodies stay in managed HAR state.
|
|
3963
|
+
} else if (command.length !== 4 || command[2] !== "--filter" || !safePlainArgument(command[3], 2_000)) {
|
|
3964
|
+
throw new Error("derive browser network requests accepts only one bounded --filter value");
|
|
3965
|
+
}
|
|
3966
|
+
}
|
|
3967
|
+
else if (action === "wait") {
|
|
3968
|
+
const milliseconds = command.length === 2 && /^\d{1,5}$/u.test(command[1] ?? "") ? Number(command[1]) : null;
|
|
3969
|
+
const semanticWait = command.length === 3 && (command[1] === "--text" || command[1] === "--url") && safePlainArgument(command[2], 2_000);
|
|
3970
|
+
if ((milliseconds === null || milliseconds < 1 || milliseconds > 30_000) && !semanticWait) {
|
|
3971
|
+
throw new Error("derive browser wait accepts 1-30000ms, --text, or --url only");
|
|
3972
|
+
}
|
|
3973
|
+
}
|
|
3974
|
+
else if (action === "scroll") {
|
|
3975
|
+
if (command.length < 2 || command.length > 3 || !["up", "down"].includes(command[1] ?? "")) {
|
|
3976
|
+
throw new Error("derive browser scroll requires up or down and an optional bounded distance");
|
|
3977
|
+
}
|
|
3978
|
+
if (command[2] !== undefined && (!/^\d{1,5}$/u.test(command[2]) || Number(command[2]) > 10_000)) {
|
|
3979
|
+
throw new Error("derive browser scroll distance is invalid");
|
|
3980
|
+
}
|
|
3981
|
+
}
|
|
3982
|
+
else if (action === "scrollintoview" || action === "focus" || action === "hover" || action === "click" || action === "dblclick" || action === "check" || action === "uncheck") {
|
|
3983
|
+
if (command.length !== 2 || !isReference(command[1])) throw new Error(`derive browser ${action} requires one snapshot reference`);
|
|
3984
|
+
}
|
|
3985
|
+
else if (action === "fill" || action === "type") {
|
|
3986
|
+
const validValue = action === "fill" ? safeFillValue(command[2]) : safePlainArgument(command[2]);
|
|
3987
|
+
if (command.length !== 3 || !isReference(command[1]) || !validValue) throw new Error(`derive browser ${action} requires a reference and bounded value`);
|
|
3988
|
+
}
|
|
3989
|
+
else if (action === "cleartext") {
|
|
3990
|
+
if (command.length !== 2 || !isReference(command[1])) {
|
|
3991
|
+
throw new Error("derive browser cleartext requires one snapshot textbox reference");
|
|
3992
|
+
}
|
|
3993
|
+
}
|
|
3994
|
+
else if (action === "press") {
|
|
3995
|
+
if (command.length !== 2 || !/^[A-Za-z0-9+_-]{1,100}$/u.test(command[1] ?? "")) throw new Error("derive browser press key is invalid");
|
|
3996
|
+
}
|
|
3997
|
+
else if (action === "select") {
|
|
3998
|
+
if (command.length < 3 || command.length > 20 || !isReference(command[1]) || command.slice(2).some((value) => !safePlainArgument(value, 2_000))) {
|
|
3999
|
+
throw new Error("derive browser select requires a reference and bounded values");
|
|
4000
|
+
}
|
|
4001
|
+
}
|
|
4002
|
+
else if (action === "upload" || action === "upload-and-seal" || action === "choose-upload") {
|
|
4003
|
+
const references = command.slice(2);
|
|
4004
|
+
const available = new Set((policy.fixtures ?? []).map((fixture) => fixture.reference));
|
|
4005
|
+
if (
|
|
4006
|
+
command.length < 3
|
|
4007
|
+
|| command.length > 22
|
|
4008
|
+
|| (
|
|
4009
|
+
action === "choose-upload"
|
|
4010
|
+
? !isReference(command[1])
|
|
4011
|
+
: !isReference(command[1])
|
|
4012
|
+
&& command[1] !== singleFileInputReference
|
|
4013
|
+
&& command[1] !== singleImageInputReference
|
|
4014
|
+
&& command[1] !== singleVideoInputReference
|
|
4015
|
+
)
|
|
4016
|
+
|| references.length !== new Set(references).size
|
|
4017
|
+
|| references.some((reference) => !/^fixture:(?:[1-9]|1[0-9]|20)$/u.test(reference) || !available.has(reference))
|
|
4018
|
+
) {
|
|
4019
|
+
throw new Error(
|
|
4020
|
+
action === "choose-upload"
|
|
4021
|
+
? "derive browser choose-upload requires one snapshot upload-control reference and unique staged fixture:<n> references"
|
|
4022
|
+
: `derive browser ${action} requires a snapshot, @single-file-input, @single-image-input, or @single-video-input reference and unique staged fixture:<n> references`,
|
|
4023
|
+
);
|
|
4024
|
+
}
|
|
4025
|
+
if (
|
|
4026
|
+
command[1] === singleVideoInputReference
|
|
4027
|
+
&& references.some((reference) =>
|
|
4028
|
+
policy.fixtures?.find((fixture) => fixture.reference === reference)?.mediaType !== "video/mp4")
|
|
4029
|
+
) {
|
|
4030
|
+
throw new Error("derive browser @single-video-input accepts MP4 fixtures only");
|
|
4031
|
+
}
|
|
4032
|
+
}
|
|
4033
|
+
else if (action === "find") validateFindCommand(command);
|
|
4034
|
+
if (!policy.allowRemoteActions && commandCanMutate(command)) {
|
|
4035
|
+
throw new Error("derive session is read-only; restart it with --allow-remote-actions only after the remote action is authorized");
|
|
4036
|
+
}
|
|
4037
|
+
}
|
|
4038
|
+
|
|
4039
|
+
function boundedNetworkLabel(value: unknown, maximum: number): string | null {
|
|
4040
|
+
if (typeof value !== "string" || value.length < 1 || value.length > maximum) return null;
|
|
4041
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
4042
|
+
const code = value.charCodeAt(index);
|
|
4043
|
+
if (code <= 0x1f || code === 0x7f) return null;
|
|
4044
|
+
}
|
|
4045
|
+
return value;
|
|
4046
|
+
}
|
|
4047
|
+
|
|
4048
|
+
/** Strip credentials, header values, query values, timestamps, and browser internals. */
|
|
4049
|
+
export function sanitizeDerivationNetworkResult(value: unknown): {
|
|
4050
|
+
readonly requests: readonly Readonly<Record<string, unknown>>[];
|
|
4051
|
+
readonly truncated: boolean;
|
|
4052
|
+
} {
|
|
4053
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
4054
|
+
throw new Error("agent-browser returned malformed network request metadata");
|
|
4055
|
+
}
|
|
4056
|
+
const raw = (value as Record<string, unknown>).requests;
|
|
4057
|
+
if (!Array.isArray(raw)) throw new Error("agent-browser returned malformed network request metadata");
|
|
4058
|
+
const maximum = 1_000;
|
|
4059
|
+
const requests: Readonly<Record<string, unknown>>[] = [];
|
|
4060
|
+
for (const candidate of raw.slice(0, maximum)) {
|
|
4061
|
+
if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) continue;
|
|
4062
|
+
const record = candidate as Record<string, unknown>;
|
|
4063
|
+
const method = boundedNetworkLabel(record.method, 16);
|
|
4064
|
+
const rawUrl = boundedNetworkLabel(record.url, 64 * 1024);
|
|
4065
|
+
if (method === null || rawUrl === null || !/^[A-Z]{3,10}$/u.test(method)) continue;
|
|
4066
|
+
let url: URL;
|
|
4067
|
+
try {
|
|
4068
|
+
url = new URL(rawUrl);
|
|
4069
|
+
} catch {
|
|
4070
|
+
continue;
|
|
4071
|
+
}
|
|
4072
|
+
if (url.protocol !== "https:" || url.username !== "" || url.password !== "") continue;
|
|
4073
|
+
const status = Number.isSafeInteger(record.status) && (record.status as number) >= 0 && (record.status as number) <= 999
|
|
4074
|
+
? record.status as number
|
|
4075
|
+
: null;
|
|
4076
|
+
const mimeType = boundedNetworkLabel(record.mimeType, 256);
|
|
4077
|
+
const resourceType = boundedNetworkLabel(record.resourceType, 128);
|
|
4078
|
+
requests.push({
|
|
4079
|
+
method,
|
|
4080
|
+
origin: url.origin,
|
|
4081
|
+
path: url.pathname,
|
|
4082
|
+
queryNames: [...new Set(url.searchParams.keys())].sort(),
|
|
4083
|
+
...(status === null ? {} : { status }),
|
|
4084
|
+
...(mimeType === null ? {} : { mimeType }),
|
|
4085
|
+
...(resourceType === null ? {} : { resourceType }),
|
|
4086
|
+
});
|
|
4087
|
+
}
|
|
4088
|
+
return { requests, truncated: raw.length > maximum };
|
|
4089
|
+
}
|
|
4090
|
+
|
|
4091
|
+
export function derivationFixedUploadInputSelector(uploadTarget: string): string | null {
|
|
4092
|
+
return uploadTarget === singleFileInputReference
|
|
4093
|
+
? singleFileInputSelector
|
|
4094
|
+
: uploadTarget === singleImageInputReference
|
|
4095
|
+
? singleImageInputSelector
|
|
4096
|
+
: uploadTarget === singleVideoInputReference
|
|
4097
|
+
? singleVideoInputSelector
|
|
4098
|
+
: null;
|
|
4099
|
+
}
|
|
4100
|
+
|
|
4101
|
+
export function resolveDerivationFixedUploadInputTarget(
|
|
4102
|
+
uploadTarget: string,
|
|
4103
|
+
countData: unknown,
|
|
4104
|
+
): string {
|
|
4105
|
+
const fixedInputSelector = derivationFixedUploadInputSelector(uploadTarget);
|
|
4106
|
+
if (fixedInputSelector === null) return uploadTarget;
|
|
4107
|
+
const count = typeof countData === "number"
|
|
4108
|
+
? countData
|
|
4109
|
+
: typeof countData === "object"
|
|
4110
|
+
&& countData !== null
|
|
4111
|
+
&& !Array.isArray(countData)
|
|
4112
|
+
&& Object.keys(countData).sort().join(",") === "count,lifecycle,selector"
|
|
4113
|
+
&& typeof (countData as Record<string, unknown>).count === "number"
|
|
4114
|
+
&& (countData as Record<string, unknown>).selector === fixedInputSelector
|
|
4115
|
+
&& typeof (countData as Record<string, unknown>).lifecycle === "object"
|
|
4116
|
+
&& (countData as Record<string, unknown>).lifecycle !== null
|
|
4117
|
+
&& !Array.isArray((countData as Record<string, unknown>).lifecycle)
|
|
4118
|
+
? (countData as Record<string, number>).count
|
|
4119
|
+
: null;
|
|
4120
|
+
if (!Number.isSafeInteger(count) || count !== 1) {
|
|
4121
|
+
const shape = Array.isArray(countData)
|
|
4122
|
+
? "array"
|
|
4123
|
+
: typeof countData === "object" && countData !== null
|
|
4124
|
+
? `object(${Object.keys(countData).sort().slice(0, 20).join(",")})`
|
|
4125
|
+
: typeof countData;
|
|
4126
|
+
throw new Error(`derive browser ${uploadTarget} requires exactly one matching file input on the current page${Number.isSafeInteger(count) ? `; found ${count}` : `; browser returned ${shape}`}`);
|
|
4127
|
+
}
|
|
4128
|
+
return fixedInputSelector;
|
|
4129
|
+
}
|
|
4130
|
+
|
|
4131
|
+
/** Executes one upload batch without weakening an ambiguous mutation result. */
|
|
4132
|
+
export async function runDerivationFixtureUploadBatch(
|
|
4133
|
+
mode: "upload" | "upload-and-seal",
|
|
4134
|
+
invoke: () => Promise<readonly Record<string, unknown>[]>,
|
|
4135
|
+
directory: string,
|
|
4136
|
+
fixtures: readonly DerivationFixture[],
|
|
4137
|
+
): Promise<readonly Record<string, unknown>[]> {
|
|
4138
|
+
try {
|
|
4139
|
+
return await invoke();
|
|
4140
|
+
} catch (error) {
|
|
4141
|
+
// Once a profile or pinned confirmation was attempted, neither fixture
|
|
4142
|
+
// revalidation nor diagnostic redaction may turn the result into an
|
|
4143
|
+
// ordinary retryable error.
|
|
4144
|
+
if (error instanceof IndeterminateDerivationConfirmationError) throw error;
|
|
4145
|
+
for (const fixture of fixtures) assertDerivationFixtureFile(directory, fixture);
|
|
4146
|
+
if (mode === "upload-and-seal") {
|
|
4147
|
+
throw new Error("managed browser could not upload and seal the staged derivation fixture");
|
|
4148
|
+
}
|
|
4149
|
+
let detail = error instanceof Error ? error.message : "";
|
|
4150
|
+
detail = detail.replaceAll(directory, "<private-derivation>");
|
|
4151
|
+
for (const fixture of fixtures) {
|
|
4152
|
+
detail = detail
|
|
4153
|
+
.replaceAll(`./${fixture.fileName}`, fixture.reference)
|
|
4154
|
+
.replaceAll(fixture.fileName, fixture.reference);
|
|
4155
|
+
}
|
|
4156
|
+
if (
|
|
4157
|
+
detail.length < 1
|
|
4158
|
+
|| detail.length > 1_000
|
|
4159
|
+
|| /[\u0000-\u001f\u007f]/u.test(detail)
|
|
4160
|
+
|| detail.includes("/Users/")
|
|
4161
|
+
|| detail.includes("/private/")
|
|
4162
|
+
|| detail.includes("/tmp/")
|
|
4163
|
+
|| detail.includes("\\")
|
|
4164
|
+
) detail = "";
|
|
4165
|
+
throw new Error(`managed browser could not upload the staged derivation fixture${detail === "" ? "" : `: ${detail}`}`);
|
|
4166
|
+
}
|
|
4167
|
+
}
|
|
4168
|
+
|
|
4169
|
+
/** Keeps every failure after a confirmed mutation permanently non-retryable. */
|
|
4170
|
+
export async function runDerivationMutationWindow<T>(
|
|
4171
|
+
kind: "pinned" | "profile",
|
|
4172
|
+
invoke: (markMutationMayHaveApplied: () => void) => Promise<T>,
|
|
4173
|
+
containFailure: (error: unknown) => Promise<unknown> = async (error) => error,
|
|
4174
|
+
): Promise<T> {
|
|
4175
|
+
let mutationMayHaveApplied = false;
|
|
4176
|
+
try {
|
|
4177
|
+
return await invoke(() => {
|
|
4178
|
+
mutationMayHaveApplied = true;
|
|
4179
|
+
});
|
|
4180
|
+
} catch (error) {
|
|
4181
|
+
let failure: unknown;
|
|
4182
|
+
try {
|
|
4183
|
+
failure = await containFailure(error);
|
|
4184
|
+
} catch (containmentError) {
|
|
4185
|
+
failure = containmentError;
|
|
4186
|
+
}
|
|
4187
|
+
if (
|
|
4188
|
+
mutationMayHaveApplied
|
|
4189
|
+
&& !(failure instanceof IndeterminateDerivationConfirmationError)
|
|
4190
|
+
) {
|
|
4191
|
+
throw new IndeterminateDerivationConfirmationError(kind);
|
|
4192
|
+
}
|
|
4193
|
+
throw failure;
|
|
4194
|
+
}
|
|
4195
|
+
}
|
|
4196
|
+
|
|
4197
|
+
export async function runDerivationBrowserCommand(
|
|
4198
|
+
id: string,
|
|
4199
|
+
command: readonly string[],
|
|
4200
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
4201
|
+
): Promise<unknown> {
|
|
4202
|
+
return withDerivationLifecycleGate(id, environment, async () => {
|
|
4203
|
+
const session = loadSession(id, environment);
|
|
4204
|
+
assertDerivationRecorderCommandAllowed(
|
|
4205
|
+
command,
|
|
4206
|
+
readReviewSeal(session, environment) !== null || hasCapturedHar(session, environment),
|
|
4207
|
+
);
|
|
4208
|
+
assertBrowserDerivationTargetAllowed(new URL(session.targetOrigin));
|
|
4209
|
+
validateDerivationBrowserCommand(session, command);
|
|
4210
|
+
if (command[0] === "close") {
|
|
4211
|
+
if (!await closeSession(session, environment)) throw new Error(`derivation ${id} could not be closed safely`);
|
|
4212
|
+
return { closed: true };
|
|
4213
|
+
}
|
|
4214
|
+
if (command[0] === "wait" && command.length === 2 && /^\d{1,5}$/u.test(command[1] ?? "")) {
|
|
4215
|
+
const waitedMs = Number(command[1]);
|
|
4216
|
+
await assertDerivationOrigin(session);
|
|
4217
|
+
await Bun.sleep(waitedMs);
|
|
4218
|
+
await assertDerivationOrigin(session);
|
|
4219
|
+
return { waitedMs };
|
|
4220
|
+
}
|
|
4221
|
+
const chooserUploadAction = command[0] === "choose-upload";
|
|
4222
|
+
const uploadAction = command[0] === "upload" || command[0] === "upload-and-seal" || chooserUploadAction;
|
|
4223
|
+
const requestedFixtures = uploadAction
|
|
4224
|
+
? command.slice(2).map((reference) => {
|
|
4225
|
+
const fixture = session.fixtures.find((candidate) => candidate.reference === reference);
|
|
4226
|
+
if (fixture === undefined) throw new Error("derivation fixture reference is unavailable");
|
|
4227
|
+
return fixture;
|
|
4228
|
+
})
|
|
4229
|
+
: [];
|
|
4230
|
+
const fixturePaths = requestedFixtures.map((fixture) => {
|
|
4231
|
+
assertDerivationFixtureFile(session.directory, fixture);
|
|
4232
|
+
// The persistent browser daemon may outlive the short helper process
|
|
4233
|
+
// that launched it and therefore need not retain that helper's cwd.
|
|
4234
|
+
// Resolve only Ghostget-owned, identity-verified staged files here; raw
|
|
4235
|
+
// caller paths remain outside the browser command grammar and errors
|
|
4236
|
+
// redact the private derivation directory below.
|
|
4237
|
+
return join(session.directory, fixture.fileName);
|
|
4238
|
+
});
|
|
4239
|
+
return runDerivationMutationWindow(
|
|
4240
|
+
session.profilePath === null ? "pinned" : "profile",
|
|
4241
|
+
async (markMutationMayHaveApplied) => {
|
|
4242
|
+
const trackedBatch = (commands: readonly (readonly string[])[]) => batch(
|
|
4243
|
+
session,
|
|
4244
|
+
commands,
|
|
4245
|
+
(completedCommand) => {
|
|
4246
|
+
if (commandCanMutate(completedCommand)) markMutationMayHaveApplied();
|
|
4247
|
+
},
|
|
4248
|
+
);
|
|
4249
|
+
if (commandCanMutate(command)) await assertDerivationOrigin(session);
|
|
4250
|
+
if (command[0] === "cleartext") {
|
|
4251
|
+
const reference = command[1] ?? "";
|
|
4252
|
+
await trackedBatch([["fill", reference, ""]]);
|
|
4253
|
+
await assertDerivationOrigin(session);
|
|
4254
|
+
return { cleared: reference };
|
|
4255
|
+
}
|
|
4256
|
+
if (chooserUploadAction) {
|
|
4257
|
+
const currentUrl = await currentDerivationUrl(session);
|
|
4258
|
+
if (currentUrl.origin !== session.targetOrigin) {
|
|
4259
|
+
throw new Error(`derivation left its target origin: ${currentUrl.origin}`);
|
|
4260
|
+
}
|
|
4261
|
+
const [cdpRecord] = await trackedBatch([["get", "cdp-url"]]);
|
|
4262
|
+
const cdpData = cdpRecord === undefined ? null : browserResultData(cdpRecord);
|
|
4263
|
+
const cdpUrl = typeof cdpData === "object" && cdpData !== null && !Array.isArray(cdpData)
|
|
4264
|
+
? localBrowserCdpUrl((cdpData as Record<string, unknown>).cdpUrl)
|
|
4265
|
+
: localBrowserCdpUrl(null);
|
|
4266
|
+
await uploadThroughInterceptedFileChooser({
|
|
4267
|
+
cdpUrl,
|
|
4268
|
+
click: async () => {
|
|
4269
|
+
await trackedBatch([["click", command[1] ?? ""]]);
|
|
4270
|
+
},
|
|
4271
|
+
currentUrl: currentUrl.href,
|
|
4272
|
+
filePaths: fixturePaths,
|
|
4273
|
+
});
|
|
4274
|
+
await trackedBatch([["wait", String(uploadSettlingDelayMs)]]);
|
|
4275
|
+
for (const fixture of requestedFixtures) assertDerivationFixtureFile(session.directory, fixture);
|
|
4276
|
+
await assertDerivationOrigin(session);
|
|
4277
|
+
return { attachedFiles: requestedFixtures.length };
|
|
4278
|
+
}
|
|
4279
|
+
let uploadTarget = command[1] ?? "";
|
|
4280
|
+
const fixedInputSelector = derivationFixedUploadInputSelector(uploadTarget);
|
|
4281
|
+
if (uploadAction && fixedInputSelector !== null) {
|
|
4282
|
+
const [countRecord] = await trackedBatch([["get", "count", fixedInputSelector]]);
|
|
4283
|
+
const countData = countRecord === undefined ? null : browserResultData(countRecord);
|
|
4284
|
+
uploadTarget = resolveDerivationFixedUploadInputTarget(uploadTarget, countData);
|
|
4285
|
+
}
|
|
4286
|
+
const browserCommand = uploadAction
|
|
4287
|
+
? ["upload", uploadTarget, ...fixturePaths]
|
|
4288
|
+
: command;
|
|
4289
|
+
if (command[0] === "upload-and-seal") {
|
|
4290
|
+
const records = await runDerivationFixtureUploadBatch(
|
|
4291
|
+
"upload-and-seal",
|
|
4292
|
+
() => trackedBatch([
|
|
4293
|
+
browserCommand,
|
|
4294
|
+
["wait", String(uploadSettlingDelayMs)],
|
|
4295
|
+
["network", "har", "stop", "capture.har"],
|
|
4296
|
+
]),
|
|
4297
|
+
session.directory,
|
|
4298
|
+
requestedFixtures,
|
|
4299
|
+
);
|
|
4300
|
+
for (const fixture of requestedFixtures) assertDerivationFixtureFile(session.directory, fixture);
|
|
4301
|
+
if (!hasCapturedHar(session, environment)) {
|
|
4302
|
+
throw new Error("managed browser upload completed without a sealed derivation recorder");
|
|
4303
|
+
}
|
|
4304
|
+
const uploadRecord = records[0];
|
|
4305
|
+
return {
|
|
4306
|
+
upload: uploadRecord === undefined ? null : browserResultData(uploadRecord),
|
|
4307
|
+
recorder: "sealed",
|
|
4308
|
+
};
|
|
4309
|
+
}
|
|
4310
|
+
let record: Record<string, unknown> | undefined;
|
|
4311
|
+
if (uploadAction) {
|
|
4312
|
+
// Keep the pinned browser batch alive while page code consumes the
|
|
4313
|
+
// selected File and settles its first-party upload. Ending the batch
|
|
4314
|
+
// immediately after setInputFiles can abort a deferred request even
|
|
4315
|
+
// though the page already rendered a local blob preview.
|
|
4316
|
+
const records = await runDerivationFixtureUploadBatch(
|
|
4317
|
+
"upload",
|
|
4318
|
+
() => trackedBatch([browserCommand, ["wait", String(uploadSettlingDelayMs)]]),
|
|
4319
|
+
session.directory,
|
|
4320
|
+
requestedFixtures,
|
|
4321
|
+
);
|
|
4322
|
+
[record] = records;
|
|
4323
|
+
} else {
|
|
4324
|
+
const records = await trackedBatch([browserCommand]);
|
|
4325
|
+
[record] = records;
|
|
4326
|
+
}
|
|
4327
|
+
for (const fixture of requestedFixtures) assertDerivationFixtureFile(session.directory, fixture);
|
|
4328
|
+
await assertDerivationOrigin(session);
|
|
4329
|
+
const data = record === undefined ? null : browserResultData(record);
|
|
4330
|
+
return command[0] === "network" ? sanitizeDerivationNetworkResult(data) : data;
|
|
4331
|
+
},
|
|
4332
|
+
async (error) => {
|
|
4333
|
+
if (!(error instanceof Error) || !error.message.includes("left its target origin")) {
|
|
4334
|
+
return error;
|
|
4335
|
+
}
|
|
4336
|
+
if (!await closeSession(session, environment)) {
|
|
4337
|
+
return new Error(
|
|
4338
|
+
`derivation ${id} left its target origin and could not be closed; its private session was preserved`,
|
|
4339
|
+
{ cause: error },
|
|
4340
|
+
);
|
|
4341
|
+
}
|
|
4342
|
+
removeSessionTrees(session, environment);
|
|
4343
|
+
return error;
|
|
4344
|
+
},
|
|
4345
|
+
);
|
|
4346
|
+
});
|
|
4347
|
+
}
|
|
4348
|
+
|
|
4349
|
+
export function assertDerivationRecorderCommandAllowed(
|
|
4350
|
+
command: readonly string[],
|
|
4351
|
+
sealed: boolean,
|
|
4352
|
+
): void {
|
|
4353
|
+
if (sealed && command[0] !== "close") {
|
|
4354
|
+
throw new Error("derivation recorder is sealed for private review; only review, finish, discard, or close is allowed");
|
|
4355
|
+
}
|
|
4356
|
+
}
|
|
4357
|
+
|
|
4358
|
+
async function sealDerivationReview(
|
|
4359
|
+
session: DerivationSession,
|
|
4360
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
4361
|
+
): Promise<{ readonly seal: SealedDerivationReview; readonly text: string }> {
|
|
4362
|
+
const existing = readReviewSeal(session, environment);
|
|
4363
|
+
if (existing !== null) return { seal: existing, text: readSealedReviewHar(session, existing) };
|
|
4364
|
+
|
|
4365
|
+
assertBrowserDerivationTargetAllowed(new URL(session.targetOrigin));
|
|
4366
|
+
let stopFailure: unknown = null;
|
|
4367
|
+
if (!hasCapturedHar(session, environment)) {
|
|
4368
|
+
await assertDerivationOrigin(session);
|
|
4369
|
+
try {
|
|
4370
|
+
await batch(session, [["network", "har", "stop", "capture.har"]]);
|
|
4371
|
+
} catch (error) {
|
|
4372
|
+
stopFailure = error;
|
|
4373
|
+
}
|
|
4374
|
+
}
|
|
4375
|
+
if (!hasCapturedHar(session, environment)) {
|
|
4376
|
+
if (stopFailure instanceof Error) throw stopFailure;
|
|
4377
|
+
throw new Error("derivation recorder did not produce its managed HAR");
|
|
4378
|
+
}
|
|
4379
|
+
|
|
4380
|
+
const path = captureHarPath(session);
|
|
4381
|
+
const before = inspectCapturedHar(path);
|
|
4382
|
+
const text = readRegularFile(path, MAX_HAR_BYTES, "derivation review HAR", session.directoryIdentity);
|
|
4383
|
+
const after = inspectCapturedHar(path);
|
|
4384
|
+
if (!sameCapturedHar(before, after) || Buffer.byteLength(text, "utf8") !== before.byteLength) {
|
|
4385
|
+
throw new Error("derivation review HAR changed while it was being sealed");
|
|
4386
|
+
}
|
|
4387
|
+
// Parse before persisting the seal. A crash after HAR stop but before this
|
|
4388
|
+
// write is recoverable: the next review validates and seals the retained HAR.
|
|
4389
|
+
reviewDerivationHarText(text, session.targetOrigin, { kind: "list", offset: 0, limit: 1 });
|
|
4390
|
+
const seal: SealedDerivationReview = {
|
|
4391
|
+
schemaVersion: 1,
|
|
4392
|
+
state: "sealed",
|
|
4393
|
+
har: { ...before, sha256: sha256(text) },
|
|
4394
|
+
};
|
|
4395
|
+
createPrivateJsonIfAbsent(reviewMarkerPath(session), seal, {
|
|
4396
|
+
environment,
|
|
4397
|
+
expectedStateParent: session.directoryIdentity,
|
|
4398
|
+
});
|
|
4399
|
+
const persisted = readReviewSeal(session, environment);
|
|
4400
|
+
if (persisted === null) throw new Error("derivation review seal was not persisted");
|
|
4401
|
+
if (
|
|
4402
|
+
persisted.schemaVersion !== seal.schemaVersion
|
|
4403
|
+
|| persisted.state !== seal.state
|
|
4404
|
+
|| !sameCapturedHar(persisted.har, seal.har)
|
|
4405
|
+
|| persisted.har.sha256 !== seal.har.sha256
|
|
4406
|
+
) {
|
|
4407
|
+
throw new Error("derivation review seal publication elected a different HAR");
|
|
4408
|
+
}
|
|
4409
|
+
return { seal: persisted, text: readSealedReviewHar(session, persisted) };
|
|
4410
|
+
}
|
|
4411
|
+
|
|
4412
|
+
function admittedDerivationReviewOrigin(
|
|
4413
|
+
session: DerivationSession,
|
|
4414
|
+
reviewOriginValue: string | undefined,
|
|
4415
|
+
command: "review" | "finish",
|
|
4416
|
+
): string {
|
|
4417
|
+
if (reviewOriginValue === undefined) return session.targetOrigin;
|
|
4418
|
+
let reviewOrigin: URL;
|
|
4419
|
+
try {
|
|
4420
|
+
if (reviewOriginValue.length > 4_096) throw new Error("oversized origin");
|
|
4421
|
+
reviewOrigin = new URL(reviewOriginValue);
|
|
4422
|
+
} catch {
|
|
4423
|
+
throw new Error(`derive ${command} --review-origin must be an exact HTTPS origin`);
|
|
4424
|
+
}
|
|
4425
|
+
if (
|
|
4426
|
+
reviewOrigin.protocol !== "https:"
|
|
4427
|
+
|| reviewOrigin.username !== ""
|
|
4428
|
+
|| reviewOrigin.password !== ""
|
|
4429
|
+
|| reviewOrigin.origin !== reviewOriginValue
|
|
4430
|
+
) {
|
|
4431
|
+
throw new Error(`derive ${command} --review-origin must be an exact HTTPS origin`);
|
|
4432
|
+
}
|
|
4433
|
+
if (!browserDomainsCover(session.browserDomains, reviewOrigin.hostname.toLowerCase())) {
|
|
4434
|
+
throw new Error(
|
|
4435
|
+
`derive ${command} --review-origin was not admitted by the derivation start-time browser domains`,
|
|
4436
|
+
);
|
|
4437
|
+
}
|
|
4438
|
+
return reviewOrigin.origin;
|
|
4439
|
+
}
|
|
4440
|
+
|
|
4441
|
+
function stableDirectDerivationReviewSelection(
|
|
4442
|
+
selection: DerivationReviewSelection,
|
|
4443
|
+
): DerivationReviewSelection {
|
|
4444
|
+
if (selection.kind !== "entry") return selection;
|
|
4445
|
+
const direct = selection as {
|
|
4446
|
+
readonly entryIndex: number;
|
|
4447
|
+
readonly fieldNames?: unknown;
|
|
4448
|
+
readonly fixtures?: unknown;
|
|
4449
|
+
readonly kind: "entry";
|
|
4450
|
+
};
|
|
4451
|
+
const fixtures = direct.fixtures;
|
|
4452
|
+
const fieldNames = direct.fieldNames;
|
|
4453
|
+
if (fixtures !== undefined && fieldNames !== undefined) {
|
|
4454
|
+
throw new Error("private review entry probes are mutually exclusive");
|
|
4455
|
+
}
|
|
4456
|
+
if (fieldNames === undefined) return selection;
|
|
4457
|
+
return Object.freeze({
|
|
4458
|
+
kind: "entry",
|
|
4459
|
+
entryIndex: direct.entryIndex,
|
|
4460
|
+
fieldNames: parseDerivationReviewFieldNames(fieldNames),
|
|
4461
|
+
});
|
|
4462
|
+
}
|
|
4463
|
+
|
|
4464
|
+
export async function reviewDerivation(
|
|
4465
|
+
id: string,
|
|
4466
|
+
selection: DerivationReviewSelection,
|
|
4467
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
4468
|
+
reviewOriginValue?: string,
|
|
4469
|
+
): Promise<DerivationReviewResult> {
|
|
4470
|
+
// Direct callers can retain and mutate their selection while HAR sealing
|
|
4471
|
+
// awaits browser shutdown. Validate and take an immutable field-name copy
|
|
4472
|
+
// before entering that lifecycle so a malformed or changed probe cannot
|
|
4473
|
+
// seal the recorder first.
|
|
4474
|
+
const stableSelection = stableDirectDerivationReviewSelection(selection);
|
|
4475
|
+
return withDerivationLifecycleGate(id, environment, async () => {
|
|
4476
|
+
const session = loadSession(id, environment);
|
|
4477
|
+
// Reject caller-selected origins before stopping or sealing the recorder.
|
|
4478
|
+
// Admission comes only from immutable start-time session metadata.
|
|
4479
|
+
const reviewOrigin = admittedDerivationReviewOrigin(session, reviewOriginValue, "review");
|
|
4480
|
+
const sealed = await sealDerivationReview(session, environment);
|
|
4481
|
+
return reviewDerivationHarText(sealed.text, reviewOrigin, stableSelection);
|
|
4482
|
+
});
|
|
4483
|
+
}
|
|
4484
|
+
|
|
4485
|
+
export async function finishDerivation(
|
|
4486
|
+
id: string,
|
|
4487
|
+
outputDirectory: string,
|
|
4488
|
+
options: {
|
|
4489
|
+
readonly force: boolean;
|
|
4490
|
+
readonly registry: ProviderPluginRegistry;
|
|
4491
|
+
readonly reviewOrigin?: string;
|
|
4492
|
+
readonly surfaceId?: PlatformSurfaceId;
|
|
4493
|
+
readonly environment?: Readonly<Record<string, string | undefined>>;
|
|
4494
|
+
},
|
|
4495
|
+
): Promise<{
|
|
4496
|
+
readonly analysis: HarAnalysis;
|
|
4497
|
+
readonly internalEvidence: InternalHarEvidence;
|
|
4498
|
+
readonly manifestPath: string;
|
|
4499
|
+
readonly candidatesPath: string;
|
|
4500
|
+
readonly reservationPath: string;
|
|
4501
|
+
readonly evidencePath: string;
|
|
4502
|
+
}> {
|
|
4503
|
+
const environment = options.environment ?? process.env;
|
|
4504
|
+
return withDerivationLifecycleGate(id, environment, async () => {
|
|
4505
|
+
const session = loadSession(id, environment);
|
|
4506
|
+
// A finish selection is invocation-local. Validate it against immutable
|
|
4507
|
+
// start-time admission before sealing the HAR or touching scaffold output.
|
|
4508
|
+
const reviewOrigin = admittedDerivationReviewOrigin(session, options.reviewOrigin, "finish");
|
|
4509
|
+
assertBrowserDerivationTargetAllowed(new URL(reviewOrigin));
|
|
4510
|
+
assertScaffoldOutput(outputDirectory, options.force);
|
|
4511
|
+
const harPath = captureHarPath(session);
|
|
4512
|
+
let reviewSeal = readReviewSeal(session, environment);
|
|
4513
|
+
if (reviewSeal === null && hasCapturedHar(session, environment)) {
|
|
4514
|
+
reviewSeal = (await sealDerivationReview(session, environment)).seal;
|
|
4515
|
+
}
|
|
4516
|
+
const sealedReview = reviewSeal !== null;
|
|
4517
|
+
let completed: {
|
|
4518
|
+
readonly analysis: HarAnalysis;
|
|
4519
|
+
readonly internalEvidence: InternalHarEvidence;
|
|
4520
|
+
readonly manifestPath: string;
|
|
4521
|
+
readonly candidatesPath: string;
|
|
4522
|
+
readonly reservationPath: string;
|
|
4523
|
+
readonly evidencePath: string;
|
|
4524
|
+
} | null = null;
|
|
4525
|
+
let failure: unknown = null;
|
|
4526
|
+
try {
|
|
4527
|
+
let analysis: HarAnalysis;
|
|
4528
|
+
let internalEvidence: InternalHarEvidence;
|
|
4529
|
+
if (reviewSeal !== null) {
|
|
4530
|
+
const text = readSealedReviewHar(session, reviewSeal);
|
|
4531
|
+
let value: unknown;
|
|
4532
|
+
try {
|
|
4533
|
+
value = JSON.parse(text) as unknown;
|
|
4534
|
+
} catch (error) {
|
|
4535
|
+
throw new Error("sealed derivation review HAR is not valid JSON", { cause: error });
|
|
4536
|
+
}
|
|
4537
|
+
analysis = analyzeHarValue(
|
|
4538
|
+
value,
|
|
4539
|
+
session.adapterId,
|
|
4540
|
+
reviewOrigin,
|
|
4541
|
+
new Date(),
|
|
4542
|
+
session.browserDomains,
|
|
4543
|
+
);
|
|
4544
|
+
internalEvidence = analyzeInternalHarValue(
|
|
4545
|
+
value,
|
|
4546
|
+
session.adapterId,
|
|
4547
|
+
reviewOrigin,
|
|
4548
|
+
);
|
|
4549
|
+
} else {
|
|
4550
|
+
await batch(session, [["network", "har", "stop", "capture.har"]]);
|
|
4551
|
+
analysis = analyzeHarFile(
|
|
4552
|
+
harPath,
|
|
4553
|
+
session.adapterId,
|
|
4554
|
+
reviewOrigin,
|
|
4555
|
+
session.browserDomains,
|
|
4556
|
+
session.directoryIdentity,
|
|
4557
|
+
);
|
|
4558
|
+
internalEvidence = analyzeInternalHarFile(
|
|
4559
|
+
harPath,
|
|
4560
|
+
session.adapterId,
|
|
4561
|
+
reviewOrigin,
|
|
4562
|
+
session.directoryIdentity,
|
|
4563
|
+
);
|
|
4564
|
+
}
|
|
4565
|
+
const written = writeDerivationScaffold(outputDirectory, analysis, {
|
|
4566
|
+
force: options.force,
|
|
4567
|
+
registry: options.registry,
|
|
4568
|
+
...(options.surfaceId === undefined ? {} : { surfaceId: options.surfaceId }),
|
|
4569
|
+
extraFiles: { "internal-api-evidence.json": internalEvidence },
|
|
4570
|
+
});
|
|
4571
|
+
completed = {
|
|
4572
|
+
analysis,
|
|
4573
|
+
internalEvidence,
|
|
4574
|
+
...written,
|
|
4575
|
+
evidencePath: join(outputDirectory, "internal-api-evidence.json"),
|
|
4576
|
+
};
|
|
4577
|
+
} catch (error) {
|
|
4578
|
+
failure = error;
|
|
4579
|
+
}
|
|
4580
|
+
if (completed !== null) {
|
|
4581
|
+
if (!await closeSession(session, environment)) {
|
|
4582
|
+
throw new Error(`derivation ${id} finished its scaffold but the browser could not be closed; discard the preserved private session`);
|
|
4583
|
+
}
|
|
4584
|
+
removePrivateStateFile(harPath, environment, session.directoryIdentity);
|
|
4585
|
+
if (sealedReview) removePrivateStateFile(reviewMarkerPath(session), environment, session.directoryIdentity);
|
|
4586
|
+
removeSessionTrees(session, environment);
|
|
4587
|
+
return completed;
|
|
4588
|
+
}
|
|
4589
|
+
if (sealedReview) {
|
|
4590
|
+
if (failure === null) throw new Error("sealed derivation failed without an error");
|
|
4591
|
+
throw failure instanceof Error
|
|
4592
|
+
? failure
|
|
4593
|
+
: new Error(typeof failure === "string" ? failure : "sealed derivation failed with a non-error rejection");
|
|
4594
|
+
}
|
|
4595
|
+
removePrivateStateFile(harPath, environment, session.directoryIdentity);
|
|
4596
|
+
try {
|
|
4597
|
+
await batch(session, [["network", "har", "start", "--content", session.contentMode]]);
|
|
4598
|
+
} catch (error) {
|
|
4599
|
+
if (!await closeSession(session, environment)) {
|
|
4600
|
+
throw new Error(`derivation ${id} failed and could not be closed; its private session was preserved`, { cause: error });
|
|
4601
|
+
}
|
|
4602
|
+
removeSessionTrees(session, environment);
|
|
4603
|
+
}
|
|
4604
|
+
if (failure === null) throw new Error("derivation failed without an error");
|
|
4605
|
+
throw failure instanceof Error
|
|
4606
|
+
? failure
|
|
4607
|
+
: new Error(typeof failure === "string" ? failure : "derivation failed with a non-error rejection");
|
|
4608
|
+
});
|
|
4609
|
+
}
|
|
4610
|
+
|
|
4611
|
+
export async function discardDerivation(
|
|
4612
|
+
id: string,
|
|
4613
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
4614
|
+
): Promise<boolean> {
|
|
4615
|
+
return withDerivationLifecycleGate(id, environment, async () => {
|
|
4616
|
+
const directory = derivationDirectory(id, environment);
|
|
4617
|
+
if (!existsSync(directory)) return false;
|
|
4618
|
+
const directoryIdentity = inspectDirectoryIdentity(directory);
|
|
4619
|
+
const boundaryEntries = listPrivateStateDirectory(directory, environment, directoryIdentity);
|
|
4620
|
+
const phaseMarkerPresent = boundaryEntries
|
|
4621
|
+
.some((entry) => entry.name === derivationPhaseMarkerName);
|
|
4622
|
+
const initializationMarkerPresent = boundaryEntries
|
|
4623
|
+
.some((entry) => entry.name === derivationInitializationMarkerName);
|
|
4624
|
+
let phase: DerivationDirectoryPhase | null = null;
|
|
4625
|
+
let phaseFailure: unknown = null;
|
|
4626
|
+
if (phaseMarkerPresent) {
|
|
4627
|
+
try {
|
|
4628
|
+
phase = readDerivationDirectoryPhase(id, directory, directoryIdentity, environment);
|
|
4629
|
+
} catch (error) {
|
|
4630
|
+
phaseFailure = error;
|
|
4631
|
+
}
|
|
4632
|
+
}
|
|
4633
|
+
let initialization: DerivationInitialization | null = null;
|
|
4634
|
+
let initializationFailure: unknown = null;
|
|
4635
|
+
if (initializationMarkerPresent) {
|
|
4636
|
+
try {
|
|
4637
|
+
initialization = readDerivationInitialization(id, directory, directoryIdentity, environment);
|
|
4638
|
+
} catch (error) {
|
|
4639
|
+
initializationFailure = error;
|
|
4640
|
+
}
|
|
4641
|
+
}
|
|
4642
|
+
let loaded: ReturnType<typeof loadSessionWithSocketPolicy>;
|
|
4643
|
+
try {
|
|
4644
|
+
loaded = loadSessionWithSocketPolicy(id, environment, "allow-missing", "allow-missing");
|
|
4645
|
+
} catch (error) {
|
|
4646
|
+
const readyEntry = listPrivateStateDirectory(directory, environment, directoryIdentity)
|
|
4647
|
+
.find((entry) => entry.name === derivationReadyMarkerName);
|
|
4648
|
+
if (readyEntry !== undefined) throw error;
|
|
4649
|
+
if (initializationMarkerPresent && initialization === null) {
|
|
4650
|
+
throw initializationFailure instanceof Error ? initializationFailure : error;
|
|
4651
|
+
}
|
|
4652
|
+
if (initialization !== null && inspectInitializationSocket(initialization)) {
|
|
4653
|
+
throw new Error(
|
|
4654
|
+
`derivation ${id} is interrupted but its browser socket is still present; its private session was preserved`,
|
|
4655
|
+
{ cause: error },
|
|
4656
|
+
);
|
|
4657
|
+
}
|
|
4658
|
+
if (initialization !== null) {
|
|
4659
|
+
const owner = await closeInterruptedDerivationNetworkBoundary({
|
|
4660
|
+
derivationId: initialization.derivationId,
|
|
4661
|
+
directory: derivationDirectory(initialization.derivationId, environment),
|
|
4662
|
+
directoryIdentity: initialization.directoryIdentity,
|
|
4663
|
+
socketDirectory: initialization.socketDirectory,
|
|
4664
|
+
socketIdentity: initialization.socketIdentity,
|
|
4665
|
+
});
|
|
4666
|
+
removeInterruptedSessionTrees(initialization, environment, owner);
|
|
4667
|
+
return true;
|
|
4668
|
+
}
|
|
4669
|
+
if (phaseMarkerPresent && phase === null) {
|
|
4670
|
+
throw phaseFailure instanceof Error ? phaseFailure : error;
|
|
4671
|
+
}
|
|
4672
|
+
if (phase !== null) {
|
|
4673
|
+
removeDirectoryPhaseState(phase, environment);
|
|
4674
|
+
return true;
|
|
4675
|
+
}
|
|
4676
|
+
if (boundaryEntries.length === 0) {
|
|
4677
|
+
removeEmptyMarkerlessDirectoryState(id, directoryIdentity, environment);
|
|
4678
|
+
return true;
|
|
4679
|
+
}
|
|
4680
|
+
throw error;
|
|
4681
|
+
}
|
|
4682
|
+
if (phaseMarkerPresent && phase === null) {
|
|
4683
|
+
throw phaseFailure instanceof Error
|
|
4684
|
+
? phaseFailure
|
|
4685
|
+
: new Error("derivation directory phase marker is unavailable or unsafe");
|
|
4686
|
+
}
|
|
4687
|
+
if (initializationMarkerPresent && initialization === null) {
|
|
4688
|
+
throw initializationFailure instanceof Error
|
|
4689
|
+
? initializationFailure
|
|
4690
|
+
: new Error("derivation initialization marker is unavailable or unsafe");
|
|
4691
|
+
}
|
|
4692
|
+
if (initialization === null) {
|
|
4693
|
+
if (loaded.ready) {
|
|
4694
|
+
throw initializationFailure instanceof Error
|
|
4695
|
+
? initializationFailure
|
|
4696
|
+
: new Error("derivation initialization marker is unavailable or unsafe");
|
|
4697
|
+
}
|
|
4698
|
+
if (phase !== null) {
|
|
4699
|
+
removeDirectoryPhaseState(phase, environment);
|
|
4700
|
+
return true;
|
|
4701
|
+
}
|
|
4702
|
+
initialization = initializationFromSession(loaded.session);
|
|
4703
|
+
}
|
|
4704
|
+
const { session } = loaded;
|
|
4705
|
+
if (
|
|
4706
|
+
(phase !== null && !sameDirectoryIdentity(session.directoryIdentity, phase.directoryIdentity))
|
|
4707
|
+
|| !sameDirectoryIdentity(session.directoryIdentity, initialization.directoryIdentity)
|
|
4708
|
+
|| !sameDirectoryIdentity(session.socketIdentity, initialization.socketIdentity)
|
|
4709
|
+
|| session.socketDirectory !== initialization.socketDirectory
|
|
4710
|
+
) throw new Error("derivation session does not match its initialization boundary");
|
|
4711
|
+
if (!loaded.ready) {
|
|
4712
|
+
if (
|
|
4713
|
+
loaded.socketAvailable
|
|
4714
|
+
&& !await closeSession(session, environment)
|
|
4715
|
+
&& inspectInitializationSocket(initialization)
|
|
4716
|
+
) {
|
|
4717
|
+
throw new Error(`derivation ${id} could not be closed; its interrupted private session was preserved`);
|
|
4718
|
+
}
|
|
4719
|
+
if (!await closeSessionNetworkBoundary(session)) {
|
|
4720
|
+
throw new Error(`derivation ${id} network boundary could not be closed; its interrupted private session was preserved`);
|
|
4721
|
+
}
|
|
4722
|
+
removeInterruptedSessionTrees(initialization, environment, sessionNetworkOwner(session));
|
|
4723
|
+
return true;
|
|
4724
|
+
}
|
|
4725
|
+
if (loaded.socketAvailable) {
|
|
4726
|
+
if (!await closeSession(session, environment)) {
|
|
4727
|
+
if (inspectSessionSocket(session, "allow-missing")) {
|
|
4728
|
+
throw new Error(`derivation ${id} could not be closed; its private session was preserved`);
|
|
4729
|
+
}
|
|
4730
|
+
// Recheck the exact ephemeral boundary immediately before terminal
|
|
4731
|
+
// cleanup. A replacement directory remains fatal; a live boundary that
|
|
4732
|
+
// reappeared preserves the state for an ordinary close.
|
|
4733
|
+
if (inspectSessionSocket(session, "allow-missing")) {
|
|
4734
|
+
throw new Error(`derivation ${id} socket directory reappeared; its private session was preserved`);
|
|
4735
|
+
}
|
|
4736
|
+
if (!await closeSessionNetworkBoundary(session)) {
|
|
4737
|
+
throw new Error(`derivation ${id} network boundary could not be closed; its private session was preserved`);
|
|
4738
|
+
}
|
|
4739
|
+
removeSessionStateWithoutSocket(session, environment);
|
|
4740
|
+
return true;
|
|
4741
|
+
}
|
|
4742
|
+
removeSessionTrees(session, environment);
|
|
4743
|
+
} else {
|
|
4744
|
+
if (inspectSessionSocket(session, "allow-missing")) {
|
|
4745
|
+
throw new Error(`derivation ${id} socket directory reappeared; its private session was preserved`);
|
|
4746
|
+
}
|
|
4747
|
+
if (!await closeSessionNetworkBoundary(session)) {
|
|
4748
|
+
throw new Error(`derivation ${id} network boundary could not be closed; its private session was preserved`);
|
|
4749
|
+
}
|
|
4750
|
+
removeSessionStateWithoutSocket(session, environment);
|
|
4751
|
+
}
|
|
4752
|
+
return true;
|
|
4753
|
+
});
|
|
4754
|
+
}
|