@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,1430 @@
|
|
|
1
|
+
From 292db82d89293867ef847a2875667fea0fdd5dc1 Mon Sep 17 00:00:00 2001
|
|
2
|
+
From: 0thernet <ben@substrate.run>
|
|
3
|
+
Date: Thu, 27 Aug 2026 16:32:47 -0400
|
|
4
|
+
Subject: [PATCH] fix: keep AppleScript send payloads out of child argv
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
CHANGELOG.md | 10 +
|
|
8
|
+
.../IMsgCore/AppleScriptPrivatePayload.swift | 485 ++++++++++++++++++
|
|
9
|
+
.../IMsgCore/AppleScriptSendTransport.swift | 97 ++--
|
|
10
|
+
Sources/IMsgCore/MessageSender.swift | 20 +-
|
|
11
|
+
...AppleScriptPrivatePayloadTestSupport.swift | 90 ++++
|
|
12
|
+
.../AppleScriptPrivatePayloadTests.swift | 455 ++++++++++++++++
|
|
13
|
+
docs/private-applescript-transport.md | 107 ++++
|
|
14
|
+
docs/rpc.md | 14 +
|
|
15
|
+
docs/send.md | 9 +-
|
|
16
|
+
9 files changed, 1247 insertions(+), 40 deletions(-)
|
|
17
|
+
create mode 100644 Sources/IMsgCore/AppleScriptPrivatePayload.swift
|
|
18
|
+
create mode 100644 Tests/IMsgCoreTests/AppleScriptPrivatePayloadTestSupport.swift
|
|
19
|
+
create mode 100644 Tests/IMsgCoreTests/AppleScriptPrivatePayloadTests.swift
|
|
20
|
+
create mode 100644 docs/private-applescript-transport.md
|
|
21
|
+
|
|
22
|
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
|
|
23
|
+
index e236f77..dcf90d8 100644
|
|
24
|
+
--- a/CHANGELOG.md
|
|
25
|
+
+++ b/CHANGELOG.md
|
|
26
|
+
@@ -1,5 +1,15 @@
|
|
27
|
+
# Changelog
|
|
28
|
+
|
|
29
|
+
+## Unreleased
|
|
30
|
+
+
|
|
31
|
+
+### Security
|
|
32
|
+
+
|
|
33
|
+
+- Keep AppleScript send recipients, message bodies, service selection, attachment paths, chat targets, and routing flags out of the nested `osascript` process arguments. Each send now uses a random owner-only payload directory with checked `0600` field files, immutable namespace sealing for the child lifetime, and cleanup only after the child has been reaped.
|
|
34
|
+
+
|
|
35
|
+
+### Documentation
|
|
36
|
+
+
|
|
37
|
+
+- Clarify that AppleScript itself returns no message GUID. A GUID in CLI or RPC output comes from the separately observed outgoing `chat.db` row, and a missing row remains an uncertain, no-retry result.
|
|
38
|
+
+
|
|
39
|
+
## 0.14.1 - 2026-08-11
|
|
40
|
+
|
|
41
|
+
**Highlight:** search now finds messages whose text lives only in the rich-text
|
|
42
|
+
diff --git a/Sources/IMsgCore/AppleScriptPrivatePayload.swift b/Sources/IMsgCore/AppleScriptPrivatePayload.swift
|
|
43
|
+
new file mode 100644
|
|
44
|
+
index 0000000..b733d2e
|
|
45
|
+
--- /dev/null
|
|
46
|
+
+++ b/Sources/IMsgCore/AppleScriptPrivatePayload.swift
|
|
47
|
+
@@ -0,0 +1,485 @@
|
|
48
|
+
+import Foundation
|
|
49
|
+
+
|
|
50
|
+
+#if canImport(Darwin)
|
|
51
|
+
+ import Darwin
|
|
52
|
+
+#elseif canImport(Glibc)
|
|
53
|
+
+ import Glibc
|
|
54
|
+
+#endif
|
|
55
|
+
+
|
|
56
|
+
+/// A short-lived, owner-only payload consumed by the AppleScript send child.
|
|
57
|
+
+///
|
|
58
|
+
+/// Messages can carry arbitrary private text. None of it belongs in a process
|
|
59
|
+
+/// argument, where unrelated same-user process inspection can expose it. The
|
|
60
|
+
+/// parent writes each field to an independently checked file and gives
|
|
61
|
+
+/// `osascript` only this directory's random locator.
|
|
62
|
+
+final class AppleScriptPrivatePayload {
|
|
63
|
+
+ static let fieldNames = [
|
|
64
|
+
+ "recipient",
|
|
65
|
+
+ "text",
|
|
66
|
+
+ "service",
|
|
67
|
+
+ "attachment-path",
|
|
68
|
+
+ "use-attachment",
|
|
69
|
+
+ "chat-target",
|
|
70
|
+
+ "use-chat",
|
|
71
|
+
+ ]
|
|
72
|
+
+ private static let captureNames = ["stdout", "stderr"]
|
|
73
|
+
+
|
|
74
|
+
+ let directoryURL: URL
|
|
75
|
+
+
|
|
76
|
+
+ private let expectedOwner: uid_t
|
|
77
|
+
+ private let directoryFD: Int32
|
|
78
|
+
+ private let directoryIdentity: FileIdentity
|
|
79
|
+
+ private let fieldDescriptors: [String: Int32]
|
|
80
|
+
+ private let fieldIdentities: [String: FileIdentity]
|
|
81
|
+
+ private let fieldContents: [String: Data]
|
|
82
|
+
+ private var sealed = false
|
|
83
|
+
+ private var cleaned = false
|
|
84
|
+
+
|
|
85
|
+
+ private struct FileIdentity: Equatable {
|
|
86
|
+
+ let device: UInt64
|
|
87
|
+
+ let inode: UInt64
|
|
88
|
+
+ let owner: uid_t
|
|
89
|
+
+ let mode: mode_t
|
|
90
|
+
+ let links: UInt64
|
|
91
|
+
+ let size: Int64
|
|
92
|
+
+
|
|
93
|
+
+ init(_ info: stat) {
|
|
94
|
+
+ device = UInt64(info.st_dev)
|
|
95
|
+
+ inode = UInt64(info.st_ino)
|
|
96
|
+
+ owner = info.st_uid
|
|
97
|
+
+ mode = info.st_mode
|
|
98
|
+
+ links = UInt64(info.st_nlink)
|
|
99
|
+
+ size = Int64(info.st_size)
|
|
100
|
+
+ }
|
|
101
|
+
+ }
|
|
102
|
+
+
|
|
103
|
+
+ private init(
|
|
104
|
+
+ directoryURL: URL,
|
|
105
|
+
+ expectedOwner: uid_t,
|
|
106
|
+
+ directoryFD: Int32,
|
|
107
|
+
+ directoryIdentity: FileIdentity,
|
|
108
|
+
+ fieldDescriptors: [String: Int32],
|
|
109
|
+
+ fieldIdentities: [String: FileIdentity],
|
|
110
|
+
+ fieldContents: [String: Data]
|
|
111
|
+
+ ) {
|
|
112
|
+
+ self.directoryURL = directoryURL
|
|
113
|
+
+ self.expectedOwner = expectedOwner
|
|
114
|
+
+ self.directoryFD = directoryFD
|
|
115
|
+
+ self.directoryIdentity = directoryIdentity
|
|
116
|
+
+ self.fieldDescriptors = fieldDescriptors
|
|
117
|
+
+ self.fieldIdentities = fieldIdentities
|
|
118
|
+
+ self.fieldContents = fieldContents
|
|
119
|
+
+ }
|
|
120
|
+
+
|
|
121
|
+
+ deinit {
|
|
122
|
+
+ cleanup()
|
|
123
|
+
+ }
|
|
124
|
+
+
|
|
125
|
+
+ static func create(
|
|
126
|
+
+ arguments: [String],
|
|
127
|
+
+ expectedOwner: uid_t = geteuid(),
|
|
128
|
+
+ temporaryRoot: URL = FileManager.default.temporaryDirectory
|
|
129
|
+
+ ) throws -> AppleScriptPrivatePayload {
|
|
130
|
+
+ guard arguments.count == fieldNames.count else {
|
|
131
|
+
+ throw PrivatePayloadError.invalidFieldCount
|
|
132
|
+
+ }
|
|
133
|
+
+
|
|
134
|
+
+ let canonicalRoot = temporaryRoot.resolvingSymlinksInPath()
|
|
135
|
+
+ let template =
|
|
136
|
+
+ canonicalRoot
|
|
137
|
+
+ .appendingPathComponent("imsg-private-send.XXXXXXXX", isDirectory: true)
|
|
138
|
+
+ .path
|
|
139
|
+
+ var templateBytes = Array(template.utf8CString)
|
|
140
|
+
+ let createdPath: String? = templateBytes.withUnsafeMutableBufferPointer { buffer in
|
|
141
|
+
+ guard let baseAddress = buffer.baseAddress, mkdtemp(baseAddress) != nil else { return nil }
|
|
142
|
+
+ return String(cString: baseAddress)
|
|
143
|
+
+ }
|
|
144
|
+
+ guard let createdPath else {
|
|
145
|
+
+ throw PrivatePayloadError.createDirectory
|
|
146
|
+
+ }
|
|
147
|
+
+
|
|
148
|
+
+ let directoryURL = URL(fileURLWithPath: createdPath, isDirectory: true)
|
|
149
|
+
+ var shouldRemoveDirectory = true
|
|
150
|
+
+ defer {
|
|
151
|
+
+ if shouldRemoveDirectory {
|
|
152
|
+
+ try? FileManager.default.removeItem(at: directoryURL)
|
|
153
|
+
+ }
|
|
154
|
+
+ }
|
|
155
|
+
+
|
|
156
|
+
+ let directoryFD = open(
|
|
157
|
+
+ createdPath,
|
|
158
|
+
+ O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC
|
|
159
|
+
+ )
|
|
160
|
+
+ guard directoryFD >= 0 else {
|
|
161
|
+
+ throw PrivatePayloadError.openDirectory
|
|
162
|
+
+ }
|
|
163
|
+
+ var shouldCloseDirectory = true
|
|
164
|
+
+ defer {
|
|
165
|
+
+ if shouldCloseDirectory { close(directoryFD) }
|
|
166
|
+
+ }
|
|
167
|
+
+
|
|
168
|
+
+ guard fchmod(directoryFD, 0o700) == 0 else {
|
|
169
|
+
+ throw PrivatePayloadError.secureDirectory
|
|
170
|
+
+ }
|
|
171
|
+
+ let directoryIdentity = try checkedDirectoryIdentity(
|
|
172
|
+
+ descriptor: directoryFD,
|
|
173
|
+
+ expectedOwner: expectedOwner
|
|
174
|
+
+ )
|
|
175
|
+
+
|
|
176
|
+
+ var descriptors: [String: Int32] = [:]
|
|
177
|
+
+ var identities: [String: FileIdentity] = [:]
|
|
178
|
+
+ var contents: [String: Data] = [:]
|
|
179
|
+
+ var shouldCloseFields = true
|
|
180
|
+
+ defer {
|
|
181
|
+
+ if shouldCloseFields {
|
|
182
|
+
+ for descriptor in descriptors.values { close(descriptor) }
|
|
183
|
+
+ }
|
|
184
|
+
+ }
|
|
185
|
+
+ for (name, value) in zip(fieldNames, arguments) {
|
|
186
|
+
+ let content = Data(value.utf8)
|
|
187
|
+
+ let descriptor = openat(
|
|
188
|
+
+ directoryFD,
|
|
189
|
+
+ name,
|
|
190
|
+
+ O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC,
|
|
191
|
+
+ 0o600
|
|
192
|
+
+ )
|
|
193
|
+
+ guard descriptor >= 0 else {
|
|
194
|
+
+ throw PrivatePayloadError.createField
|
|
195
|
+
+ }
|
|
196
|
+
+ descriptors[name] = descriptor
|
|
197
|
+
+
|
|
198
|
+
+ guard fchmod(descriptor, 0o600) == 0 else {
|
|
199
|
+
+ throw PrivatePayloadError.secureField
|
|
200
|
+
+ }
|
|
201
|
+
+ try writeAll(content, to: descriptor)
|
|
202
|
+
+ guard fsync(descriptor) == 0 else {
|
|
203
|
+
+ throw PrivatePayloadError.writeField
|
|
204
|
+
+ }
|
|
205
|
+
+ identities[name] = try checkedFileIdentity(
|
|
206
|
+
+ descriptor: descriptor,
|
|
207
|
+
+ expectedOwner: expectedOwner,
|
|
208
|
+
+ expectedSize: content.count
|
|
209
|
+
+ )
|
|
210
|
+
+ contents[name] = content
|
|
211
|
+
+ }
|
|
212
|
+
+
|
|
213
|
+
+ shouldCloseDirectory = false
|
|
214
|
+
+ shouldCloseFields = false
|
|
215
|
+
+ shouldRemoveDirectory = false
|
|
216
|
+
+ return AppleScriptPrivatePayload(
|
|
217
|
+
+ directoryURL: directoryURL,
|
|
218
|
+
+ expectedOwner: expectedOwner,
|
|
219
|
+
+ directoryFD: directoryFD,
|
|
220
|
+
+ directoryIdentity: directoryIdentity,
|
|
221
|
+
+ fieldDescriptors: descriptors,
|
|
222
|
+
+ fieldIdentities: identities,
|
|
223
|
+
+ fieldContents: contents
|
|
224
|
+
+ )
|
|
225
|
+
+ }
|
|
226
|
+
+}
|
|
227
|
+
+
|
|
228
|
+
+extension AppleScriptPrivatePayload {
|
|
229
|
+
+ /// Re-check every pathname immediately before launch. The identity checks
|
|
230
|
+
+ /// reject root replacement, field replacement, symlinks, hard links,
|
|
231
|
+
+ /// ownership changes, and permission changes.
|
|
232
|
+
+ func validateForLaunch() throws {
|
|
233
|
+
+ var pathInfo = stat()
|
|
234
|
+
+ guard lstat(directoryURL.path, &pathInfo) == 0 else {
|
|
235
|
+
+ throw PrivatePayloadError.substitutedDirectory
|
|
236
|
+
+ }
|
|
237
|
+
+ let pathIdentity = FileIdentity(pathInfo)
|
|
238
|
+
+ guard Self.isSecureDirectory(pathIdentity, expectedOwner: expectedOwner),
|
|
239
|
+
+ Self.sameObject(pathIdentity, directoryIdentity)
|
|
240
|
+
+ else {
|
|
241
|
+
+ throw PrivatePayloadError.substitutedDirectory
|
|
242
|
+
+ }
|
|
243
|
+
+
|
|
244
|
+
+ let pathFD = open(
|
|
245
|
+
+ directoryURL.path,
|
|
246
|
+
+ O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC
|
|
247
|
+
+ )
|
|
248
|
+
+ guard pathFD >= 0 else {
|
|
249
|
+
+ throw PrivatePayloadError.substitutedDirectory
|
|
250
|
+
+ }
|
|
251
|
+
+ defer { close(pathFD) }
|
|
252
|
+
+ let reopenedDirectory = try Self.checkedDirectoryIdentity(
|
|
253
|
+
+ descriptor: pathFD,
|
|
254
|
+
+ expectedOwner: expectedOwner
|
|
255
|
+
+ )
|
|
256
|
+
+ guard Self.sameObject(reopenedDirectory, directoryIdentity) else {
|
|
257
|
+
+ throw PrivatePayloadError.substitutedDirectory
|
|
258
|
+
+ }
|
|
259
|
+
+
|
|
260
|
+
+ let pinnedDirectory = try Self.checkedDirectoryIdentity(
|
|
261
|
+
+ descriptor: directoryFD,
|
|
262
|
+
+ expectedOwner: expectedOwner
|
|
263
|
+
+ )
|
|
264
|
+
+ guard Self.sameObject(pinnedDirectory, directoryIdentity) else {
|
|
265
|
+
+ throw PrivatePayloadError.substitutedDirectory
|
|
266
|
+
+ }
|
|
267
|
+
+
|
|
268
|
+
+ for name in Self.fieldNames {
|
|
269
|
+
+ guard let expected = fieldIdentities[name],
|
|
270
|
+
+ let expectedContent = fieldContents[name],
|
|
271
|
+
+ let pinnedDescriptor = fieldDescriptors[name]
|
|
272
|
+
+ else {
|
|
273
|
+
+ throw PrivatePayloadError.missingField
|
|
274
|
+
+ }
|
|
275
|
+
+ let pinned = try Self.checkedFileIdentity(
|
|
276
|
+
+ descriptor: pinnedDescriptor,
|
|
277
|
+
+ expectedOwner: expectedOwner,
|
|
278
|
+
+ expectedSize: Int(expected.size)
|
|
279
|
+
+ )
|
|
280
|
+
+ guard pinned == expected else {
|
|
281
|
+
+ throw PrivatePayloadError.substitutedField
|
|
282
|
+
+ }
|
|
283
|
+
+ let descriptor = openat(
|
|
284
|
+
+ pathFD,
|
|
285
|
+
+ name,
|
|
286
|
+
+ O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC
|
|
287
|
+
+ )
|
|
288
|
+
+ guard descriptor >= 0 else {
|
|
289
|
+
+ throw PrivatePayloadError.substitutedField
|
|
290
|
+
+ }
|
|
291
|
+
+ defer { close(descriptor) }
|
|
292
|
+
+ let current = try Self.checkedFileIdentity(
|
|
293
|
+
+ descriptor: descriptor,
|
|
294
|
+
+ expectedOwner: expectedOwner,
|
|
295
|
+
+ expectedSize: Int(expected.size)
|
|
296
|
+
+ )
|
|
297
|
+
+ guard current == expected else {
|
|
298
|
+
+ throw PrivatePayloadError.substitutedField
|
|
299
|
+
+ }
|
|
300
|
+
+ guard try Self.readAll(from: descriptor) == expectedContent else {
|
|
301
|
+
+ throw PrivatePayloadError.substitutedField
|
|
302
|
+
+ }
|
|
303
|
+
+ }
|
|
304
|
+
+ }
|
|
305
|
+
+
|
|
306
|
+
+ /// Seal the namespace between the final identity check and child exit. On
|
|
307
|
+
+ /// Darwin, user-immutable field and directory flags reject ordinary
|
|
308
|
+
+ /// replacement or editing through a discovered payload path. This is not an
|
|
309
|
+
+ /// isolation boundary against a compromised process running as the same
|
|
310
|
+
+ /// user, which can deliberately clear user flags. The parent retains pinned
|
|
311
|
+
+ /// descriptors so it can unseal during cleanup.
|
|
312
|
+
+ func sealForLaunch() throws {
|
|
313
|
+
+ sealed = true
|
|
314
|
+
+ #if canImport(Darwin)
|
|
315
|
+
+ for descriptor in fieldDescriptors.values {
|
|
316
|
+
+ guard fchflags(descriptor, UInt32(UF_IMMUTABLE)) == 0 else {
|
|
317
|
+
+ throw PrivatePayloadError.sealField
|
|
318
|
+
+ }
|
|
319
|
+
+ }
|
|
320
|
+
+ guard fchflags(directoryFD, UInt32(UF_IMMUTABLE)) == 0 else {
|
|
321
|
+
+ throw PrivatePayloadError.sealDirectory
|
|
322
|
+
+ }
|
|
323
|
+
+ guard Self.hasImmutableFlag(descriptor: directoryFD) else {
|
|
324
|
+
+ throw PrivatePayloadError.sealDirectory
|
|
325
|
+
+ }
|
|
326
|
+
+ for descriptor in fieldDescriptors.values {
|
|
327
|
+
+ guard Self.hasImmutableFlag(descriptor: descriptor) else {
|
|
328
|
+
+ throw PrivatePayloadError.sealField
|
|
329
|
+
+ }
|
|
330
|
+
+ }
|
|
331
|
+
+ #endif
|
|
332
|
+
+ }
|
|
333
|
+
+
|
|
334
|
+
+ /// Create an output sink under the same private root without resolving any
|
|
335
|
+
+ /// caller-controlled pathname.
|
|
336
|
+
+ func makeCaptureFile(named name: String) throws -> FileHandle {
|
|
337
|
+
+ guard !name.isEmpty, !name.contains("/"), !Self.fieldNames.contains(name) else {
|
|
338
|
+
+ throw PrivatePayloadError.invalidCaptureName
|
|
339
|
+
+ }
|
|
340
|
+
+ let descriptor = openat(
|
|
341
|
+
+ directoryFD,
|
|
342
|
+
+ name,
|
|
343
|
+
+ O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC,
|
|
344
|
+
+ 0o600
|
|
345
|
+
+ )
|
|
346
|
+
+ guard descriptor >= 0 else {
|
|
347
|
+
+ throw PrivatePayloadError.createCapture
|
|
348
|
+
+ }
|
|
349
|
+
+ guard fchmod(descriptor, 0o600) == 0 else {
|
|
350
|
+
+ close(descriptor)
|
|
351
|
+
+ throw PrivatePayloadError.secureCapture
|
|
352
|
+
+ }
|
|
353
|
+
+ do {
|
|
354
|
+
+ _ = try Self.checkedFileIdentity(
|
|
355
|
+
+ descriptor: descriptor,
|
|
356
|
+
+ expectedOwner: expectedOwner,
|
|
357
|
+
+ expectedSize: 0
|
|
358
|
+
+ )
|
|
359
|
+
+ } catch {
|
|
360
|
+
+ close(descriptor)
|
|
361
|
+
+ throw error
|
|
362
|
+
+ }
|
|
363
|
+
+ return FileHandle(fileDescriptor: descriptor, closeOnDealloc: true)
|
|
364
|
+
+ }
|
|
365
|
+
+
|
|
366
|
+
+ func cleanup() {
|
|
367
|
+
+ guard !cleaned else { return }
|
|
368
|
+
+ cleaned = true
|
|
369
|
+
+
|
|
370
|
+
+ #if canImport(Darwin)
|
|
371
|
+
+ if sealed {
|
|
372
|
+
+ _ = fchflags(directoryFD, 0)
|
|
373
|
+
+ for descriptor in fieldDescriptors.values {
|
|
374
|
+
+ _ = fchflags(descriptor, 0)
|
|
375
|
+
+ }
|
|
376
|
+
+ }
|
|
377
|
+
+ #endif
|
|
378
|
+
+
|
|
379
|
+
+ // Unlink only known entries through the retained directory descriptor.
|
|
380
|
+
+ // This removes private fields even if the directory itself was renamed and
|
|
381
|
+
+ // avoids recursively deleting a substituted tree at the public locator.
|
|
382
|
+
+ for name in Self.fieldNames + Self.captureNames {
|
|
383
|
+
+ _ = unlinkat(directoryFD, name, 0)
|
|
384
|
+
+ }
|
|
385
|
+
+
|
|
386
|
+
+ // Remove only the exact directory we created. If the path was replaced,
|
|
387
|
+
+ // preserving an empty opaque directory is safer than deleting whatever now
|
|
388
|
+
+ // occupies the locator. `rmdir` cannot recursively consume a tree swapped
|
|
389
|
+
+ // in after this identity check.
|
|
390
|
+
+ var pathInfo = stat()
|
|
391
|
+
+ if lstat(directoryURL.path, &pathInfo) == 0 {
|
|
392
|
+
+ let current = FileIdentity(pathInfo)
|
|
393
|
+
+ if (current.mode & S_IFMT) == S_IFDIR,
|
|
394
|
+
+ current.owner == expectedOwner,
|
|
395
|
+
+ Self.sameObject(current, directoryIdentity)
|
|
396
|
+
+ {
|
|
397
|
+
+ // Validation deliberately rejects a mode change. Cleanup can safely
|
|
398
|
+
+ // repair it through the still-pinned descriptor after proving this is
|
|
399
|
+
+ // the same owner and inode, so a failed launch does not leak payloads.
|
|
400
|
+
+ _ = fchmod(directoryFD, 0o700)
|
|
401
|
+
+ _ = rmdir(directoryURL.path)
|
|
402
|
+
+ }
|
|
403
|
+
+ }
|
|
404
|
+
+ for descriptor in fieldDescriptors.values { close(descriptor) }
|
|
405
|
+
+ close(directoryFD)
|
|
406
|
+
+ }
|
|
407
|
+
+}
|
|
408
|
+
+
|
|
409
|
+
+extension AppleScriptPrivatePayload {
|
|
410
|
+
+ private static func checkedDirectoryIdentity(
|
|
411
|
+
+ descriptor: Int32,
|
|
412
|
+
+ expectedOwner: uid_t
|
|
413
|
+
+ ) throws -> FileIdentity {
|
|
414
|
+
+ var info = stat()
|
|
415
|
+
+ guard fstat(descriptor, &info) == 0 else {
|
|
416
|
+
+ throw PrivatePayloadError.inspectDirectory
|
|
417
|
+
+ }
|
|
418
|
+
+ let identity = FileIdentity(info)
|
|
419
|
+
+ guard isSecureDirectory(identity, expectedOwner: expectedOwner) else {
|
|
420
|
+
+ throw PrivatePayloadError.insecureDirectory
|
|
421
|
+
+ }
|
|
422
|
+
+ return identity
|
|
423
|
+
+ }
|
|
424
|
+
+
|
|
425
|
+
+ private static func checkedFileIdentity(
|
|
426
|
+
+ descriptor: Int32,
|
|
427
|
+
+ expectedOwner: uid_t,
|
|
428
|
+
+ expectedSize: Int
|
|
429
|
+
+ ) throws -> FileIdentity {
|
|
430
|
+
+ var info = stat()
|
|
431
|
+
+ guard fstat(descriptor, &info) == 0 else {
|
|
432
|
+
+ throw PrivatePayloadError.inspectField
|
|
433
|
+
+ }
|
|
434
|
+
+ let identity = FileIdentity(info)
|
|
435
|
+
+ guard (identity.mode & S_IFMT) == S_IFREG,
|
|
436
|
+
+ identity.owner == expectedOwner,
|
|
437
|
+
+ (identity.mode & 0o7777) == 0o600,
|
|
438
|
+
+ identity.links == 1,
|
|
439
|
+
+ identity.size == Int64(expectedSize)
|
|
440
|
+
+ else {
|
|
441
|
+
+ throw PrivatePayloadError.insecureField
|
|
442
|
+
+ }
|
|
443
|
+
+ return identity
|
|
444
|
+
+ }
|
|
445
|
+
+
|
|
446
|
+
+ private static func isSecureDirectory(
|
|
447
|
+
+ _ identity: FileIdentity,
|
|
448
|
+
+ expectedOwner: uid_t
|
|
449
|
+
+ ) -> Bool {
|
|
450
|
+
+ (identity.mode & S_IFMT) == S_IFDIR
|
|
451
|
+
+ && identity.owner == expectedOwner
|
|
452
|
+
+ && (identity.mode & 0o7777) == 0o700
|
|
453
|
+
+ }
|
|
454
|
+
+
|
|
455
|
+
+ private static func sameObject(_ lhs: FileIdentity, _ rhs: FileIdentity) -> Bool {
|
|
456
|
+
+ lhs.device == rhs.device && lhs.inode == rhs.inode
|
|
457
|
+
+ }
|
|
458
|
+
+
|
|
459
|
+
+ #if canImport(Darwin)
|
|
460
|
+
+ private static func hasImmutableFlag(descriptor: Int32) -> Bool {
|
|
461
|
+
+ var info = stat()
|
|
462
|
+
+ return fstat(descriptor, &info) == 0
|
|
463
|
+
+ && (info.st_flags & UInt32(UF_IMMUTABLE)) == UInt32(UF_IMMUTABLE)
|
|
464
|
+
+ }
|
|
465
|
+
+ #endif
|
|
466
|
+
+
|
|
467
|
+
+ private static func writeAll(_ data: Data, to descriptor: Int32) throws {
|
|
468
|
+
+ try data.withUnsafeBytes { rawBuffer in
|
|
469
|
+
+ guard var cursor = rawBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
|
|
470
|
+
+ return
|
|
471
|
+
+ }
|
|
472
|
+
+ var remaining = rawBuffer.count
|
|
473
|
+
+ while remaining > 0 {
|
|
474
|
+
+ let count: Int
|
|
475
|
+
+ #if canImport(Darwin)
|
|
476
|
+
+ count = Darwin.write(descriptor, cursor, remaining)
|
|
477
|
+
+ #else
|
|
478
|
+
+ count = Glibc.write(descriptor, cursor, remaining)
|
|
479
|
+
+ #endif
|
|
480
|
+
+ if count < 0, errno == EINTR { continue }
|
|
481
|
+
+ guard count > 0 else {
|
|
482
|
+
+ throw PrivatePayloadError.writeField
|
|
483
|
+
+ }
|
|
484
|
+
+ cursor = cursor.advanced(by: count)
|
|
485
|
+
+ remaining -= count
|
|
486
|
+
+ }
|
|
487
|
+
+ }
|
|
488
|
+
+ }
|
|
489
|
+
+
|
|
490
|
+
+ private static func readAll(from descriptor: Int32) throws -> Data {
|
|
491
|
+
+ var result = Data()
|
|
492
|
+
+ var buffer = [UInt8](repeating: 0, count: 64 * 1024)
|
|
493
|
+
+ while true {
|
|
494
|
+
+ let count: Int = buffer.withUnsafeMutableBytes { rawBuffer in
|
|
495
|
+
+ #if canImport(Darwin)
|
|
496
|
+
+ Darwin.read(descriptor, rawBuffer.baseAddress, rawBuffer.count)
|
|
497
|
+
+ #else
|
|
498
|
+
+ Glibc.read(descriptor, rawBuffer.baseAddress, rawBuffer.count)
|
|
499
|
+
+ #endif
|
|
500
|
+
+ }
|
|
501
|
+
+ if count < 0, errno == EINTR { continue }
|
|
502
|
+
+ guard count >= 0 else {
|
|
503
|
+
+ throw PrivatePayloadError.readField
|
|
504
|
+
+ }
|
|
505
|
+
+ if count == 0 { return result }
|
|
506
|
+
+ result.append(contentsOf: buffer.prefix(count))
|
|
507
|
+
+ }
|
|
508
|
+
+ }
|
|
509
|
+
+}
|
|
510
|
+
+
|
|
511
|
+
+private enum PrivatePayloadError: Error {
|
|
512
|
+
+ case invalidFieldCount
|
|
513
|
+
+ case createDirectory
|
|
514
|
+
+ case openDirectory
|
|
515
|
+
+ case secureDirectory
|
|
516
|
+
+ case inspectDirectory
|
|
517
|
+
+ case insecureDirectory
|
|
518
|
+
+ case createField
|
|
519
|
+
+ case secureField
|
|
520
|
+
+ case writeField
|
|
521
|
+
+ case readField
|
|
522
|
+
+ case inspectField
|
|
523
|
+
+ case insecureField
|
|
524
|
+
+ case missingField
|
|
525
|
+
+ case substitutedDirectory
|
|
526
|
+
+ case substitutedField
|
|
527
|
+
+ case invalidCaptureName
|
|
528
|
+
+ case createCapture
|
|
529
|
+
+ case secureCapture
|
|
530
|
+
+ case sealField
|
|
531
|
+
+ case sealDirectory
|
|
532
|
+
+}
|
|
533
|
+
diff --git a/Sources/IMsgCore/AppleScriptSendTransport.swift b/Sources/IMsgCore/AppleScriptSendTransport.swift
|
|
534
|
+
index 3086d5c..385dee1 100644
|
|
535
|
+
--- a/Sources/IMsgCore/AppleScriptSendTransport.swift
|
|
536
|
+
+++ b/Sources/IMsgCore/AppleScriptSendTransport.swift
|
|
537
|
+
@@ -1,43 +1,51 @@
|
|
538
|
+
import Foundation
|
|
539
|
+
|
|
540
|
+
+#if canImport(Darwin)
|
|
541
|
+
+ import Darwin
|
|
542
|
+
+#elseif canImport(Glibc)
|
|
543
|
+
+ import Glibc
|
|
544
|
+
+#endif
|
|
545
|
+
+
|
|
546
|
+
enum AppleScriptSendTransport {
|
|
547
|
+
/// Messages can legitimately take longer than the short helper default, but
|
|
548
|
+
/// the child is always bounded.
|
|
549
|
+
static let timeout: TimeInterval = IMsgBridgeProtocol.defaultSendResponseTimeout
|
|
550
|
+
|
|
551
|
+
- static func run(source: String, arguments: [String]) throws {
|
|
552
|
+
+ static func run(
|
|
553
|
+
+ source: String,
|
|
554
|
+
+ arguments: [String],
|
|
555
|
+
+ executableURL: URL = URL(fileURLWithPath: "/usr/bin/osascript"),
|
|
556
|
+
+ processTimeout: TimeInterval = timeout,
|
|
557
|
+
+ expectedOwner: uid_t = geteuid(),
|
|
558
|
+
+ beforeLaunch: ((URL) throws -> Void)? = nil,
|
|
559
|
+
+ afterLaunch: (() throws -> Void)? = nil
|
|
560
|
+
+ ) throws {
|
|
561
|
+
+ let payload: AppleScriptPrivatePayload
|
|
562
|
+
+ do {
|
|
563
|
+
+ payload = try AppleScriptPrivatePayload.create(
|
|
564
|
+
+ arguments: arguments,
|
|
565
|
+
+ expectedOwner: expectedOwner
|
|
566
|
+
+ )
|
|
567
|
+
+ } catch {
|
|
568
|
+
+ throw failure(.notStarted, "Unable to prepare the private osascript payload.")
|
|
569
|
+
+ }
|
|
570
|
+
+ defer { payload.cleanup() }
|
|
571
|
+
+
|
|
572
|
+
let process = Process()
|
|
573
|
+
- process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
|
574
|
+
- process.arguments = ["-l", "AppleScript", "-"] + arguments
|
|
575
|
+
+ process.executableURL = executableURL
|
|
576
|
+
+ process.arguments = ["-l", "AppleScript", "-", payload.directoryURL.path]
|
|
577
|
+
let stdinPipe = Pipe()
|
|
578
|
+
- let captureDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(
|
|
579
|
+
- "imsg-osascript-\(UUID().uuidString)", isDirectory: true)
|
|
580
|
+
- let stdoutURL = captureDirectory.appendingPathComponent("stdout")
|
|
581
|
+
- let stderrURL = captureDirectory.appendingPathComponent("stderr")
|
|
582
|
+
+ let stdoutHandle: FileHandle
|
|
583
|
+
+ let stderrHandle: FileHandle
|
|
584
|
+
do {
|
|
585
|
+
- try FileManager.default.createDirectory(
|
|
586
|
+
- at: captureDirectory,
|
|
587
|
+
- withIntermediateDirectories: true,
|
|
588
|
+
- attributes: [.posixPermissions: 0o700])
|
|
589
|
+
- guard
|
|
590
|
+
- FileManager.default.createFile(atPath: stdoutURL.path, contents: nil),
|
|
591
|
+
- FileManager.default.createFile(atPath: stderrURL.path, contents: nil)
|
|
592
|
+
- else {
|
|
593
|
+
- throw CocoaError(.fileWriteUnknown)
|
|
594
|
+
- }
|
|
595
|
+
- try FileManager.default.setAttributes(
|
|
596
|
+
- [.posixPermissions: 0o600], ofItemAtPath: stdoutURL.path)
|
|
597
|
+
- try FileManager.default.setAttributes(
|
|
598
|
+
- [.posixPermissions: 0o600], ofItemAtPath: stderrURL.path)
|
|
599
|
+
+ stdoutHandle = try payload.makeCaptureFile(named: "stdout")
|
|
600
|
+
+ stderrHandle = try payload.makeCaptureFile(named: "stderr")
|
|
601
|
+
+ try beforeLaunch?(payload.directoryURL)
|
|
602
|
+
+ try payload.validateForLaunch()
|
|
603
|
+
+ try payload.sealForLaunch()
|
|
604
|
+
+ try payload.validateForLaunch()
|
|
605
|
+
} catch {
|
|
606
|
+
- throw failure(.notStarted, "Unable to prepare bounded osascript output capture.")
|
|
607
|
+
- }
|
|
608
|
+
- defer { try? FileManager.default.removeItem(at: captureDirectory) }
|
|
609
|
+
- guard
|
|
610
|
+
- let stdoutHandle = FileHandle(forWritingAtPath: stdoutURL.path),
|
|
611
|
+
- let stderrHandle = FileHandle(forWritingAtPath: stderrURL.path)
|
|
612
|
+
- else {
|
|
613
|
+
- throw failure(.notStarted, "Unable to prepare bounded osascript output capture.")
|
|
614
|
+
+ throw failure(.notStarted, "The private osascript payload failed its launch check.")
|
|
615
|
+
}
|
|
616
|
+
defer {
|
|
617
|
+
try? stdoutHandle.close()
|
|
618
|
+
@@ -51,6 +59,16 @@ enum AppleScriptSendTransport {
|
|
619
|
+
} catch {
|
|
620
|
+
throw failure(.notStarted, "osascript could not be launched.")
|
|
621
|
+
}
|
|
622
|
+
+ do {
|
|
623
|
+
+ try afterLaunch?()
|
|
624
|
+
+ } catch {
|
|
625
|
+
+ ProcessTimeout.terminate(process)
|
|
626
|
+
+ process.waitUntilExit()
|
|
627
|
+
+ throw failure(
|
|
628
|
+
+ .mayHaveCompleted,
|
|
629
|
+
+ "The osascript launch supervisor failed after process launch."
|
|
630
|
+
+ )
|
|
631
|
+
+ }
|
|
632
|
+
do {
|
|
633
|
+
if let data = source.data(using: .utf8) {
|
|
634
|
+
try stdinPipe.fileHandleForWriting.write(contentsOf: data)
|
|
635
|
+
@@ -64,9 +82,15 @@ enum AppleScriptSendTransport {
|
|
636
|
+
"The osascript input channel failed after process launch."
|
|
637
|
+
)
|
|
638
|
+
}
|
|
639
|
+
- if ProcessTimeout.waitUntilExit(process, timeout: timeout) {
|
|
640
|
+
- throw failure(.mayHaveCompleted, "osascript timed out after \(Int(timeout)) seconds.")
|
|
641
|
+
+ if ProcessTimeout.waitUntilExit(process, timeout: processTimeout) {
|
|
642
|
+
+ throw failure(
|
|
643
|
+
+ .mayHaveCompleted,
|
|
644
|
+
+ "osascript timed out after \(Int(processTimeout)) seconds."
|
|
645
|
+
+ )
|
|
646
|
+
}
|
|
647
|
+
+ // `isRunning == false` is not the ownership boundary we promise here.
|
|
648
|
+
+ // Explicitly reap the child before any defer can unseal or remove payloads.
|
|
649
|
+
+ process.waitUntilExit()
|
|
650
|
+
try? stdoutHandle.synchronize()
|
|
651
|
+
if process.terminationReason == .uncaughtSignal || process.terminationStatus != 0 {
|
|
652
|
+
throw failure(
|
|
653
|
+
@@ -74,7 +98,16 @@ enum AppleScriptSendTransport {
|
|
654
|
+
"osascript exited without an authoritative delivery phase."
|
|
655
|
+
)
|
|
656
|
+
}
|
|
657
|
+
- let output = (try? String(contentsOf: stdoutURL, encoding: .utf8)) ?? ""
|
|
658
|
+
+ let output: String
|
|
659
|
+
+ do {
|
|
660
|
+
+ try stdoutHandle.seek(toOffset: 0)
|
|
661
|
+
+ output = String(data: try stdoutHandle.readToEnd() ?? Data(), encoding: .utf8) ?? ""
|
|
662
|
+
+ } catch {
|
|
663
|
+
+ throw failure(
|
|
664
|
+
+ .mayHaveCompleted,
|
|
665
|
+
+ "osascript exited but its structured delivery result could not be read."
|
|
666
|
+
+ )
|
|
667
|
+
+ }
|
|
668
|
+
switch interpret(output) {
|
|
669
|
+
case .success:
|
|
670
|
+
return
|
|
671
|
+
diff --git a/Sources/IMsgCore/MessageSender.swift b/Sources/IMsgCore/MessageSender.swift
|
|
672
|
+
index fe81a73..195311f 100644
|
|
673
|
+
--- a/Sources/IMsgCore/MessageSender.swift
|
|
674
|
+
+++ b/Sources/IMsgCore/MessageSender.swift
|
|
675
|
+
@@ -185,16 +185,22 @@ public struct MessageSender {
|
|
676
|
+
|
|
677
|
+
private func appleScript() -> String {
|
|
678
|
+
return """
|
|
679
|
+
+ on readPrivateField(payloadRoot, fieldName)
|
|
680
|
+
+ return read POSIX file (payloadRoot & "/" & fieldName) as «class utf8»
|
|
681
|
+
+ end readPrivateField
|
|
682
|
+
+
|
|
683
|
+
on run argv
|
|
684
|
+
set dispatchPhase to "pre_dispatch"
|
|
685
|
+
try
|
|
686
|
+
- set theRecipient to item 1 of argv
|
|
687
|
+
- set theMessage to item 2 of argv
|
|
688
|
+
- set theService to item 3 of argv
|
|
689
|
+
- set theFilePath to item 4 of argv
|
|
690
|
+
- set useAttachment to item 5 of argv
|
|
691
|
+
- set chatId to item 6 of argv
|
|
692
|
+
- set useChat to item 7 of argv
|
|
693
|
+
+ if (count of argv) is not 1 then error number -1701
|
|
694
|
+
+ set payloadRoot to item 1 of argv
|
|
695
|
+
+ set theRecipient to my readPrivateField(payloadRoot, "recipient")
|
|
696
|
+
+ set theMessage to my readPrivateField(payloadRoot, "text")
|
|
697
|
+
+ set theService to my readPrivateField(payloadRoot, "service")
|
|
698
|
+
+ set theFilePath to my readPrivateField(payloadRoot, "attachment-path")
|
|
699
|
+
+ set useAttachment to my readPrivateField(payloadRoot, "use-attachment")
|
|
700
|
+
+ set chatId to my readPrivateField(payloadRoot, "chat-target")
|
|
701
|
+
+ set useChat to my readPrivateField(payloadRoot, "use-chat")
|
|
702
|
+
|
|
703
|
+
tell application "Messages"
|
|
704
|
+
if useChat is "1" then
|
|
705
|
+
diff --git a/Tests/IMsgCoreTests/AppleScriptPrivatePayloadTestSupport.swift b/Tests/IMsgCoreTests/AppleScriptPrivatePayloadTestSupport.swift
|
|
706
|
+
new file mode 100644
|
|
707
|
+
index 0000000..1525212
|
|
708
|
+
--- /dev/null
|
|
709
|
+
+++ b/Tests/IMsgCoreTests/AppleScriptPrivatePayloadTestSupport.swift
|
|
710
|
+
@@ -0,0 +1,90 @@
|
|
711
|
+
+import Darwin
|
|
712
|
+
+import Foundation
|
|
713
|
+
+import Testing
|
|
714
|
+
+
|
|
715
|
+
+@testable import IMsgCore
|
|
716
|
+
+
|
|
717
|
+
+func deliveryFailure(_ operation: () throws -> Void) -> DeliveryFailure? {
|
|
718
|
+
+ do {
|
|
719
|
+
+ try operation()
|
|
720
|
+
+ return nil
|
|
721
|
+
+ } catch let failure as DeliveryFailure {
|
|
722
|
+
+ return failure
|
|
723
|
+
+ } catch {
|
|
724
|
+
+ return nil
|
|
725
|
+
+ }
|
|
726
|
+
+}
|
|
727
|
+
+
|
|
728
|
+
+func processHasExited(_ processID: pid_t) -> Bool {
|
|
729
|
+
+ let deadline = ContinuousClock().now + .seconds(2)
|
|
730
|
+
+ let clock = ContinuousClock()
|
|
731
|
+
+ while clock.now < deadline {
|
|
732
|
+
+ errno = 0
|
|
733
|
+
+ if kill(processID, 0) == -1, errno == ESRCH {
|
|
734
|
+
+ return true
|
|
735
|
+
+ }
|
|
736
|
+
+ Thread.sleep(forTimeInterval: 0.02)
|
|
737
|
+
+ }
|
|
738
|
+
+ return false
|
|
739
|
+
+}
|
|
740
|
+
+
|
|
741
|
+
+func waitForFile(_ url: URL, timeout: TimeInterval) -> Bool {
|
|
742
|
+
+ let clock = ContinuousClock()
|
|
743
|
+
+ let deadline = clock.now + .seconds(timeout)
|
|
744
|
+
+ while clock.now < deadline {
|
|
745
|
+
+ if FileManager.default.fileExists(atPath: url.path) { return true }
|
|
746
|
+
+ Thread.sleep(forTimeInterval: 0.01)
|
|
747
|
+
+ }
|
|
748
|
+
+ return false
|
|
749
|
+
+}
|
|
750
|
+
+
|
|
751
|
+
+enum PrivateTransportTestError: Error {
|
|
752
|
+
+ case helperDidNotStart
|
|
753
|
+
+ case fieldWriteFailed
|
|
754
|
+
+}
|
|
755
|
+
+
|
|
756
|
+
+final class PrivateTransportFixture {
|
|
757
|
+
+ let root: URL
|
|
758
|
+
+
|
|
759
|
+
+ init() throws {
|
|
760
|
+
+ root = FileManager.default.temporaryDirectory
|
|
761
|
+
+ .appendingPathComponent("imsg-private-transport-tests-\(UUID().uuidString)")
|
|
762
|
+
+ try FileManager.default.createDirectory(
|
|
763
|
+
+ at: root,
|
|
764
|
+
+ withIntermediateDirectories: false,
|
|
765
|
+
+ attributes: [.posixPermissions: 0o700]
|
|
766
|
+
+ )
|
|
767
|
+
+ }
|
|
768
|
+
+
|
|
769
|
+
+ func cleanup() {
|
|
770
|
+
+ try? FileManager.default.removeItem(at: root)
|
|
771
|
+
+ }
|
|
772
|
+
+
|
|
773
|
+
+ func executable(named name: String, source: String) throws -> URL {
|
|
774
|
+
+ let url = root.appendingPathComponent(name)
|
|
775
|
+
+ try source.write(to: url, atomically: false, encoding: .utf8)
|
|
776
|
+
+ try FileManager.default.setAttributes(
|
|
777
|
+
+ [.posixPermissions: 0o700],
|
|
778
|
+
+ ofItemAtPath: url.path
|
|
779
|
+
+ )
|
|
780
|
+
+ return url
|
|
781
|
+
+ }
|
|
782
|
+
+
|
|
783
|
+
+ func successHelper(launchMarker: URL? = nil) throws -> URL {
|
|
784
|
+
+ let marker = launchMarker.map { ": > '\($0.path)'" } ?? ":"
|
|
785
|
+
+ return try executable(
|
|
786
|
+
+ named: "success-\(UUID().uuidString)",
|
|
787
|
+
+ source: """
|
|
788
|
+
+ #!/bin/sh
|
|
789
|
+
+ \(marker)
|
|
790
|
+
+ cat >/dev/null
|
|
791
|
+
+ printf 'IMSG_RESULT\\tok\\tcompleted\\t0\\n'
|
|
792
|
+
+ """
|
|
793
|
+
+ )
|
|
794
|
+
+ }
|
|
795
|
+
+
|
|
796
|
+
+ func mode(of url: URL) throws -> Int {
|
|
797
|
+
+ let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
|
|
798
|
+
+ return try #require((attributes[.posixPermissions] as? NSNumber)?.intValue)
|
|
799
|
+
+ }
|
|
800
|
+
+}
|
|
801
|
+
diff --git a/Tests/IMsgCoreTests/AppleScriptPrivatePayloadTests.swift b/Tests/IMsgCoreTests/AppleScriptPrivatePayloadTests.swift
|
|
802
|
+
new file mode 100644
|
|
803
|
+
index 0000000..88f8657
|
|
804
|
+
--- /dev/null
|
|
805
|
+
+++ b/Tests/IMsgCoreTests/AppleScriptPrivatePayloadTests.swift
|
|
806
|
+
@@ -0,0 +1,455 @@
|
|
807
|
+
+import Darwin
|
|
808
|
+
+import Foundation
|
|
809
|
+
+import Testing
|
|
810
|
+
+
|
|
811
|
+
+@testable import IMsgCore
|
|
812
|
+
+
|
|
813
|
+
+private let privateSendArguments = [
|
|
814
|
+
+ "+15550001111-CANARY",
|
|
815
|
+
+ "private body CANARY two lines\nsecond line",
|
|
816
|
+
+ "imessage-CANARY",
|
|
817
|
+
+ "/private/tmp/private-attachment-CANARY",
|
|
818
|
+
+ "1",
|
|
819
|
+
+ "iMessage;+;private-chat-CANARY",
|
|
820
|
+
+ "1",
|
|
821
|
+
+]
|
|
822
|
+
+
|
|
823
|
+
+@Test
|
|
824
|
+
+func osascriptChildArgvContainsOnlyTheOpaquePayloadLocator() throws {
|
|
825
|
+
+ let fixture = try PrivateTransportFixture()
|
|
826
|
+
+ defer { fixture.cleanup() }
|
|
827
|
+
+ let argvCapture = fixture.root.appendingPathComponent("argv")
|
|
828
|
+
+ let helper = try fixture.executable(
|
|
829
|
+
+ named: "capture-argv",
|
|
830
|
+
+ source: """
|
|
831
|
+
+ #!/bin/sh
|
|
832
|
+
+ : > '\(argvCapture.path)'
|
|
833
|
+
+ for argument in "$@"; do
|
|
834
|
+
+ printf '%s\\n' "$argument" >> '\(argvCapture.path)'
|
|
835
|
+
+ done
|
|
836
|
+
+ cat >/dev/null
|
|
837
|
+
+ printf 'IMSG_RESULT\\tok\\tcompleted\\t0\\n'
|
|
838
|
+
+ """
|
|
839
|
+
+ )
|
|
840
|
+
+
|
|
841
|
+
+ var payloadRoot: URL?
|
|
842
|
+
+ try AppleScriptSendTransport.run(
|
|
843
|
+
+ source: "return \"unused\"",
|
|
844
|
+
+ arguments: privateSendArguments,
|
|
845
|
+
+ executableURL: helper,
|
|
846
|
+
+ processTimeout: 2,
|
|
847
|
+
+ beforeLaunch: { directory in
|
|
848
|
+
+ payloadRoot = directory
|
|
849
|
+
+ let directoryMode = try fixture.mode(of: directory)
|
|
850
|
+
+ #expect(directoryMode == 0o700)
|
|
851
|
+
+ for (name, expectedValue) in zip(
|
|
852
|
+
+ AppleScriptPrivatePayload.fieldNames,
|
|
853
|
+
+ privateSendArguments
|
|
854
|
+
+ ) {
|
|
855
|
+
+ let file = directory.appendingPathComponent(name)
|
|
856
|
+
+ #expect(try fixture.mode(of: file) == 0o600)
|
|
857
|
+
+ #expect(try String(contentsOf: file, encoding: .utf8) == expectedValue)
|
|
858
|
+
+ }
|
|
859
|
+
+ }
|
|
860
|
+
+ )
|
|
861
|
+
+
|
|
862
|
+
+ let argv = try String(contentsOf: argvCapture, encoding: .utf8)
|
|
863
|
+
+ .split(separator: "\n", omittingEmptySubsequences: false)
|
|
864
|
+
+ .dropLast()
|
|
865
|
+
+ .map(String.init)
|
|
866
|
+
+ #expect(argv.count == 4)
|
|
867
|
+
+ #expect(argv[0...2] == ["-l", "AppleScript", "-"])
|
|
868
|
+
+ let locator = argv[3]
|
|
869
|
+
+ #expect(locator.contains("imsg-private-send."))
|
|
870
|
+
+ for privateValue in privateSendArguments where !privateValue.isEmpty {
|
|
871
|
+
+ #expect(!argv.contains(privateValue))
|
|
872
|
+
+ }
|
|
873
|
+
+ let joined = argv.joined(separator: " ")
|
|
874
|
+
+ #expect(!joined.contains("CANARY"))
|
|
875
|
+
+ #expect(payloadRoot.map { !FileManager.default.fileExists(atPath: $0.path) } == true)
|
|
876
|
+
+}
|
|
877
|
+
+
|
|
878
|
+
+@Test
|
|
879
|
+
+func launchedPayloadCannotBeEditedOrReplacedByTheChild() throws {
|
|
880
|
+
+ let fixture = try PrivateTransportFixture()
|
|
881
|
+
+ defer { fixture.cleanup() }
|
|
882
|
+
+ let helper = try fixture.executable(
|
|
883
|
+
+ named: "try-to-replace",
|
|
884
|
+
+ source: """
|
|
885
|
+
+ #!/bin/sh
|
|
886
|
+
+ cat >/dev/null
|
|
887
|
+
+ if chmod 0644 "$4/text" 2>/dev/null; then exit 71; fi
|
|
888
|
+
+ if rm "$4/text" 2>/dev/null; then exit 72; fi
|
|
889
|
+
+ if printf 'changed' > "$4/text" 2>/dev/null; then exit 73; fi
|
|
890
|
+
+ printf 'IMSG_RESULT\\tok\\tcompleted\\t0\\n'
|
|
891
|
+
+ """
|
|
892
|
+
+ )
|
|
893
|
+
+ var payloadRoot: URL?
|
|
894
|
+
+
|
|
895
|
+
+ try AppleScriptSendTransport.run(
|
|
896
|
+
+ source: "return \"unused\"",
|
|
897
|
+
+ arguments: privateSendArguments,
|
|
898
|
+
+ executableURL: helper,
|
|
899
|
+
+ processTimeout: 2,
|
|
900
|
+
+ beforeLaunch: { payloadRoot = $0 }
|
|
901
|
+
+ )
|
|
902
|
+
+
|
|
903
|
+
+ #expect(payloadRoot.map { !FileManager.default.fileExists(atPath: $0.path) } == true)
|
|
904
|
+
+}
|
|
905
|
+
+
|
|
906
|
+
+@Test(arguments: ["text", "chat-target", "use-chat"])
|
|
907
|
+
+func privatePayloadRejectsSymlinkedFields(_ fieldName: String) throws {
|
|
908
|
+
+ let fixture = try PrivateTransportFixture()
|
|
909
|
+
+ defer { fixture.cleanup() }
|
|
910
|
+
+ let launchMarker = fixture.root.appendingPathComponent("launched")
|
|
911
|
+
+ let helper = try fixture.successHelper(launchMarker: launchMarker)
|
|
912
|
+
+ let external = fixture.root.appendingPathComponent("external")
|
|
913
|
+
+ try Data("outside".utf8).write(to: external)
|
|
914
|
+
+ var payloadRoot: URL?
|
|
915
|
+
+
|
|
916
|
+
+ let failure = try #require(
|
|
917
|
+
+ deliveryFailure {
|
|
918
|
+
+ try AppleScriptSendTransport.run(
|
|
919
|
+
+ source: "return \"unused\"",
|
|
920
|
+
+ arguments: privateSendArguments,
|
|
921
|
+
+ executableURL: helper,
|
|
922
|
+
+ processTimeout: 2,
|
|
923
|
+
+ beforeLaunch: { directory in
|
|
924
|
+
+ payloadRoot = directory
|
|
925
|
+
+ let field = directory.appendingPathComponent(fieldName)
|
|
926
|
+
+ try FileManager.default.removeItem(at: field)
|
|
927
|
+
+ try FileManager.default.createSymbolicLink(
|
|
928
|
+
+ at: field,
|
|
929
|
+
+ withDestinationURL: external
|
|
930
|
+
+ )
|
|
931
|
+
+ }
|
|
932
|
+
+ )
|
|
933
|
+
+ }
|
|
934
|
+
+ )
|
|
935
|
+
+ #expect(failure.disposition == .notStarted)
|
|
936
|
+
+ #expect(failure.retrySafe)
|
|
937
|
+
+ #expect(!FileManager.default.fileExists(atPath: launchMarker.path))
|
|
938
|
+
+ #expect(payloadRoot.map { !FileManager.default.fileExists(atPath: $0.path) } == true)
|
|
939
|
+
+ #expect(try String(contentsOf: external, encoding: .utf8) == "outside")
|
|
940
|
+
+}
|
|
941
|
+
+
|
|
942
|
+
+@Test
|
|
943
|
+
+func privatePayloadRejectsSameModeSameSizeFileSubstitution() throws {
|
|
944
|
+
+ let fixture = try PrivateTransportFixture()
|
|
945
|
+
+ defer { fixture.cleanup() }
|
|
946
|
+
+ let launchMarker = fixture.root.appendingPathComponent("launched")
|
|
947
|
+
+ let helper = try fixture.successHelper(launchMarker: launchMarker)
|
|
948
|
+
+ var payloadRoot: URL?
|
|
949
|
+
+
|
|
950
|
+
+ let failure = try #require(
|
|
951
|
+
+ deliveryFailure {
|
|
952
|
+
+ try AppleScriptSendTransport.run(
|
|
953
|
+
+ source: "return \"unused\"",
|
|
954
|
+
+ arguments: privateSendArguments,
|
|
955
|
+
+ executableURL: helper,
|
|
956
|
+
+ processTimeout: 2,
|
|
957
|
+
+ beforeLaunch: { directory in
|
|
958
|
+
+ payloadRoot = directory
|
|
959
|
+
+ let field = directory.appendingPathComponent("text")
|
|
960
|
+
+ let size = try Data(contentsOf: field).count
|
|
961
|
+
+ try FileManager.default.removeItem(at: field)
|
|
962
|
+
+ try Data(repeating: UInt8(ascii: "x"), count: size).write(to: field)
|
|
963
|
+
+ try FileManager.default.setAttributes(
|
|
964
|
+
+ [.posixPermissions: 0o600],
|
|
965
|
+
+ ofItemAtPath: field.path
|
|
966
|
+
+ )
|
|
967
|
+
+ }
|
|
968
|
+
+ )
|
|
969
|
+
+ }
|
|
970
|
+
+ )
|
|
971
|
+
+ #expect(failure.disposition == .notStarted)
|
|
972
|
+
+ #expect(!FileManager.default.fileExists(atPath: launchMarker.path))
|
|
973
|
+
+ #expect(payloadRoot.map { !FileManager.default.fileExists(atPath: $0.path) } == true)
|
|
974
|
+
+}
|
|
975
|
+
+
|
|
976
|
+
+@Test
|
|
977
|
+
+func privatePayloadRejectsSameInodeSameSizeContentChanges() throws {
|
|
978
|
+
+ let fixture = try PrivateTransportFixture()
|
|
979
|
+
+ defer { fixture.cleanup() }
|
|
980
|
+
+ let launchMarker = fixture.root.appendingPathComponent("launched")
|
|
981
|
+
+ let helper = try fixture.successHelper(launchMarker: launchMarker)
|
|
982
|
+
+ var payloadRoot: URL?
|
|
983
|
+
+
|
|
984
|
+
+ let failure = try #require(
|
|
985
|
+
+ deliveryFailure {
|
|
986
|
+
+ try AppleScriptSendTransport.run(
|
|
987
|
+
+ source: "return \"unused\"",
|
|
988
|
+
+ arguments: privateSendArguments,
|
|
989
|
+
+ executableURL: helper,
|
|
990
|
+
+ processTimeout: 2,
|
|
991
|
+
+ beforeLaunch: { directory in
|
|
992
|
+
+ payloadRoot = directory
|
|
993
|
+
+ let field = directory.appendingPathComponent("text")
|
|
994
|
+
+ let original = try Data(contentsOf: field)
|
|
995
|
+
+ let descriptor = open(field.path, O_WRONLY | O_CLOEXEC)
|
|
996
|
+
+ #expect(descriptor >= 0)
|
|
997
|
+
+ defer { close(descriptor) }
|
|
998
|
+
+ let replacement = Data(repeating: UInt8(ascii: "x"), count: original.count)
|
|
999
|
+
+ try replacement.withUnsafeBytes { bytes in
|
|
1000
|
+
+ guard Darwin.write(descriptor, bytes.baseAddress, bytes.count) == bytes.count else {
|
|
1001
|
+
+ throw PrivateTransportTestError.fieldWriteFailed
|
|
1002
|
+
+ }
|
|
1003
|
+
+ }
|
|
1004
|
+
+ #expect(try Data(contentsOf: field).count == original.count)
|
|
1005
|
+
+ #expect(try fixture.mode(of: field) == 0o600)
|
|
1006
|
+
+ }
|
|
1007
|
+
+ )
|
|
1008
|
+
+ }
|
|
1009
|
+
+ )
|
|
1010
|
+
+ #expect(failure.disposition == .notStarted)
|
|
1011
|
+
+ #expect(!FileManager.default.fileExists(atPath: launchMarker.path))
|
|
1012
|
+
+ #expect(payloadRoot.map { !FileManager.default.fileExists(atPath: $0.path) } == true)
|
|
1013
|
+
+}
|
|
1014
|
+
+
|
|
1015
|
+
+@Test
|
|
1016
|
+
+func privatePayloadRejectsRootSymlinkSubstitution() throws {
|
|
1017
|
+
+ let fixture = try PrivateTransportFixture()
|
|
1018
|
+
+ defer { fixture.cleanup() }
|
|
1019
|
+
+ let launchMarker = fixture.root.appendingPathComponent("launched")
|
|
1020
|
+
+ let helper = try fixture.successHelper(launchMarker: launchMarker)
|
|
1021
|
+
+ let movedRoot = fixture.root.appendingPathComponent("moved-payload")
|
|
1022
|
+
+ var originalRoot: URL?
|
|
1023
|
+
+ defer {
|
|
1024
|
+
+ if let originalRoot {
|
|
1025
|
+
+ try? FileManager.default.removeItem(at: originalRoot)
|
|
1026
|
+
+ }
|
|
1027
|
+
+ try? FileManager.default.removeItem(at: movedRoot)
|
|
1028
|
+
+ }
|
|
1029
|
+
+
|
|
1030
|
+
+ let failure = try #require(
|
|
1031
|
+
+ deliveryFailure {
|
|
1032
|
+
+ try AppleScriptSendTransport.run(
|
|
1033
|
+
+ source: "return \"unused\"",
|
|
1034
|
+
+ arguments: privateSendArguments,
|
|
1035
|
+
+ executableURL: helper,
|
|
1036
|
+
+ processTimeout: 2,
|
|
1037
|
+
+ beforeLaunch: { directory in
|
|
1038
|
+
+ originalRoot = directory
|
|
1039
|
+
+ try FileManager.default.moveItem(at: directory, to: movedRoot)
|
|
1040
|
+
+ try FileManager.default.createSymbolicLink(
|
|
1041
|
+
+ at: directory,
|
|
1042
|
+
+ withDestinationURL: movedRoot
|
|
1043
|
+
+ )
|
|
1044
|
+
+ }
|
|
1045
|
+
+ )
|
|
1046
|
+
+ }
|
|
1047
|
+
+ )
|
|
1048
|
+
+ #expect(failure.disposition == .notStarted)
|
|
1049
|
+
+ #expect(!FileManager.default.fileExists(atPath: launchMarker.path))
|
|
1050
|
+
+ #expect(FileManager.default.fileExists(atPath: movedRoot.path))
|
|
1051
|
+
+ for name in AppleScriptPrivatePayload.fieldNames {
|
|
1052
|
+
+ #expect(!FileManager.default.fileExists(atPath: movedRoot.appendingPathComponent(name).path))
|
|
1053
|
+
+ }
|
|
1054
|
+
+}
|
|
1055
|
+
+
|
|
1056
|
+
+@Test(arguments: [0o400, 0o644])
|
|
1057
|
+
+func privatePayloadRejectsFieldModeChanges(_ changedMode: Int) throws {
|
|
1058
|
+
+ let fixture = try PrivateTransportFixture()
|
|
1059
|
+
+ defer { fixture.cleanup() }
|
|
1060
|
+
+ let helper = try fixture.successHelper()
|
|
1061
|
+
+ var payloadRoot: URL?
|
|
1062
|
+
+
|
|
1063
|
+
+ let failure = try #require(
|
|
1064
|
+
+ deliveryFailure {
|
|
1065
|
+
+ try AppleScriptSendTransport.run(
|
|
1066
|
+
+ source: "return \"unused\"",
|
|
1067
|
+
+ arguments: privateSendArguments,
|
|
1068
|
+
+ executableURL: helper,
|
|
1069
|
+
+ processTimeout: 2,
|
|
1070
|
+
+ beforeLaunch: { directory in
|
|
1071
|
+
+ payloadRoot = directory
|
|
1072
|
+
+ try FileManager.default.setAttributes(
|
|
1073
|
+
+ [.posixPermissions: changedMode],
|
|
1074
|
+
+ ofItemAtPath: directory.appendingPathComponent("recipient").path
|
|
1075
|
+
+ )
|
|
1076
|
+
+ }
|
|
1077
|
+
+ )
|
|
1078
|
+
+ }
|
|
1079
|
+
+ )
|
|
1080
|
+
+ #expect(failure.disposition == .notStarted)
|
|
1081
|
+
+ #expect(payloadRoot.map { !FileManager.default.fileExists(atPath: $0.path) } == true)
|
|
1082
|
+
+}
|
|
1083
|
+
+
|
|
1084
|
+
+@Test
|
|
1085
|
+
+func privatePayloadRejectsDirectoryModeChangesAndCleansUp() throws {
|
|
1086
|
+
+ let fixture = try PrivateTransportFixture()
|
|
1087
|
+
+ defer { fixture.cleanup() }
|
|
1088
|
+
+ let helper = try fixture.successHelper()
|
|
1089
|
+
+ var payloadRoot: URL?
|
|
1090
|
+
+
|
|
1091
|
+
+ let failure = try #require(
|
|
1092
|
+
+ deliveryFailure {
|
|
1093
|
+
+ try AppleScriptSendTransport.run(
|
|
1094
|
+
+ source: "return \"unused\"",
|
|
1095
|
+
+ arguments: privateSendArguments,
|
|
1096
|
+
+ executableURL: helper,
|
|
1097
|
+
+ processTimeout: 2,
|
|
1098
|
+
+ beforeLaunch: { directory in
|
|
1099
|
+
+ payloadRoot = directory
|
|
1100
|
+
+ try FileManager.default.setAttributes(
|
|
1101
|
+
+ [.posixPermissions: 0o755],
|
|
1102
|
+
+ ofItemAtPath: directory.path
|
|
1103
|
+
+ )
|
|
1104
|
+
+ }
|
|
1105
|
+
+ )
|
|
1106
|
+
+ }
|
|
1107
|
+
+ )
|
|
1108
|
+
+ #expect(failure.disposition == .notStarted)
|
|
1109
|
+
+ #expect(payloadRoot.map { !FileManager.default.fileExists(atPath: $0.path) } == true)
|
|
1110
|
+
+}
|
|
1111
|
+
+
|
|
1112
|
+
+@Test
|
|
1113
|
+
+func privatePayloadRejectsUnexpectedOwnerBeforeLaunch() throws {
|
|
1114
|
+
+ let fixture = try PrivateTransportFixture()
|
|
1115
|
+
+ defer { fixture.cleanup() }
|
|
1116
|
+
+ let launchMarker = fixture.root.appendingPathComponent("launched")
|
|
1117
|
+
+ let helper = try fixture.successHelper(launchMarker: launchMarker)
|
|
1118
|
+
+
|
|
1119
|
+
+ let failure = try #require(
|
|
1120
|
+
+ deliveryFailure {
|
|
1121
|
+
+ try AppleScriptSendTransport.run(
|
|
1122
|
+
+ source: "return \"unused\"",
|
|
1123
|
+
+ arguments: privateSendArguments,
|
|
1124
|
+
+ executableURL: helper,
|
|
1125
|
+
+ processTimeout: 2,
|
|
1126
|
+
+ expectedOwner: geteuid() &+ 1
|
|
1127
|
+
+ )
|
|
1128
|
+
+ }
|
|
1129
|
+
+ )
|
|
1130
|
+
+ #expect(failure.disposition == .notStarted)
|
|
1131
|
+
+ #expect(!FileManager.default.fileExists(atPath: launchMarker.path))
|
|
1132
|
+
+}
|
|
1133
|
+
+
|
|
1134
|
+
+@Test
|
|
1135
|
+
+func crashedChildIsReapedAndPrivatePayloadIsRemoved() throws {
|
|
1136
|
+
+ let fixture = try PrivateTransportFixture()
|
|
1137
|
+
+ defer { fixture.cleanup() }
|
|
1138
|
+
+ let pidFile = fixture.root.appendingPathComponent("pid")
|
|
1139
|
+
+ let helper = try fixture.executable(
|
|
1140
|
+
+ named: "crash",
|
|
1141
|
+
+ source: """
|
|
1142
|
+
+ #!/bin/sh
|
|
1143
|
+
+ printf '%s' "$$" > '\(pidFile.path)'
|
|
1144
|
+
+ cat >/dev/null
|
|
1145
|
+
+ kill -SEGV "$$"
|
|
1146
|
+
+ """
|
|
1147
|
+
+ )
|
|
1148
|
+
+ var payloadRoot: URL?
|
|
1149
|
+
+
|
|
1150
|
+
+ let failure = try #require(
|
|
1151
|
+
+ deliveryFailure {
|
|
1152
|
+
+ try AppleScriptSendTransport.run(
|
|
1153
|
+
+ source: "return \"unused\"",
|
|
1154
|
+
+ arguments: privateSendArguments,
|
|
1155
|
+
+ executableURL: helper,
|
|
1156
|
+
+ processTimeout: 2,
|
|
1157
|
+
+ beforeLaunch: { payloadRoot = $0 }
|
|
1158
|
+
+ )
|
|
1159
|
+
+ }
|
|
1160
|
+
+ )
|
|
1161
|
+
+ #expect(failure.disposition == .mayHaveCompleted)
|
|
1162
|
+
+ let pid = try #require(pid_t(String(contentsOf: pidFile, encoding: .utf8)))
|
|
1163
|
+
+ #expect(processHasExited(pid))
|
|
1164
|
+
+ #expect(payloadRoot.map { !FileManager.default.fileExists(atPath: $0.path) } == true)
|
|
1165
|
+
+}
|
|
1166
|
+
+
|
|
1167
|
+
+@Test
|
|
1168
|
+
+func timedOutChildIsReapedBeforePrivatePayloadCleanup() throws {
|
|
1169
|
+
+ let fixture = try PrivateTransportFixture()
|
|
1170
|
+
+ defer { fixture.cleanup() }
|
|
1171
|
+
+ let pidFile = fixture.root.appendingPathComponent("pid")
|
|
1172
|
+
+ let exitMarker = fixture.root.appendingPathComponent("payload-existed-at-exit")
|
|
1173
|
+
+ let helper = try fixture.executable(
|
|
1174
|
+
+ named: "hang",
|
|
1175
|
+
+ source: """
|
|
1176
|
+
+ #!/bin/sh
|
|
1177
|
+
+ printf '%s' "$$" > '\(pidFile.path)'
|
|
1178
|
+
+ trap 'test -d "$4" && : > "\(exitMarker.path)"; exit 0' TERM
|
|
1179
|
+
+ cat >/dev/null
|
|
1180
|
+
+ while :; do :; done
|
|
1181
|
+
+ """
|
|
1182
|
+
+ )
|
|
1183
|
+
+ var payloadRoot: URL?
|
|
1184
|
+
+
|
|
1185
|
+
+ let failure = try #require(
|
|
1186
|
+
+ deliveryFailure {
|
|
1187
|
+
+ try AppleScriptSendTransport.run(
|
|
1188
|
+
+ source: "return \"unused\"",
|
|
1189
|
+
+ arguments: privateSendArguments,
|
|
1190
|
+
+ executableURL: helper,
|
|
1191
|
+
+ processTimeout: 0.2,
|
|
1192
|
+
+ beforeLaunch: { payloadRoot = $0 },
|
|
1193
|
+
+ afterLaunch: {
|
|
1194
|
+
+ guard waitForFile(pidFile, timeout: 5) else {
|
|
1195
|
+
+ throw PrivateTransportTestError.helperDidNotStart
|
|
1196
|
+
+ }
|
|
1197
|
+
+ }
|
|
1198
|
+
+ )
|
|
1199
|
+
+ }
|
|
1200
|
+
+ )
|
|
1201
|
+
+ #expect(failure.disposition == .mayHaveCompleted)
|
|
1202
|
+
+ #expect(!failure.retrySafe)
|
|
1203
|
+
+ let pid = try #require(pid_t(String(contentsOf: pidFile, encoding: .utf8)))
|
|
1204
|
+
+ #expect(processHasExited(pid))
|
|
1205
|
+
+ #expect(FileManager.default.fileExists(atPath: exitMarker.path))
|
|
1206
|
+
+ #expect(payloadRoot.map { !FileManager.default.fileExists(atPath: $0.path) } == true)
|
|
1207
|
+
+}
|
|
1208
|
+
+
|
|
1209
|
+
+@Test
|
|
1210
|
+
+func productionAppleScriptReadsEveryPrivateFieldFromTheLocator() throws {
|
|
1211
|
+
+ var source = ""
|
|
1212
|
+
+ let sender = MessageSender(runner: { script, _ in source = script })
|
|
1213
|
+
+ try sender.send(
|
|
1214
|
+
+ MessageSendOptions(
|
|
1215
|
+
+ recipient: "+15550001111",
|
|
1216
|
+
+ text: "hello",
|
|
1217
|
+
+ service: .imessage,
|
|
1218
|
+
+ chatGUID: "iMessage;+;opaque-chat",
|
|
1219
|
+
+ allowSMSFallback: false
|
|
1220
|
+
+ )
|
|
1221
|
+
+ )
|
|
1222
|
+
+
|
|
1223
|
+
+ #expect(source.contains("if (count of argv) is not 1"))
|
|
1224
|
+
+ #expect(source.contains("set payloadRoot to item 1 of argv"))
|
|
1225
|
+
+ for fieldName in AppleScriptPrivatePayload.fieldNames {
|
|
1226
|
+
+ #expect(source.contains("readPrivateField(payloadRoot, \"\(fieldName)\")"))
|
|
1227
|
+
+ }
|
|
1228
|
+
+ #expect(!source.contains("item 2 of argv"))
|
|
1229
|
+
+ #expect(!source.contains("item 7 of argv"))
|
|
1230
|
+
+}
|
|
1231
|
+
+
|
|
1232
|
+
+@Test
|
|
1233
|
+
+func productionAppleScriptCompilesWithoutExecutingIt() throws {
|
|
1234
|
+
+ let fixture = try PrivateTransportFixture()
|
|
1235
|
+
+ defer { fixture.cleanup() }
|
|
1236
|
+
+ var source = ""
|
|
1237
|
+
+ let sender = MessageSender(runner: { script, _ in source = script })
|
|
1238
|
+
+ try sender.send(
|
|
1239
|
+
+ MessageSendOptions(
|
|
1240
|
+
+ recipient: "+15550001111",
|
|
1241
|
+
+ text: "hello",
|
|
1242
|
+
+ service: .imessage,
|
|
1243
|
+
+ allowSMSFallback: false
|
|
1244
|
+
+ )
|
|
1245
|
+
+ )
|
|
1246
|
+
+
|
|
1247
|
+
+ let compiled = fixture.root.appendingPathComponent("send.scpt")
|
|
1248
|
+
+ let process = Process()
|
|
1249
|
+
+ let input = Pipe()
|
|
1250
|
+
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/osacompile")
|
|
1251
|
+
+ process.arguments = ["-l", "AppleScript", "-o", compiled.path]
|
|
1252
|
+
+ process.standardInput = input
|
|
1253
|
+
+ process.standardOutput = FileHandle(forWritingAtPath: "/dev/null")
|
|
1254
|
+
+ process.standardError = FileHandle(forWritingAtPath: "/dev/null")
|
|
1255
|
+
+ try process.run()
|
|
1256
|
+
+ try input.fileHandleForWriting.write(contentsOf: Data(source.utf8))
|
|
1257
|
+
+ try input.fileHandleForWriting.close()
|
|
1258
|
+
+ #expect(!ProcessTimeout.waitUntilExit(process, timeout: 5))
|
|
1259
|
+
+ #expect(process.terminationStatus == 0)
|
|
1260
|
+
+ #expect(FileManager.default.fileExists(atPath: compiled.path))
|
|
1261
|
+
+}
|
|
1262
|
+
diff --git a/docs/private-applescript-transport.md b/docs/private-applescript-transport.md
|
|
1263
|
+
new file mode 100644
|
|
1264
|
+
index 0000000..20cac67
|
|
1265
|
+
--- /dev/null
|
|
1266
|
+
+++ b/docs/private-applescript-transport.md
|
|
1267
|
+
@@ -0,0 +1,107 @@
|
|
1268
|
+
+---
|
|
1269
|
+
+title: Private AppleScript transport payload
|
|
1270
|
+
+description: "Security boundary, provenance, evidence, and qualification limits for AppleScript sends."
|
|
1271
|
+
+---
|
|
1272
|
+
+
|
|
1273
|
+
+## Provenance
|
|
1274
|
+
+
|
|
1275
|
+
+This transport change is based narrowly on
|
|
1276
|
+
+[`openclaw/imsg` v0.14.1 at `25beb76c902b0acf2dd7ae392f1b0792f6813240`](https://github.com/openclaw/imsg/tree/25beb76c902b0acf2dd7ae392f1b0792f6813240).
|
|
1277
|
+
+It changes only the published AppleScript send path, its tests, and the
|
|
1278
|
+
+documentation of that path. It does not change bridge sends or provider
|
|
1279
|
+
+routing.
|
|
1280
|
+
+
|
|
1281
|
+
+## Security boundary
|
|
1282
|
+
+
|
|
1283
|
+
+The AppleScript source remains on the child's standard input. The nested
|
|
1284
|
+
+`osascript` process receives four arguments: `-l`, `AppleScript`, `-`, and one
|
|
1285
|
+
+random payload-directory locator. Recipient, text, service, attachment path,
|
|
1286
|
+
+attachment flag, chat target, and chat flag are stored separately in that
|
|
1287
|
+
+directory.
|
|
1288
|
+
+
|
|
1289
|
+
+The parent creates the directory atomically with mode `0700`. It creates every
|
|
1290
|
+
+field with `openat`, `O_EXCL`, and `O_NOFOLLOW`, then requires a regular,
|
|
1291
|
+
+single-link, exact-size file owned by the effective user with mode `0600`.
|
|
1292
|
+
+Immediately before launch it reopens the directory and every field, compares
|
|
1293
|
+
+device and inode identities with retained descriptors, compares every field's
|
|
1294
|
+
+contents with the staged value, and rejects symlinks, replacement, in-place
|
|
1295
|
+
+edits, hard links, ownership changes, and mode changes. On macOS it then applies
|
|
1296
|
+
+the user-immutable flag to the fields and directory for the child lifetime. The
|
|
1297
|
+
+parent retains descriptors, reaps the child, clears the flags, unlinks only
|
|
1298
|
+
+known entries through the retained directory descriptor, and removes the exact
|
|
1299
|
+
+directory it created.
|
|
1300
|
+
+
|
|
1301
|
+
+This design reduces exposure through process arguments and ordinary diagnostic
|
|
1302
|
+
+records. It is not process isolation from malicious software already running as
|
|
1303
|
+
+the same macOS user. Such a process shares owner access to `0600` files and can
|
|
1304
|
+
+deliberately clear user-immutable flags. That stronger threat model requires a
|
|
1305
|
+
+separate privilege or sandbox boundary.
|
|
1306
|
+
+
|
|
1307
|
+
+The transport preserves the existing delivery-state boundary:
|
|
1308
|
+
+
|
|
1309
|
+
+- A payload or launch check that fails before `Process.run()` is `not_started`.
|
|
1310
|
+
+- Lost input after launch, timeout, signal, nonzero exit, malformed output, or
|
|
1311
|
+
+ unreadable output is `may_have_completed`.
|
|
1312
|
+
+- It does not infer a retry, add a retry, or convert uncertainty into success.
|
|
1313
|
+
+
|
|
1314
|
+
+SMS fallback remains available to existing callers only for the already
|
|
1315
|
+
+qualified `not_started` case. A caller that requires one iMessage attempt can
|
|
1316
|
+
+set `transport: "applescript"`, `service: "imessage"`, and
|
|
1317
|
+
+`allow_sms_fallback: false`.
|
|
1318
|
+
+
|
|
1319
|
+
+## Receipt limit
|
|
1320
|
+
+
|
|
1321
|
+
+Messages' AppleScript `send` command does not return a message GUID. Its
|
|
1322
|
+
+structured `completed` result proves only that the automation call returned.
|
|
1323
|
+
+The CLI and RPC server obtain any outgoing GUID by separately matching a new
|
|
1324
|
+
+`chat.db` row by chat, text, and a bounded time window. No matching row is
|
|
1325
|
+
+`may_have_completed`; it is not proof that nothing was sent. An unknown GUID
|
|
1326
|
+
+reported as pending by `message.send_status` is also not proof of submission.
|
|
1327
|
+
+
|
|
1328
|
+
+For a caller that launches one fresh supervised `imsg rpc` process per visible
|
|
1329
|
+
+message part, the safe topology is one request, explicit AppleScript transport,
|
|
1330
|
+
+SMS fallback disabled, stdin closed after the request, and the process awaited
|
|
1331
|
+
+through response and exit. This repository's RPC server still supports many
|
|
1332
|
+
+requests per process. The transport patch does not enforce the one-request
|
|
1333
|
+
+topology.
|
|
1334
|
+
+
|
|
1335
|
+
+## Deterministic evidence
|
|
1336
|
+
+
|
|
1337
|
+
+`AppleScriptPrivatePayloadTests` verifies:
|
|
1338
|
+
+
|
|
1339
|
+
+- nested child argv contains no recipient, body, service, attachment path,
|
|
1340
|
+
+ chat target, or flag canary;
|
|
1341
|
+
+- the payload directory and files have exact modes and contents;
|
|
1342
|
+
+- symlink, root replacement, field replacement, in-place field editing, owner
|
|
1343
|
+
+ mismatch, and mode changes fail before child launch;
|
|
1344
|
+
+- immutable sealing blocks child-side edit, unlink, and replacement;
|
|
1345
|
+
+- crash and timeout paths reap the child and remove the payload;
|
|
1346
|
+
+- timeout cleanup begins only after the child's exit handler observes the
|
|
1347
|
+
+ payload still present;
|
|
1348
|
+
+- the complete production AppleScript compiles without executing it.
|
|
1349
|
+
+
|
|
1350
|
+
+Existing delivery, SMS-fallback, RPC fallback-control, queue-state, and
|
|
1351
|
+
+outgoing-row verification tests continue to cover the unchanged typed outcome
|
|
1352
|
+
+contract.
|
|
1353
|
+
+
|
|
1354
|
+
+## Production qualification still required
|
|
1355
|
+
+
|
|
1356
|
+
+This local patch is not a distributable provider by itself. Production use
|
|
1357
|
+
+still requires:
|
|
1358
|
+
+
|
|
1359
|
+
+1. upstream or maintained-fork review of the exact diff;
|
|
1360
|
+
+2. a pinned release build with checksum, signing, notarization, and installer
|
|
1361
|
+
+ attestation;
|
|
1362
|
+
+3. macOS CI for the full test suite and Linux CI for the read-only build;
|
|
1363
|
+
+4. argv inspection against the signed release binary, including the nested
|
|
1364
|
+
+ `osascript` child;
|
|
1365
|
+
+5. a separately authorized self-chat test for Automation permission, exact
|
|
1366
|
+
+ chat routing, outgoing-row/GUID reconciliation, timeout classification, and
|
|
1367
|
+
+ no SMS fallback;
|
|
1368
|
+
+6. caller integration that launches one supervised RPC process per visible
|
|
1369
|
+
+ message part and never retries `may_have_completed` or `still_in_flight`;
|
|
1370
|
+
+7. an operational rule that treats a missing or ambiguous outgoing row as
|
|
1371
|
+
+ unresolved rather than delivered or absent.
|
|
1372
|
+
+
|
|
1373
|
+
+No live message send is part of the deterministic test suite or this
|
|
1374
|
+
+prototype's qualification evidence.
|
|
1375
|
+
diff --git a/docs/rpc.md b/docs/rpc.md
|
|
1376
|
+
index 94cdc43..c788989 100644
|
|
1377
|
+
--- a/docs/rpc.md
|
|
1378
|
+
+++ b/docs/rpc.md
|
|
1379
|
+
@@ -754,6 +754,12 @@ Send and receive verification:
|
|
1380
|
+
{"jsonrpc":"2.0","id":"3","result":{"ok":true,"transport":"applescript","id":1979,"guid":"8DF..."}}
|
|
1381
|
+
```
|
|
1382
|
+
|
|
1383
|
+
+Require the published AppleScript surface without an automatic SMS retry:
|
|
1384
|
+
+
|
|
1385
|
+
+```json
|
|
1386
|
+
+{"jsonrpc":"2.0","id":"4","method":"send","params":{"chat_guid":"iMessage;+;chat123","text":"hi","service":"imessage","transport":"applescript","allow_sms_fallback":false}}
|
|
1387
|
+
+```
|
|
1388
|
+
+
|
|
1389
|
+
`send` accepts `transport: "auto" | "bridge" | "applescript"`. `auto`
|
|
1390
|
+
uses the IMCore bridge for existing chats when it is running. It falls back to
|
|
1391
|
+
AppleScript only when the bridge is not ready or returns authoritative
|
|
1392
|
+
@@ -761,3 +767,11 @@ AppleScript only when the bridge is not ready or returns authoritative
|
|
1393
|
+
malformed response, or other uncertain post-publication failure never falls
|
|
1394
|
+
back. Use `bridge` when the caller requires private-API delivery and should
|
|
1395
|
+
fail instead of falling back. Replies remain bridge-only.
|
|
1396
|
+
+
|
|
1397
|
+
+For AppleScript sends, the nested `osascript` argv contains only fixed
|
|
1398
|
+
+interpreter switches and a random private-payload locator. Recipient, body,
|
|
1399
|
+
+service, attachment path, chat target, and routing flags are owner-only files
|
|
1400
|
+
+sealed for the child lifetime. AppleScript does not return a message GUID. Any
|
|
1401
|
+
+`guid` in the response came from the independently matched outgoing database
|
|
1402
|
+
+row; failure to observe that row is `may_have_completed`, never a retry-safe
|
|
1403
|
+
+absence.
|
|
1404
|
+
diff --git a/docs/send.md b/docs/send.md
|
|
1405
|
+
index ba2403d..b3d40ce 100644
|
|
1406
|
+
--- a/docs/send.md
|
|
1407
|
+
+++ b/docs/send.md
|
|
1408
|
+
@@ -3,7 +3,7 @@ title: Send
|
|
1409
|
+
description: "Send text and files to direct chats and groups through Messages.app automation, plus standard tapbacks."
|
|
1410
|
+
---
|
|
1411
|
+
|
|
1412
|
+
-`imsg send` rides Messages' published AppleScript surface — no private send APIs, no IMCore injection. Production sends run in a bounded `/usr/bin/osascript` child so a stuck Messages automation call can be terminated and reaped. Sending requires Automation permission for Messages (see [Permissions](permissions.md)).
|
|
1413
|
+
+`imsg send` rides Messages' published AppleScript surface — no private send APIs, no IMCore injection. Production sends run in a bounded `/usr/bin/osascript` child so a stuck Messages automation call can be terminated and reaped. The recipient, body, service, attachment path, chat target, and routing flags never enter that child's process arguments. They live in checked `0600` files under one random `0700` directory, whose namespace stays immutable until the child exits. Sending requires Automation permission for Messages (see [Permissions](permissions.md)).
|
|
1414
|
+
|
|
1415
|
+
## Direct sends
|
|
1416
|
+
|
|
1417
|
+
@@ -99,6 +99,13 @@ verification is unchanged.
|
|
1418
|
+
|
|
1419
|
+
The [JSON-RPC `send` method](rpc.md#send) includes the rowid and GUID of the inserted message when available. RPC `send` also accepts `transport` (`auto`, `bridge`, or `applescript`) for callers that want to prefer or require the IMCore bridge.
|
|
1420
|
+
|
|
1421
|
+
+AppleScript's `send` command does not return a message GUID. A GUID reported by
|
|
1422
|
+
+`imsg` is evidence from the separately observed outgoing `chat.db` row, not an
|
|
1423
|
+
+AppleScript receipt. If the row cannot be matched, a text send reports
|
|
1424
|
+
+`may_have_completed` and must not be retried automatically. `message.send_status`
|
|
1425
|
+
+can inspect a GUID only after some transport or database observation supplied
|
|
1426
|
+
+that exact GUID.
|
|
1427
|
+
+
|
|
1428
|
+
## Tahoe ghost-row protection
|
|
1429
|
+
|
|
1430
|
+
On macOS 26 (Tahoe), Messages.app has a failure mode where AppleScript reports success but writes an empty outgoing SMS row that isn't joined to the target chat. The send looks fine to the caller but never reaches the recipient.
|