@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
|
@@ -0,0 +1,3108 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
constants as fsConstants,
|
|
4
|
+
createReadStream,
|
|
5
|
+
type Stats,
|
|
6
|
+
type BigIntStats,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import {
|
|
9
|
+
chmod,
|
|
10
|
+
lstat,
|
|
11
|
+
mkdtemp,
|
|
12
|
+
mkdir,
|
|
13
|
+
open,
|
|
14
|
+
readdir,
|
|
15
|
+
realpath,
|
|
16
|
+
rmdir,
|
|
17
|
+
unlink,
|
|
18
|
+
type FileHandle,
|
|
19
|
+
} from "node:fs/promises";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
import { BoundedByteBuffer } from "@hraness/kb/clip/bounded-byte-buffer";
|
|
25
|
+
|
|
26
|
+
import type { GhostgetAuth } from "../auth";
|
|
27
|
+
import { canonicalJson } from "../canonical-json";
|
|
28
|
+
import type { OperationInput, WebSessionRecipe } from "../model";
|
|
29
|
+
import { OperationDeadline } from "../operation-deadline";
|
|
30
|
+
import type {
|
|
31
|
+
ProviderPluginLinkedDeviceAttemptBoundaryV1,
|
|
32
|
+
} from "../provider-plugin";
|
|
33
|
+
import { assertSafeStatePath, ghostgetStateHome } from "../storage";
|
|
34
|
+
import type {
|
|
35
|
+
WebSessionExecution,
|
|
36
|
+
WebSessionCleanupBarrierRegistrar,
|
|
37
|
+
WebSessionOperationDeadline,
|
|
38
|
+
} from "../web-session-execution";
|
|
39
|
+
import { startWebSessionCleanupTrackedOperation } from "../web-session-execution";
|
|
40
|
+
import {
|
|
41
|
+
WHATSAPP_CONTACT_PROJECTION_MAX_STDOUT_BYTES,
|
|
42
|
+
WHATSAPP_CONTACT_PROJECTION_PROTOCOL_VERSION,
|
|
43
|
+
isExactWhatsAppContactProjectionMode,
|
|
44
|
+
parseWhatsAppContactProjectionResponse,
|
|
45
|
+
parseWhatsAppContactProjectionSubject,
|
|
46
|
+
type WhatsAppContactProjectionRequest,
|
|
47
|
+
} from "./whatsapp-contact-projection-protocol";
|
|
48
|
+
import {
|
|
49
|
+
WHATSAPP_INTERACTION_PROJECTION_MAX_STDOUT_BYTES,
|
|
50
|
+
WHATSAPP_INTERACTION_PROJECTION_PROTOCOL_VERSION,
|
|
51
|
+
parseWhatsAppInteractionProjectionRequest,
|
|
52
|
+
parseWhatsAppInteractionProjectionResponse,
|
|
53
|
+
parseWhatsAppInteractionRowid,
|
|
54
|
+
type WhatsAppInteractionProjectionRequest,
|
|
55
|
+
} from "./whatsapp-interaction-projection-protocol";
|
|
56
|
+
import {
|
|
57
|
+
WHATSAPP_PROTOCOL_PIN,
|
|
58
|
+
WHATSAPP_WEB_OPERATIONS,
|
|
59
|
+
WHATSAPP_WEB_OPERATION_NAMES,
|
|
60
|
+
parseWhatsAppAuthStatusEnvelope,
|
|
61
|
+
parseWhatsAppJid,
|
|
62
|
+
projectWhatsAppChatsEnvelope,
|
|
63
|
+
projectWhatsAppMessageEnvelope,
|
|
64
|
+
projectWhatsAppMessagesEnvelope,
|
|
65
|
+
whatsappMessageId,
|
|
66
|
+
whatsappTargetJid,
|
|
67
|
+
type WhatsAppWebOperationName,
|
|
68
|
+
} from "./whatsapp-web";
|
|
69
|
+
import { projectContactDirectionStats } from "./contact-projection";
|
|
70
|
+
|
|
71
|
+
const WHATSAPP_ORIGIN = "https://web.whatsapp.com";
|
|
72
|
+
const DEFAULT_LIMIT = 50;
|
|
73
|
+
const MAX_STDERR_BYTES = 64 * 1024;
|
|
74
|
+
const MAX_STORE_ENTRIES = 10_000;
|
|
75
|
+
const MAX_SYNC_MESSAGES = 200_000;
|
|
76
|
+
const MAX_SYNC_DB_SIZE = "2GB";
|
|
77
|
+
const MAX_CONTACT_PROJECTION_STDERR_BYTES = 16 * 1024;
|
|
78
|
+
const CONTACT_PROJECTION_FORCE_KILL_DELAY_MS = 1_000;
|
|
79
|
+
const MESSAGE_EXPORT_SESSION_MAX_TOTAL_STDOUT_BYTES = 512 * 1024 * 1024;
|
|
80
|
+
const MESSAGE_EXPORT_SESSION_MAX_FRAMES = 1_001;
|
|
81
|
+
const MESSAGE_EXPORT_SESSION_SPOOL_CHUNK_BYTES = 64 * 1024;
|
|
82
|
+
const MESSAGE_EXPORT_SESSION_PRIVATE_DIRECTORY_MODE = 0o700;
|
|
83
|
+
const MESSAGE_EXPORT_SESSION_PRIVATE_FILE_MODE = 0o600;
|
|
84
|
+
const WEB_SESSION_OPERATION_LABEL = "authenticated web operation deadline";
|
|
85
|
+
|
|
86
|
+
type WhatsAppAuth = Extract<
|
|
87
|
+
GhostgetAuth,
|
|
88
|
+
{ readonly kind: "linked-device-store" }
|
|
89
|
+
>;
|
|
90
|
+
|
|
91
|
+
export type WacliInvocation = {
|
|
92
|
+
readonly binary: string;
|
|
93
|
+
readonly arguments: readonly string[];
|
|
94
|
+
readonly environment: Readonly<Record<string, string>>;
|
|
95
|
+
readonly timeoutMs: number;
|
|
96
|
+
readonly maxOutputBytes: number;
|
|
97
|
+
readonly signal?: AbortSignal;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type WacliInvocationResult = {
|
|
101
|
+
readonly exitCode: number;
|
|
102
|
+
readonly stdout: string;
|
|
103
|
+
readonly stderr: string;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export type WhatsAppContactProjectionHelperInvocation = {
|
|
107
|
+
readonly command: readonly string[];
|
|
108
|
+
readonly cwd: string;
|
|
109
|
+
readonly environment: Readonly<Record<string, string>>;
|
|
110
|
+
readonly stdin: string;
|
|
111
|
+
readonly timeoutMs: number;
|
|
112
|
+
readonly maxOutputBytes: number;
|
|
113
|
+
readonly maxStderrBytes: number;
|
|
114
|
+
readonly signal?: AbortSignal;
|
|
115
|
+
/** Called exactly once after spawn with the owned helper PID. */
|
|
116
|
+
readonly onSpawned?: (pid: number) => void;
|
|
117
|
+
/** Internal streaming validator; called synchronously for each canonical frame. */
|
|
118
|
+
readonly onCanonicalFrame?: (
|
|
119
|
+
frame: WhatsAppMessageExportSessionCanonicalFrame,
|
|
120
|
+
) => void;
|
|
121
|
+
/** Test-only process-group probe seam. */
|
|
122
|
+
readonly processGroupIsAbsentForTest?: () => boolean;
|
|
123
|
+
/** Test-only process-group poll observation seam. */
|
|
124
|
+
readonly onProcessGroupPollForTest?: () => void;
|
|
125
|
+
/** Test-only delay inside private-spool setup, before custody returns. */
|
|
126
|
+
readonly beforeSpoolReadyForTest?: () => Promise<void>;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export type WhatsAppContactProjectionHelperResult = {
|
|
130
|
+
readonly exitCode: number;
|
|
131
|
+
readonly stdout: string;
|
|
132
|
+
readonly stderr: string;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export type WhatsAppMessageExportSessionCanonicalFrame = Readonly<{
|
|
136
|
+
index: number;
|
|
137
|
+
canonical: string;
|
|
138
|
+
value: unknown;
|
|
139
|
+
}>;
|
|
140
|
+
|
|
141
|
+
export type WhatsAppMessageExportSessionSpool = Readonly<{
|
|
142
|
+
frameCount: number;
|
|
143
|
+
totalBytes: number;
|
|
144
|
+
stdoutSha256: string;
|
|
145
|
+
replay: <Value>(
|
|
146
|
+
project: (frame: WhatsAppMessageExportSessionCanonicalFrame) => Value,
|
|
147
|
+
) => AsyncGenerator<Value>;
|
|
148
|
+
close: () => Promise<void>;
|
|
149
|
+
}>;
|
|
150
|
+
|
|
151
|
+
export type WhatsAppMessageExportSessionHelperResult = Readonly<{
|
|
152
|
+
exitCode: number;
|
|
153
|
+
spool: WhatsAppMessageExportSessionSpool;
|
|
154
|
+
stderr: string;
|
|
155
|
+
}>;
|
|
156
|
+
|
|
157
|
+
export class WhatsAppContactProjectionCleanupUnverifiedError extends Error {
|
|
158
|
+
constructor() {
|
|
159
|
+
super(
|
|
160
|
+
"WhatsApp contact projection helper cleanup could not be verified",
|
|
161
|
+
);
|
|
162
|
+
this.name = "WhatsAppContactProjectionCleanupUnverifiedError";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function containsWhatsAppContactProjectionCleanupUnverified(
|
|
167
|
+
error: unknown,
|
|
168
|
+
): boolean {
|
|
169
|
+
const pending: unknown[] = [error];
|
|
170
|
+
const visited = new Set<object>();
|
|
171
|
+
while (pending.length > 0) {
|
|
172
|
+
const current = pending.pop();
|
|
173
|
+
if (current instanceof WhatsAppContactProjectionCleanupUnverifiedError) return true;
|
|
174
|
+
if (typeof current !== "object" || current === null || visited.has(current)) continue;
|
|
175
|
+
visited.add(current);
|
|
176
|
+
if (current instanceof AggregateError) {
|
|
177
|
+
try {
|
|
178
|
+
pending.push(...current.errors);
|
|
179
|
+
} catch {
|
|
180
|
+
// A hostile wrapper cannot prove cleanup safety.
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
if ("cause" in current) pending.push(current.cause);
|
|
186
|
+
} catch {
|
|
187
|
+
// An uninspectable wrapper cannot prove cleanup safety.
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export type WhatsAppWebRuntimeDependencies = {
|
|
195
|
+
/**
|
|
196
|
+
* Test-only binary seam. Production resolution accepts only the pinned
|
|
197
|
+
* release in fixed installation locations and verifies its SHA-256.
|
|
198
|
+
*/
|
|
199
|
+
readonly binaryPath?: string;
|
|
200
|
+
readonly run?: (
|
|
201
|
+
invocation: WacliInvocation,
|
|
202
|
+
) => Promise<WacliInvocationResult>;
|
|
203
|
+
readonly runInteractive?: (
|
|
204
|
+
plan: WhatsAppPairingPlan,
|
|
205
|
+
) => Promise<number>;
|
|
206
|
+
/** Test-only observation seam; production always uses the fixed Bun helper. */
|
|
207
|
+
readonly runContactProjectionHelper?: (
|
|
208
|
+
invocation: WhatsAppContactProjectionHelperInvocation,
|
|
209
|
+
) => Promise<WhatsAppContactProjectionHelperResult>;
|
|
210
|
+
/** Test-only observation seam; production always uses the fixed Bun helper. */
|
|
211
|
+
readonly runInteractionProjectionHelper?: (
|
|
212
|
+
invocation: WhatsAppContactProjectionHelperInvocation,
|
|
213
|
+
) => Promise<WhatsAppContactProjectionHelperResult>;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
function isWhatsAppOperation(
|
|
217
|
+
value: string,
|
|
218
|
+
): value is WhatsAppWebOperationName {
|
|
219
|
+
return (WHATSAPP_WEB_OPERATION_NAMES as readonly string[]).includes(value);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function requireWhatsAppAuth(auth: GhostgetAuth): WhatsAppAuth {
|
|
223
|
+
if (
|
|
224
|
+
auth.kind !== "linked-device-store"
|
|
225
|
+
|| auth.provider !== "whatsapp"
|
|
226
|
+
) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
"WhatsApp protocol operations require a WhatsApp linked-device-store auth realm",
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (!isAbsolute(auth.path)) {
|
|
232
|
+
throw new Error("WhatsApp linked-device store path must be absolute");
|
|
233
|
+
}
|
|
234
|
+
return auth;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function ownedByCurrentUser(stats: Stats): boolean {
|
|
238
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
239
|
+
return uid === null || stats.uid === uid;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function assertPrivateOwned(
|
|
243
|
+
stats: Stats,
|
|
244
|
+
label: string,
|
|
245
|
+
kind: "directory" | "file" | "socket",
|
|
246
|
+
): void {
|
|
247
|
+
const matchesKind = kind === "directory"
|
|
248
|
+
? stats.isDirectory()
|
|
249
|
+
: kind === "file"
|
|
250
|
+
? stats.isFile()
|
|
251
|
+
: stats.isSocket();
|
|
252
|
+
if (!matchesKind || !ownedByCurrentUser(stats)) {
|
|
253
|
+
throw new Error(`${label} must be an owned ${kind}`);
|
|
254
|
+
}
|
|
255
|
+
if ((stats.mode & 0o077) !== 0) {
|
|
256
|
+
throw new Error(`${label} must not grant group or world access`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export type WhatsAppStoreValidationPurpose =
|
|
261
|
+
| "pair"
|
|
262
|
+
| "probe"
|
|
263
|
+
| "sync"
|
|
264
|
+
| "projection"
|
|
265
|
+
| "contact-projection";
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Validate the complete top level of the credential/message store. This
|
|
269
|
+
* complements wacli's own 0700/0600 creation policy and catches later
|
|
270
|
+
* permission widening or symlink substitution before any trusted read.
|
|
271
|
+
*/
|
|
272
|
+
export async function validateWhatsAppStoreDirectory(
|
|
273
|
+
pathValue: string,
|
|
274
|
+
purpose: WhatsAppStoreValidationPurpose,
|
|
275
|
+
): Promise<string> {
|
|
276
|
+
if (!isAbsolute(pathValue)) {
|
|
277
|
+
throw new Error("WhatsApp linked-device store path must be absolute");
|
|
278
|
+
}
|
|
279
|
+
const lexical = resolve(pathValue);
|
|
280
|
+
let directoryStats: Stats;
|
|
281
|
+
try {
|
|
282
|
+
directoryStats = await lstat(lexical);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (
|
|
285
|
+
purpose === "pair"
|
|
286
|
+
&& typeof error === "object"
|
|
287
|
+
&& error !== null
|
|
288
|
+
&& "code" in error
|
|
289
|
+
&& error.code === "ENOENT"
|
|
290
|
+
) {
|
|
291
|
+
await mkdir(lexical, { recursive: true, mode: 0o700 });
|
|
292
|
+
await chmod(lexical, 0o700);
|
|
293
|
+
directoryStats = await lstat(lexical);
|
|
294
|
+
} else {
|
|
295
|
+
throw new Error("WhatsApp linked-device store is unavailable");
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (directoryStats.isSymbolicLink()) {
|
|
299
|
+
throw new Error("WhatsApp linked-device store must not be a symbolic link");
|
|
300
|
+
}
|
|
301
|
+
assertPrivateOwned(directoryStats, "WhatsApp linked-device store", "directory");
|
|
302
|
+
const canonical = await realpath(lexical);
|
|
303
|
+
if (canonical !== lexical) {
|
|
304
|
+
throw new Error("WhatsApp linked-device store path must be canonical");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const entries = await readdir(canonical, { withFileTypes: true });
|
|
308
|
+
if (entries.length > MAX_STORE_ENTRIES) {
|
|
309
|
+
throw new Error("WhatsApp linked-device store has too many entries");
|
|
310
|
+
}
|
|
311
|
+
for (const entry of entries) {
|
|
312
|
+
if (
|
|
313
|
+
entry.name.length < 1
|
|
314
|
+
|| entry.name.length > 255
|
|
315
|
+
|| entry.name.includes("\0")
|
|
316
|
+
|| entry.isSymbolicLink()
|
|
317
|
+
) throw new Error("WhatsApp linked-device store contains an unsafe entry");
|
|
318
|
+
const entryPath = join(canonical, entry.name);
|
|
319
|
+
const stats = await lstat(entryPath);
|
|
320
|
+
if (stats.isSymbolicLink()) {
|
|
321
|
+
throw new Error("WhatsApp linked-device store contains a symbolic link");
|
|
322
|
+
}
|
|
323
|
+
if (stats.isDirectory()) {
|
|
324
|
+
assertPrivateOwned(stats, "WhatsApp linked-device store entry", "directory");
|
|
325
|
+
} else if (stats.isFile()) {
|
|
326
|
+
assertPrivateOwned(stats, "WhatsApp linked-device store entry", "file");
|
|
327
|
+
} else if (stats.isSocket()) {
|
|
328
|
+
assertPrivateOwned(stats, "WhatsApp linked-device store entry", "socket");
|
|
329
|
+
} else {
|
|
330
|
+
throw new Error("WhatsApp linked-device store contains an unsupported entry");
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const requireRegular = async (name: string): Promise<void> => {
|
|
335
|
+
let stats: Stats;
|
|
336
|
+
try {
|
|
337
|
+
stats = await lstat(join(canonical, name));
|
|
338
|
+
} catch {
|
|
339
|
+
throw new Error(`WhatsApp linked-device store omitted ${name}`);
|
|
340
|
+
}
|
|
341
|
+
assertPrivateOwned(stats, `WhatsApp ${name}`, "file");
|
|
342
|
+
};
|
|
343
|
+
if (
|
|
344
|
+
purpose === "sync"
|
|
345
|
+
|| purpose === "projection"
|
|
346
|
+
|| purpose === "contact-projection"
|
|
347
|
+
) {
|
|
348
|
+
await requireRegular("session.db");
|
|
349
|
+
}
|
|
350
|
+
if (purpose === "projection") {
|
|
351
|
+
await requireRegular("wacli.db");
|
|
352
|
+
}
|
|
353
|
+
return canonical;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function sha256File(path: string): Promise<string> {
|
|
357
|
+
const hash = createHash("sha256");
|
|
358
|
+
const stream = createReadStream(path, { flags: "r" });
|
|
359
|
+
for await (const chunkValue of stream) {
|
|
360
|
+
const chunk: unknown = chunkValue;
|
|
361
|
+
if (!Buffer.isBuffer(chunk)) {
|
|
362
|
+
throw new Error("WhatsApp protocol binary stream returned non-byte data");
|
|
363
|
+
}
|
|
364
|
+
hash.update(chunk);
|
|
365
|
+
}
|
|
366
|
+
return hash.digest("hex");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
type FixedCommandResult = {
|
|
370
|
+
readonly exitCode: number;
|
|
371
|
+
readonly output: string;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
async function runFixedCodesign(
|
|
375
|
+
arguments_: readonly string[],
|
|
376
|
+
signal?: AbortSignal,
|
|
377
|
+
): Promise<FixedCommandResult> {
|
|
378
|
+
const isAborted = (): boolean => signal?.aborted === true;
|
|
379
|
+
if (isAborted()) {
|
|
380
|
+
return Object.freeze({ exitCode: -1, output: "" });
|
|
381
|
+
}
|
|
382
|
+
const child = Bun.spawn(["/usr/bin/codesign", ...arguments_], {
|
|
383
|
+
env: {
|
|
384
|
+
PATH: "/usr/bin:/bin",
|
|
385
|
+
LANG: "C",
|
|
386
|
+
LC_ALL: "C",
|
|
387
|
+
},
|
|
388
|
+
stdin: "ignore",
|
|
389
|
+
stdout: "pipe",
|
|
390
|
+
stderr: "pipe",
|
|
391
|
+
});
|
|
392
|
+
let interrupted = false;
|
|
393
|
+
const interrupt = (): void => {
|
|
394
|
+
interrupted = true;
|
|
395
|
+
try {
|
|
396
|
+
child.kill("SIGKILL");
|
|
397
|
+
} catch {
|
|
398
|
+
// child.exited remains the process-cleanup proof.
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
const onAbort = (): void => interrupt();
|
|
402
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
403
|
+
if (isAborted()) onAbort();
|
|
404
|
+
const timeout = setTimeout(interrupt, 5_000);
|
|
405
|
+
try {
|
|
406
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
407
|
+
readBoundedStream(child.stdout, 64 * 1024),
|
|
408
|
+
readBoundedStream(child.stderr, 64 * 1024),
|
|
409
|
+
child.exited,
|
|
410
|
+
]);
|
|
411
|
+
return Object.freeze({
|
|
412
|
+
exitCode: interrupted ? -1 : exitCode,
|
|
413
|
+
output: interrupted ? "" : `${stdout}${stderr}`,
|
|
414
|
+
});
|
|
415
|
+
} catch (error) {
|
|
416
|
+
interrupt();
|
|
417
|
+
await child.exited;
|
|
418
|
+
throw error;
|
|
419
|
+
} finally {
|
|
420
|
+
clearTimeout(timeout);
|
|
421
|
+
signal?.removeEventListener("abort", onAbort);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function codeSignatureValue(
|
|
426
|
+
display: string,
|
|
427
|
+
key: string,
|
|
428
|
+
): string | null {
|
|
429
|
+
const prefix = `${key}=`;
|
|
430
|
+
const lines = display.split(/\r?\n/u)
|
|
431
|
+
.filter((line) => line.startsWith(prefix));
|
|
432
|
+
if (lines.length !== 1) return null;
|
|
433
|
+
return lines[0]?.slice(prefix.length) ?? null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function normalizeCodeRequirement(value: string): string {
|
|
437
|
+
return value
|
|
438
|
+
.replace(/\s+/gu, " ")
|
|
439
|
+
.replace(/"([A-Za-z0-9.]+)"/gu, "$1")
|
|
440
|
+
.trim();
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function verifyPinnedWacliSignature(
|
|
444
|
+
path: string,
|
|
445
|
+
signal?: AbortSignal,
|
|
446
|
+
): Promise<boolean> {
|
|
447
|
+
const verified = await runFixedCodesign([
|
|
448
|
+
"--verify",
|
|
449
|
+
"--strict",
|
|
450
|
+
"--verbose=4",
|
|
451
|
+
path,
|
|
452
|
+
], signal);
|
|
453
|
+
if (verified.exitCode !== 0) return false;
|
|
454
|
+
|
|
455
|
+
const display = await runFixedCodesign([
|
|
456
|
+
"--display",
|
|
457
|
+
"--verbose=4",
|
|
458
|
+
path,
|
|
459
|
+
], signal);
|
|
460
|
+
if (display.exitCode !== 0) return false;
|
|
461
|
+
const signature = WHATSAPP_PROTOCOL_PIN.signature;
|
|
462
|
+
const authorities = display.output.split(/\r?\n/u)
|
|
463
|
+
.filter((line) => line.startsWith("Authority="))
|
|
464
|
+
.map((line) => line.slice("Authority=".length))
|
|
465
|
+
.filter((authority) => authority.startsWith("Developer ID Application:"));
|
|
466
|
+
if (
|
|
467
|
+
codeSignatureValue(display.output, "Identifier") !== signature.identifier
|
|
468
|
+
|| codeSignatureValue(display.output, "TeamIdentifier")
|
|
469
|
+
!== signature.teamIdentifier
|
|
470
|
+
|| codeSignatureValue(display.output, "CDHash") !== signature.cdHash
|
|
471
|
+
|| codeSignatureValue(display.output, "CandidateCDHashFull sha256")
|
|
472
|
+
!== signature.cdHashFull
|
|
473
|
+
|| authorities.length !== 1
|
|
474
|
+
|| authorities[0] !== signature.authority
|
|
475
|
+
|| !/\bflags=0x[0-9a-f]+\(runtime\)/iu.test(display.output)
|
|
476
|
+
|| !/^Timestamp=(?!none$).+/mu.test(display.output)
|
|
477
|
+
) return false;
|
|
478
|
+
|
|
479
|
+
const requirements = await runFixedCodesign([
|
|
480
|
+
"--display",
|
|
481
|
+
"--requirements",
|
|
482
|
+
"-",
|
|
483
|
+
path,
|
|
484
|
+
], signal);
|
|
485
|
+
if (requirements.exitCode !== 0) return false;
|
|
486
|
+
const embeddedRequirements = requirements.output.split(/\r?\n/u)
|
|
487
|
+
.map((line) => line.trim())
|
|
488
|
+
.filter((line) => line.startsWith("designated =>"));
|
|
489
|
+
if (
|
|
490
|
+
embeddedRequirements.length !== 1
|
|
491
|
+
|| normalizeCodeRequirement(embeddedRequirements[0] ?? "")
|
|
492
|
+
!== normalizeCodeRequirement(signature.designatedRequirement)
|
|
493
|
+
) return false;
|
|
494
|
+
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function pinnedBinaryCandidate(
|
|
499
|
+
pathValue: string,
|
|
500
|
+
signal?: AbortSignal,
|
|
501
|
+
): Promise<string | null> {
|
|
502
|
+
let canonical: string;
|
|
503
|
+
try {
|
|
504
|
+
canonical = await realpath(pathValue);
|
|
505
|
+
} catch {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
try {
|
|
509
|
+
const stats = await lstat(canonical);
|
|
510
|
+
if (
|
|
511
|
+
!stats.isFile()
|
|
512
|
+
|| (stats.mode & 0o077) !== 0
|
|
513
|
+
|| (stats.mode & 0o111) === 0
|
|
514
|
+
|| !ownedByCurrentUser(stats)
|
|
515
|
+
) return null;
|
|
516
|
+
if (process.platform !== "darwin" || process.arch !== "arm64") return null;
|
|
517
|
+
if (
|
|
518
|
+
await sha256File(canonical)
|
|
519
|
+
!== WHATSAPP_PROTOCOL_PIN.darwinArm64BinarySha256
|
|
520
|
+
) return null;
|
|
521
|
+
if (!await verifyPinnedWacliSignature(canonical, signal)) return null;
|
|
522
|
+
const finalStats = await lstat(canonical);
|
|
523
|
+
if (
|
|
524
|
+
!finalStats.isFile()
|
|
525
|
+
|| finalStats.dev !== stats.dev
|
|
526
|
+
|| finalStats.ino !== stats.ino
|
|
527
|
+
|| finalStats.size !== stats.size
|
|
528
|
+
|| finalStats.mode !== stats.mode
|
|
529
|
+
|| !ownedByCurrentUser(finalStats)
|
|
530
|
+
|| await sha256File(canonical)
|
|
531
|
+
!== WHATSAPP_PROTOCOL_PIN.darwinArm64BinarySha256
|
|
532
|
+
) return null;
|
|
533
|
+
return canonical;
|
|
534
|
+
} catch {
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export async function resolvePinnedWacliBinary(
|
|
540
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
541
|
+
signal?: AbortSignal,
|
|
542
|
+
): Promise<string> {
|
|
543
|
+
const candidates = [
|
|
544
|
+
join(
|
|
545
|
+
ghostgetStateHome(environment),
|
|
546
|
+
"tools",
|
|
547
|
+
"wacli",
|
|
548
|
+
WHATSAPP_PROTOCOL_PIN.version,
|
|
549
|
+
WHATSAPP_PROTOCOL_PIN.transport,
|
|
550
|
+
"wacli",
|
|
551
|
+
),
|
|
552
|
+
];
|
|
553
|
+
for (const candidate of candidates) {
|
|
554
|
+
try {
|
|
555
|
+
assertSafeStatePath(candidate, environment);
|
|
556
|
+
} catch {
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
const found = await pinnedBinaryCandidate(candidate, signal);
|
|
560
|
+
if (found === null) continue;
|
|
561
|
+
try {
|
|
562
|
+
assertSafeStatePath(candidate, environment);
|
|
563
|
+
if (await realpath(candidate) === found) return found;
|
|
564
|
+
} catch {
|
|
565
|
+
// The lexical state path changed after validation.
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
throw new Error(
|
|
569
|
+
`pinned WhatsApp protocol runtime wacli ${WHATSAPP_PROTOCOL_PIN.version} is not installed or failed integrity verification`,
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export type WhatsAppProtocolRuntimeStatus = {
|
|
574
|
+
readonly ready: boolean;
|
|
575
|
+
readonly implementation: typeof WHATSAPP_PROTOCOL_PIN.implementation;
|
|
576
|
+
readonly version: typeof WHATSAPP_PROTOCOL_PIN.version;
|
|
577
|
+
readonly integrity: "official-release+sha256+offline-code-signature";
|
|
578
|
+
readonly transport: typeof WHATSAPP_PROTOCOL_PIN.transport;
|
|
579
|
+
readonly archiveSha256:
|
|
580
|
+
typeof WHATSAPP_PROTOCOL_PIN.darwinArm64ArchiveSha256;
|
|
581
|
+
readonly binarySha256:
|
|
582
|
+
typeof WHATSAPP_PROTOCOL_PIN.darwinArm64BinarySha256;
|
|
583
|
+
readonly signature: Omit<typeof WHATSAPP_PROTOCOL_PIN.signature, "notarized">;
|
|
584
|
+
readonly qualification: "read-only-runtime";
|
|
585
|
+
readonly setupCommand: string;
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
function shellQuote(value: string): string {
|
|
589
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export async function inspectWhatsAppProtocolRuntime(
|
|
593
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
594
|
+
): Promise<WhatsAppProtocolRuntimeStatus> {
|
|
595
|
+
const installer = fileURLToPath(
|
|
596
|
+
new URL("../scripts/install-whatsapp-protocol.sh", import.meta.url),
|
|
597
|
+
);
|
|
598
|
+
let ready = false;
|
|
599
|
+
try {
|
|
600
|
+
await resolvePinnedWacliBinary(environment);
|
|
601
|
+
ready = true;
|
|
602
|
+
} catch {
|
|
603
|
+
// Doctor exposes categorical readiness and a deterministic setup command,
|
|
604
|
+
// never candidate paths or integrity failure details.
|
|
605
|
+
}
|
|
606
|
+
return Object.freeze({
|
|
607
|
+
ready,
|
|
608
|
+
implementation: WHATSAPP_PROTOCOL_PIN.implementation,
|
|
609
|
+
version: WHATSAPP_PROTOCOL_PIN.version,
|
|
610
|
+
integrity: "official-release+sha256+offline-code-signature",
|
|
611
|
+
transport: WHATSAPP_PROTOCOL_PIN.transport,
|
|
612
|
+
archiveSha256: WHATSAPP_PROTOCOL_PIN.darwinArm64ArchiveSha256,
|
|
613
|
+
binarySha256: WHATSAPP_PROTOCOL_PIN.darwinArm64BinarySha256,
|
|
614
|
+
signature: Object.freeze({
|
|
615
|
+
authority: WHATSAPP_PROTOCOL_PIN.signature.authority,
|
|
616
|
+
identifier: WHATSAPP_PROTOCOL_PIN.signature.identifier,
|
|
617
|
+
teamIdentifier: WHATSAPP_PROTOCOL_PIN.signature.teamIdentifier,
|
|
618
|
+
designatedRequirement: WHATSAPP_PROTOCOL_PIN.signature.designatedRequirement,
|
|
619
|
+
cdHash: WHATSAPP_PROTOCOL_PIN.signature.cdHash,
|
|
620
|
+
cdHashFull: WHATSAPP_PROTOCOL_PIN.signature.cdHashFull,
|
|
621
|
+
hardenedRuntime: WHATSAPP_PROTOCOL_PIN.signature.hardenedRuntime,
|
|
622
|
+
}),
|
|
623
|
+
qualification: "read-only-runtime",
|
|
624
|
+
setupCommand: `/bin/sh ${shellQuote(installer)}`,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async function readBoundedStream(
|
|
629
|
+
stream: ReadableStream<Uint8Array>,
|
|
630
|
+
maximum: number,
|
|
631
|
+
): Promise<string> {
|
|
632
|
+
return readBoundedStreamControlled(stream, maximum).promise;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function readBoundedStreamControlled(
|
|
636
|
+
stream: ReadableStream<Uint8Array>,
|
|
637
|
+
maximum: number,
|
|
638
|
+
): Readonly<{ promise: Promise<string>; cancel: () => Promise<void> }> {
|
|
639
|
+
const reader = stream.getReader();
|
|
640
|
+
const output = new BoundedByteBuffer(maximum);
|
|
641
|
+
const promise = (async (): Promise<string> => {
|
|
642
|
+
try {
|
|
643
|
+
for (;;) {
|
|
644
|
+
const item = await reader.read();
|
|
645
|
+
if (item.done) break;
|
|
646
|
+
if (!output.append(item.value)) {
|
|
647
|
+
throw new Error("WhatsApp protocol process output exceeded its bound");
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
} finally {
|
|
651
|
+
reader.releaseLock();
|
|
652
|
+
}
|
|
653
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(
|
|
654
|
+
output.toUint8Array(),
|
|
655
|
+
);
|
|
656
|
+
})();
|
|
657
|
+
return Object.freeze({
|
|
658
|
+
promise,
|
|
659
|
+
cancel: async () => {
|
|
660
|
+
try { await reader.cancel(); } catch { /* settlement remains authoritative */ }
|
|
661
|
+
},
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function runWacli(
|
|
666
|
+
invocation: WacliInvocation,
|
|
667
|
+
): Promise<WacliInvocationResult> {
|
|
668
|
+
const cancellationSignal = invocation.signal;
|
|
669
|
+
const isCancelled = (): boolean =>
|
|
670
|
+
cancellationSignal?.aborted === true;
|
|
671
|
+
if (isCancelled()) {
|
|
672
|
+
throw new Error("WhatsApp protocol command was cancelled");
|
|
673
|
+
}
|
|
674
|
+
const ownsProcessGroup = (
|
|
675
|
+
cancellationSignal !== undefined
|
|
676
|
+
&& process.platform !== "win32"
|
|
677
|
+
);
|
|
678
|
+
const child = Bun.spawn(
|
|
679
|
+
[invocation.binary, ...invocation.arguments],
|
|
680
|
+
{
|
|
681
|
+
env: { ...invocation.environment },
|
|
682
|
+
stdin: "ignore",
|
|
683
|
+
stdout: "pipe",
|
|
684
|
+
stderr: "pipe",
|
|
685
|
+
detached: ownsProcessGroup,
|
|
686
|
+
},
|
|
687
|
+
);
|
|
688
|
+
let timedOut = false;
|
|
689
|
+
let cancelled = false;
|
|
690
|
+
let forceKill: ReturnType<typeof setTimeout> | null = null;
|
|
691
|
+
const signalChild = (signal: "SIGTERM" | "SIGKILL"): void => {
|
|
692
|
+
if (ownsProcessGroup) {
|
|
693
|
+
try {
|
|
694
|
+
process.kill(-child.pid, signal);
|
|
695
|
+
return;
|
|
696
|
+
} catch (error) {
|
|
697
|
+
if (
|
|
698
|
+
typeof error === "object"
|
|
699
|
+
&& error !== null
|
|
700
|
+
&& "code" in error
|
|
701
|
+
&& error.code === "ESRCH"
|
|
702
|
+
) return;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
try {
|
|
706
|
+
child.kill(signal);
|
|
707
|
+
} catch {
|
|
708
|
+
// The process already exited between the state check and the signal.
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
const terminate = (): void => {
|
|
712
|
+
signalChild("SIGTERM");
|
|
713
|
+
if (forceKill === null) {
|
|
714
|
+
forceKill = setTimeout(() => signalChild("SIGKILL"), 1_000);
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
const onAbort = (): void => {
|
|
718
|
+
cancelled = true;
|
|
719
|
+
terminate();
|
|
720
|
+
};
|
|
721
|
+
cancellationSignal?.addEventListener("abort", onAbort, { once: true });
|
|
722
|
+
if (isCancelled()) onAbort();
|
|
723
|
+
const timeout = setTimeout(() => {
|
|
724
|
+
timedOut = true;
|
|
725
|
+
terminate();
|
|
726
|
+
}, invocation.timeoutMs);
|
|
727
|
+
try {
|
|
728
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
729
|
+
readBoundedStream(child.stdout, invocation.maxOutputBytes),
|
|
730
|
+
readBoundedStream(
|
|
731
|
+
child.stderr,
|
|
732
|
+
Math.min(invocation.maxOutputBytes, MAX_STDERR_BYTES),
|
|
733
|
+
),
|
|
734
|
+
child.exited,
|
|
735
|
+
]);
|
|
736
|
+
if (cancelled) throw new Error("WhatsApp protocol command was cancelled");
|
|
737
|
+
if (timedOut) throw new Error("WhatsApp protocol command timed out");
|
|
738
|
+
return { exitCode, stdout, stderr };
|
|
739
|
+
} catch (error) {
|
|
740
|
+
signalChild("SIGKILL");
|
|
741
|
+
await child.exited;
|
|
742
|
+
throw error;
|
|
743
|
+
} finally {
|
|
744
|
+
clearTimeout(timeout);
|
|
745
|
+
if (forceKill !== null) clearTimeout(forceKill);
|
|
746
|
+
cancellationSignal?.removeEventListener("abort", onAbort);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function wacliEnvironment(
|
|
751
|
+
readOnly: boolean,
|
|
752
|
+
): Readonly<Record<string, string>> {
|
|
753
|
+
return Object.freeze({
|
|
754
|
+
PATH: "/usr/bin:/bin",
|
|
755
|
+
LANG: "C.UTF-8",
|
|
756
|
+
...(readOnly ? { WACLI_READONLY: "1" } : {}),
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function remainingTimeoutMs(
|
|
761
|
+
timeoutMs: number,
|
|
762
|
+
operationDeadline: WebSessionOperationDeadline | undefined,
|
|
763
|
+
): number {
|
|
764
|
+
operationDeadline?.throwIfUnavailable(WEB_SESSION_OPERATION_LABEL);
|
|
765
|
+
const remaining = Math.min(
|
|
766
|
+
timeoutMs,
|
|
767
|
+
operationDeadline?.remainingTimeMs() ?? timeoutMs,
|
|
768
|
+
);
|
|
769
|
+
if (remaining < 1) {
|
|
770
|
+
throw new Error("WhatsApp authenticated web operation timed out");
|
|
771
|
+
}
|
|
772
|
+
return remaining;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
async function checkedRun(
|
|
776
|
+
binary: string,
|
|
777
|
+
arguments_: readonly string[],
|
|
778
|
+
options: {
|
|
779
|
+
readonly timeoutMs: number;
|
|
780
|
+
readonly maxOutputBytes: number;
|
|
781
|
+
readonly readOnly: boolean;
|
|
782
|
+
readonly dependencies?: WhatsAppWebRuntimeDependencies;
|
|
783
|
+
readonly operationDeadline?: WebSessionOperationDeadline;
|
|
784
|
+
},
|
|
785
|
+
): Promise<unknown> {
|
|
786
|
+
const timeoutMs = remainingTimeoutMs(
|
|
787
|
+
options.timeoutMs,
|
|
788
|
+
options.operationDeadline,
|
|
789
|
+
);
|
|
790
|
+
const run = options.dependencies?.run ?? runWacli;
|
|
791
|
+
const invoke = () => run({
|
|
792
|
+
binary,
|
|
793
|
+
arguments: arguments_,
|
|
794
|
+
environment: wacliEnvironment(options.readOnly),
|
|
795
|
+
timeoutMs,
|
|
796
|
+
maxOutputBytes: options.maxOutputBytes,
|
|
797
|
+
...(options.operationDeadline === undefined
|
|
798
|
+
? {}
|
|
799
|
+
: { signal: options.operationDeadline.signal }),
|
|
800
|
+
});
|
|
801
|
+
const result = options.operationDeadline === undefined
|
|
802
|
+
? await invoke()
|
|
803
|
+
: await options.operationDeadline.run(
|
|
804
|
+
invoke,
|
|
805
|
+
WEB_SESSION_OPERATION_LABEL,
|
|
806
|
+
);
|
|
807
|
+
options.operationDeadline?.throwIfUnavailable(
|
|
808
|
+
WEB_SESSION_OPERATION_LABEL,
|
|
809
|
+
);
|
|
810
|
+
if (result.exitCode !== 0) {
|
|
811
|
+
throw new Error(
|
|
812
|
+
"WhatsApp protocol command failed before producing reviewed output",
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
const raw = result.stdout.trim();
|
|
816
|
+
if (raw.length < 1) {
|
|
817
|
+
throw new Error("WhatsApp protocol command omitted JSON output");
|
|
818
|
+
}
|
|
819
|
+
try {
|
|
820
|
+
return JSON.parse(raw) as unknown;
|
|
821
|
+
} catch {
|
|
822
|
+
throw new Error("WhatsApp protocol command returned malformed JSON");
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
async function runtimeBinary(
|
|
827
|
+
dependencies: WhatsAppWebRuntimeDependencies | undefined,
|
|
828
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
829
|
+
operationDeadline?: WebSessionOperationDeadline,
|
|
830
|
+
): Promise<string> {
|
|
831
|
+
operationDeadline?.throwIfUnavailable(WEB_SESSION_OPERATION_LABEL);
|
|
832
|
+
const resolveBinary = () => resolvePinnedWacliBinary(
|
|
833
|
+
environment,
|
|
834
|
+
operationDeadline?.signal,
|
|
835
|
+
);
|
|
836
|
+
const binary = dependencies?.binaryPath
|
|
837
|
+
?? (operationDeadline === undefined
|
|
838
|
+
? await resolveBinary()
|
|
839
|
+
: await operationDeadline.run(
|
|
840
|
+
resolveBinary,
|
|
841
|
+
WEB_SESSION_OPERATION_LABEL,
|
|
842
|
+
));
|
|
843
|
+
if (dependencies?.binaryPath !== undefined && !isAbsolute(binary)) {
|
|
844
|
+
throw new Error("test WhatsApp protocol binary path must be absolute");
|
|
845
|
+
}
|
|
846
|
+
const timeoutMs = remainingTimeoutMs(5_000, operationDeadline);
|
|
847
|
+
const run = dependencies?.run ?? runWacli;
|
|
848
|
+
const invoke = () => run({
|
|
849
|
+
binary,
|
|
850
|
+
arguments: ["version"],
|
|
851
|
+
environment: wacliEnvironment(true),
|
|
852
|
+
timeoutMs,
|
|
853
|
+
maxOutputBytes: 1_024,
|
|
854
|
+
...(operationDeadline === undefined
|
|
855
|
+
? {}
|
|
856
|
+
: { signal: operationDeadline.signal }),
|
|
857
|
+
});
|
|
858
|
+
const result = operationDeadline === undefined
|
|
859
|
+
? await invoke()
|
|
860
|
+
: await operationDeadline.run(
|
|
861
|
+
invoke,
|
|
862
|
+
WEB_SESSION_OPERATION_LABEL,
|
|
863
|
+
);
|
|
864
|
+
operationDeadline?.throwIfUnavailable(WEB_SESSION_OPERATION_LABEL);
|
|
865
|
+
if (
|
|
866
|
+
result.exitCode !== 0
|
|
867
|
+
|| result.stdout.trim() !== WHATSAPP_PROTOCOL_PIN.version
|
|
868
|
+
) {
|
|
869
|
+
throw new Error("WhatsApp protocol runtime version did not match its pin");
|
|
870
|
+
}
|
|
871
|
+
return binary;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function readOnlyArguments(
|
|
875
|
+
store: string,
|
|
876
|
+
timeoutMs: number,
|
|
877
|
+
command: readonly string[],
|
|
878
|
+
): readonly string[] {
|
|
879
|
+
return Object.freeze([
|
|
880
|
+
"--store",
|
|
881
|
+
store,
|
|
882
|
+
"--read-only",
|
|
883
|
+
"--json",
|
|
884
|
+
"--full",
|
|
885
|
+
"--timeout",
|
|
886
|
+
`${Math.max(1, timeoutMs)}ms`,
|
|
887
|
+
...command,
|
|
888
|
+
]);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
async function authStatus(
|
|
892
|
+
binary: string,
|
|
893
|
+
store: string,
|
|
894
|
+
timeoutMs: number,
|
|
895
|
+
dependencies: WhatsAppWebRuntimeDependencies | undefined,
|
|
896
|
+
operationDeadline?: WebSessionOperationDeadline,
|
|
897
|
+
): Promise<ReturnType<typeof parseWhatsAppAuthStatusEnvelope>> {
|
|
898
|
+
const commandTimeoutMs = remainingTimeoutMs(
|
|
899
|
+
timeoutMs,
|
|
900
|
+
operationDeadline,
|
|
901
|
+
);
|
|
902
|
+
return parseWhatsAppAuthStatusEnvelope(await checkedRun(
|
|
903
|
+
binary,
|
|
904
|
+
readOnlyArguments(store, commandTimeoutMs, ["auth", "status"]),
|
|
905
|
+
{
|
|
906
|
+
timeoutMs: commandTimeoutMs,
|
|
907
|
+
maxOutputBytes: 64 * 1024,
|
|
908
|
+
readOnly: true,
|
|
909
|
+
...(dependencies === undefined ? {} : { dependencies }),
|
|
910
|
+
...(operationDeadline === undefined
|
|
911
|
+
? {}
|
|
912
|
+
: { operationDeadline }),
|
|
913
|
+
},
|
|
914
|
+
));
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async function boundRuntime(
|
|
918
|
+
auth: GhostgetAuth,
|
|
919
|
+
purpose: "probe" | "projection",
|
|
920
|
+
timeoutMs: number,
|
|
921
|
+
dependencies: WhatsAppWebRuntimeDependencies | undefined,
|
|
922
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
923
|
+
operationDeadline?: WebSessionOperationDeadline,
|
|
924
|
+
): Promise<{
|
|
925
|
+
readonly auth: WhatsAppAuth;
|
|
926
|
+
readonly binary: string;
|
|
927
|
+
readonly store: string;
|
|
928
|
+
readonly subject: string;
|
|
929
|
+
}> {
|
|
930
|
+
operationDeadline?.throwIfUnavailable(WEB_SESSION_OPERATION_LABEL);
|
|
931
|
+
const linked = requireWhatsAppAuth(auth);
|
|
932
|
+
const validateStore = () =>
|
|
933
|
+
validateWhatsAppStoreDirectory(linked.path, purpose);
|
|
934
|
+
const store = operationDeadline === undefined
|
|
935
|
+
? await validateStore()
|
|
936
|
+
: await operationDeadline.run(
|
|
937
|
+
validateStore,
|
|
938
|
+
WEB_SESSION_OPERATION_LABEL,
|
|
939
|
+
);
|
|
940
|
+
const binary = await runtimeBinary(
|
|
941
|
+
dependencies,
|
|
942
|
+
environment,
|
|
943
|
+
operationDeadline,
|
|
944
|
+
);
|
|
945
|
+
const status = await authStatus(
|
|
946
|
+
binary,
|
|
947
|
+
store,
|
|
948
|
+
timeoutMs,
|
|
949
|
+
dependencies,
|
|
950
|
+
operationDeadline,
|
|
951
|
+
);
|
|
952
|
+
if (!status.authenticated || status.subject === null) {
|
|
953
|
+
throw new Error(
|
|
954
|
+
"WhatsApp linked-device store is not paired; run the explicit auth pairing flow",
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
if (purpose === "projection") {
|
|
958
|
+
if (linked.subject === undefined) {
|
|
959
|
+
throw new Error(
|
|
960
|
+
"WhatsApp linked-device auth must be bound to its current account before private reads",
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
if (linked.subject !== status.subject) {
|
|
964
|
+
throw new Error(
|
|
965
|
+
"WhatsApp linked-device account did not match the bound auth realm",
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
return {
|
|
970
|
+
auth: linked,
|
|
971
|
+
binary,
|
|
972
|
+
store,
|
|
973
|
+
subject: status.subject,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
export async function probeWhatsAppWebSubject(
|
|
978
|
+
auth: GhostgetAuth,
|
|
979
|
+
options: {
|
|
980
|
+
readonly dependencies?: WhatsAppWebRuntimeDependencies;
|
|
981
|
+
readonly environment?: Readonly<Record<string, string | undefined>>;
|
|
982
|
+
readonly timeoutMs?: number;
|
|
983
|
+
readonly signal?: AbortSignal;
|
|
984
|
+
} = {},
|
|
985
|
+
): Promise<string> {
|
|
986
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
987
|
+
const deadline = new OperationDeadline(timeoutMs, {
|
|
988
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
989
|
+
});
|
|
990
|
+
try {
|
|
991
|
+
const runtime = await boundRuntime(
|
|
992
|
+
auth,
|
|
993
|
+
"probe",
|
|
994
|
+
timeoutMs,
|
|
995
|
+
options.dependencies,
|
|
996
|
+
options.environment ?? process.env,
|
|
997
|
+
deadline,
|
|
998
|
+
);
|
|
999
|
+
deadline.throwIfUnavailable(WEB_SESSION_OPERATION_LABEL);
|
|
1000
|
+
return runtime.subject;
|
|
1001
|
+
} finally {
|
|
1002
|
+
deadline.dispose();
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function inputInteger(
|
|
1007
|
+
input: OperationInput,
|
|
1008
|
+
name: string,
|
|
1009
|
+
fallback: number,
|
|
1010
|
+
maximum: number,
|
|
1011
|
+
): number {
|
|
1012
|
+
const value = input[name] ?? fallback;
|
|
1013
|
+
if (!Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > maximum) {
|
|
1014
|
+
throw new Error(`input.${name} must be an integer between 1 and ${maximum}`);
|
|
1015
|
+
}
|
|
1016
|
+
return value as number;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function inputFolder(input: OperationInput): "all" | "active" | "archived" | "unread" {
|
|
1020
|
+
const value = input.folder ?? "all";
|
|
1021
|
+
if (
|
|
1022
|
+
value !== "all"
|
|
1023
|
+
&& value !== "active"
|
|
1024
|
+
&& value !== "archived"
|
|
1025
|
+
&& value !== "unread"
|
|
1026
|
+
) throw new Error("input.folder must be all, active, archived, or unread");
|
|
1027
|
+
return value;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
type ExactContactInput =
|
|
1031
|
+
| { readonly collection: "contacts"; readonly cursor: string | null; readonly limit: number }
|
|
1032
|
+
| { readonly collection: "interactions"; readonly cursor: string; readonly cursorAnchor: string | null; readonly limit: number };
|
|
1033
|
+
|
|
1034
|
+
function exactContactInput(input: OperationInput): ExactContactInput {
|
|
1035
|
+
const unexpected = Object.keys(input).filter(
|
|
1036
|
+
(key) => key !== "collection" && key !== "cursor" && key !== "cursor_anchor" && key !== "limit",
|
|
1037
|
+
);
|
|
1038
|
+
if (unexpected.length > 0) {
|
|
1039
|
+
throw new Error("WhatsApp contacts.list input contained unsupported fields");
|
|
1040
|
+
}
|
|
1041
|
+
const collection = input.collection ?? "contacts";
|
|
1042
|
+
if (collection !== "contacts" && collection !== "interactions") {
|
|
1043
|
+
throw new Error("input.collection must be contacts or interactions");
|
|
1044
|
+
}
|
|
1045
|
+
if (collection === "interactions") {
|
|
1046
|
+
const cursor = input.cursor === undefined
|
|
1047
|
+
? "0"
|
|
1048
|
+
: parseWhatsAppInteractionRowid(input.cursor, "input.cursor");
|
|
1049
|
+
const cursorAnchor = input.cursor_anchor === undefined
|
|
1050
|
+
? null
|
|
1051
|
+
: typeof input.cursor_anchor === "string" && /^[a-f0-9]{64}$/u.test(input.cursor_anchor)
|
|
1052
|
+
? input.cursor_anchor
|
|
1053
|
+
: (() => { throw new Error("input.cursor_anchor must be a SHA-256 digest"); })();
|
|
1054
|
+
if ((cursor === "0") !== (cursorAnchor === null)) {
|
|
1055
|
+
throw new Error("input.cursor_anchor must bind every nonzero interaction cursor");
|
|
1056
|
+
}
|
|
1057
|
+
return Object.freeze({
|
|
1058
|
+
collection,
|
|
1059
|
+
cursor,
|
|
1060
|
+
cursorAnchor,
|
|
1061
|
+
limit: inputInteger(input, "limit", DEFAULT_LIMIT, 1_000),
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
if (input.cursor_anchor !== undefined) {
|
|
1065
|
+
throw new Error("input.cursor_anchor is only supported for interaction scans");
|
|
1066
|
+
}
|
|
1067
|
+
let cursor: string | null = null;
|
|
1068
|
+
if (input.cursor !== undefined) {
|
|
1069
|
+
const parsed = parseWhatsAppJid(input.cursor, "input.cursor");
|
|
1070
|
+
if (
|
|
1071
|
+
(parsed.kind !== "user" && parsed.kind !== "lid")
|
|
1072
|
+
|| parsed.jid.includes(":")
|
|
1073
|
+
) {
|
|
1074
|
+
throw new Error("input.cursor must be one exact contact user or LID JID");
|
|
1075
|
+
}
|
|
1076
|
+
cursor = parsed.jid;
|
|
1077
|
+
}
|
|
1078
|
+
return Object.freeze({
|
|
1079
|
+
collection,
|
|
1080
|
+
cursor,
|
|
1081
|
+
limit: inputInteger(input, "limit", DEFAULT_LIMIT, 100),
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
type ContactProjectionParentIdentity = Readonly<{
|
|
1086
|
+
store: BigIntStats;
|
|
1087
|
+
session: BigIntStats;
|
|
1088
|
+
}>;
|
|
1089
|
+
|
|
1090
|
+
type InteractionProjectionParentIdentity = Readonly<{
|
|
1091
|
+
store: BigIntStats;
|
|
1092
|
+
session: BigIntStats;
|
|
1093
|
+
messageStore: BigIntStats;
|
|
1094
|
+
}>;
|
|
1095
|
+
|
|
1096
|
+
function sameContactProjectionSnapshot(
|
|
1097
|
+
left: BigIntStats,
|
|
1098
|
+
right: BigIntStats,
|
|
1099
|
+
): boolean {
|
|
1100
|
+
return left.dev === right.dev
|
|
1101
|
+
&& left.ino === right.ino
|
|
1102
|
+
&& left.mode === right.mode
|
|
1103
|
+
&& left.uid === right.uid
|
|
1104
|
+
&& left.gid === right.gid
|
|
1105
|
+
&& left.nlink === right.nlink
|
|
1106
|
+
&& left.size === right.size
|
|
1107
|
+
&& left.mtimeNs === right.mtimeNs
|
|
1108
|
+
&& left.ctimeNs === right.ctimeNs;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function currentBigIntUid(): bigint | null {
|
|
1112
|
+
return typeof process.getuid === "function" ? BigInt(process.getuid()) : null;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function assertParentContactProjectionIdentity(
|
|
1116
|
+
store: BigIntStats,
|
|
1117
|
+
session: BigIntStats,
|
|
1118
|
+
): void {
|
|
1119
|
+
const uid = currentBigIntUid();
|
|
1120
|
+
if (
|
|
1121
|
+
!store.isDirectory()
|
|
1122
|
+
|| store.isSymbolicLink()
|
|
1123
|
+
|| (uid !== null && store.uid !== uid)
|
|
1124
|
+
|| !isExactWhatsAppContactProjectionMode(store.mode, 0o700)
|
|
1125
|
+
|| !session.isFile()
|
|
1126
|
+
|| session.isSymbolicLink()
|
|
1127
|
+
|| session.nlink !== 1n
|
|
1128
|
+
|| (uid !== null && session.uid !== uid)
|
|
1129
|
+
|| !isExactWhatsAppContactProjectionMode(session.mode, 0o600)
|
|
1130
|
+
|| session.size < 1n
|
|
1131
|
+
|| session.size > 128n * 1024n * 1024n
|
|
1132
|
+
) {
|
|
1133
|
+
throw new Error(
|
|
1134
|
+
"WhatsApp contact projection parent could not verify its private session store",
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
async function captureContactProjectionParentIdentity(
|
|
1140
|
+
store: string,
|
|
1141
|
+
): Promise<ContactProjectionParentIdentity> {
|
|
1142
|
+
try {
|
|
1143
|
+
if (await realpath(store) !== store) {
|
|
1144
|
+
throw new Error("non-canonical");
|
|
1145
|
+
}
|
|
1146
|
+
const [storeStats, sessionStats] = await Promise.all([
|
|
1147
|
+
lstat(store, { bigint: true }),
|
|
1148
|
+
lstat(join(store, "session.db"), { bigint: true }),
|
|
1149
|
+
]);
|
|
1150
|
+
assertParentContactProjectionIdentity(storeStats, sessionStats);
|
|
1151
|
+
return Object.freeze({ store: storeStats, session: sessionStats });
|
|
1152
|
+
} catch {
|
|
1153
|
+
throw new Error(
|
|
1154
|
+
"WhatsApp contact projection parent could not bind its private session store",
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
async function revalidateContactProjectionParentIdentity(
|
|
1160
|
+
store: string,
|
|
1161
|
+
initial: ContactProjectionParentIdentity,
|
|
1162
|
+
): Promise<void> {
|
|
1163
|
+
try {
|
|
1164
|
+
if (await realpath(store) !== store) throw new Error("non-canonical");
|
|
1165
|
+
const [storeStats, sessionStats] = await Promise.all([
|
|
1166
|
+
lstat(store, { bigint: true }),
|
|
1167
|
+
lstat(join(store, "session.db"), { bigint: true }),
|
|
1168
|
+
]);
|
|
1169
|
+
assertParentContactProjectionIdentity(storeStats, sessionStats);
|
|
1170
|
+
if (
|
|
1171
|
+
!sameContactProjectionSnapshot(initial.store, storeStats)
|
|
1172
|
+
|| !sameContactProjectionSnapshot(initial.session, sessionStats)
|
|
1173
|
+
) throw new Error("identity changed");
|
|
1174
|
+
} catch {
|
|
1175
|
+
throw new Error(
|
|
1176
|
+
"WhatsApp contact projection parent binding changed during the helper read",
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function assertParentInteractionProjectionIdentity(
|
|
1182
|
+
store: BigIntStats,
|
|
1183
|
+
session: BigIntStats,
|
|
1184
|
+
messageStore: BigIntStats,
|
|
1185
|
+
): void {
|
|
1186
|
+
assertParentContactProjectionIdentity(store, session);
|
|
1187
|
+
const uid = currentBigIntUid();
|
|
1188
|
+
if (
|
|
1189
|
+
!messageStore.isFile()
|
|
1190
|
+
|| messageStore.isSymbolicLink()
|
|
1191
|
+
|| messageStore.nlink !== 1n
|
|
1192
|
+
|| (uid !== null && messageStore.uid !== uid)
|
|
1193
|
+
|| !isExactWhatsAppContactProjectionMode(messageStore.mode, 0o600)
|
|
1194
|
+
|| messageStore.size < 1n
|
|
1195
|
+
|| messageStore.size > 2n * 1024n * 1024n * 1024n
|
|
1196
|
+
) {
|
|
1197
|
+
throw new Error(
|
|
1198
|
+
"WhatsApp interaction projection parent could not verify its private message store",
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
async function captureInteractionProjectionParentIdentity(
|
|
1204
|
+
store: string,
|
|
1205
|
+
): Promise<InteractionProjectionParentIdentity> {
|
|
1206
|
+
try {
|
|
1207
|
+
if (await realpath(store) !== store) throw new Error("non-canonical");
|
|
1208
|
+
const [storeStats, sessionStats, messageStoreStats] = await Promise.all([
|
|
1209
|
+
lstat(store, { bigint: true }),
|
|
1210
|
+
lstat(join(store, "session.db"), { bigint: true }),
|
|
1211
|
+
lstat(join(store, "wacli.db"), { bigint: true }),
|
|
1212
|
+
]);
|
|
1213
|
+
assertParentInteractionProjectionIdentity(
|
|
1214
|
+
storeStats,
|
|
1215
|
+
sessionStats,
|
|
1216
|
+
messageStoreStats,
|
|
1217
|
+
);
|
|
1218
|
+
return Object.freeze({
|
|
1219
|
+
store: storeStats,
|
|
1220
|
+
session: sessionStats,
|
|
1221
|
+
messageStore: messageStoreStats,
|
|
1222
|
+
});
|
|
1223
|
+
} catch {
|
|
1224
|
+
throw new Error(
|
|
1225
|
+
"WhatsApp interaction projection parent could not bind its private stores",
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
async function revalidateInteractionProjectionParentIdentity(
|
|
1231
|
+
store: string,
|
|
1232
|
+
initial: InteractionProjectionParentIdentity,
|
|
1233
|
+
): Promise<void> {
|
|
1234
|
+
try {
|
|
1235
|
+
if (await realpath(store) !== store) throw new Error("non-canonical");
|
|
1236
|
+
const [storeStats, sessionStats, messageStoreStats] = await Promise.all([
|
|
1237
|
+
lstat(store, { bigint: true }),
|
|
1238
|
+
lstat(join(store, "session.db"), { bigint: true }),
|
|
1239
|
+
lstat(join(store, "wacli.db"), { bigint: true }),
|
|
1240
|
+
]);
|
|
1241
|
+
assertParentInteractionProjectionIdentity(
|
|
1242
|
+
storeStats,
|
|
1243
|
+
sessionStats,
|
|
1244
|
+
messageStoreStats,
|
|
1245
|
+
);
|
|
1246
|
+
if (
|
|
1247
|
+
!sameContactProjectionSnapshot(initial.store, storeStats)
|
|
1248
|
+
|| !sameContactProjectionSnapshot(initial.session, sessionStats)
|
|
1249
|
+
|| !sameContactProjectionSnapshot(initial.messageStore, messageStoreStats)
|
|
1250
|
+
) throw new Error("identity changed");
|
|
1251
|
+
} catch {
|
|
1252
|
+
throw new Error(
|
|
1253
|
+
"WhatsApp interaction projection parent binding changed during the helper read",
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
type FixedContactProjectionFiles = Readonly<{
|
|
1259
|
+
helper: string;
|
|
1260
|
+
config: string;
|
|
1261
|
+
}>;
|
|
1262
|
+
|
|
1263
|
+
async function fixedContactProjectionFile(
|
|
1264
|
+
pathValue: string,
|
|
1265
|
+
): Promise<string | null> {
|
|
1266
|
+
try {
|
|
1267
|
+
const stats = await lstat(pathValue);
|
|
1268
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
1269
|
+
if (
|
|
1270
|
+
stats.isSymbolicLink()
|
|
1271
|
+
|| !stats.isFile()
|
|
1272
|
+
|| stats.nlink !== 1
|
|
1273
|
+
|| stats.size < 1
|
|
1274
|
+
|| stats.size > 2 * 1024 * 1024
|
|
1275
|
+
|| (stats.mode & 0o022) !== 0
|
|
1276
|
+
|| (uid !== null && stats.uid !== uid && stats.uid !== 0)
|
|
1277
|
+
|| await realpath(pathValue) !== pathValue
|
|
1278
|
+
) {
|
|
1279
|
+
throw new Error("unsafe fixed file");
|
|
1280
|
+
}
|
|
1281
|
+
return pathValue;
|
|
1282
|
+
} catch (error) {
|
|
1283
|
+
if (
|
|
1284
|
+
typeof error === "object"
|
|
1285
|
+
&& error !== null
|
|
1286
|
+
&& "code" in error
|
|
1287
|
+
&& error.code === "ENOENT"
|
|
1288
|
+
) return null;
|
|
1289
|
+
throw new Error(
|
|
1290
|
+
"WhatsApp contact projection fixed helper files failed validation",
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
async function resolveFixedContactProjectionFiles(
|
|
1296
|
+
helperName = "whatsapp-contact-projection-helper.ts",
|
|
1297
|
+
): Promise<
|
|
1298
|
+
FixedContactProjectionFiles
|
|
1299
|
+
> {
|
|
1300
|
+
const moduleDirectory = dirname(fileURLToPath(import.meta.url));
|
|
1301
|
+
const candidates = [
|
|
1302
|
+
{
|
|
1303
|
+
helper: resolve(
|
|
1304
|
+
moduleDirectory,
|
|
1305
|
+
helperName,
|
|
1306
|
+
),
|
|
1307
|
+
config: resolve(moduleDirectory, "../state-helper.bunfig.toml"),
|
|
1308
|
+
},
|
|
1309
|
+
{
|
|
1310
|
+
helper: resolve(
|
|
1311
|
+
moduleDirectory,
|
|
1312
|
+
`../src/providers/${helperName}`,
|
|
1313
|
+
),
|
|
1314
|
+
config: resolve(
|
|
1315
|
+
moduleDirectory,
|
|
1316
|
+
"../src/state-helper.bunfig.toml",
|
|
1317
|
+
),
|
|
1318
|
+
},
|
|
1319
|
+
] as const;
|
|
1320
|
+
for (const candidate of candidates) {
|
|
1321
|
+
const [helper, config] = await Promise.all([
|
|
1322
|
+
fixedContactProjectionFile(candidate.helper),
|
|
1323
|
+
fixedContactProjectionFile(candidate.config),
|
|
1324
|
+
]);
|
|
1325
|
+
if (helper === null && config === null) continue;
|
|
1326
|
+
if (helper === null || config === null) {
|
|
1327
|
+
throw new Error(
|
|
1328
|
+
"WhatsApp contact projection fixed helper installation is incomplete",
|
|
1329
|
+
);
|
|
1330
|
+
}
|
|
1331
|
+
return Object.freeze({ helper, config });
|
|
1332
|
+
}
|
|
1333
|
+
throw new Error(
|
|
1334
|
+
"WhatsApp contact projection fixed helper is not installed",
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function contactProjectionEnvironment(): Readonly<Record<string, string>> {
|
|
1339
|
+
return Object.freeze({
|
|
1340
|
+
PATH: "/usr/bin:/bin",
|
|
1341
|
+
LANG: "C.UTF-8",
|
|
1342
|
+
LC_ALL: "C.UTF-8",
|
|
1343
|
+
TZ: "UTC",
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
export async function runWhatsAppContactProjectionHelperChild(
|
|
1348
|
+
invocation: WhatsAppContactProjectionHelperInvocation,
|
|
1349
|
+
): Promise<WhatsAppContactProjectionHelperResult> {
|
|
1350
|
+
const isAborted = (): boolean => invocation.signal?.aborted === true;
|
|
1351
|
+
if (isAborted()) {
|
|
1352
|
+
throw new Error("WhatsApp contact projection helper was cancelled");
|
|
1353
|
+
}
|
|
1354
|
+
let child: Bun.Subprocess<"pipe", "pipe", "pipe">;
|
|
1355
|
+
try {
|
|
1356
|
+
child = Bun.spawn([...invocation.command], {
|
|
1357
|
+
cwd: invocation.cwd,
|
|
1358
|
+
env: { ...invocation.environment },
|
|
1359
|
+
stdin: "pipe",
|
|
1360
|
+
stdout: "pipe",
|
|
1361
|
+
stderr: "pipe",
|
|
1362
|
+
detached: process.platform !== "win32",
|
|
1363
|
+
});
|
|
1364
|
+
} catch {
|
|
1365
|
+
throw new Error("WhatsApp contact projection helper could not start");
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
if (invocation.onSpawned !== undefined) {
|
|
1369
|
+
try {
|
|
1370
|
+
invocation.onSpawned(child.pid);
|
|
1371
|
+
} catch (error) {
|
|
1372
|
+
try {
|
|
1373
|
+
if (process.platform !== "win32") process.kill(-child.pid, "SIGKILL");
|
|
1374
|
+
else child.kill("SIGKILL");
|
|
1375
|
+
} catch {
|
|
1376
|
+
// child.exited below is still the exact cleanup proof.
|
|
1377
|
+
}
|
|
1378
|
+
try {
|
|
1379
|
+
await child.exited;
|
|
1380
|
+
} catch {
|
|
1381
|
+
throw new WhatsAppContactProjectionCleanupUnverifiedError();
|
|
1382
|
+
}
|
|
1383
|
+
throw error;
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
let timedOut = false;
|
|
1388
|
+
let cancelled = false;
|
|
1389
|
+
let forceKill: ReturnType<typeof setTimeout> | undefined;
|
|
1390
|
+
let terminationStarted = false;
|
|
1391
|
+
const signalChild = (signal: "SIGTERM" | "SIGKILL"): void => {
|
|
1392
|
+
if (process.platform !== "win32") {
|
|
1393
|
+
try {
|
|
1394
|
+
process.kill(-child.pid, signal);
|
|
1395
|
+
return;
|
|
1396
|
+
} catch (error) {
|
|
1397
|
+
if (
|
|
1398
|
+
typeof error === "object"
|
|
1399
|
+
&& error !== null
|
|
1400
|
+
&& "code" in error
|
|
1401
|
+
&& error.code === "ESRCH"
|
|
1402
|
+
) return;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
try {
|
|
1406
|
+
child.kill(signal);
|
|
1407
|
+
} catch {
|
|
1408
|
+
// child.exited remains the cleanup proof. If it cannot settle, the
|
|
1409
|
+
// registered cleanup barrier reaches the kernel's unsafe bounded join.
|
|
1410
|
+
}
|
|
1411
|
+
};
|
|
1412
|
+
const terminate = (): void => {
|
|
1413
|
+
if (!terminationStarted) {
|
|
1414
|
+
terminationStarted = true;
|
|
1415
|
+
signalChild("SIGTERM");
|
|
1416
|
+
}
|
|
1417
|
+
forceKill ??= setTimeout(
|
|
1418
|
+
() => signalChild("SIGKILL"),
|
|
1419
|
+
CONTACT_PROJECTION_FORCE_KILL_DELAY_MS,
|
|
1420
|
+
);
|
|
1421
|
+
};
|
|
1422
|
+
const onAbort = (): void => {
|
|
1423
|
+
cancelled = true;
|
|
1424
|
+
terminate();
|
|
1425
|
+
};
|
|
1426
|
+
invocation.signal?.addEventListener("abort", onAbort, { once: true });
|
|
1427
|
+
if (isAborted()) onAbort();
|
|
1428
|
+
const timeout = setTimeout(() => {
|
|
1429
|
+
timedOut = true;
|
|
1430
|
+
terminate();
|
|
1431
|
+
}, invocation.timeoutMs);
|
|
1432
|
+
|
|
1433
|
+
const guarded = <T>(promise: Promise<T>): Promise<T> =>
|
|
1434
|
+
promise.catch((error: unknown) => {
|
|
1435
|
+
terminate();
|
|
1436
|
+
throw error;
|
|
1437
|
+
});
|
|
1438
|
+
const stdin = guarded((async () => {
|
|
1439
|
+
await child.stdin.write(invocation.stdin);
|
|
1440
|
+
await child.stdin.end();
|
|
1441
|
+
})());
|
|
1442
|
+
const stdout = guarded(readBoundedStream(
|
|
1443
|
+
child.stdout,
|
|
1444
|
+
invocation.maxOutputBytes,
|
|
1445
|
+
));
|
|
1446
|
+
const stderr = guarded(readBoundedStream(
|
|
1447
|
+
child.stderr,
|
|
1448
|
+
invocation.maxStderrBytes,
|
|
1449
|
+
));
|
|
1450
|
+
const exited = child.exited;
|
|
1451
|
+
try {
|
|
1452
|
+
const [stdinResult, stdoutResult, stderrResult, exitResult] =
|
|
1453
|
+
await Promise.allSettled([stdin, stdout, stderr, exited]);
|
|
1454
|
+
if (exitResult.status === "rejected") {
|
|
1455
|
+
throw new WhatsAppContactProjectionCleanupUnverifiedError();
|
|
1456
|
+
}
|
|
1457
|
+
if (
|
|
1458
|
+
stdinResult.status === "rejected"
|
|
1459
|
+
|| stdoutResult.status === "rejected"
|
|
1460
|
+
|| stderrResult.status === "rejected"
|
|
1461
|
+
) {
|
|
1462
|
+
throw new Error(
|
|
1463
|
+
"WhatsApp contact projection helper stream failed within its bound",
|
|
1464
|
+
);
|
|
1465
|
+
}
|
|
1466
|
+
if (cancelled) {
|
|
1467
|
+
throw new Error("WhatsApp contact projection helper was cancelled");
|
|
1468
|
+
}
|
|
1469
|
+
if (timedOut) {
|
|
1470
|
+
throw new Error("WhatsApp contact projection helper timed out");
|
|
1471
|
+
}
|
|
1472
|
+
return Object.freeze({
|
|
1473
|
+
exitCode: exitResult.value,
|
|
1474
|
+
stdout: stdoutResult.value,
|
|
1475
|
+
stderr: stderrResult.value,
|
|
1476
|
+
});
|
|
1477
|
+
} finally {
|
|
1478
|
+
clearTimeout(timeout);
|
|
1479
|
+
if (forceKill !== undefined) clearTimeout(forceKill);
|
|
1480
|
+
invocation.signal?.removeEventListener("abort", onAbort);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
class CanonicalSessionFrameDecoder {
|
|
1485
|
+
readonly #maximumFrameBytes: number;
|
|
1486
|
+
readonly #onFrame: (frame: WhatsAppMessageExportSessionCanonicalFrame) => void;
|
|
1487
|
+
#chunks: Buffer[] = [];
|
|
1488
|
+
#pendingBytes = 0;
|
|
1489
|
+
#frameCount = 0;
|
|
1490
|
+
|
|
1491
|
+
constructor(
|
|
1492
|
+
maximumFrameBytes: number,
|
|
1493
|
+
onFrame: (frame: WhatsAppMessageExportSessionCanonicalFrame) => void,
|
|
1494
|
+
) {
|
|
1495
|
+
this.#maximumFrameBytes = maximumFrameBytes;
|
|
1496
|
+
this.#onFrame = onFrame;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
get frameCount(): number {
|
|
1500
|
+
return this.#frameCount;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
#append(bytes: Uint8Array): void {
|
|
1504
|
+
if (bytes.byteLength === 0) return;
|
|
1505
|
+
this.#pendingBytes += bytes.byteLength;
|
|
1506
|
+
if (this.#pendingBytes + 1 > this.#maximumFrameBytes) {
|
|
1507
|
+
throw new Error("WhatsApp projection session frame exceeded its bound");
|
|
1508
|
+
}
|
|
1509
|
+
// Copy bounded pieces so replay may safely reuse its fixed read buffer.
|
|
1510
|
+
this.#chunks.push(Buffer.from(bytes));
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
#finishLine(): void {
|
|
1514
|
+
if (
|
|
1515
|
+
this.#pendingBytes < 2
|
|
1516
|
+
|| this.#pendingBytes + 1 > this.#maximumFrameBytes
|
|
1517
|
+
|| this.#frameCount >= MESSAGE_EXPORT_SESSION_MAX_FRAMES
|
|
1518
|
+
) throw new Error("WhatsApp projection session frame exceeded its bound");
|
|
1519
|
+
const bytes = this.#chunks.length === 1
|
|
1520
|
+
? this.#chunks[0]!
|
|
1521
|
+
: Buffer.concat(this.#chunks, this.#pendingBytes);
|
|
1522
|
+
this.#chunks = [];
|
|
1523
|
+
this.#pendingBytes = 0;
|
|
1524
|
+
let line: string;
|
|
1525
|
+
try {
|
|
1526
|
+
line = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
1527
|
+
} catch {
|
|
1528
|
+
throw new Error("WhatsApp projection session frame was malformed");
|
|
1529
|
+
}
|
|
1530
|
+
if (line.includes("\r")) {
|
|
1531
|
+
throw new Error("WhatsApp projection session frame exceeded its bound");
|
|
1532
|
+
}
|
|
1533
|
+
let value: unknown;
|
|
1534
|
+
try {
|
|
1535
|
+
value = JSON.parse(line) as unknown;
|
|
1536
|
+
} catch {
|
|
1537
|
+
throw new Error("WhatsApp projection session frame was malformed");
|
|
1538
|
+
}
|
|
1539
|
+
const canonical = canonicalJson(value);
|
|
1540
|
+
if (canonical !== line || !Buffer.from(canonical, "utf8").equals(bytes)) {
|
|
1541
|
+
throw new Error("WhatsApp projection session frame was not canonical");
|
|
1542
|
+
}
|
|
1543
|
+
this.#frameCount += 1;
|
|
1544
|
+
this.#onFrame(Object.freeze({
|
|
1545
|
+
index: this.#frameCount,
|
|
1546
|
+
canonical,
|
|
1547
|
+
value,
|
|
1548
|
+
}));
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
push(bytes: Uint8Array): void {
|
|
1552
|
+
let start = 0;
|
|
1553
|
+
for (let index = 0; index < bytes.byteLength; index += 1) {
|
|
1554
|
+
if (bytes[index] !== 0x0a) continue;
|
|
1555
|
+
this.#append(bytes.subarray(start, index));
|
|
1556
|
+
this.#finishLine();
|
|
1557
|
+
start = index + 1;
|
|
1558
|
+
}
|
|
1559
|
+
this.#append(bytes.subarray(start));
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
finish(): void {
|
|
1563
|
+
if (this.#pendingBytes !== 0 || this.#chunks.length !== 0) {
|
|
1564
|
+
throw new Error("WhatsApp projection session ended inside a frame");
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
type MutableWhatsAppMessageExportSessionSpool = Readonly<{
|
|
1570
|
+
handle: FileHandle;
|
|
1571
|
+
capture: (
|
|
1572
|
+
stream: ReadableStream<Uint8Array>,
|
|
1573
|
+
maximumFrameBytes: number,
|
|
1574
|
+
onFrame: ((frame: WhatsAppMessageExportSessionCanonicalFrame) => void) | undefined,
|
|
1575
|
+
) => Readonly<{ promise: Promise<void>; cancel: () => Promise<void> }>;
|
|
1576
|
+
publicSpool: WhatsAppMessageExportSessionSpool;
|
|
1577
|
+
}>;
|
|
1578
|
+
|
|
1579
|
+
function sameSpoolDirectory(left: BigIntStats, right: BigIntStats): boolean {
|
|
1580
|
+
return left.dev === right.dev
|
|
1581
|
+
&& left.ino === right.ino
|
|
1582
|
+
&& left.uid === right.uid
|
|
1583
|
+
&& left.mode === right.mode
|
|
1584
|
+
&& left.birthtimeNs === right.birthtimeNs;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
function assertPrivateSpoolDirectory(stats: BigIntStats): void {
|
|
1588
|
+
const uid = process.getuid?.();
|
|
1589
|
+
if (
|
|
1590
|
+
uid === undefined
|
|
1591
|
+
|| !stats.isDirectory()
|
|
1592
|
+
|| stats.isSymbolicLink()
|
|
1593
|
+
|| stats.uid !== BigInt(uid)
|
|
1594
|
+
|| (stats.mode & 0o777n) !== BigInt(MESSAGE_EXPORT_SESSION_PRIVATE_DIRECTORY_MODE)
|
|
1595
|
+
) throw new WhatsAppContactProjectionCleanupUnverifiedError();
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
function assertPrivateSpoolFile(
|
|
1599
|
+
stats: BigIntStats,
|
|
1600
|
+
identity: Readonly<{ dev: bigint; ino: bigint }>,
|
|
1601
|
+
expectedBytes?: number,
|
|
1602
|
+
): void {
|
|
1603
|
+
const uid = process.getuid?.();
|
|
1604
|
+
if (
|
|
1605
|
+
!stats.isFile()
|
|
1606
|
+
|| stats.isSymbolicLink()
|
|
1607
|
+
|| uid === undefined
|
|
1608
|
+
|| stats.uid !== BigInt(uid)
|
|
1609
|
+
|| (stats.mode & 0o777n) !== BigInt(MESSAGE_EXPORT_SESSION_PRIVATE_FILE_MODE)
|
|
1610
|
+
|| stats.nlink !== 0n
|
|
1611
|
+
|| stats.dev !== identity.dev
|
|
1612
|
+
|| stats.ino !== identity.ino
|
|
1613
|
+
|| (expectedBytes !== undefined && stats.size !== BigInt(expectedBytes))
|
|
1614
|
+
) throw new WhatsAppContactProjectionCleanupUnverifiedError();
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
async function writeSpoolBytes(
|
|
1618
|
+
handle: FileHandle,
|
|
1619
|
+
bytes: Uint8Array,
|
|
1620
|
+
position: number,
|
|
1621
|
+
): Promise<void> {
|
|
1622
|
+
let offset = 0;
|
|
1623
|
+
while (offset < bytes.byteLength) {
|
|
1624
|
+
const written = await handle.write(
|
|
1625
|
+
bytes,
|
|
1626
|
+
offset,
|
|
1627
|
+
bytes.byteLength - offset,
|
|
1628
|
+
position + offset,
|
|
1629
|
+
);
|
|
1630
|
+
if (written.bytesWritten < 1) {
|
|
1631
|
+
throw new WhatsAppContactProjectionCleanupUnverifiedError();
|
|
1632
|
+
}
|
|
1633
|
+
offset += written.bytesWritten;
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
async function createWhatsAppMessageExportSessionSpool(
|
|
1638
|
+
beforeReadyForTest?: () => Promise<void>,
|
|
1639
|
+
): Promise<
|
|
1640
|
+
MutableWhatsAppMessageExportSessionSpool
|
|
1641
|
+
> {
|
|
1642
|
+
let createdDirectory: string;
|
|
1643
|
+
try {
|
|
1644
|
+
createdDirectory = await mkdtemp(join(tmpdir(), "wrench-whatsapp-stdout-"));
|
|
1645
|
+
} catch {
|
|
1646
|
+
throw new Error("WhatsApp projection session private spool could not be created");
|
|
1647
|
+
}
|
|
1648
|
+
let directory: string;
|
|
1649
|
+
try {
|
|
1650
|
+
directory = await realpath(createdDirectory);
|
|
1651
|
+
} catch {
|
|
1652
|
+
// A created directory whose physical identity cannot be established cannot
|
|
1653
|
+
// be removed with proof, so retain the durable cleanup-unsafe boundary.
|
|
1654
|
+
throw new WhatsAppContactProjectionCleanupUnverifiedError();
|
|
1655
|
+
}
|
|
1656
|
+
const path = join(directory, "stdout.ndjson");
|
|
1657
|
+
let handle: FileHandle | undefined;
|
|
1658
|
+
let directoryHandle: FileHandle | undefined;
|
|
1659
|
+
let cleanupDirectoryIdentity: BigIntStats | undefined;
|
|
1660
|
+
try {
|
|
1661
|
+
await chmod(directory, MESSAGE_EXPORT_SESSION_PRIVATE_DIRECTORY_MODE);
|
|
1662
|
+
const directoryFlags = fsConstants.O_RDONLY
|
|
1663
|
+
| fsConstants.O_NOFOLLOW
|
|
1664
|
+
| (typeof fsConstants.O_DIRECTORY === "number" ? fsConstants.O_DIRECTORY : 0);
|
|
1665
|
+
directoryHandle = await open(directory, directoryFlags);
|
|
1666
|
+
const directoryIdentity = await directoryHandle.stat({ bigint: true });
|
|
1667
|
+
cleanupDirectoryIdentity = directoryIdentity;
|
|
1668
|
+
assertPrivateSpoolDirectory(directoryIdentity);
|
|
1669
|
+
const directoryPathIdentity = await lstat(directory, { bigint: true });
|
|
1670
|
+
assertPrivateSpoolDirectory(directoryPathIdentity);
|
|
1671
|
+
if (!sameSpoolDirectory(directoryIdentity, directoryPathIdentity)) {
|
|
1672
|
+
throw new WhatsAppContactProjectionCleanupUnverifiedError();
|
|
1673
|
+
}
|
|
1674
|
+
const assertDirectoryBinding = async (): Promise<void> => {
|
|
1675
|
+
const [descriptor, current] = await Promise.all([
|
|
1676
|
+
directoryHandle!.stat({ bigint: true }),
|
|
1677
|
+
lstat(directory, { bigint: true }),
|
|
1678
|
+
]);
|
|
1679
|
+
assertPrivateSpoolDirectory(descriptor);
|
|
1680
|
+
assertPrivateSpoolDirectory(current);
|
|
1681
|
+
if (
|
|
1682
|
+
!sameSpoolDirectory(directoryIdentity, descriptor)
|
|
1683
|
+
|| !sameSpoolDirectory(directoryIdentity, current)
|
|
1684
|
+
) throw new WhatsAppContactProjectionCleanupUnverifiedError();
|
|
1685
|
+
};
|
|
1686
|
+
const uid = process.getuid?.();
|
|
1687
|
+
if (uid === undefined) throw new WhatsAppContactProjectionCleanupUnverifiedError();
|
|
1688
|
+
handle = await open(
|
|
1689
|
+
path,
|
|
1690
|
+
fsConstants.O_RDWR | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
|
|
1691
|
+
MESSAGE_EXPORT_SESSION_PRIVATE_FILE_MODE,
|
|
1692
|
+
);
|
|
1693
|
+
await handle.chmod(MESSAGE_EXPORT_SESSION_PRIVATE_FILE_MODE);
|
|
1694
|
+
const linked = await handle.stat({ bigint: true });
|
|
1695
|
+
const entry = await lstat(path, { bigint: true });
|
|
1696
|
+
if (
|
|
1697
|
+
!linked.isFile()
|
|
1698
|
+
|| linked.isSymbolicLink()
|
|
1699
|
+
|| linked.nlink !== 1n
|
|
1700
|
+
|| linked.dev !== entry.dev
|
|
1701
|
+
|| linked.ino !== entry.ino
|
|
1702
|
+
|| linked.uid !== BigInt(uid)
|
|
1703
|
+
|| (linked.mode & 0o777n) !== BigInt(MESSAGE_EXPORT_SESSION_PRIVATE_FILE_MODE)
|
|
1704
|
+
) throw new Error("WhatsApp projection session private spool file was invalid");
|
|
1705
|
+
const fileIdentity = Object.freeze({ dev: linked.dev, ino: linked.ino });
|
|
1706
|
+
await assertDirectoryBinding();
|
|
1707
|
+
await unlink(path);
|
|
1708
|
+
assertPrivateSpoolFile(await handle.stat({ bigint: true }), fileIdentity, 0);
|
|
1709
|
+
await assertDirectoryBinding();
|
|
1710
|
+
|
|
1711
|
+
let sealed: Readonly<{
|
|
1712
|
+
frameCount: number;
|
|
1713
|
+
totalBytes: number;
|
|
1714
|
+
stdoutSha256: string;
|
|
1715
|
+
maximumFrameBytes: number;
|
|
1716
|
+
metadata: BigIntStats;
|
|
1717
|
+
}> | undefined;
|
|
1718
|
+
let closed = false;
|
|
1719
|
+
let replayStarted = false;
|
|
1720
|
+
let replayActive = false;
|
|
1721
|
+
|
|
1722
|
+
const capture = (
|
|
1723
|
+
stream: ReadableStream<Uint8Array>,
|
|
1724
|
+
maximumFrameBytes: number,
|
|
1725
|
+
onFrame: ((frame: WhatsAppMessageExportSessionCanonicalFrame) => void) | undefined,
|
|
1726
|
+
) => {
|
|
1727
|
+
const reader = stream.getReader();
|
|
1728
|
+
const digest = createHash("sha256");
|
|
1729
|
+
const decoder = new CanonicalSessionFrameDecoder(
|
|
1730
|
+
maximumFrameBytes,
|
|
1731
|
+
onFrame ?? (() => undefined),
|
|
1732
|
+
);
|
|
1733
|
+
let totalBytes = 0;
|
|
1734
|
+
const promise = (async (): Promise<void> => {
|
|
1735
|
+
try {
|
|
1736
|
+
for (;;) {
|
|
1737
|
+
const item = await reader.read();
|
|
1738
|
+
if (item.done) break;
|
|
1739
|
+
const nextTotal = totalBytes + item.value.byteLength;
|
|
1740
|
+
if (nextTotal > MESSAGE_EXPORT_SESSION_MAX_TOTAL_STDOUT_BYTES) {
|
|
1741
|
+
throw new Error("WhatsApp projection session output exceeded its total bound");
|
|
1742
|
+
}
|
|
1743
|
+
await writeSpoolBytes(handle!, item.value, totalBytes);
|
|
1744
|
+
digest.update(item.value);
|
|
1745
|
+
decoder.push(item.value);
|
|
1746
|
+
totalBytes = nextTotal;
|
|
1747
|
+
}
|
|
1748
|
+
decoder.finish();
|
|
1749
|
+
await assertDirectoryBinding();
|
|
1750
|
+
await handle!.sync();
|
|
1751
|
+
const metadata = await handle!.stat({ bigint: true });
|
|
1752
|
+
assertPrivateSpoolFile(metadata, fileIdentity, totalBytes);
|
|
1753
|
+
sealed = Object.freeze({
|
|
1754
|
+
frameCount: decoder.frameCount,
|
|
1755
|
+
totalBytes,
|
|
1756
|
+
stdoutSha256: digest.digest("hex"),
|
|
1757
|
+
maximumFrameBytes,
|
|
1758
|
+
metadata,
|
|
1759
|
+
});
|
|
1760
|
+
} finally {
|
|
1761
|
+
reader.releaseLock();
|
|
1762
|
+
}
|
|
1763
|
+
})().catch((error: unknown) => {
|
|
1764
|
+
if (
|
|
1765
|
+
containsWhatsAppContactProjectionCleanupUnverified(error)
|
|
1766
|
+
|| errnoCode(error) !== undefined
|
|
1767
|
+
) throw new WhatsAppContactProjectionCleanupUnverifiedError();
|
|
1768
|
+
throw error;
|
|
1769
|
+
});
|
|
1770
|
+
return Object.freeze({
|
|
1771
|
+
promise,
|
|
1772
|
+
cancel: async () => {
|
|
1773
|
+
try { await reader.cancel(); } catch { /* settlement remains authoritative */ }
|
|
1774
|
+
},
|
|
1775
|
+
});
|
|
1776
|
+
};
|
|
1777
|
+
|
|
1778
|
+
const replay = async function* <Value>(
|
|
1779
|
+
project: (frame: WhatsAppMessageExportSessionCanonicalFrame) => Value,
|
|
1780
|
+
): AsyncGenerator<Value> {
|
|
1781
|
+
if (closed || sealed === undefined || replayStarted || replayActive) {
|
|
1782
|
+
throw new WhatsAppContactProjectionCleanupUnverifiedError();
|
|
1783
|
+
}
|
|
1784
|
+
replayStarted = true;
|
|
1785
|
+
replayActive = true;
|
|
1786
|
+
const expected = sealed;
|
|
1787
|
+
const digest = createHash("sha256");
|
|
1788
|
+
const decoded: WhatsAppMessageExportSessionCanonicalFrame[] = [];
|
|
1789
|
+
const decoder = new CanonicalSessionFrameDecoder(
|
|
1790
|
+
expected.maximumFrameBytes,
|
|
1791
|
+
(frame) => decoded.push(frame),
|
|
1792
|
+
);
|
|
1793
|
+
const buffer = Buffer.allocUnsafe(MESSAGE_EXPORT_SESSION_SPOOL_CHUNK_BYTES);
|
|
1794
|
+
let position = 0;
|
|
1795
|
+
try {
|
|
1796
|
+
await assertDirectoryBinding();
|
|
1797
|
+
assertPrivateSpoolFile(await handle!.stat({ bigint: true }), fileIdentity, expected.totalBytes);
|
|
1798
|
+
while (position < expected.totalBytes) {
|
|
1799
|
+
const length = Math.min(buffer.byteLength, expected.totalBytes - position);
|
|
1800
|
+
const item = await handle!.read(buffer, 0, length, position);
|
|
1801
|
+
if (item.bytesRead < 1) throw new WhatsAppContactProjectionCleanupUnverifiedError();
|
|
1802
|
+
const bytes = buffer.subarray(0, item.bytesRead);
|
|
1803
|
+
digest.update(bytes);
|
|
1804
|
+
decoder.push(bytes);
|
|
1805
|
+
position += item.bytesRead;
|
|
1806
|
+
while (decoded.length > 0) yield project(decoded.shift()!);
|
|
1807
|
+
}
|
|
1808
|
+
decoder.finish();
|
|
1809
|
+
if (
|
|
1810
|
+
position !== expected.totalBytes
|
|
1811
|
+
|| decoder.frameCount !== expected.frameCount
|
|
1812
|
+
|| digest.digest("hex") !== expected.stdoutSha256
|
|
1813
|
+
) throw new WhatsAppContactProjectionCleanupUnverifiedError();
|
|
1814
|
+
await assertDirectoryBinding();
|
|
1815
|
+
const after = await handle!.stat({ bigint: true });
|
|
1816
|
+
assertPrivateSpoolFile(after, fileIdentity, expected.totalBytes);
|
|
1817
|
+
if (
|
|
1818
|
+
after.mtimeNs !== expected.metadata.mtimeNs
|
|
1819
|
+
|| after.ctimeNs !== expected.metadata.ctimeNs
|
|
1820
|
+
) throw new WhatsAppContactProjectionCleanupUnverifiedError();
|
|
1821
|
+
} catch (error) {
|
|
1822
|
+
if (
|
|
1823
|
+
containsWhatsAppContactProjectionCleanupUnverified(error)
|
|
1824
|
+
|| errnoCode(error) !== undefined
|
|
1825
|
+
) throw new WhatsAppContactProjectionCleanupUnverifiedError();
|
|
1826
|
+
throw error;
|
|
1827
|
+
} finally {
|
|
1828
|
+
replayActive = false;
|
|
1829
|
+
}
|
|
1830
|
+
};
|
|
1831
|
+
|
|
1832
|
+
const close = async (): Promise<void> => {
|
|
1833
|
+
if (closed) return;
|
|
1834
|
+
closed = true;
|
|
1835
|
+
let failure: unknown;
|
|
1836
|
+
try {
|
|
1837
|
+
await handle!.close();
|
|
1838
|
+
} catch (error) {
|
|
1839
|
+
failure = error;
|
|
1840
|
+
}
|
|
1841
|
+
try {
|
|
1842
|
+
const descriptor = await directoryHandle!.stat({ bigint: true });
|
|
1843
|
+
const current = await lstat(directory, { bigint: true });
|
|
1844
|
+
const entries = await readdir(directory);
|
|
1845
|
+
assertPrivateSpoolDirectory(descriptor);
|
|
1846
|
+
assertPrivateSpoolDirectory(current);
|
|
1847
|
+
if (
|
|
1848
|
+
!sameSpoolDirectory(directoryIdentity, descriptor)
|
|
1849
|
+
|| !sameSpoolDirectory(directoryIdentity, current)
|
|
1850
|
+
|| entries.length !== 0
|
|
1851
|
+
) {
|
|
1852
|
+
throw new WhatsAppContactProjectionCleanupUnverifiedError();
|
|
1853
|
+
}
|
|
1854
|
+
await rmdir(directory);
|
|
1855
|
+
} catch (error) {
|
|
1856
|
+
failure = failure === undefined
|
|
1857
|
+
? error
|
|
1858
|
+
: new AggregateError([failure, error], "WhatsApp projection spool cleanup failed");
|
|
1859
|
+
}
|
|
1860
|
+
try {
|
|
1861
|
+
await directoryHandle!.close();
|
|
1862
|
+
} catch (error) {
|
|
1863
|
+
failure = failure === undefined
|
|
1864
|
+
? error
|
|
1865
|
+
: new AggregateError([failure, error], "WhatsApp projection spool cleanup failed");
|
|
1866
|
+
}
|
|
1867
|
+
if (failure !== undefined) throw new WhatsAppContactProjectionCleanupUnverifiedError();
|
|
1868
|
+
};
|
|
1869
|
+
|
|
1870
|
+
const publicSpool: WhatsAppMessageExportSessionSpool = Object.freeze({
|
|
1871
|
+
get frameCount() { return sealed?.frameCount ?? 0; },
|
|
1872
|
+
get totalBytes() { return sealed?.totalBytes ?? 0; },
|
|
1873
|
+
get stdoutSha256() { return sealed?.stdoutSha256 ?? ""; },
|
|
1874
|
+
replay,
|
|
1875
|
+
close,
|
|
1876
|
+
});
|
|
1877
|
+
await beforeReadyForTest?.();
|
|
1878
|
+
return Object.freeze({ handle, capture, publicSpool });
|
|
1879
|
+
} catch (error) {
|
|
1880
|
+
let cleanupVerified = true;
|
|
1881
|
+
try { await handle?.close(); } catch { cleanupVerified = false; }
|
|
1882
|
+
let exactDirectory = false;
|
|
1883
|
+
if (directoryHandle !== undefined && cleanupDirectoryIdentity !== undefined) {
|
|
1884
|
+
try {
|
|
1885
|
+
const [descriptor, current] = await Promise.all([
|
|
1886
|
+
directoryHandle.stat({ bigint: true }),
|
|
1887
|
+
lstat(directory, { bigint: true }),
|
|
1888
|
+
]);
|
|
1889
|
+
exactDirectory = sameSpoolDirectory(cleanupDirectoryIdentity, descriptor)
|
|
1890
|
+
&& sameSpoolDirectory(cleanupDirectoryIdentity, current);
|
|
1891
|
+
} catch {
|
|
1892
|
+
exactDirectory = false;
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
if (exactDirectory) {
|
|
1896
|
+
try {
|
|
1897
|
+
await unlink(path);
|
|
1898
|
+
} catch (unlinkError) {
|
|
1899
|
+
if (errnoCode(unlinkError) !== "ENOENT") cleanupVerified = false;
|
|
1900
|
+
}
|
|
1901
|
+
try {
|
|
1902
|
+
if ((await readdir(directory)).length !== 0) {
|
|
1903
|
+
cleanupVerified = false;
|
|
1904
|
+
} else {
|
|
1905
|
+
await rmdir(directory);
|
|
1906
|
+
}
|
|
1907
|
+
} catch {
|
|
1908
|
+
cleanupVerified = false;
|
|
1909
|
+
}
|
|
1910
|
+
} else {
|
|
1911
|
+
cleanupVerified = false;
|
|
1912
|
+
}
|
|
1913
|
+
try { await directoryHandle?.close(); } catch { cleanupVerified = false; }
|
|
1914
|
+
if (
|
|
1915
|
+
!cleanupVerified
|
|
1916
|
+
|| containsWhatsAppContactProjectionCleanupUnverified(error)
|
|
1917
|
+
) throw new WhatsAppContactProjectionCleanupUnverifiedError();
|
|
1918
|
+
throw new Error("WhatsApp projection session private spool could not be created");
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
|
|
1923
|
+
if (typeof timer === "object" && timer !== null && "unref" in timer) {
|
|
1924
|
+
(timer as { unref: () => void }).unref();
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
function errnoCode(error: unknown): string | undefined {
|
|
1929
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
1930
|
+
? String(error.code)
|
|
1931
|
+
: undefined;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
/**
|
|
1935
|
+
* Runs the one-process Message Like Me projection session. Its anonymous
|
|
1936
|
+
* parent-owned stdout spool is withheld until stdout EOF, bounded stderr,
|
|
1937
|
+
* exact child exit, and process-group absence all settle. Any unproved reap is
|
|
1938
|
+
* a durable cleanup-boundary failure.
|
|
1939
|
+
*/
|
|
1940
|
+
export async function runWhatsAppMessageExportSessionHelperChild(
|
|
1941
|
+
invocation: WhatsAppContactProjectionHelperInvocation,
|
|
1942
|
+
): Promise<WhatsAppMessageExportSessionHelperResult> {
|
|
1943
|
+
if (
|
|
1944
|
+
(invocation.processGroupIsAbsentForTest !== undefined
|
|
1945
|
+
|| invocation.onProcessGroupPollForTest !== undefined
|
|
1946
|
+
|| invocation.beforeSpoolReadyForTest !== undefined)
|
|
1947
|
+
&& process.env.NODE_ENV !== "test"
|
|
1948
|
+
) throw new Error("WhatsApp projection process-group injection is test-only");
|
|
1949
|
+
const runnerDeadline = new OperationDeadline(invocation.timeoutMs, {
|
|
1950
|
+
...(invocation.signal === undefined ? {} : { signal: invocation.signal }),
|
|
1951
|
+
});
|
|
1952
|
+
const deadlineFailure = (): Error => new Error(
|
|
1953
|
+
invocation.signal?.aborted === true
|
|
1954
|
+
? "WhatsApp projection session helper was cancelled"
|
|
1955
|
+
: "WhatsApp projection session helper timed out",
|
|
1956
|
+
);
|
|
1957
|
+
const assertRunnerAvailable = (): void => {
|
|
1958
|
+
try {
|
|
1959
|
+
runnerDeadline.throwIfUnavailable("WhatsApp projection session helper");
|
|
1960
|
+
} catch {
|
|
1961
|
+
throw deadlineFailure();
|
|
1962
|
+
}
|
|
1963
|
+
};
|
|
1964
|
+
try {
|
|
1965
|
+
assertRunnerAvailable();
|
|
1966
|
+
} catch (error) {
|
|
1967
|
+
runnerDeadline.dispose();
|
|
1968
|
+
throw error;
|
|
1969
|
+
}
|
|
1970
|
+
let spool: MutableWhatsAppMessageExportSessionSpool;
|
|
1971
|
+
try {
|
|
1972
|
+
spool = await createWhatsAppMessageExportSessionSpool(
|
|
1973
|
+
invocation.beforeSpoolReadyForTest,
|
|
1974
|
+
);
|
|
1975
|
+
} catch (error) {
|
|
1976
|
+
runnerDeadline.dispose();
|
|
1977
|
+
throw error;
|
|
1978
|
+
}
|
|
1979
|
+
try {
|
|
1980
|
+
assertRunnerAvailable();
|
|
1981
|
+
} catch (error) {
|
|
1982
|
+
try {
|
|
1983
|
+
await spool.publicSpool.close();
|
|
1984
|
+
} catch (cleanupError) {
|
|
1985
|
+
runnerDeadline.dispose();
|
|
1986
|
+
throw new AggregateError(
|
|
1987
|
+
[error, cleanupError],
|
|
1988
|
+
"WhatsApp projection session deadline and spool cleanup both failed",
|
|
1989
|
+
);
|
|
1990
|
+
}
|
|
1991
|
+
runnerDeadline.dispose();
|
|
1992
|
+
throw error;
|
|
1993
|
+
}
|
|
1994
|
+
let child: Bun.Subprocess<"pipe", "pipe", "pipe">;
|
|
1995
|
+
try {
|
|
1996
|
+
child = Bun.spawn([...invocation.command], {
|
|
1997
|
+
cwd: invocation.cwd,
|
|
1998
|
+
env: { ...invocation.environment },
|
|
1999
|
+
stdin: "pipe",
|
|
2000
|
+
stdout: "pipe",
|
|
2001
|
+
stderr: "pipe",
|
|
2002
|
+
detached: process.platform !== "win32",
|
|
2003
|
+
});
|
|
2004
|
+
} catch {
|
|
2005
|
+
const launchUncertainty = new WhatsAppContactProjectionCleanupUnverifiedError();
|
|
2006
|
+
try {
|
|
2007
|
+
await spool.publicSpool.close();
|
|
2008
|
+
} catch (cleanupError) {
|
|
2009
|
+
runnerDeadline.dispose();
|
|
2010
|
+
throw new AggregateError(
|
|
2011
|
+
[launchUncertainty, cleanupError],
|
|
2012
|
+
"WhatsApp projection session spawn and spool cleanup both failed",
|
|
2013
|
+
);
|
|
2014
|
+
}
|
|
2015
|
+
runnerDeadline.dispose();
|
|
2016
|
+
throw launchUncertainty;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
let childExited = false;
|
|
2020
|
+
const signalChild = (signal: "SIGTERM" | "SIGKILL"): void => {
|
|
2021
|
+
if (process.platform !== "win32") {
|
|
2022
|
+
try {
|
|
2023
|
+
process.kill(-child.pid, signal);
|
|
2024
|
+
return;
|
|
2025
|
+
} catch (error) {
|
|
2026
|
+
if (errnoCode(error) === "ESRCH") return;
|
|
2027
|
+
// Fall through to the exact child handle when group signalling is not
|
|
2028
|
+
// available; child.exited and the post-join group probe remain proof.
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
try { child.kill(signal); } catch { /* exact child.exited remains authoritative */ }
|
|
2032
|
+
};
|
|
2033
|
+
let terminationStarted = false;
|
|
2034
|
+
let forceKill: ReturnType<typeof setTimeout> | undefined;
|
|
2035
|
+
let reapTimer: ReturnType<typeof setTimeout> | undefined;
|
|
2036
|
+
let rejectCleanupDeadline: ((error: unknown) => void) | undefined;
|
|
2037
|
+
let cleanupDeadlineExpired = false;
|
|
2038
|
+
const cleanupDeadline = new Promise<never>((_resolve, reject) => {
|
|
2039
|
+
rejectCleanupDeadline = reject;
|
|
2040
|
+
});
|
|
2041
|
+
const terminate = (): void => {
|
|
2042
|
+
if (!terminationStarted) {
|
|
2043
|
+
terminationStarted = true;
|
|
2044
|
+
signalChild("SIGTERM");
|
|
2045
|
+
forceKill = setTimeout(() => {
|
|
2046
|
+
signalChild("SIGKILL");
|
|
2047
|
+
}, CONTACT_PROJECTION_FORCE_KILL_DELAY_MS);
|
|
2048
|
+
reapTimer = setTimeout(
|
|
2049
|
+
() => {
|
|
2050
|
+
cleanupDeadlineExpired = true;
|
|
2051
|
+
rejectCleanupDeadline?.(new WhatsAppContactProjectionCleanupUnverifiedError());
|
|
2052
|
+
},
|
|
2053
|
+
CONTACT_PROJECTION_FORCE_KILL_DELAY_MS * 2,
|
|
2054
|
+
);
|
|
2055
|
+
unrefTimer(forceKill);
|
|
2056
|
+
unrefTimer(reapTimer);
|
|
2057
|
+
}
|
|
2058
|
+
};
|
|
2059
|
+
let timedOut = false;
|
|
2060
|
+
let cancelled = false;
|
|
2061
|
+
const onAbort = (): void => {
|
|
2062
|
+
cancelled = invocation.signal?.aborted === true;
|
|
2063
|
+
timedOut = !cancelled;
|
|
2064
|
+
terminate();
|
|
2065
|
+
};
|
|
2066
|
+
runnerDeadline.signal.addEventListener("abort", onAbort, { once: true });
|
|
2067
|
+
if (runnerDeadline.signal.aborted) onAbort();
|
|
2068
|
+
const guarded = <T>(promise: Promise<T>): Promise<T> => promise.catch((error: unknown) => {
|
|
2069
|
+
terminate();
|
|
2070
|
+
throw error;
|
|
2071
|
+
});
|
|
2072
|
+
const capture = spool.capture(
|
|
2073
|
+
child.stdout,
|
|
2074
|
+
invocation.maxOutputBytes,
|
|
2075
|
+
invocation.onCanonicalFrame,
|
|
2076
|
+
);
|
|
2077
|
+
const frames = guarded(capture.promise);
|
|
2078
|
+
const stderrRead = readBoundedStreamControlled(child.stderr, invocation.maxStderrBytes);
|
|
2079
|
+
const stderr = guarded(stderrRead.promise);
|
|
2080
|
+
const exited = child.exited.then(
|
|
2081
|
+
(exitCode) => {
|
|
2082
|
+
childExited = true;
|
|
2083
|
+
return exitCode;
|
|
2084
|
+
},
|
|
2085
|
+
(error: unknown) => {
|
|
2086
|
+
terminate();
|
|
2087
|
+
throw error;
|
|
2088
|
+
},
|
|
2089
|
+
);
|
|
2090
|
+
let callbackFailure: unknown;
|
|
2091
|
+
try {
|
|
2092
|
+
invocation.onSpawned?.(child.pid);
|
|
2093
|
+
} catch (error) {
|
|
2094
|
+
callbackFailure = error;
|
|
2095
|
+
terminate();
|
|
2096
|
+
}
|
|
2097
|
+
const stdin = callbackFailure === undefined
|
|
2098
|
+
? guarded((async () => {
|
|
2099
|
+
await child.stdin.write(invocation.stdin);
|
|
2100
|
+
await child.stdin.end();
|
|
2101
|
+
})())
|
|
2102
|
+
: guarded((async () => {
|
|
2103
|
+
await child.stdin.end();
|
|
2104
|
+
})());
|
|
2105
|
+
|
|
2106
|
+
const processGroupIsAbsent = (): boolean => {
|
|
2107
|
+
if (invocation.processGroupIsAbsentForTest !== undefined) {
|
|
2108
|
+
return invocation.processGroupIsAbsentForTest();
|
|
2109
|
+
}
|
|
2110
|
+
if (process.platform === "win32") return true;
|
|
2111
|
+
try {
|
|
2112
|
+
process.kill(-child.pid, 0);
|
|
2113
|
+
return false;
|
|
2114
|
+
} catch (error) {
|
|
2115
|
+
if (errnoCode(error) === "ESRCH") return true;
|
|
2116
|
+
throw new WhatsAppContactProjectionCleanupUnverifiedError();
|
|
2117
|
+
}
|
|
2118
|
+
};
|
|
2119
|
+
let unexpectedDescendantObserved = false;
|
|
2120
|
+
let groupPollTimer: ReturnType<typeof setTimeout> | undefined;
|
|
2121
|
+
let resolveGroupPoll: (() => void) | undefined;
|
|
2122
|
+
const cancelGroupPoll = (): void => {
|
|
2123
|
+
if (groupPollTimer !== undefined) clearTimeout(groupPollTimer);
|
|
2124
|
+
groupPollTimer = undefined;
|
|
2125
|
+
const resolve = resolveGroupPoll;
|
|
2126
|
+
resolveGroupPoll = undefined;
|
|
2127
|
+
resolve?.();
|
|
2128
|
+
};
|
|
2129
|
+
const waitForGroupPoll = (): Promise<void> => new Promise<void>((resolve) => {
|
|
2130
|
+
invocation.onProcessGroupPollForTest?.();
|
|
2131
|
+
resolveGroupPoll = resolve;
|
|
2132
|
+
groupPollTimer = setTimeout(() => {
|
|
2133
|
+
groupPollTimer = undefined;
|
|
2134
|
+
resolveGroupPoll = undefined;
|
|
2135
|
+
resolve();
|
|
2136
|
+
}, 20);
|
|
2137
|
+
unrefTimer(groupPollTimer);
|
|
2138
|
+
});
|
|
2139
|
+
const joinProcessGroup = async (): Promise<void> => {
|
|
2140
|
+
if (processGroupIsAbsent()) return;
|
|
2141
|
+
unexpectedDescendantObserved = true;
|
|
2142
|
+
terminate();
|
|
2143
|
+
for (;;) {
|
|
2144
|
+
if (cleanupDeadlineExpired) {
|
|
2145
|
+
throw new WhatsAppContactProjectionCleanupUnverifiedError();
|
|
2146
|
+
}
|
|
2147
|
+
if (processGroupIsAbsent()) return;
|
|
2148
|
+
await waitForGroupPoll();
|
|
2149
|
+
}
|
|
2150
|
+
};
|
|
2151
|
+
let transferred = false;
|
|
2152
|
+
try {
|
|
2153
|
+
const settled = await Promise.race([
|
|
2154
|
+
Promise.allSettled([stdin, frames, stderr, exited]),
|
|
2155
|
+
cleanupDeadline,
|
|
2156
|
+
]);
|
|
2157
|
+
const [stdinResult, framesResult, stderrResult, exitResult] = settled;
|
|
2158
|
+
await Promise.race([joinProcessGroup(), cleanupDeadline]);
|
|
2159
|
+
if (exitResult.status === "rejected") {
|
|
2160
|
+
throw new WhatsAppContactProjectionCleanupUnverifiedError();
|
|
2161
|
+
}
|
|
2162
|
+
if (unexpectedDescendantObserved) {
|
|
2163
|
+
throw new Error("WhatsApp projection session helper left an unexpected descendant");
|
|
2164
|
+
}
|
|
2165
|
+
if (callbackFailure !== undefined) throw callbackFailure;
|
|
2166
|
+
if (cancelled) throw new Error("WhatsApp projection session helper was cancelled");
|
|
2167
|
+
if (timedOut) throw new Error("WhatsApp projection session helper timed out");
|
|
2168
|
+
if (
|
|
2169
|
+
stdinResult.status === "rejected"
|
|
2170
|
+
|| framesResult.status === "rejected"
|
|
2171
|
+
|| stderrResult.status === "rejected"
|
|
2172
|
+
) {
|
|
2173
|
+
const failure = framesResult.status === "rejected"
|
|
2174
|
+
? framesResult.reason
|
|
2175
|
+
: stderrResult.status === "rejected"
|
|
2176
|
+
? stderrResult.reason
|
|
2177
|
+
: stdinResult.status === "rejected"
|
|
2178
|
+
? stdinResult.reason
|
|
2179
|
+
: undefined;
|
|
2180
|
+
throw failure instanceof Error
|
|
2181
|
+
? failure
|
|
2182
|
+
: new Error("WhatsApp projection session stream failed within its bound");
|
|
2183
|
+
}
|
|
2184
|
+
transferred = true;
|
|
2185
|
+
return Object.freeze({
|
|
2186
|
+
exitCode: exitResult.value,
|
|
2187
|
+
spool: spool.publicSpool,
|
|
2188
|
+
stderr: stderrResult.value,
|
|
2189
|
+
});
|
|
2190
|
+
} catch (error) {
|
|
2191
|
+
const detach = (): void => {
|
|
2192
|
+
void capture.cancel();
|
|
2193
|
+
void stderrRead.cancel();
|
|
2194
|
+
void Promise.resolve(child.stdin.end()).catch(() => undefined);
|
|
2195
|
+
if (!childExited) child.unref();
|
|
2196
|
+
};
|
|
2197
|
+
detach();
|
|
2198
|
+
if (cleanupDeadlineExpired) {
|
|
2199
|
+
void spool.publicSpool.close().catch(() => undefined);
|
|
2200
|
+
throw error;
|
|
2201
|
+
}
|
|
2202
|
+
await Promise.allSettled([
|
|
2203
|
+
capture.promise,
|
|
2204
|
+
stderrRead.promise,
|
|
2205
|
+
child.stdin.end(),
|
|
2206
|
+
]);
|
|
2207
|
+
try {
|
|
2208
|
+
await spool.publicSpool.close();
|
|
2209
|
+
} catch (cleanupError) {
|
|
2210
|
+
throw new AggregateError(
|
|
2211
|
+
[error, cleanupError],
|
|
2212
|
+
"WhatsApp projection session operation and spool cleanup both failed",
|
|
2213
|
+
);
|
|
2214
|
+
}
|
|
2215
|
+
throw error;
|
|
2216
|
+
} finally {
|
|
2217
|
+
cancelGroupPoll();
|
|
2218
|
+
if (reapTimer !== undefined) clearTimeout(reapTimer);
|
|
2219
|
+
if (forceKill !== undefined) clearTimeout(forceKill);
|
|
2220
|
+
runnerDeadline.signal.removeEventListener("abort", onAbort);
|
|
2221
|
+
runnerDeadline.dispose();
|
|
2222
|
+
if (!transferred && !childExited) child.unref();
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
/**
|
|
2227
|
+
* Ordinary projection and schema errors have no live-resource consequence.
|
|
2228
|
+
* Only the dedicated process-cleanup uncertainty crosses the cleanup barrier.
|
|
2229
|
+
*/
|
|
2230
|
+
export function whatsappContactProjectionCleanupBarrier(
|
|
2231
|
+
operation: Promise<unknown>,
|
|
2232
|
+
): Promise<void> {
|
|
2233
|
+
return operation.then(
|
|
2234
|
+
() => undefined,
|
|
2235
|
+
(error: unknown) => {
|
|
2236
|
+
if (containsWhatsAppContactProjectionCleanupUnverified(error)) {
|
|
2237
|
+
throw error;
|
|
2238
|
+
}
|
|
2239
|
+
},
|
|
2240
|
+
);
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
function unavailableWhatsAppDirectionStats() {
|
|
2244
|
+
return Object.freeze({
|
|
2245
|
+
count: null,
|
|
2246
|
+
complete: false,
|
|
2247
|
+
lowerBound: false,
|
|
2248
|
+
truncated: false,
|
|
2249
|
+
lastAt: null,
|
|
2250
|
+
lastAtComplete: false,
|
|
2251
|
+
lastAtBasis: "unavailable" as const,
|
|
2252
|
+
incompleteReasons: Object.freeze([
|
|
2253
|
+
"whatsapp-message-store-account-owner-unavailable",
|
|
2254
|
+
]),
|
|
2255
|
+
});
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
async function projectWhatsAppLocalContacts(
|
|
2259
|
+
auth: WhatsAppAuth,
|
|
2260
|
+
recipe: WebSessionRecipe,
|
|
2261
|
+
input: OperationInput,
|
|
2262
|
+
dependencies: WhatsAppWebRuntimeDependencies | undefined,
|
|
2263
|
+
operationDeadline: WebSessionOperationDeadline | undefined,
|
|
2264
|
+
registerCleanupBarrier: WebSessionCleanupBarrierRegistrar | undefined,
|
|
2265
|
+
): Promise<WebSessionExecution> {
|
|
2266
|
+
const parsedInput = exactContactInput(input);
|
|
2267
|
+
if (parsedInput.collection !== "contacts") {
|
|
2268
|
+
throw new Error("WhatsApp contact projection requires collection=contacts");
|
|
2269
|
+
}
|
|
2270
|
+
if (auth.subject === undefined) {
|
|
2271
|
+
throw new Error(
|
|
2272
|
+
"WhatsApp linked-device auth must be bound to its current account before private reads",
|
|
2273
|
+
);
|
|
2274
|
+
}
|
|
2275
|
+
let accountSubject: string;
|
|
2276
|
+
try {
|
|
2277
|
+
accountSubject = parseWhatsAppContactProjectionSubject(
|
|
2278
|
+
auth.subject,
|
|
2279
|
+
).subject;
|
|
2280
|
+
} catch {
|
|
2281
|
+
throw new Error(
|
|
2282
|
+
"WhatsApp linked-device auth subject is not a PN or LID account",
|
|
2283
|
+
);
|
|
2284
|
+
}
|
|
2285
|
+
const rawOperation = startWebSessionCleanupTrackedOperation(
|
|
2286
|
+
registerCleanupBarrier,
|
|
2287
|
+
async () => {
|
|
2288
|
+
operationDeadline?.throwIfUnavailable(WEB_SESSION_OPERATION_LABEL);
|
|
2289
|
+
const store = await validateWhatsAppStoreDirectory(
|
|
2290
|
+
auth.path,
|
|
2291
|
+
"contact-projection",
|
|
2292
|
+
);
|
|
2293
|
+
const initial = await captureContactProjectionParentIdentity(store);
|
|
2294
|
+
const fixed = await resolveFixedContactProjectionFiles();
|
|
2295
|
+
const request = Object.freeze({
|
|
2296
|
+
schemaVersion: WHATSAPP_CONTACT_PROJECTION_PROTOCOL_VERSION,
|
|
2297
|
+
operation: "contacts.list",
|
|
2298
|
+
accountSubject,
|
|
2299
|
+
cursor: parsedInput.cursor,
|
|
2300
|
+
limit: parsedInput.limit,
|
|
2301
|
+
storeIdentity: Object.freeze({
|
|
2302
|
+
dev: initial.store.dev.toString(),
|
|
2303
|
+
ino: initial.store.ino.toString(),
|
|
2304
|
+
}),
|
|
2305
|
+
sessionIdentity: Object.freeze({
|
|
2306
|
+
dev: initial.session.dev.toString(),
|
|
2307
|
+
ino: initial.session.ino.toString(),
|
|
2308
|
+
}),
|
|
2309
|
+
}) satisfies WhatsAppContactProjectionRequest;
|
|
2310
|
+
const timeoutMs = remainingTimeoutMs(
|
|
2311
|
+
recipe.timeoutMs,
|
|
2312
|
+
operationDeadline,
|
|
2313
|
+
);
|
|
2314
|
+
const invocation = Object.freeze({
|
|
2315
|
+
command: Object.freeze([
|
|
2316
|
+
process.execPath,
|
|
2317
|
+
"--no-env-file",
|
|
2318
|
+
"--no-install",
|
|
2319
|
+
"--no-macros",
|
|
2320
|
+
"--no-addons",
|
|
2321
|
+
`--config=${fixed.config}`,
|
|
2322
|
+
fixed.helper,
|
|
2323
|
+
]),
|
|
2324
|
+
cwd: store,
|
|
2325
|
+
environment: contactProjectionEnvironment(),
|
|
2326
|
+
stdin: `${JSON.stringify(request)}\n`,
|
|
2327
|
+
timeoutMs,
|
|
2328
|
+
maxOutputBytes: Math.min(
|
|
2329
|
+
recipe.maxOutputBytes,
|
|
2330
|
+
WHATSAPP_CONTACT_PROJECTION_MAX_STDOUT_BYTES,
|
|
2331
|
+
),
|
|
2332
|
+
maxStderrBytes: MAX_CONTACT_PROJECTION_STDERR_BYTES,
|
|
2333
|
+
...(operationDeadline === undefined
|
|
2334
|
+
? {}
|
|
2335
|
+
: { signal: operationDeadline.signal }),
|
|
2336
|
+
}) satisfies WhatsAppContactProjectionHelperInvocation;
|
|
2337
|
+
const run = dependencies?.runContactProjectionHelper
|
|
2338
|
+
?? runWhatsAppContactProjectionHelperChild;
|
|
2339
|
+
let childResult: WhatsAppContactProjectionHelperResult | undefined;
|
|
2340
|
+
let helperFailure: unknown;
|
|
2341
|
+
try {
|
|
2342
|
+
childResult = await run(invocation);
|
|
2343
|
+
} catch (error) {
|
|
2344
|
+
helperFailure = error;
|
|
2345
|
+
}
|
|
2346
|
+
let identityFailure: unknown;
|
|
2347
|
+
try {
|
|
2348
|
+
await revalidateContactProjectionParentIdentity(store, initial);
|
|
2349
|
+
} catch (error) {
|
|
2350
|
+
identityFailure = error;
|
|
2351
|
+
}
|
|
2352
|
+
if (
|
|
2353
|
+
helperFailure
|
|
2354
|
+
instanceof WhatsAppContactProjectionCleanupUnverifiedError
|
|
2355
|
+
) {
|
|
2356
|
+
throw helperFailure;
|
|
2357
|
+
}
|
|
2358
|
+
if (identityFailure !== undefined) {
|
|
2359
|
+
throw identityFailure instanceof Error
|
|
2360
|
+
? identityFailure
|
|
2361
|
+
: new Error(
|
|
2362
|
+
"WhatsApp contact projection parent binding became unverifiable",
|
|
2363
|
+
);
|
|
2364
|
+
}
|
|
2365
|
+
if (helperFailure !== undefined) {
|
|
2366
|
+
throw helperFailure instanceof Error
|
|
2367
|
+
? helperFailure
|
|
2368
|
+
: new Error("WhatsApp contact projection helper failed");
|
|
2369
|
+
}
|
|
2370
|
+
if (childResult === undefined) {
|
|
2371
|
+
throw new Error("WhatsApp contact projection helper omitted its result");
|
|
2372
|
+
}
|
|
2373
|
+
if (childResult.exitCode !== 0 || childResult.stderr.length !== 0) {
|
|
2374
|
+
throw new Error(
|
|
2375
|
+
"WhatsApp contact projection helper failed before reviewed output",
|
|
2376
|
+
);
|
|
2377
|
+
}
|
|
2378
|
+
let parsed: unknown;
|
|
2379
|
+
try {
|
|
2380
|
+
parsed = JSON.parse(childResult.stdout.trim()) as unknown;
|
|
2381
|
+
} catch {
|
|
2382
|
+
throw new Error(
|
|
2383
|
+
"WhatsApp contact projection helper returned malformed output",
|
|
2384
|
+
);
|
|
2385
|
+
}
|
|
2386
|
+
let response: ReturnType<typeof parseWhatsAppContactProjectionResponse>;
|
|
2387
|
+
try {
|
|
2388
|
+
response = parseWhatsAppContactProjectionResponse(parsed, request);
|
|
2389
|
+
} catch {
|
|
2390
|
+
throw new Error(
|
|
2391
|
+
"WhatsApp contact projection helper returned unsupported output",
|
|
2392
|
+
);
|
|
2393
|
+
}
|
|
2394
|
+
if (response.status === "failed") {
|
|
2395
|
+
throw new Error(
|
|
2396
|
+
`WhatsApp contact projection helper rejected the local store (${response.errorCode})`,
|
|
2397
|
+
);
|
|
2398
|
+
}
|
|
2399
|
+
return response;
|
|
2400
|
+
},
|
|
2401
|
+
whatsappContactProjectionCleanupBarrier,
|
|
2402
|
+
);
|
|
2403
|
+
const response = operationDeadline === undefined
|
|
2404
|
+
? await rawOperation
|
|
2405
|
+
: await operationDeadline.run(
|
|
2406
|
+
() => rawOperation,
|
|
2407
|
+
WEB_SESSION_OPERATION_LABEL,
|
|
2408
|
+
);
|
|
2409
|
+
operationDeadline?.throwIfUnavailable(WEB_SESSION_OPERATION_LABEL);
|
|
2410
|
+
|
|
2411
|
+
const directionStats = unavailableWhatsAppDirectionStats();
|
|
2412
|
+
const projectedStats = projectContactDirectionStats(
|
|
2413
|
+
directionStats,
|
|
2414
|
+
directionStats,
|
|
2415
|
+
);
|
|
2416
|
+
const output = Object.freeze({
|
|
2417
|
+
provider: "whatsapp",
|
|
2418
|
+
operation: "contacts.list",
|
|
2419
|
+
accountSubject,
|
|
2420
|
+
projection: "quiescent-account-bound-session-store",
|
|
2421
|
+
contacts: Object.freeze(response.contacts.map((contact) => Object.freeze({
|
|
2422
|
+
...contact,
|
|
2423
|
+
alias: null,
|
|
2424
|
+
tags: Object.freeze([]),
|
|
2425
|
+
updatedAt: null,
|
|
2426
|
+
localProjectionStatsComplete: false,
|
|
2427
|
+
...projectedStats,
|
|
2428
|
+
}))),
|
|
2429
|
+
nextCursor: response.nextCursor,
|
|
2430
|
+
localContactTablePageComplete: response.localContactTablePageComplete,
|
|
2431
|
+
remoteContactSetComplete: false,
|
|
2432
|
+
contactSetIncompleteReasons: Object.freeze([
|
|
2433
|
+
"linked-device-contact-sync-coverage-unknown",
|
|
2434
|
+
]),
|
|
2435
|
+
statsScope: "unavailable",
|
|
2436
|
+
statsCompleteness: "unavailable",
|
|
2437
|
+
});
|
|
2438
|
+
return {
|
|
2439
|
+
status: "succeeded",
|
|
2440
|
+
output: outputWithinBound(output, recipe.maxOutputBytes),
|
|
2441
|
+
finalUrl: WHATSAPP_ORIGIN,
|
|
2442
|
+
dispatchStarted: false,
|
|
2443
|
+
dispatch: { planned: 0, started: 0, verified: 0 },
|
|
2444
|
+
};
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
async function projectWhatsAppLocalInteractions(
|
|
2448
|
+
auth: WhatsAppAuth,
|
|
2449
|
+
recipe: WebSessionRecipe,
|
|
2450
|
+
parsedInput: Extract<ExactContactInput, { readonly collection: "interactions" }>,
|
|
2451
|
+
dependencies: WhatsAppWebRuntimeDependencies | undefined,
|
|
2452
|
+
operationDeadline: WebSessionOperationDeadline | undefined,
|
|
2453
|
+
registerCleanupBarrier: WebSessionCleanupBarrierRegistrar | undefined,
|
|
2454
|
+
): Promise<WebSessionExecution> {
|
|
2455
|
+
if (auth.subject === undefined) {
|
|
2456
|
+
throw new Error(
|
|
2457
|
+
"WhatsApp linked-device auth must be bound to its current account before private reads",
|
|
2458
|
+
);
|
|
2459
|
+
}
|
|
2460
|
+
const accountSubject = auth.subject;
|
|
2461
|
+
const rawOperation = startWebSessionCleanupTrackedOperation(
|
|
2462
|
+
registerCleanupBarrier,
|
|
2463
|
+
async () => {
|
|
2464
|
+
operationDeadline?.throwIfUnavailable(WEB_SESSION_OPERATION_LABEL);
|
|
2465
|
+
const store = await validateWhatsAppStoreDirectory(auth.path, "projection");
|
|
2466
|
+
const initial = await captureInteractionProjectionParentIdentity(store);
|
|
2467
|
+
const fixed = await resolveFixedContactProjectionFiles(
|
|
2468
|
+
"whatsapp-interaction-projection-helper.ts",
|
|
2469
|
+
);
|
|
2470
|
+
const request = parseWhatsAppInteractionProjectionRequest({
|
|
2471
|
+
schemaVersion: WHATSAPP_INTERACTION_PROJECTION_PROTOCOL_VERSION,
|
|
2472
|
+
operation: "contacts.interactions.list",
|
|
2473
|
+
accountSubject,
|
|
2474
|
+
cursor: parsedInput.cursor,
|
|
2475
|
+
cursorAnchor: parsedInput.cursorAnchor,
|
|
2476
|
+
limit: parsedInput.limit,
|
|
2477
|
+
storeIdentity: {
|
|
2478
|
+
dev: initial.store.dev.toString(),
|
|
2479
|
+
ino: initial.store.ino.toString(),
|
|
2480
|
+
},
|
|
2481
|
+
sessionIdentity: {
|
|
2482
|
+
dev: initial.session.dev.toString(),
|
|
2483
|
+
ino: initial.session.ino.toString(),
|
|
2484
|
+
},
|
|
2485
|
+
messageStoreIdentity: {
|
|
2486
|
+
dev: initial.messageStore.dev.toString(),
|
|
2487
|
+
ino: initial.messageStore.ino.toString(),
|
|
2488
|
+
},
|
|
2489
|
+
}) satisfies WhatsAppInteractionProjectionRequest;
|
|
2490
|
+
const timeoutMs = remainingTimeoutMs(recipe.timeoutMs, operationDeadline);
|
|
2491
|
+
const invocation = Object.freeze({
|
|
2492
|
+
command: Object.freeze([
|
|
2493
|
+
process.execPath,
|
|
2494
|
+
"--no-env-file",
|
|
2495
|
+
"--no-install",
|
|
2496
|
+
"--no-macros",
|
|
2497
|
+
"--no-addons",
|
|
2498
|
+
`--config=${fixed.config}`,
|
|
2499
|
+
fixed.helper,
|
|
2500
|
+
]),
|
|
2501
|
+
cwd: store,
|
|
2502
|
+
environment: contactProjectionEnvironment(),
|
|
2503
|
+
stdin: `${JSON.stringify(request)}\n`,
|
|
2504
|
+
timeoutMs,
|
|
2505
|
+
maxOutputBytes: Math.min(
|
|
2506
|
+
recipe.maxOutputBytes,
|
|
2507
|
+
WHATSAPP_INTERACTION_PROJECTION_MAX_STDOUT_BYTES,
|
|
2508
|
+
),
|
|
2509
|
+
maxStderrBytes: MAX_CONTACT_PROJECTION_STDERR_BYTES,
|
|
2510
|
+
...(operationDeadline === undefined ? {} : { signal: operationDeadline.signal }),
|
|
2511
|
+
}) satisfies WhatsAppContactProjectionHelperInvocation;
|
|
2512
|
+
const run = dependencies?.runInteractionProjectionHelper
|
|
2513
|
+
?? runWhatsAppContactProjectionHelperChild;
|
|
2514
|
+
let childResult: WhatsAppContactProjectionHelperResult | undefined;
|
|
2515
|
+
let helperFailure: unknown;
|
|
2516
|
+
try {
|
|
2517
|
+
childResult = await run(invocation);
|
|
2518
|
+
} catch (error) {
|
|
2519
|
+
helperFailure = error;
|
|
2520
|
+
}
|
|
2521
|
+
let identityFailure: unknown;
|
|
2522
|
+
try {
|
|
2523
|
+
await revalidateInteractionProjectionParentIdentity(store, initial);
|
|
2524
|
+
} catch (error) {
|
|
2525
|
+
identityFailure = error;
|
|
2526
|
+
}
|
|
2527
|
+
if (helperFailure instanceof WhatsAppContactProjectionCleanupUnverifiedError) {
|
|
2528
|
+
throw helperFailure;
|
|
2529
|
+
}
|
|
2530
|
+
if (identityFailure !== undefined) {
|
|
2531
|
+
throw identityFailure instanceof Error
|
|
2532
|
+
? identityFailure
|
|
2533
|
+
: new Error("WhatsApp interaction projection parent binding became unverifiable");
|
|
2534
|
+
}
|
|
2535
|
+
if (helperFailure !== undefined) {
|
|
2536
|
+
throw helperFailure instanceof Error
|
|
2537
|
+
? helperFailure
|
|
2538
|
+
: new Error("WhatsApp interaction projection helper failed");
|
|
2539
|
+
}
|
|
2540
|
+
if (
|
|
2541
|
+
childResult === undefined
|
|
2542
|
+
|| childResult.exitCode !== 0
|
|
2543
|
+
|| childResult.stderr.length !== 0
|
|
2544
|
+
) {
|
|
2545
|
+
throw new Error(
|
|
2546
|
+
"WhatsApp interaction projection helper failed before reviewed output",
|
|
2547
|
+
);
|
|
2548
|
+
}
|
|
2549
|
+
let parsed: unknown;
|
|
2550
|
+
try {
|
|
2551
|
+
parsed = JSON.parse(childResult.stdout.trim()) as unknown;
|
|
2552
|
+
} catch {
|
|
2553
|
+
throw new Error("WhatsApp interaction projection helper returned malformed output");
|
|
2554
|
+
}
|
|
2555
|
+
let response: ReturnType<typeof parseWhatsAppInteractionProjectionResponse>;
|
|
2556
|
+
try {
|
|
2557
|
+
response = parseWhatsAppInteractionProjectionResponse(parsed, request);
|
|
2558
|
+
} catch {
|
|
2559
|
+
throw new Error("WhatsApp interaction projection helper returned unsupported output");
|
|
2560
|
+
}
|
|
2561
|
+
if (response.status === "failed") {
|
|
2562
|
+
throw new Error(
|
|
2563
|
+
`WhatsApp interaction projection helper rejected the local store (${response.errorCode})`,
|
|
2564
|
+
);
|
|
2565
|
+
}
|
|
2566
|
+
return response;
|
|
2567
|
+
},
|
|
2568
|
+
whatsappContactProjectionCleanupBarrier,
|
|
2569
|
+
);
|
|
2570
|
+
const response = operationDeadline === undefined
|
|
2571
|
+
? await rawOperation
|
|
2572
|
+
: await operationDeadline.run(() => rawOperation, WEB_SESSION_OPERATION_LABEL);
|
|
2573
|
+
operationDeadline?.throwIfUnavailable(WEB_SESSION_OPERATION_LABEL);
|
|
2574
|
+
const output = Object.freeze({
|
|
2575
|
+
provider: "whatsapp",
|
|
2576
|
+
operation: "contacts.list",
|
|
2577
|
+
accountSubject,
|
|
2578
|
+
contactCollection: "interactions",
|
|
2579
|
+
projection: "quiescent-account-bound-local-message-inserts",
|
|
2580
|
+
projectionGeneration: response.projectionGeneration,
|
|
2581
|
+
interactions: response.interactions,
|
|
2582
|
+
nextCursor: response.nextCursor,
|
|
2583
|
+
localInsertPageComplete: response.localInsertPageComplete,
|
|
2584
|
+
checkpoint: response.checkpoint,
|
|
2585
|
+
remoteHistoryComplete: false,
|
|
2586
|
+
incompleteReasons: Object.freeze([
|
|
2587
|
+
"linked-device-history-coverage-unknown",
|
|
2588
|
+
"rowid-cursor-discovers-inserts-only",
|
|
2589
|
+
]),
|
|
2590
|
+
});
|
|
2591
|
+
return {
|
|
2592
|
+
status: "succeeded",
|
|
2593
|
+
output: outputWithinBound(output, recipe.maxOutputBytes),
|
|
2594
|
+
finalUrl: WHATSAPP_ORIGIN,
|
|
2595
|
+
dispatchStarted: false,
|
|
2596
|
+
dispatch: { planned: 0, started: 0, verified: 0 },
|
|
2597
|
+
};
|
|
2598
|
+
}
|
|
2599
|
+
export type WhatsAppReadPlan =
|
|
2600
|
+
| {
|
|
2601
|
+
readonly action: "messaging.list";
|
|
2602
|
+
readonly command: readonly string[];
|
|
2603
|
+
readonly limit: number;
|
|
2604
|
+
readonly folder: "all" | "active" | "archived" | "unread";
|
|
2605
|
+
}
|
|
2606
|
+
| {
|
|
2607
|
+
readonly action: "messaging.read";
|
|
2608
|
+
readonly command: readonly string[];
|
|
2609
|
+
readonly limit: number;
|
|
2610
|
+
readonly conversationJid: string;
|
|
2611
|
+
}
|
|
2612
|
+
| {
|
|
2613
|
+
readonly action: "media.read";
|
|
2614
|
+
readonly command: readonly string[];
|
|
2615
|
+
readonly conversationJid: string;
|
|
2616
|
+
readonly messageId: string;
|
|
2617
|
+
};
|
|
2618
|
+
|
|
2619
|
+
export function planWhatsAppReadCommand(
|
|
2620
|
+
action: "messaging.list",
|
|
2621
|
+
input: OperationInput,
|
|
2622
|
+
): Extract<WhatsAppReadPlan, { readonly action: "messaging.list" }>;
|
|
2623
|
+
export function planWhatsAppReadCommand(
|
|
2624
|
+
action: "messaging.read",
|
|
2625
|
+
input: OperationInput,
|
|
2626
|
+
): Extract<WhatsAppReadPlan, { readonly action: "messaging.read" }>;
|
|
2627
|
+
export function planWhatsAppReadCommand(
|
|
2628
|
+
action: "media.read",
|
|
2629
|
+
input: OperationInput,
|
|
2630
|
+
): Extract<WhatsAppReadPlan, { readonly action: "media.read" }>;
|
|
2631
|
+
export function planWhatsAppReadCommand(
|
|
2632
|
+
action: WhatsAppReadPlan["action"],
|
|
2633
|
+
input: OperationInput,
|
|
2634
|
+
): WhatsAppReadPlan;
|
|
2635
|
+
export function planWhatsAppReadCommand(
|
|
2636
|
+
action: WhatsAppReadPlan["action"],
|
|
2637
|
+
input: OperationInput,
|
|
2638
|
+
): WhatsAppReadPlan {
|
|
2639
|
+
if (action === "messaging.list") {
|
|
2640
|
+
const limit = inputInteger(input, "limit", DEFAULT_LIMIT, 100);
|
|
2641
|
+
const folder = inputFolder(input);
|
|
2642
|
+
const flags = folder === "active"
|
|
2643
|
+
? ["--no-archived"]
|
|
2644
|
+
: folder === "archived"
|
|
2645
|
+
? ["--archived"]
|
|
2646
|
+
: folder === "unread"
|
|
2647
|
+
? ["--unread"]
|
|
2648
|
+
: [];
|
|
2649
|
+
return Object.freeze({
|
|
2650
|
+
action,
|
|
2651
|
+
limit,
|
|
2652
|
+
folder,
|
|
2653
|
+
command: Object.freeze([
|
|
2654
|
+
"chats",
|
|
2655
|
+
"list",
|
|
2656
|
+
"--limit",
|
|
2657
|
+
String(limit),
|
|
2658
|
+
...flags,
|
|
2659
|
+
]),
|
|
2660
|
+
});
|
|
2661
|
+
}
|
|
2662
|
+
const conversationJid = whatsappTargetJid(
|
|
2663
|
+
input.conversation_jid,
|
|
2664
|
+
"input.conversation_jid",
|
|
2665
|
+
);
|
|
2666
|
+
if (action === "messaging.read") {
|
|
2667
|
+
const limit = inputInteger(input, "limit", DEFAULT_LIMIT, 200);
|
|
2668
|
+
return Object.freeze({
|
|
2669
|
+
action,
|
|
2670
|
+
limit,
|
|
2671
|
+
conversationJid,
|
|
2672
|
+
command: Object.freeze([
|
|
2673
|
+
"messages",
|
|
2674
|
+
"list",
|
|
2675
|
+
"--chat",
|
|
2676
|
+
conversationJid,
|
|
2677
|
+
"--limit",
|
|
2678
|
+
String(limit),
|
|
2679
|
+
]),
|
|
2680
|
+
});
|
|
2681
|
+
}
|
|
2682
|
+
const messageId = whatsappMessageId(
|
|
2683
|
+
input.message_id,
|
|
2684
|
+
"input.message_id",
|
|
2685
|
+
);
|
|
2686
|
+
return Object.freeze({
|
|
2687
|
+
action,
|
|
2688
|
+
conversationJid,
|
|
2689
|
+
messageId,
|
|
2690
|
+
command: Object.freeze([
|
|
2691
|
+
"messages",
|
|
2692
|
+
"show",
|
|
2693
|
+
"--chat",
|
|
2694
|
+
conversationJid,
|
|
2695
|
+
"--id",
|
|
2696
|
+
messageId,
|
|
2697
|
+
]),
|
|
2698
|
+
});
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
function outputWithinBound(value: unknown, maximum: number): unknown {
|
|
2702
|
+
if (Buffer.byteLength(JSON.stringify(value), "utf8") > maximum) {
|
|
2703
|
+
throw new Error("WhatsApp projected output exceeded its reviewed byte limit");
|
|
2704
|
+
}
|
|
2705
|
+
return value;
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
async function executeLocalProjection(
|
|
2709
|
+
runtime: Awaited<ReturnType<typeof boundRuntime>>,
|
|
2710
|
+
recipe: WebSessionRecipe,
|
|
2711
|
+
input: OperationInput,
|
|
2712
|
+
dependencies: WhatsAppWebRuntimeDependencies | undefined,
|
|
2713
|
+
operationDeadline?: WebSessionOperationDeadline,
|
|
2714
|
+
): Promise<WebSessionExecution> {
|
|
2715
|
+
let output: unknown;
|
|
2716
|
+
if (recipe.action === "messaging.list") {
|
|
2717
|
+
const plan = planWhatsAppReadCommand(recipe.action, input);
|
|
2718
|
+
const timeoutMs = remainingTimeoutMs(
|
|
2719
|
+
recipe.timeoutMs,
|
|
2720
|
+
operationDeadline,
|
|
2721
|
+
);
|
|
2722
|
+
const raw = await checkedRun(
|
|
2723
|
+
runtime.binary,
|
|
2724
|
+
readOnlyArguments(runtime.store, timeoutMs, plan.command),
|
|
2725
|
+
{
|
|
2726
|
+
timeoutMs,
|
|
2727
|
+
maxOutputBytes: recipe.maxOutputBytes,
|
|
2728
|
+
readOnly: true,
|
|
2729
|
+
...(dependencies === undefined ? {} : { dependencies }),
|
|
2730
|
+
...(operationDeadline === undefined
|
|
2731
|
+
? {}
|
|
2732
|
+
: { operationDeadline }),
|
|
2733
|
+
},
|
|
2734
|
+
);
|
|
2735
|
+
output = Object.freeze({
|
|
2736
|
+
accountSubject: runtime.subject,
|
|
2737
|
+
projection: "local-store",
|
|
2738
|
+
completeness: "bounded-current-local-projection",
|
|
2739
|
+
chats: projectWhatsAppChatsEnvelope(raw, plan.limit),
|
|
2740
|
+
});
|
|
2741
|
+
} else if (recipe.action === "messaging.read") {
|
|
2742
|
+
const plan = planWhatsAppReadCommand(recipe.action, input);
|
|
2743
|
+
const timeoutMs = remainingTimeoutMs(
|
|
2744
|
+
recipe.timeoutMs,
|
|
2745
|
+
operationDeadline,
|
|
2746
|
+
);
|
|
2747
|
+
const raw = await checkedRun(
|
|
2748
|
+
runtime.binary,
|
|
2749
|
+
readOnlyArguments(runtime.store, timeoutMs, plan.command),
|
|
2750
|
+
{
|
|
2751
|
+
timeoutMs,
|
|
2752
|
+
maxOutputBytes: recipe.maxOutputBytes,
|
|
2753
|
+
readOnly: true,
|
|
2754
|
+
...(dependencies === undefined ? {} : { dependencies }),
|
|
2755
|
+
...(operationDeadline === undefined
|
|
2756
|
+
? {}
|
|
2757
|
+
: { operationDeadline }),
|
|
2758
|
+
},
|
|
2759
|
+
);
|
|
2760
|
+
output = Object.freeze({
|
|
2761
|
+
accountSubject: runtime.subject,
|
|
2762
|
+
projection: "local-store",
|
|
2763
|
+
completeness: "bounded-current-local-projection",
|
|
2764
|
+
conversationJid: plan.conversationJid,
|
|
2765
|
+
...projectWhatsAppMessagesEnvelope(
|
|
2766
|
+
raw,
|
|
2767
|
+
plan.conversationJid,
|
|
2768
|
+
plan.limit,
|
|
2769
|
+
),
|
|
2770
|
+
});
|
|
2771
|
+
} else if (recipe.action === "media.read") {
|
|
2772
|
+
const plan = planWhatsAppReadCommand(recipe.action, input);
|
|
2773
|
+
const timeoutMs = remainingTimeoutMs(
|
|
2774
|
+
recipe.timeoutMs,
|
|
2775
|
+
operationDeadline,
|
|
2776
|
+
);
|
|
2777
|
+
const raw = await checkedRun(
|
|
2778
|
+
runtime.binary,
|
|
2779
|
+
readOnlyArguments(runtime.store, timeoutMs, plan.command),
|
|
2780
|
+
{
|
|
2781
|
+
timeoutMs,
|
|
2782
|
+
maxOutputBytes: recipe.maxOutputBytes,
|
|
2783
|
+
readOnly: true,
|
|
2784
|
+
...(dependencies === undefined ? {} : { dependencies }),
|
|
2785
|
+
...(operationDeadline === undefined
|
|
2786
|
+
? {}
|
|
2787
|
+
: { operationDeadline }),
|
|
2788
|
+
},
|
|
2789
|
+
);
|
|
2790
|
+
const message = projectWhatsAppMessageEnvelope(
|
|
2791
|
+
raw,
|
|
2792
|
+
plan.conversationJid,
|
|
2793
|
+
plan.messageId,
|
|
2794
|
+
);
|
|
2795
|
+
output = Object.freeze({
|
|
2796
|
+
accountSubject: runtime.subject,
|
|
2797
|
+
projection: "local-store",
|
|
2798
|
+
completeness: "one-local-message",
|
|
2799
|
+
conversationJid: plan.conversationJid,
|
|
2800
|
+
messageId: plan.messageId,
|
|
2801
|
+
media: message.media,
|
|
2802
|
+
});
|
|
2803
|
+
} else {
|
|
2804
|
+
throw new Error("WhatsApp operation has no local projection");
|
|
2805
|
+
}
|
|
2806
|
+
return {
|
|
2807
|
+
status: "succeeded",
|
|
2808
|
+
output: outputWithinBound(output, recipe.maxOutputBytes),
|
|
2809
|
+
finalUrl: WHATSAPP_ORIGIN,
|
|
2810
|
+
dispatchStarted: false,
|
|
2811
|
+
dispatch: { planned: 0, started: 0, verified: 0 },
|
|
2812
|
+
};
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
export async function executeWhatsAppWebOperation(
|
|
2816
|
+
recipe: WebSessionRecipe,
|
|
2817
|
+
input: OperationInput,
|
|
2818
|
+
auth: GhostgetAuth,
|
|
2819
|
+
options: {
|
|
2820
|
+
readonly dependencies?: WhatsAppWebRuntimeDependencies;
|
|
2821
|
+
readonly environment?: Readonly<Record<string, string | undefined>>;
|
|
2822
|
+
readonly operationDeadline?: WebSessionOperationDeadline;
|
|
2823
|
+
readonly registerCleanupBarrier?: WebSessionCleanupBarrierRegistrar;
|
|
2824
|
+
} = {},
|
|
2825
|
+
): Promise<WebSessionExecution> {
|
|
2826
|
+
if (
|
|
2827
|
+
recipe.site !== "whatsapp"
|
|
2828
|
+
|| (recipe.action === "contacts.list"
|
|
2829
|
+
? recipe.contractVersion !== 2
|
|
2830
|
+
: recipe.contractVersion !== 1)
|
|
2831
|
+
|| !isWhatsAppOperation(recipe.action)
|
|
2832
|
+
) throw new Error("WhatsApp linked-device recipe is not installed");
|
|
2833
|
+
const contract = WHATSAPP_WEB_OPERATIONS[recipe.action];
|
|
2834
|
+
if (contract.state !== "observed") {
|
|
2835
|
+
throw new Error(
|
|
2836
|
+
`WhatsApp linked-device operation ${recipe.action} is capture-required: ${contract.reason}`,
|
|
2837
|
+
);
|
|
2838
|
+
}
|
|
2839
|
+
const localProjection = recipe.action === "contacts.list"
|
|
2840
|
+
|| recipe.action === "messaging.list"
|
|
2841
|
+
|| recipe.action === "messaging.read"
|
|
2842
|
+
|| recipe.action === "media.read";
|
|
2843
|
+
if (!localProjection) {
|
|
2844
|
+
throw new Error(
|
|
2845
|
+
`WhatsApp linked-device operation ${recipe.action} has no executable local projection`,
|
|
2846
|
+
);
|
|
2847
|
+
}
|
|
2848
|
+
options.operationDeadline?.throwIfUnavailable(
|
|
2849
|
+
WEB_SESSION_OPERATION_LABEL,
|
|
2850
|
+
);
|
|
2851
|
+
if (recipe.action === "contacts.list") {
|
|
2852
|
+
const parsedInput = exactContactInput(input);
|
|
2853
|
+
if (parsedInput.collection === "interactions") {
|
|
2854
|
+
return projectWhatsAppLocalInteractions(
|
|
2855
|
+
requireWhatsAppAuth(auth),
|
|
2856
|
+
recipe,
|
|
2857
|
+
parsedInput,
|
|
2858
|
+
options.dependencies,
|
|
2859
|
+
options.operationDeadline,
|
|
2860
|
+
options.registerCleanupBarrier,
|
|
2861
|
+
);
|
|
2862
|
+
}
|
|
2863
|
+
return projectWhatsAppLocalContacts(
|
|
2864
|
+
requireWhatsAppAuth(auth),
|
|
2865
|
+
recipe,
|
|
2866
|
+
input,
|
|
2867
|
+
options.dependencies,
|
|
2868
|
+
options.operationDeadline,
|
|
2869
|
+
options.registerCleanupBarrier,
|
|
2870
|
+
);
|
|
2871
|
+
}
|
|
2872
|
+
const runtime = await boundRuntime(
|
|
2873
|
+
auth,
|
|
2874
|
+
"projection",
|
|
2875
|
+
recipe.timeoutMs,
|
|
2876
|
+
options.dependencies,
|
|
2877
|
+
options.environment ?? process.env,
|
|
2878
|
+
options.operationDeadline,
|
|
2879
|
+
);
|
|
2880
|
+
if (localProjection) {
|
|
2881
|
+
return executeLocalProjection(
|
|
2882
|
+
runtime,
|
|
2883
|
+
recipe,
|
|
2884
|
+
input,
|
|
2885
|
+
options.dependencies,
|
|
2886
|
+
options.operationDeadline,
|
|
2887
|
+
);
|
|
2888
|
+
}
|
|
2889
|
+
throw new Error("WhatsApp local projection classification changed during execution");
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
export type WhatsAppPairingPlan = {
|
|
2893
|
+
readonly binary: string;
|
|
2894
|
+
readonly store: string;
|
|
2895
|
+
readonly arguments: readonly string[];
|
|
2896
|
+
readonly environment: Readonly<Record<string, string>>;
|
|
2897
|
+
};
|
|
2898
|
+
|
|
2899
|
+
export async function planWhatsAppPairing(
|
|
2900
|
+
auth: GhostgetAuth,
|
|
2901
|
+
options: {
|
|
2902
|
+
readonly phone?: string;
|
|
2903
|
+
readonly dependencies?: WhatsAppWebRuntimeDependencies;
|
|
2904
|
+
readonly environment?: Readonly<Record<string, string | undefined>>;
|
|
2905
|
+
} = {},
|
|
2906
|
+
): Promise<WhatsAppPairingPlan> {
|
|
2907
|
+
const linked = requireWhatsAppAuth(auth);
|
|
2908
|
+
const store = await validateWhatsAppStoreDirectory(linked.path, "pair");
|
|
2909
|
+
const binary = await runtimeBinary(
|
|
2910
|
+
options.dependencies,
|
|
2911
|
+
options.environment ?? process.env,
|
|
2912
|
+
);
|
|
2913
|
+
const phone = options.phone;
|
|
2914
|
+
if (
|
|
2915
|
+
phone !== undefined
|
|
2916
|
+
&& !/^\+?[0-9]{5,20}$/u.test(phone)
|
|
2917
|
+
) throw new Error("WhatsApp pairing phone must be one international number");
|
|
2918
|
+
return Object.freeze({
|
|
2919
|
+
binary,
|
|
2920
|
+
store,
|
|
2921
|
+
arguments: Object.freeze([
|
|
2922
|
+
"--store",
|
|
2923
|
+
store,
|
|
2924
|
+
"--timeout",
|
|
2925
|
+
"10m",
|
|
2926
|
+
"auth",
|
|
2927
|
+
"--idle-exit",
|
|
2928
|
+
"30s",
|
|
2929
|
+
"--qr-format",
|
|
2930
|
+
"terminal",
|
|
2931
|
+
...(phone === undefined ? [] : ["--phone", phone]),
|
|
2932
|
+
]),
|
|
2933
|
+
environment: Object.freeze({
|
|
2934
|
+
...wacliEnvironment(false),
|
|
2935
|
+
WACLI_SYNC_MAX_MESSAGES: String(MAX_SYNC_MESSAGES),
|
|
2936
|
+
WACLI_SYNC_MAX_DB_SIZE: MAX_SYNC_DB_SIZE,
|
|
2937
|
+
}),
|
|
2938
|
+
});
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
async function runInteractivePairing(
|
|
2942
|
+
plan: WhatsAppPairingPlan,
|
|
2943
|
+
): Promise<number> {
|
|
2944
|
+
const child = Bun.spawn(
|
|
2945
|
+
[plan.binary, ...plan.arguments],
|
|
2946
|
+
{
|
|
2947
|
+
env: { ...plan.environment },
|
|
2948
|
+
stdin: "inherit",
|
|
2949
|
+
stdout: "inherit",
|
|
2950
|
+
stderr: "inherit",
|
|
2951
|
+
},
|
|
2952
|
+
);
|
|
2953
|
+
return child.exited;
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
export async function pairWhatsAppAuth(
|
|
2957
|
+
auth: GhostgetAuth,
|
|
2958
|
+
options: {
|
|
2959
|
+
readonly phone?: string;
|
|
2960
|
+
readonly dependencies?: WhatsAppWebRuntimeDependencies;
|
|
2961
|
+
readonly environment?: Readonly<Record<string, string | undefined>>;
|
|
2962
|
+
readonly attempt: ProviderPluginLinkedDeviceAttemptBoundaryV1;
|
|
2963
|
+
},
|
|
2964
|
+
): Promise<string> {
|
|
2965
|
+
const plan = await planWhatsAppPairing(auth, options);
|
|
2966
|
+
const run = options.dependencies?.runInteractive ?? runInteractivePairing;
|
|
2967
|
+
await options.attempt.beforeExternalBegin();
|
|
2968
|
+
if (await run(plan) !== 0) {
|
|
2969
|
+
throw new Error("WhatsApp linked-device pairing did not complete");
|
|
2970
|
+
}
|
|
2971
|
+
return probeWhatsAppWebSubject(auth, {
|
|
2972
|
+
...(options.dependencies === undefined
|
|
2973
|
+
? {}
|
|
2974
|
+
: { dependencies: options.dependencies }),
|
|
2975
|
+
...(options.environment === undefined
|
|
2976
|
+
? {}
|
|
2977
|
+
: { environment: options.environment }),
|
|
2978
|
+
timeoutMs: 10_000,
|
|
2979
|
+
});
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
export type WhatsAppSyncPlan = {
|
|
2983
|
+
readonly binary: string;
|
|
2984
|
+
readonly store: string;
|
|
2985
|
+
readonly arguments: readonly string[];
|
|
2986
|
+
readonly environment: Readonly<Record<string, string>>;
|
|
2987
|
+
readonly emitsProtocolAcknowledgements: true;
|
|
2988
|
+
};
|
|
2989
|
+
|
|
2990
|
+
export async function planWhatsAppSyncOnce(
|
|
2991
|
+
auth: GhostgetAuth,
|
|
2992
|
+
options: {
|
|
2993
|
+
readonly dependencies?: WhatsAppWebRuntimeDependencies;
|
|
2994
|
+
readonly environment?: Readonly<Record<string, string | undefined>>;
|
|
2995
|
+
} = {},
|
|
2996
|
+
): Promise<WhatsAppSyncPlan> {
|
|
2997
|
+
const linked = requireWhatsAppAuth(auth);
|
|
2998
|
+
const store = await validateWhatsAppStoreDirectory(linked.path, "sync");
|
|
2999
|
+
const binary = await runtimeBinary(
|
|
3000
|
+
options.dependencies,
|
|
3001
|
+
options.environment ?? process.env,
|
|
3002
|
+
);
|
|
3003
|
+
return Object.freeze({
|
|
3004
|
+
binary,
|
|
3005
|
+
store,
|
|
3006
|
+
arguments: Object.freeze([
|
|
3007
|
+
"--store",
|
|
3008
|
+
store,
|
|
3009
|
+
"--json",
|
|
3010
|
+
"--full",
|
|
3011
|
+
"--timeout",
|
|
3012
|
+
"5m",
|
|
3013
|
+
"sync",
|
|
3014
|
+
"--once",
|
|
3015
|
+
"--presence-mode",
|
|
3016
|
+
"quiet",
|
|
3017
|
+
"--idle-exit",
|
|
3018
|
+
"30s",
|
|
3019
|
+
"--max-reconnect",
|
|
3020
|
+
"1m",
|
|
3021
|
+
"--max-messages",
|
|
3022
|
+
String(MAX_SYNC_MESSAGES),
|
|
3023
|
+
"--max-db-size",
|
|
3024
|
+
MAX_SYNC_DB_SIZE,
|
|
3025
|
+
]),
|
|
3026
|
+
environment: wacliEnvironment(false),
|
|
3027
|
+
emitsProtocolAcknowledgements: true,
|
|
3028
|
+
});
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
export async function syncWhatsAppAuthOnce(
|
|
3032
|
+
auth: GhostgetAuth,
|
|
3033
|
+
options: {
|
|
3034
|
+
readonly dependencies?: WhatsAppWebRuntimeDependencies;
|
|
3035
|
+
readonly environment?: Readonly<Record<string, string | undefined>>;
|
|
3036
|
+
readonly attempt: ProviderPluginLinkedDeviceAttemptBoundaryV1;
|
|
3037
|
+
},
|
|
3038
|
+
): Promise<{ readonly messagesStored: number }> {
|
|
3039
|
+
const linked = requireWhatsAppAuth(auth);
|
|
3040
|
+
if (linked.subject === undefined) {
|
|
3041
|
+
throw new Error("WhatsApp linked-device auth must be account-bound before sync");
|
|
3042
|
+
}
|
|
3043
|
+
const currentSubject = await probeWhatsAppWebSubject(linked, {
|
|
3044
|
+
...(options.dependencies === undefined
|
|
3045
|
+
? {}
|
|
3046
|
+
: { dependencies: options.dependencies }),
|
|
3047
|
+
...(options.environment === undefined
|
|
3048
|
+
? {}
|
|
3049
|
+
: { environment: options.environment }),
|
|
3050
|
+
timeoutMs: 10_000,
|
|
3051
|
+
});
|
|
3052
|
+
if (currentSubject !== linked.subject) {
|
|
3053
|
+
throw new Error("WhatsApp sync account did not match the bound auth realm");
|
|
3054
|
+
}
|
|
3055
|
+
const plan = await planWhatsAppSyncOnce(auth, options);
|
|
3056
|
+
const run = options.dependencies?.run ?? runWacli;
|
|
3057
|
+
await options.attempt.beforeExternalBegin();
|
|
3058
|
+
const result = await run({
|
|
3059
|
+
binary: plan.binary,
|
|
3060
|
+
arguments: plan.arguments,
|
|
3061
|
+
environment: plan.environment,
|
|
3062
|
+
timeoutMs: 5 * 60_000,
|
|
3063
|
+
maxOutputBytes: 1024 * 1024,
|
|
3064
|
+
});
|
|
3065
|
+
if (result.exitCode !== 0) {
|
|
3066
|
+
throw new Error("WhatsApp linked-device synchronization failed");
|
|
3067
|
+
}
|
|
3068
|
+
let parsed: unknown;
|
|
3069
|
+
try {
|
|
3070
|
+
parsed = JSON.parse(result.stdout.trim()) as unknown;
|
|
3071
|
+
} catch {
|
|
3072
|
+
throw new Error("WhatsApp linked-device synchronization returned malformed JSON");
|
|
3073
|
+
}
|
|
3074
|
+
if (
|
|
3075
|
+
typeof parsed !== "object"
|
|
3076
|
+
|| parsed === null
|
|
3077
|
+
|| Array.isArray(parsed)
|
|
3078
|
+
|| Object.keys(parsed).sort().join(",") !== "data,error,success"
|
|
3079
|
+
|| !("success" in parsed)
|
|
3080
|
+
|| parsed.success !== true
|
|
3081
|
+
|| !("error" in parsed)
|
|
3082
|
+
|| parsed.error !== null
|
|
3083
|
+
|| !("data" in parsed)
|
|
3084
|
+
|| typeof parsed.data !== "object"
|
|
3085
|
+
|| parsed.data === null
|
|
3086
|
+
|| Array.isArray(parsed.data)
|
|
3087
|
+
) throw new Error("WhatsApp linked-device synchronization returned an unsupported response");
|
|
3088
|
+
const data = parsed.data as Record<string, unknown>;
|
|
3089
|
+
if (
|
|
3090
|
+
Object.keys(data).sort().join(",") !== "messages_stored,synced"
|
|
3091
|
+
|| data.synced !== true
|
|
3092
|
+
|| !Number.isSafeInteger(data.messages_stored)
|
|
3093
|
+
|| (data.messages_stored as number) < 0
|
|
3094
|
+
) throw new Error("WhatsApp linked-device synchronization returned an unsupported response");
|
|
3095
|
+
const finalSubject = await probeWhatsAppWebSubject(linked, {
|
|
3096
|
+
...(options.dependencies === undefined
|
|
3097
|
+
? {}
|
|
3098
|
+
: { dependencies: options.dependencies }),
|
|
3099
|
+
...(options.environment === undefined
|
|
3100
|
+
? {}
|
|
3101
|
+
: { environment: options.environment }),
|
|
3102
|
+
timeoutMs: 10_000,
|
|
3103
|
+
});
|
|
3104
|
+
if (finalSubject !== linked.subject) {
|
|
3105
|
+
throw new Error("WhatsApp linked-device account changed during sync");
|
|
3106
|
+
}
|
|
3107
|
+
return Object.freeze({ messagesStored: data.messages_stored as number });
|
|
3108
|
+
}
|