@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,2530 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
lstatSync,
|
|
5
|
+
type BigIntStats,
|
|
6
|
+
} from "node:fs";
|
|
7
|
+
import { isIP } from "node:net";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import ts from "typescript";
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
ArrayInputField,
|
|
14
|
+
FileInputField,
|
|
15
|
+
IdempotencyKind,
|
|
16
|
+
InputField,
|
|
17
|
+
InputSchema,
|
|
18
|
+
OperationRisk,
|
|
19
|
+
ScalarInputField,
|
|
20
|
+
} from "./model";
|
|
21
|
+
import {
|
|
22
|
+
isPortableProviderPluginVersion,
|
|
23
|
+
} from "./provider-plugin-identifiers";
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
isPortableProviderPluginVersion,
|
|
27
|
+
} from "./provider-plugin-identifiers";
|
|
28
|
+
|
|
29
|
+
export const PORTABLE_PROVIDER_PLUGIN_MANIFEST_SCHEMA_VERSION = 1 as const;
|
|
30
|
+
export const PORTABLE_PROVIDER_PLUGIN_HOST_API_VERSION = 1 as const;
|
|
31
|
+
export const PORTABLE_PROVIDER_PLUGIN_MANIFEST_NAME = "ghostget-plugin.json";
|
|
32
|
+
// Historical verified bundles retain their exact manifest name and bytes.
|
|
33
|
+
export const WRENCH_PORTABLE_PROVIDER_PLUGIN_MANIFEST_NAME = "wrench-plugin.json";
|
|
34
|
+
export const LEGACY_PORTABLE_PROVIDER_PLUGIN_MANIFEST_NAME = "oh-plugin.json";
|
|
35
|
+
const portableProviderPluginManifestNames = new Set([
|
|
36
|
+
PORTABLE_PROVIDER_PLUGIN_MANIFEST_NAME,
|
|
37
|
+
WRENCH_PORTABLE_PROVIDER_PLUGIN_MANIFEST_NAME,
|
|
38
|
+
LEGACY_PORTABLE_PROVIDER_PLUGIN_MANIFEST_NAME,
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
export const portableProviderPluginTransports = [
|
|
42
|
+
"provider-api",
|
|
43
|
+
"web-session-api",
|
|
44
|
+
"linked-device",
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
export const portableProviderPluginAuthKinds = [
|
|
48
|
+
"cookie-source",
|
|
49
|
+
"cookies-file",
|
|
50
|
+
"browser-profile",
|
|
51
|
+
"oauth-token-file",
|
|
52
|
+
"linked-device-store",
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
/** Session materials implemented by the host API v1 credential kernel. */
|
|
56
|
+
export const portableProviderPluginSessionMaterialNames = [
|
|
57
|
+
"cookie-jar",
|
|
58
|
+
"oauth-access-token",
|
|
59
|
+
] as const;
|
|
60
|
+
|
|
61
|
+
export type PortableProviderPluginTransport =
|
|
62
|
+
(typeof portableProviderPluginTransports)[number];
|
|
63
|
+
export type PortableProviderPluginAuthKind =
|
|
64
|
+
(typeof portableProviderPluginAuthKinds)[number];
|
|
65
|
+
export type PortableProviderPluginSessionMaterialName =
|
|
66
|
+
(typeof portableProviderPluginSessionMaterialNames)[number];
|
|
67
|
+
|
|
68
|
+
type PortableProviderPluginOperationBaseV1 = {
|
|
69
|
+
readonly name: string;
|
|
70
|
+
readonly contractVersion: number;
|
|
71
|
+
readonly timeoutMs: number;
|
|
72
|
+
readonly maxOutputBytes: number;
|
|
73
|
+
readonly state: "observed" | "capture-required";
|
|
74
|
+
readonly risk: OperationRisk;
|
|
75
|
+
readonly dispatch: "none" | "single";
|
|
76
|
+
readonly sideEffect: string;
|
|
77
|
+
readonly idempotency: IdempotencyKind;
|
|
78
|
+
readonly dedupeWindowMs: number;
|
|
79
|
+
readonly input: InputSchema;
|
|
80
|
+
readonly implementation: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export type PortableProviderApiPluginOperationV1 =
|
|
84
|
+
PortableProviderPluginOperationBaseV1 & {
|
|
85
|
+
readonly requiredScopeSets: readonly (readonly string[])[];
|
|
86
|
+
readonly coverage: readonly string[];
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type PortableWebSessionPluginOperationV1 =
|
|
90
|
+
PortableProviderPluginOperationBaseV1 & {
|
|
91
|
+
readonly requiredScopeSets?: never;
|
|
92
|
+
readonly coverage?: never;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type PortableProviderPluginOperationV1 =
|
|
96
|
+
| PortableProviderApiPluginOperationV1
|
|
97
|
+
| PortableWebSessionPluginOperationV1;
|
|
98
|
+
|
|
99
|
+
type PortableProviderPluginBindingBaseV1 = {
|
|
100
|
+
/** Stable short-form CLI address owned by this exact plugin binding. */
|
|
101
|
+
readonly adapterId: string;
|
|
102
|
+
readonly surfaceId: string;
|
|
103
|
+
readonly origin: `https://${string}`;
|
|
104
|
+
readonly authKinds: readonly PortableProviderPluginAuthKind[];
|
|
105
|
+
readonly subject: {
|
|
106
|
+
readonly format: string;
|
|
107
|
+
readonly kind: "opaque-token" | "decimal" | "did" | "uuid" | "e164";
|
|
108
|
+
readonly probe: {
|
|
109
|
+
readonly operation: string;
|
|
110
|
+
readonly contractVersion: number;
|
|
111
|
+
} | null;
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export type PortableProviderApiPluginBindingV1 =
|
|
116
|
+
PortableProviderPluginBindingBaseV1 & {
|
|
117
|
+
readonly transport: "provider-api";
|
|
118
|
+
readonly operations: readonly PortableProviderApiPluginOperationV1[];
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export type PortableWebSessionApiPluginBindingV1 =
|
|
122
|
+
PortableProviderPluginBindingBaseV1 & {
|
|
123
|
+
readonly transport: "web-session-api";
|
|
124
|
+
readonly operations: readonly PortableWebSessionPluginOperationV1[];
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export type PortableLinkedDevicePluginBindingV1 =
|
|
128
|
+
PortableProviderPluginBindingBaseV1 & {
|
|
129
|
+
readonly transport: "linked-device";
|
|
130
|
+
readonly operations: readonly PortableWebSessionPluginOperationV1[];
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export type PortableProviderPluginBindingV1 =
|
|
134
|
+
| PortableProviderApiPluginBindingV1
|
|
135
|
+
| PortableWebSessionApiPluginBindingV1
|
|
136
|
+
| PortableLinkedDevicePluginBindingV1;
|
|
137
|
+
|
|
138
|
+
export type PortableProviderPluginCapabilitiesV1 = {
|
|
139
|
+
readonly networkOrigins: readonly `https://${string}`[];
|
|
140
|
+
readonly planFiles: "none" | "read";
|
|
141
|
+
readonly state: "none" | "namespaced";
|
|
142
|
+
readonly sessionMaterial: readonly PortableProviderPluginSessionMaterialName[];
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export type PortableProviderPluginFileV1 = {
|
|
146
|
+
readonly path: string;
|
|
147
|
+
readonly kind: "runtime" | "data";
|
|
148
|
+
readonly bytes: number;
|
|
149
|
+
readonly sha256: string;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export type PortableProviderPluginProvenanceV1 =
|
|
153
|
+
| {
|
|
154
|
+
readonly kind: "local";
|
|
155
|
+
}
|
|
156
|
+
| {
|
|
157
|
+
readonly kind: "git";
|
|
158
|
+
readonly repository: `https://${string}`;
|
|
159
|
+
readonly revision: string;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export type PortableProviderPluginManifestV1 = {
|
|
163
|
+
readonly schemaVersion:
|
|
164
|
+
typeof PORTABLE_PROVIDER_PLUGIN_MANIFEST_SCHEMA_VERSION;
|
|
165
|
+
readonly hostApiVersion: typeof PORTABLE_PROVIDER_PLUGIN_HOST_API_VERSION;
|
|
166
|
+
readonly id: string;
|
|
167
|
+
readonly version: string;
|
|
168
|
+
readonly displayName: string;
|
|
169
|
+
readonly runtime: {
|
|
170
|
+
readonly kind: "bun-js";
|
|
171
|
+
readonly entrypoint: string;
|
|
172
|
+
};
|
|
173
|
+
readonly provenance: PortableProviderPluginProvenanceV1;
|
|
174
|
+
readonly capabilities: PortableProviderPluginCapabilitiesV1;
|
|
175
|
+
readonly bindings: readonly PortableProviderPluginBindingV1[];
|
|
176
|
+
readonly files: readonly PortableProviderPluginFileV1[];
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export type PortableProviderPluginManifestResult =
|
|
180
|
+
| {
|
|
181
|
+
readonly ok: true;
|
|
182
|
+
readonly value: PortableProviderPluginManifestV1;
|
|
183
|
+
}
|
|
184
|
+
| {
|
|
185
|
+
readonly ok: false;
|
|
186
|
+
readonly issues: readonly string[];
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export type VerifiedPortableProviderPluginFile = {
|
|
190
|
+
readonly path: string;
|
|
191
|
+
readonly kind: PortableProviderPluginFileV1["kind"];
|
|
192
|
+
readonly bytes: Buffer;
|
|
193
|
+
readonly sha256: string;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
export type VerifiedPortableProviderPluginPackage = {
|
|
197
|
+
readonly root: string;
|
|
198
|
+
readonly manifest: PortableProviderPluginManifestV1;
|
|
199
|
+
readonly manifestBytes: Buffer;
|
|
200
|
+
readonly manifestSha256: string;
|
|
201
|
+
readonly bundleSha256: string;
|
|
202
|
+
readonly payloadBytes: number;
|
|
203
|
+
readonly files: readonly VerifiedPortableProviderPluginFile[];
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const verifiedPortableProviderPluginPackages = new WeakSet<object>();
|
|
207
|
+
|
|
208
|
+
/** Kernel evidence that a package object came from the exact-byte verifier. */
|
|
209
|
+
export function isVerifiedPortableProviderPluginPackage(
|
|
210
|
+
value: unknown,
|
|
211
|
+
): value is VerifiedPortableProviderPluginPackage {
|
|
212
|
+
return typeof value === "object"
|
|
213
|
+
&& value !== null
|
|
214
|
+
&& verifiedPortableProviderPluginPackages.has(value);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const MAX_MANIFEST_BYTES = 256 * 1024;
|
|
218
|
+
const MAX_PLUGIN_FILES = 256;
|
|
219
|
+
const MAX_PLUGIN_FILE_BYTES = 8 * 1024 * 1024;
|
|
220
|
+
const MAX_PLUGIN_PAYLOAD_BYTES = 32 * 1024 * 1024;
|
|
221
|
+
const MAX_BINDINGS = 64;
|
|
222
|
+
const MAX_OPERATIONS_PER_BINDING = 256;
|
|
223
|
+
const MAX_NETWORK_ORIGINS = 64;
|
|
224
|
+
const MAX_SESSION_MATERIAL_NAMES = 64;
|
|
225
|
+
const MAX_PLUGIN_DIRECTORIES = MAX_PLUGIN_FILES * 15;
|
|
226
|
+
const MAX_PLUGIN_PACKAGE_ENTRIES =
|
|
227
|
+
1 + MAX_PLUGIN_FILES + MAX_PLUGIN_DIRECTORIES;
|
|
228
|
+
const allowedPortableRuntimeImports = new Set([
|
|
229
|
+
"node:readline",
|
|
230
|
+
]);
|
|
231
|
+
const portableRuntimeImportScanner = new Bun.Transpiler({ loader: "js" });
|
|
232
|
+
const pathHelperPath = join(
|
|
233
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
234
|
+
"path-helper.ts",
|
|
235
|
+
);
|
|
236
|
+
const pathHelperConfigPath = join(
|
|
237
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
238
|
+
"state-helper.bunfig.toml",
|
|
239
|
+
);
|
|
240
|
+
let pathHelperSpawnObserverForTest: (() => void) | undefined;
|
|
241
|
+
|
|
242
|
+
export function observePortableProviderPluginPathHelperSpawnsForTest(
|
|
243
|
+
observer: () => void,
|
|
244
|
+
): () => void {
|
|
245
|
+
if (process.env.NODE_ENV !== "test") {
|
|
246
|
+
throw new Error("path helper spawn observation is available only in tests");
|
|
247
|
+
}
|
|
248
|
+
if (pathHelperSpawnObserverForTest !== undefined) {
|
|
249
|
+
throw new Error("path helper spawn observation is already active");
|
|
250
|
+
}
|
|
251
|
+
pathHelperSpawnObserverForTest = observer;
|
|
252
|
+
return () => {
|
|
253
|
+
if (pathHelperSpawnObserverForTest === observer) {
|
|
254
|
+
pathHelperSpawnObserverForTest = undefined;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const pluginIdPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
|
260
|
+
const operationNamePattern =
|
|
261
|
+
/^[a-z][a-z0-9-]{0,39}(?:\.[a-z][a-z0-9-]{0,39}){1,3}$/u;
|
|
262
|
+
const materialNamePattern =
|
|
263
|
+
/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/u;
|
|
264
|
+
const packagePathSegmentPattern =
|
|
265
|
+
/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
266
|
+
const sha256Pattern = /^[a-f0-9]{64}$/u;
|
|
267
|
+
const gitRevisionPattern = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u;
|
|
268
|
+
|
|
269
|
+
const forbiddenPackageBasenames = new Set([
|
|
270
|
+
".env",
|
|
271
|
+
".npmrc",
|
|
272
|
+
".yarnrc",
|
|
273
|
+
"bun.lock",
|
|
274
|
+
"bun.lockb",
|
|
275
|
+
"bunfig.toml",
|
|
276
|
+
"deno.json",
|
|
277
|
+
"deno.jsonc",
|
|
278
|
+
"package-lock.json",
|
|
279
|
+
"package.json",
|
|
280
|
+
"pnpm-lock.yaml",
|
|
281
|
+
"yarn.lock",
|
|
282
|
+
]);
|
|
283
|
+
|
|
284
|
+
const forbiddenPackageExtensions = new Set([
|
|
285
|
+
".cmd",
|
|
286
|
+
".dll",
|
|
287
|
+
".dylib",
|
|
288
|
+
".exe",
|
|
289
|
+
".node",
|
|
290
|
+
".ps1",
|
|
291
|
+
".sh",
|
|
292
|
+
".so",
|
|
293
|
+
]);
|
|
294
|
+
const forbiddenPortablePathStems =
|
|
295
|
+
/^(?:aux|con|nul|prn|com[1-9]|lpt[1-9])$/iu;
|
|
296
|
+
|
|
297
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
298
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
const prototype = Object.getPrototypeOf(value) as unknown;
|
|
302
|
+
if (prototype !== Object.prototype && prototype !== null) return false;
|
|
303
|
+
return Reflect.ownKeys(value).every((key) => {
|
|
304
|
+
if (typeof key !== "string") return false;
|
|
305
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
306
|
+
return descriptor !== undefined
|
|
307
|
+
&& descriptor.enumerable
|
|
308
|
+
&& "value" in descriptor;
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function exactKeys(
|
|
313
|
+
value: Readonly<Record<string, unknown>>,
|
|
314
|
+
expected: readonly string[],
|
|
315
|
+
label: string,
|
|
316
|
+
): void {
|
|
317
|
+
const actual = Object.keys(value).sort();
|
|
318
|
+
const wanted = [...expected].sort();
|
|
319
|
+
if (
|
|
320
|
+
actual.length !== wanted.length
|
|
321
|
+
|| actual.some((key, index) => key !== wanted[index])
|
|
322
|
+
) {
|
|
323
|
+
throw new Error(
|
|
324
|
+
`${label} must contain exactly: ${wanted.join(", ")}`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function allowedKeys(
|
|
330
|
+
value: Readonly<Record<string, unknown>>,
|
|
331
|
+
allowed: readonly string[],
|
|
332
|
+
label: string,
|
|
333
|
+
): void {
|
|
334
|
+
const accepted = new Set(allowed);
|
|
335
|
+
const unexpected = Object.keys(value)
|
|
336
|
+
.filter((key) => !accepted.has(key))
|
|
337
|
+
.sort();
|
|
338
|
+
if (unexpected.length > 0) {
|
|
339
|
+
throw new Error(`${label} contains unsupported keys: ${unexpected.join(", ")}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function compareCanonicalText(left: string, right: string): number {
|
|
344
|
+
if (left < right) return -1;
|
|
345
|
+
if (left > right) return 1;
|
|
346
|
+
return 0;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function record(value: unknown, label: string): Record<string, unknown> {
|
|
350
|
+
if (!isRecord(value)) throw new Error(`${label} must be an object`);
|
|
351
|
+
return value;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function boundedString(
|
|
355
|
+
value: unknown,
|
|
356
|
+
label: string,
|
|
357
|
+
maximum: number,
|
|
358
|
+
): string {
|
|
359
|
+
if (
|
|
360
|
+
typeof value !== "string"
|
|
361
|
+
|| value.length < 1
|
|
362
|
+
|| value.length > maximum
|
|
363
|
+
|| /[\0\r\n]/u.test(value)
|
|
364
|
+
) {
|
|
365
|
+
throw new Error(`${label} must be bounded single-line text`);
|
|
366
|
+
}
|
|
367
|
+
return value;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function boundedSemanticText(
|
|
371
|
+
value: unknown,
|
|
372
|
+
label: string,
|
|
373
|
+
maximumBytes: number,
|
|
374
|
+
): string {
|
|
375
|
+
if (
|
|
376
|
+
typeof value !== "string"
|
|
377
|
+
|| value.length < 1
|
|
378
|
+
|| Buffer.byteLength(value, "utf8") > maximumBytes
|
|
379
|
+
) {
|
|
380
|
+
throw new Error(`${label} must be bounded text`);
|
|
381
|
+
}
|
|
382
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
383
|
+
const code = value.charCodeAt(index);
|
|
384
|
+
if (code <= 0x1f || code === 0x7f) {
|
|
385
|
+
throw new Error(`${label} must not contain control characters`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return value;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function boundedArray(
|
|
392
|
+
value: unknown,
|
|
393
|
+
label: string,
|
|
394
|
+
maximum: number,
|
|
395
|
+
): readonly unknown[] {
|
|
396
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > maximum) {
|
|
397
|
+
throw new Error(
|
|
398
|
+
`${label} must contain between 1 and ${maximum} items`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
return value;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function safePluginId(value: unknown, label: string): string {
|
|
405
|
+
const id = boundedString(value, label, 63);
|
|
406
|
+
if (!pluginIdPattern.test(id)) {
|
|
407
|
+
throw new Error(`${label} must be strict lowercase kebab-case`);
|
|
408
|
+
}
|
|
409
|
+
return id;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function safeVersion(value: unknown): string {
|
|
413
|
+
const version = boundedString(value, "plugin version", 128);
|
|
414
|
+
if (!isPortableProviderPluginVersion(version)) {
|
|
415
|
+
throw new Error("plugin version must be strict semantic version text");
|
|
416
|
+
}
|
|
417
|
+
return version;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function safeOperationName(value: unknown): string {
|
|
421
|
+
const name = boundedString(value, "plugin operation name", 163);
|
|
422
|
+
if (!operationNamePattern.test(name)) {
|
|
423
|
+
throw new Error(
|
|
424
|
+
"plugin operation name must be a bounded dotted semantic outcome",
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
return name;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function safeSha256(value: unknown, label: string): string {
|
|
431
|
+
const sha256 = boundedString(value, label, 64);
|
|
432
|
+
if (!sha256Pattern.test(sha256)) {
|
|
433
|
+
throw new Error(`${label} must be a lowercase SHA-256 digest`);
|
|
434
|
+
}
|
|
435
|
+
return sha256;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function exactPublicHttpsOrigin(value: unknown, label: string): `https://${string}` {
|
|
439
|
+
const text = boundedString(value, label, 2_048);
|
|
440
|
+
let url: URL;
|
|
441
|
+
try {
|
|
442
|
+
url = new URL(text);
|
|
443
|
+
} catch {
|
|
444
|
+
throw new Error(`${label} must be an exact public HTTPS origin`);
|
|
445
|
+
}
|
|
446
|
+
const hostname = url.hostname.toLowerCase();
|
|
447
|
+
if (
|
|
448
|
+
url.protocol !== "https:"
|
|
449
|
+
|| url.username !== ""
|
|
450
|
+
|| url.password !== ""
|
|
451
|
+
|| url.pathname !== "/"
|
|
452
|
+
|| url.search !== ""
|
|
453
|
+
|| url.hash !== ""
|
|
454
|
+
|| url.origin !== text
|
|
455
|
+
|| hostname.endsWith(".")
|
|
456
|
+
|| hostname === "localhost"
|
|
457
|
+
|| hostname.endsWith(".localhost")
|
|
458
|
+
|| hostname.endsWith(".local")
|
|
459
|
+
|| hostname.endsWith(".internal")
|
|
460
|
+
|| !hostname.includes(".")
|
|
461
|
+
|| isIP(hostname) !== 0
|
|
462
|
+
) {
|
|
463
|
+
throw new Error(`${label} must be an exact public HTTPS origin`);
|
|
464
|
+
}
|
|
465
|
+
return text as `https://${string}`;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function exactHttpsRepository(value: unknown): `https://${string}` {
|
|
469
|
+
const text = boundedString(value, "plugin provenance repository", 2_048);
|
|
470
|
+
let url: URL;
|
|
471
|
+
try {
|
|
472
|
+
url = new URL(text);
|
|
473
|
+
} catch {
|
|
474
|
+
throw new Error(
|
|
475
|
+
"plugin git provenance repository must identify an HTTPS repository",
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
const hostname = url.hostname.toLowerCase();
|
|
479
|
+
if (
|
|
480
|
+
url.protocol !== "https:"
|
|
481
|
+
|| url.username !== ""
|
|
482
|
+
|| url.password !== ""
|
|
483
|
+
|| url.search !== ""
|
|
484
|
+
|| url.hash !== ""
|
|
485
|
+
|| url.pathname === "/"
|
|
486
|
+
|| url.pathname.endsWith("/")
|
|
487
|
+
|| hostname.endsWith(".")
|
|
488
|
+
|| hostname === "localhost"
|
|
489
|
+
|| hostname.endsWith(".localhost")
|
|
490
|
+
|| hostname.endsWith(".local")
|
|
491
|
+
|| hostname.endsWith(".internal")
|
|
492
|
+
|| !hostname.includes(".")
|
|
493
|
+
|| isIP(hostname) !== 0
|
|
494
|
+
|| url.toString() !== text
|
|
495
|
+
) {
|
|
496
|
+
throw new Error(
|
|
497
|
+
"plugin git provenance repository must identify an exact credential-free HTTPS repository",
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
return text as `https://${string}`;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function safePackagePath(value: unknown, label: string): string {
|
|
504
|
+
const path = boundedString(value, label, 512);
|
|
505
|
+
if (
|
|
506
|
+
path.startsWith("/")
|
|
507
|
+
|| path.endsWith("/")
|
|
508
|
+
|| path.includes("\\")
|
|
509
|
+
|| path.includes("//")
|
|
510
|
+
) {
|
|
511
|
+
throw new Error(`${label} must be a normalized relative package path`);
|
|
512
|
+
}
|
|
513
|
+
const segments = path.split("/");
|
|
514
|
+
if (
|
|
515
|
+
segments.length > 16
|
|
516
|
+
|| segments.some(
|
|
517
|
+
(segment) =>
|
|
518
|
+
segment === "."
|
|
519
|
+
|| segment === ".."
|
|
520
|
+
|| segment.endsWith(".")
|
|
521
|
+
|| !packagePathSegmentPattern.test(segment)
|
|
522
|
+
|| forbiddenPortablePathStems.test(segment.split(".")[0] ?? ""),
|
|
523
|
+
)
|
|
524
|
+
) {
|
|
525
|
+
throw new Error(`${label} contains an unsafe path segment`);
|
|
526
|
+
}
|
|
527
|
+
if (segments.some((segment) => segment.toLowerCase() === "node_modules")) {
|
|
528
|
+
throw new Error(`${label} cannot contain node_modules`);
|
|
529
|
+
}
|
|
530
|
+
const basename = segments.at(-1)?.toLowerCase();
|
|
531
|
+
if (basename === undefined || forbiddenPackageBasenames.has(basename)) {
|
|
532
|
+
throw new Error(`${label} names a forbidden package-manager or environment file`);
|
|
533
|
+
}
|
|
534
|
+
const dot = basename.lastIndexOf(".");
|
|
535
|
+
const extension = dot < 0 ? "" : basename.slice(dot);
|
|
536
|
+
if (forbiddenPackageExtensions.has(extension)) {
|
|
537
|
+
throw new Error(`${label} names a native or shell executable`);
|
|
538
|
+
}
|
|
539
|
+
if (portableProviderPluginManifestNames.has(path)) {
|
|
540
|
+
throw new Error(`${label} cannot redeclare the package manifest`);
|
|
541
|
+
}
|
|
542
|
+
return path;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const operationRiskRank = Object.freeze({
|
|
546
|
+
R1: 1,
|
|
547
|
+
R2: 2,
|
|
548
|
+
R3: 3,
|
|
549
|
+
R4: 4,
|
|
550
|
+
} satisfies Readonly<Record<OperationRisk, number>>);
|
|
551
|
+
|
|
552
|
+
const readOperationSuffixes = new Set([
|
|
553
|
+
"get",
|
|
554
|
+
"inspect",
|
|
555
|
+
"list",
|
|
556
|
+
"lookup",
|
|
557
|
+
"probe",
|
|
558
|
+
"read",
|
|
559
|
+
"search",
|
|
560
|
+
"status",
|
|
561
|
+
]);
|
|
562
|
+
const reversibleMutationSuffixes = new Set([
|
|
563
|
+
"edit",
|
|
564
|
+
"follow",
|
|
565
|
+
"like",
|
|
566
|
+
"save",
|
|
567
|
+
"unfollow",
|
|
568
|
+
"unlike",
|
|
569
|
+
"unsave",
|
|
570
|
+
"update",
|
|
571
|
+
]);
|
|
572
|
+
const outwardMutationSuffixes = new Set([
|
|
573
|
+
"comment",
|
|
574
|
+
"create",
|
|
575
|
+
"post",
|
|
576
|
+
"publish",
|
|
577
|
+
"reply",
|
|
578
|
+
"send",
|
|
579
|
+
"upload",
|
|
580
|
+
]);
|
|
581
|
+
const highAuthorityOperationSegments = new Set([
|
|
582
|
+
"admin",
|
|
583
|
+
"administrator",
|
|
584
|
+
"ban",
|
|
585
|
+
"billing",
|
|
586
|
+
"charge",
|
|
587
|
+
"charges",
|
|
588
|
+
"delete",
|
|
589
|
+
"destroy",
|
|
590
|
+
"erase",
|
|
591
|
+
"financial",
|
|
592
|
+
"funds",
|
|
593
|
+
"invoice",
|
|
594
|
+
"invoices",
|
|
595
|
+
"money",
|
|
596
|
+
"payment",
|
|
597
|
+
"payments",
|
|
598
|
+
"permission",
|
|
599
|
+
"permissions",
|
|
600
|
+
"purchase",
|
|
601
|
+
"purge",
|
|
602
|
+
"refund",
|
|
603
|
+
"remove",
|
|
604
|
+
"role",
|
|
605
|
+
"roles",
|
|
606
|
+
"subscription",
|
|
607
|
+
"subscriptions",
|
|
608
|
+
"suspend",
|
|
609
|
+
"transfer",
|
|
610
|
+
"transfers",
|
|
611
|
+
"withdraw",
|
|
612
|
+
"withdrawal",
|
|
613
|
+
]);
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Derive the kernel's conservative minimum authority from one semantic
|
|
617
|
+
* outcome. Unknown, destructive, administrative, and financial outcomes are
|
|
618
|
+
* inert R4 reservations until the kernel learns an explicit policy.
|
|
619
|
+
*/
|
|
620
|
+
export function derivePortableProviderPluginMinimumRisk(
|
|
621
|
+
operationName: string,
|
|
622
|
+
): OperationRisk {
|
|
623
|
+
const name = safeOperationName(
|
|
624
|
+
operationName,
|
|
625
|
+
);
|
|
626
|
+
const segments = name.split(/[.-]/u);
|
|
627
|
+
if (segments.some((segment) => highAuthorityOperationSegments.has(segment))) {
|
|
628
|
+
return "R4";
|
|
629
|
+
}
|
|
630
|
+
const suffix = name.split(".").at(-1);
|
|
631
|
+
if (suffix !== undefined && readOperationSuffixes.has(suffix)) return "R1";
|
|
632
|
+
if (suffix !== undefined && reversibleMutationSuffixes.has(suffix)) return "R2";
|
|
633
|
+
if (suffix !== undefined && outwardMutationSuffixes.has(suffix)) return "R3";
|
|
634
|
+
return "R4";
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function parseDescription(value: unknown, label: string): string {
|
|
638
|
+
return boundedSemanticText(value, label, 500);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function parseMediaTypes(value: unknown, label: string): readonly string[] {
|
|
642
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 32) {
|
|
643
|
+
throw new Error(`${label} must contain between 1 and 32 media types`);
|
|
644
|
+
}
|
|
645
|
+
const mediaTypes = value.map((candidate, index) => {
|
|
646
|
+
const mediaType = boundedString(candidate, `${label}[${index}]`, 256);
|
|
647
|
+
if (
|
|
648
|
+
!/^[a-z0-9!#$&^_.+-]+\/(?:[a-z0-9!#$&^_.+-]+|\*)$/u.test(mediaType)
|
|
649
|
+
) {
|
|
650
|
+
throw new Error(`${label}[${index}] is malformed`);
|
|
651
|
+
}
|
|
652
|
+
return mediaType;
|
|
653
|
+
});
|
|
654
|
+
if (new Set(mediaTypes).size !== mediaTypes.length) {
|
|
655
|
+
throw new Error(`${label} must not contain duplicates`);
|
|
656
|
+
}
|
|
657
|
+
return Object.freeze([...mediaTypes].sort());
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function parseScalarInputField(
|
|
661
|
+
field: Readonly<Record<string, unknown>>,
|
|
662
|
+
label: string,
|
|
663
|
+
): ScalarInputField {
|
|
664
|
+
allowedKeys(
|
|
665
|
+
field,
|
|
666
|
+
[
|
|
667
|
+
"type",
|
|
668
|
+
"description",
|
|
669
|
+
"minLength",
|
|
670
|
+
"maxLength",
|
|
671
|
+
"minimum",
|
|
672
|
+
"maximum",
|
|
673
|
+
"enum",
|
|
674
|
+
"format",
|
|
675
|
+
"urlPathPrefixes",
|
|
676
|
+
],
|
|
677
|
+
label,
|
|
678
|
+
);
|
|
679
|
+
if (
|
|
680
|
+
field.type !== "string"
|
|
681
|
+
&& field.type !== "number"
|
|
682
|
+
&& field.type !== "boolean"
|
|
683
|
+
) {
|
|
684
|
+
throw new Error(`${label}.type is unsupported`);
|
|
685
|
+
}
|
|
686
|
+
const type = field.type;
|
|
687
|
+
const description = parseDescription(field.description, `${label}.description`);
|
|
688
|
+
|
|
689
|
+
let minLength: number | undefined;
|
|
690
|
+
let maxLength: number | undefined;
|
|
691
|
+
if (field.minLength !== undefined || field.maxLength !== undefined) {
|
|
692
|
+
if (type !== "string") {
|
|
693
|
+
throw new Error(`${label} length bounds require a string field`);
|
|
694
|
+
}
|
|
695
|
+
if (
|
|
696
|
+
field.minLength !== undefined
|
|
697
|
+
&& (
|
|
698
|
+
!Number.isSafeInteger(field.minLength)
|
|
699
|
+
|| (field.minLength as number) < 0
|
|
700
|
+
|| (field.minLength as number) > 1_000_000
|
|
701
|
+
)
|
|
702
|
+
) {
|
|
703
|
+
throw new Error(`${label}.minLength is invalid`);
|
|
704
|
+
}
|
|
705
|
+
if (
|
|
706
|
+
field.maxLength !== undefined
|
|
707
|
+
&& (
|
|
708
|
+
!Number.isSafeInteger(field.maxLength)
|
|
709
|
+
|| (field.maxLength as number) < 1
|
|
710
|
+
|| (field.maxLength as number) > 1_000_000
|
|
711
|
+
)
|
|
712
|
+
) {
|
|
713
|
+
throw new Error(`${label}.maxLength is invalid`);
|
|
714
|
+
}
|
|
715
|
+
minLength = field.minLength as number | undefined;
|
|
716
|
+
maxLength = field.maxLength as number | undefined;
|
|
717
|
+
if (
|
|
718
|
+
minLength !== undefined
|
|
719
|
+
&& maxLength !== undefined
|
|
720
|
+
&& minLength > maxLength
|
|
721
|
+
) {
|
|
722
|
+
throw new Error(`${label}.minLength cannot exceed maxLength`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
let minimum: number | undefined;
|
|
727
|
+
let maximum: number | undefined;
|
|
728
|
+
if (field.minimum !== undefined || field.maximum !== undefined) {
|
|
729
|
+
if (type !== "number") {
|
|
730
|
+
throw new Error(`${label} numeric bounds require a number field`);
|
|
731
|
+
}
|
|
732
|
+
if (
|
|
733
|
+
field.minimum !== undefined
|
|
734
|
+
&& (typeof field.minimum !== "number" || !Number.isFinite(field.minimum))
|
|
735
|
+
) {
|
|
736
|
+
throw new Error(`${label}.minimum is invalid`);
|
|
737
|
+
}
|
|
738
|
+
if (
|
|
739
|
+
field.maximum !== undefined
|
|
740
|
+
&& (typeof field.maximum !== "number" || !Number.isFinite(field.maximum))
|
|
741
|
+
) {
|
|
742
|
+
throw new Error(`${label}.maximum is invalid`);
|
|
743
|
+
}
|
|
744
|
+
minimum = field.minimum;
|
|
745
|
+
maximum = field.maximum;
|
|
746
|
+
if (
|
|
747
|
+
minimum !== undefined
|
|
748
|
+
&& maximum !== undefined
|
|
749
|
+
&& minimum > maximum
|
|
750
|
+
) {
|
|
751
|
+
throw new Error(`${label}.minimum cannot exceed maximum`);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
let enumValues: readonly (string | number | boolean)[] | undefined;
|
|
756
|
+
if (field.enum !== undefined) {
|
|
757
|
+
if (
|
|
758
|
+
!Array.isArray(field.enum)
|
|
759
|
+
|| field.enum.length < 1
|
|
760
|
+
|| field.enum.length > 100
|
|
761
|
+
|| field.enum.some((candidate) =>
|
|
762
|
+
typeof candidate !== type
|
|
763
|
+
|| (typeof candidate === "number" && !Number.isFinite(candidate)))
|
|
764
|
+
) {
|
|
765
|
+
throw new Error(`${label}.enum must contain bounded values matching its type`);
|
|
766
|
+
}
|
|
767
|
+
const values = field.enum as readonly (string | number | boolean)[];
|
|
768
|
+
if (
|
|
769
|
+
new Set(values.map((candidate) => JSON.stringify(candidate))).size
|
|
770
|
+
!== values.length
|
|
771
|
+
) {
|
|
772
|
+
throw new Error(`${label}.enum must not contain duplicates`);
|
|
773
|
+
}
|
|
774
|
+
enumValues = Object.freeze([...values]);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
let format: "url" | "path-segment" | undefined;
|
|
778
|
+
if (field.format !== undefined) {
|
|
779
|
+
if (
|
|
780
|
+
type !== "string"
|
|
781
|
+
|| (field.format !== "url" && field.format !== "path-segment")
|
|
782
|
+
) {
|
|
783
|
+
throw new Error(`${label}.format is invalid`);
|
|
784
|
+
}
|
|
785
|
+
format = field.format;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
let urlPathPrefixes: readonly string[] | undefined;
|
|
789
|
+
if (field.urlPathPrefixes !== undefined) {
|
|
790
|
+
if (
|
|
791
|
+
type !== "string"
|
|
792
|
+
|| format !== "url"
|
|
793
|
+
|| !Array.isArray(field.urlPathPrefixes)
|
|
794
|
+
|| field.urlPathPrefixes.length < 1
|
|
795
|
+
|| field.urlPathPrefixes.length > 20
|
|
796
|
+
) {
|
|
797
|
+
throw new Error(
|
|
798
|
+
`${label}.urlPathPrefixes requires a URL string field and 1-20 prefixes`,
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
const prefixes = field.urlPathPrefixes.map((candidate, index) => {
|
|
802
|
+
const prefix = boundedString(
|
|
803
|
+
candidate,
|
|
804
|
+
`${label}.urlPathPrefixes[${index}]`,
|
|
805
|
+
2_048,
|
|
806
|
+
);
|
|
807
|
+
if (
|
|
808
|
+
!prefix.startsWith("/")
|
|
809
|
+
|| prefix.startsWith("//")
|
|
810
|
+
|| prefix.includes("\\")
|
|
811
|
+
|| prefix.includes("?")
|
|
812
|
+
|| prefix.includes("#")
|
|
813
|
+
|| /%(?:25|2e|2f|5c)/iu.test(prefix)
|
|
814
|
+
|| prefix.split("/").some((segment) =>
|
|
815
|
+
segment === "." || segment === "..")
|
|
816
|
+
) {
|
|
817
|
+
throw new Error(`${label}.urlPathPrefixes[${index}] is ambiguous`);
|
|
818
|
+
}
|
|
819
|
+
return prefix;
|
|
820
|
+
});
|
|
821
|
+
if (new Set(prefixes).size !== prefixes.length) {
|
|
822
|
+
throw new Error(`${label}.urlPathPrefixes must not contain duplicates`);
|
|
823
|
+
}
|
|
824
|
+
urlPathPrefixes = Object.freeze([...prefixes].sort());
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return Object.freeze({
|
|
828
|
+
type,
|
|
829
|
+
description,
|
|
830
|
+
...(minLength === undefined ? {} : { minLength }),
|
|
831
|
+
...(maxLength === undefined ? {} : { maxLength }),
|
|
832
|
+
...(minimum === undefined ? {} : { minimum }),
|
|
833
|
+
...(maximum === undefined ? {} : { maximum }),
|
|
834
|
+
...(enumValues === undefined ? {} : { enum: enumValues }),
|
|
835
|
+
...(format === undefined ? {} : { format }),
|
|
836
|
+
...(urlPathPrefixes === undefined ? {} : { urlPathPrefixes }),
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function parseInputField(
|
|
841
|
+
value: unknown,
|
|
842
|
+
label: string,
|
|
843
|
+
maximumArrayItems: 25 | 100,
|
|
844
|
+
nested = false,
|
|
845
|
+
): InputField {
|
|
846
|
+
const field = record(value, label);
|
|
847
|
+
if (field.type === "file") {
|
|
848
|
+
allowedKeys(
|
|
849
|
+
field,
|
|
850
|
+
["type", "description", "maxBytes", "mediaTypes"],
|
|
851
|
+
label,
|
|
852
|
+
);
|
|
853
|
+
const description = parseDescription(field.description, `${label}.description`);
|
|
854
|
+
if (
|
|
855
|
+
!Number.isSafeInteger(field.maxBytes)
|
|
856
|
+
|| (field.maxBytes as number) < 1
|
|
857
|
+
|| (field.maxBytes as number) > 1024 * 1024 * 1024
|
|
858
|
+
) {
|
|
859
|
+
throw new Error(`${label}.maxBytes must be a bounded positive integer`);
|
|
860
|
+
}
|
|
861
|
+
const result: FileInputField = {
|
|
862
|
+
type: "file",
|
|
863
|
+
description,
|
|
864
|
+
maxBytes: field.maxBytes as number,
|
|
865
|
+
...(field.mediaTypes === undefined
|
|
866
|
+
? {}
|
|
867
|
+
: { mediaTypes: parseMediaTypes(field.mediaTypes, `${label}.mediaTypes`) }),
|
|
868
|
+
};
|
|
869
|
+
return Object.freeze(result);
|
|
870
|
+
}
|
|
871
|
+
if (field.type === "array") {
|
|
872
|
+
allowedKeys(
|
|
873
|
+
field,
|
|
874
|
+
["type", "description", "items", "minItems", "maxItems"],
|
|
875
|
+
label,
|
|
876
|
+
);
|
|
877
|
+
if (nested) throw new Error(`${label} cannot contain a nested array`);
|
|
878
|
+
if (
|
|
879
|
+
!Number.isSafeInteger(field.minItems)
|
|
880
|
+
|| !Number.isSafeInteger(field.maxItems)
|
|
881
|
+
|| (field.minItems as number) < 0
|
|
882
|
+
|| (field.maxItems as number) < 1
|
|
883
|
+
|| (field.maxItems as number) < (field.minItems as number)
|
|
884
|
+
|| (field.maxItems as number) > maximumArrayItems
|
|
885
|
+
) {
|
|
886
|
+
throw new Error(`${label} must declare valid bounded item counts`);
|
|
887
|
+
}
|
|
888
|
+
const result: ArrayInputField = {
|
|
889
|
+
type: "array",
|
|
890
|
+
description: parseDescription(field.description, `${label}.description`),
|
|
891
|
+
items: parseInputField(
|
|
892
|
+
field.items,
|
|
893
|
+
`${label}.items`,
|
|
894
|
+
maximumArrayItems,
|
|
895
|
+
true,
|
|
896
|
+
) as ScalarInputField | FileInputField,
|
|
897
|
+
minItems: field.minItems as number,
|
|
898
|
+
maxItems: field.maxItems as number,
|
|
899
|
+
};
|
|
900
|
+
return Object.freeze(result);
|
|
901
|
+
}
|
|
902
|
+
return parseScalarInputField(field, label);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function parseInputSchema(
|
|
906
|
+
value: unknown,
|
|
907
|
+
label: string,
|
|
908
|
+
maximumArrayItems: 25 | 100,
|
|
909
|
+
): InputSchema {
|
|
910
|
+
const schema = record(value, label);
|
|
911
|
+
exactKeys(schema, ["properties", "required"], label);
|
|
912
|
+
const rawProperties = record(schema.properties, `${label}.properties`);
|
|
913
|
+
const propertyEntries = Object.entries(rawProperties)
|
|
914
|
+
.sort(([left], [right]) => compareCanonicalText(left, right));
|
|
915
|
+
if (propertyEntries.length > 100) {
|
|
916
|
+
throw new Error(`${label}.properties may contain at most 100 fields`);
|
|
917
|
+
}
|
|
918
|
+
const properties: Record<string, InputField> = Object.create(null) as
|
|
919
|
+
Record<string, InputField>;
|
|
920
|
+
for (const [name, field] of propertyEntries) {
|
|
921
|
+
if (!/^[a-z][a-z0-9_]{0,63}$/u.test(name)) {
|
|
922
|
+
throw new Error(`${label}.properties contains invalid field ${name}`);
|
|
923
|
+
}
|
|
924
|
+
properties[name] = parseInputField(
|
|
925
|
+
field,
|
|
926
|
+
`${label}.properties.${name}`,
|
|
927
|
+
maximumArrayItems,
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
if (
|
|
931
|
+
!Array.isArray(schema.required)
|
|
932
|
+
|| schema.required.length > 100
|
|
933
|
+
|| schema.required.some((name) => typeof name !== "string")
|
|
934
|
+
) {
|
|
935
|
+
throw new Error(`${label}.required must contain at most 100 field names`);
|
|
936
|
+
}
|
|
937
|
+
const required = schema.required as readonly string[];
|
|
938
|
+
if (new Set(required).size !== required.length) {
|
|
939
|
+
throw new Error(`${label}.required must not contain duplicates`);
|
|
940
|
+
}
|
|
941
|
+
for (const name of required) {
|
|
942
|
+
if (properties[name] === undefined) {
|
|
943
|
+
throw new Error(`${label}.required references unknown field ${name}`);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
return Object.freeze({
|
|
947
|
+
properties: Object.freeze(properties),
|
|
948
|
+
required: Object.freeze([...required].sort()),
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function containsFileInput(field: InputField): boolean {
|
|
953
|
+
return field.type === "file"
|
|
954
|
+
|| (field.type === "array" && field.items.type === "file");
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function parseScope(value: unknown, label: string): string {
|
|
958
|
+
const scope = boundedString(value, label, 256);
|
|
959
|
+
if (!/^[\x21-\x7e]+$/u.test(scope)) {
|
|
960
|
+
throw new Error(`${label} must be printable non-whitespace ASCII`);
|
|
961
|
+
}
|
|
962
|
+
return scope;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function parseRequiredScopeSets(
|
|
966
|
+
value: unknown,
|
|
967
|
+
label: string,
|
|
968
|
+
): readonly (readonly string[])[] {
|
|
969
|
+
const rawSets = boundedArray(value, label, 32);
|
|
970
|
+
const scopeSets = rawSets.map((rawSet, setIndex) => {
|
|
971
|
+
const rawScopes = boundedArray(
|
|
972
|
+
rawSet,
|
|
973
|
+
`${label}[${setIndex}]`,
|
|
974
|
+
32,
|
|
975
|
+
);
|
|
976
|
+
const scopes = rawScopes.map((scope, scopeIndex) =>
|
|
977
|
+
parseScope(scope, `${label}[${setIndex}][${scopeIndex}]`));
|
|
978
|
+
if (new Set(scopes).size !== scopes.length) {
|
|
979
|
+
throw new Error(`${label}[${setIndex}] must not repeat a scope`);
|
|
980
|
+
}
|
|
981
|
+
return Object.freeze([...scopes].sort());
|
|
982
|
+
});
|
|
983
|
+
const identities = scopeSets.map((scopes) => scopes.join("\0"));
|
|
984
|
+
if (new Set(identities).size !== identities.length) {
|
|
985
|
+
throw new Error(`${label} must not repeat an equivalent scope set`);
|
|
986
|
+
}
|
|
987
|
+
return Object.freeze(
|
|
988
|
+
[...scopeSets].sort((left, right) =>
|
|
989
|
+
compareCanonicalText(left.join("\0"), right.join("\0"))),
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function parseCoverage(value: unknown, label: string): readonly string[] {
|
|
994
|
+
const rawCoverage = boundedArray(value, label, 64);
|
|
995
|
+
const coverage = rawCoverage.map((candidate, index) => {
|
|
996
|
+
const item = boundedString(candidate, `${label}[${index}]`, 128);
|
|
997
|
+
if (!/^[a-z][a-z0-9]*(?:[-.][a-z0-9]+)*$/u.test(item)) {
|
|
998
|
+
throw new Error(`${label}[${index}] is malformed`);
|
|
999
|
+
}
|
|
1000
|
+
return item;
|
|
1001
|
+
});
|
|
1002
|
+
if (new Set(coverage).size !== coverage.length) {
|
|
1003
|
+
throw new Error(`${label} must not contain duplicates`);
|
|
1004
|
+
}
|
|
1005
|
+
return Object.freeze([...coverage].sort());
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function parseOperation(
|
|
1009
|
+
value: unknown,
|
|
1010
|
+
transport: PortableProviderPluginTransport,
|
|
1011
|
+
): PortableProviderPluginOperationV1 {
|
|
1012
|
+
const operation = record(value, "plugin operation");
|
|
1013
|
+
const providerApi = transport === "provider-api";
|
|
1014
|
+
exactKeys(
|
|
1015
|
+
operation,
|
|
1016
|
+
[
|
|
1017
|
+
"name",
|
|
1018
|
+
"contractVersion",
|
|
1019
|
+
"timeoutMs",
|
|
1020
|
+
"maxOutputBytes",
|
|
1021
|
+
"state",
|
|
1022
|
+
"risk",
|
|
1023
|
+
"dispatch",
|
|
1024
|
+
"sideEffect",
|
|
1025
|
+
"idempotency",
|
|
1026
|
+
"dedupeWindowMs",
|
|
1027
|
+
"input",
|
|
1028
|
+
"implementation",
|
|
1029
|
+
...(providerApi ? ["requiredScopeSets", "coverage"] : []),
|
|
1030
|
+
],
|
|
1031
|
+
"plugin operation",
|
|
1032
|
+
);
|
|
1033
|
+
const name = safeOperationName(operation.name);
|
|
1034
|
+
if (
|
|
1035
|
+
!Number.isSafeInteger(operation.contractVersion)
|
|
1036
|
+
|| (operation.contractVersion as number) < 1
|
|
1037
|
+
|| (operation.contractVersion as number) > 1_000_000
|
|
1038
|
+
) {
|
|
1039
|
+
throw new Error(`plugin operation ${name} contractVersion is invalid`);
|
|
1040
|
+
}
|
|
1041
|
+
if (
|
|
1042
|
+
!Number.isSafeInteger(operation.timeoutMs)
|
|
1043
|
+
|| (operation.timeoutMs as number) < 1_000
|
|
1044
|
+
|| (operation.timeoutMs as number) > 120_000
|
|
1045
|
+
) {
|
|
1046
|
+
throw new Error(`plugin operation ${name} timeoutMs is invalid`);
|
|
1047
|
+
}
|
|
1048
|
+
if (
|
|
1049
|
+
!Number.isSafeInteger(operation.maxOutputBytes)
|
|
1050
|
+
|| (operation.maxOutputBytes as number) < 1_024
|
|
1051
|
+
|| (operation.maxOutputBytes as number) > 512 * 1024
|
|
1052
|
+
) {
|
|
1053
|
+
throw new Error(`plugin operation ${name} maxOutputBytes is invalid`);
|
|
1054
|
+
}
|
|
1055
|
+
if (operation.state !== "observed" && operation.state !== "capture-required") {
|
|
1056
|
+
throw new Error(`plugin operation ${name} state is invalid`);
|
|
1057
|
+
}
|
|
1058
|
+
if (
|
|
1059
|
+
operation.risk !== "R1"
|
|
1060
|
+
&& operation.risk !== "R2"
|
|
1061
|
+
&& operation.risk !== "R3"
|
|
1062
|
+
&& operation.risk !== "R4"
|
|
1063
|
+
) {
|
|
1064
|
+
throw new Error(`plugin operation ${name} risk is invalid`);
|
|
1065
|
+
}
|
|
1066
|
+
const risk = operation.risk;
|
|
1067
|
+
const minimumRisk = derivePortableProviderPluginMinimumRisk(name);
|
|
1068
|
+
if (operationRiskRank[risk] < operationRiskRank[minimumRisk]) {
|
|
1069
|
+
throw new Error(
|
|
1070
|
+
`plugin operation ${name} requires at least ${minimumRisk} authority, not ${risk}`,
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
if (operation.dispatch !== "none" && operation.dispatch !== "single") {
|
|
1074
|
+
throw new Error(`plugin operation ${name} dispatch is invalid`);
|
|
1075
|
+
}
|
|
1076
|
+
if (
|
|
1077
|
+
operation.idempotency !== "none"
|
|
1078
|
+
&& operation.idempotency !== "local-at-most-once"
|
|
1079
|
+
) {
|
|
1080
|
+
throw new Error(`plugin operation ${name} idempotency is invalid`);
|
|
1081
|
+
}
|
|
1082
|
+
if (
|
|
1083
|
+
!Number.isSafeInteger(operation.dedupeWindowMs)
|
|
1084
|
+
|| (operation.dedupeWindowMs as number) < 0
|
|
1085
|
+
|| (operation.dedupeWindowMs as number) > 30 * 24 * 60 * 60_000
|
|
1086
|
+
) {
|
|
1087
|
+
throw new Error(`plugin operation ${name} dedupeWindowMs is invalid`);
|
|
1088
|
+
}
|
|
1089
|
+
const sideEffect = boundedSemanticText(
|
|
1090
|
+
operation.sideEffect,
|
|
1091
|
+
`plugin operation ${name} sideEffect`,
|
|
1092
|
+
500,
|
|
1093
|
+
);
|
|
1094
|
+
const implementation = boundedSemanticText(
|
|
1095
|
+
operation.implementation,
|
|
1096
|
+
`plugin operation ${name} implementation`,
|
|
1097
|
+
500,
|
|
1098
|
+
);
|
|
1099
|
+
const input = parseInputSchema(
|
|
1100
|
+
operation.input,
|
|
1101
|
+
`plugin operation ${name} input`,
|
|
1102
|
+
providerApi ? 100 : 25,
|
|
1103
|
+
);
|
|
1104
|
+
if (
|
|
1105
|
+
risk === "R1"
|
|
1106
|
+
&& (
|
|
1107
|
+
sideEffect !== "none"
|
|
1108
|
+
|| operation.dispatch !== "none"
|
|
1109
|
+
|| operation.idempotency !== "none"
|
|
1110
|
+
|| operation.dedupeWindowMs !== 0
|
|
1111
|
+
|| Object.values(input.properties).some(containsFileInput)
|
|
1112
|
+
)
|
|
1113
|
+
) {
|
|
1114
|
+
throw new Error(
|
|
1115
|
+
`plugin operation ${name} must keep R1 semantics side-effect-free, file-free, and dispatch-free`,
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
if (
|
|
1119
|
+
(risk === "R2" || risk === "R3")
|
|
1120
|
+
&& (
|
|
1121
|
+
sideEffect === "none"
|
|
1122
|
+
|| operation.dispatch !== "single"
|
|
1123
|
+
|| operation.idempotency !== "local-at-most-once"
|
|
1124
|
+
|| (operation.dedupeWindowMs as number) < 60_000
|
|
1125
|
+
)
|
|
1126
|
+
) {
|
|
1127
|
+
throw new Error(
|
|
1128
|
+
`plugin operation ${name} must bind R2/R3 authority to one at-most-once dispatch and a 60-second minimum dedupe window`,
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
if (risk === "R4" && operation.state !== "capture-required") {
|
|
1132
|
+
throw new Error(
|
|
1133
|
+
`plugin operation ${name} must keep R4 authority capture-required`,
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
const common = {
|
|
1137
|
+
name,
|
|
1138
|
+
contractVersion: operation.contractVersion as number,
|
|
1139
|
+
timeoutMs: operation.timeoutMs as number,
|
|
1140
|
+
maxOutputBytes: operation.maxOutputBytes as number,
|
|
1141
|
+
state: operation.state,
|
|
1142
|
+
risk,
|
|
1143
|
+
dispatch: operation.dispatch,
|
|
1144
|
+
sideEffect,
|
|
1145
|
+
idempotency: operation.idempotency,
|
|
1146
|
+
dedupeWindowMs: operation.dedupeWindowMs as number,
|
|
1147
|
+
input,
|
|
1148
|
+
implementation,
|
|
1149
|
+
};
|
|
1150
|
+
if (!providerApi) {
|
|
1151
|
+
return Object.freeze(common) as PortableWebSessionPluginOperationV1;
|
|
1152
|
+
}
|
|
1153
|
+
return Object.freeze({
|
|
1154
|
+
...common,
|
|
1155
|
+
requiredScopeSets: parseRequiredScopeSets(
|
|
1156
|
+
operation.requiredScopeSets,
|
|
1157
|
+
`plugin operation ${name} requiredScopeSets`,
|
|
1158
|
+
),
|
|
1159
|
+
coverage: parseCoverage(
|
|
1160
|
+
operation.coverage,
|
|
1161
|
+
`plugin operation ${name} coverage`,
|
|
1162
|
+
),
|
|
1163
|
+
}) as PortableProviderApiPluginOperationV1;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function parseSubject(
|
|
1167
|
+
value: unknown,
|
|
1168
|
+
operations: readonly PortableProviderPluginOperationV1[],
|
|
1169
|
+
transport: PortableProviderPluginTransport,
|
|
1170
|
+
surfaceId: string,
|
|
1171
|
+
): PortableProviderPluginBindingV1["subject"] {
|
|
1172
|
+
const subject = record(value, `plugin binding ${surfaceId} subject`);
|
|
1173
|
+
exactKeys(
|
|
1174
|
+
subject,
|
|
1175
|
+
["format", "kind", "probe"],
|
|
1176
|
+
`plugin binding ${surfaceId} subject`,
|
|
1177
|
+
);
|
|
1178
|
+
const format = boundedSemanticText(
|
|
1179
|
+
subject.format,
|
|
1180
|
+
`plugin binding ${surfaceId} subject format`,
|
|
1181
|
+
200,
|
|
1182
|
+
);
|
|
1183
|
+
if (
|
|
1184
|
+
subject.kind !== "opaque-token"
|
|
1185
|
+
&& subject.kind !== "decimal"
|
|
1186
|
+
&& subject.kind !== "did"
|
|
1187
|
+
&& subject.kind !== "uuid"
|
|
1188
|
+
&& subject.kind !== "e164"
|
|
1189
|
+
) {
|
|
1190
|
+
throw new Error(`plugin binding ${surfaceId} subject kind is unsupported`);
|
|
1191
|
+
}
|
|
1192
|
+
if (subject.probe === null) {
|
|
1193
|
+
if (
|
|
1194
|
+
transport !== "provider-api"
|
|
1195
|
+
&& operations.some((operation) => operation.state === "observed")
|
|
1196
|
+
) {
|
|
1197
|
+
throw new Error(
|
|
1198
|
+
`plugin binding ${surfaceId} must declare an observed R1 subject probe before session operations become executable`,
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
return Object.freeze({ format, kind: subject.kind, probe: null });
|
|
1202
|
+
}
|
|
1203
|
+
const probe = record(
|
|
1204
|
+
subject.probe,
|
|
1205
|
+
`plugin binding ${surfaceId} subject probe`,
|
|
1206
|
+
);
|
|
1207
|
+
exactKeys(
|
|
1208
|
+
probe,
|
|
1209
|
+
["operation", "contractVersion"],
|
|
1210
|
+
`plugin binding ${surfaceId} subject probe`,
|
|
1211
|
+
);
|
|
1212
|
+
const operationName = safeOperationName(probe.operation);
|
|
1213
|
+
if (
|
|
1214
|
+
!Number.isSafeInteger(probe.contractVersion)
|
|
1215
|
+
|| (probe.contractVersion as number) < 1
|
|
1216
|
+
|| (probe.contractVersion as number) > 1_000_000
|
|
1217
|
+
) {
|
|
1218
|
+
throw new Error(
|
|
1219
|
+
`plugin binding ${surfaceId} subject probe contractVersion is invalid`,
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1222
|
+
const operation = operations.find((candidate) =>
|
|
1223
|
+
candidate.name === operationName
|
|
1224
|
+
&& candidate.contractVersion === probe.contractVersion);
|
|
1225
|
+
if (
|
|
1226
|
+
operation === undefined
|
|
1227
|
+
|| operation.state !== "observed"
|
|
1228
|
+
|| operation.risk !== "R1"
|
|
1229
|
+
|| operation.dispatch !== "none"
|
|
1230
|
+
|| Object.keys(operation.input.properties).length !== 0
|
|
1231
|
+
|| operation.input.required.length !== 0
|
|
1232
|
+
) {
|
|
1233
|
+
throw new Error(
|
|
1234
|
+
`plugin binding ${surfaceId} subject probe must reference one input-free observed dispatch-free R1 operation`,
|
|
1235
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
return Object.freeze({
|
|
1238
|
+
format,
|
|
1239
|
+
kind: subject.kind,
|
|
1240
|
+
probe: Object.freeze({
|
|
1241
|
+
operation: operationName,
|
|
1242
|
+
contractVersion: probe.contractVersion as number,
|
|
1243
|
+
}),
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function parseBinding(value: unknown): PortableProviderPluginBindingV1 {
|
|
1248
|
+
const binding = record(value, "plugin binding");
|
|
1249
|
+
exactKeys(
|
|
1250
|
+
binding,
|
|
1251
|
+
[
|
|
1252
|
+
"transport",
|
|
1253
|
+
"adapterId",
|
|
1254
|
+
"surfaceId",
|
|
1255
|
+
"origin",
|
|
1256
|
+
"authKinds",
|
|
1257
|
+
"subject",
|
|
1258
|
+
"operations",
|
|
1259
|
+
],
|
|
1260
|
+
"plugin binding",
|
|
1261
|
+
);
|
|
1262
|
+
if (
|
|
1263
|
+
typeof binding.transport !== "string"
|
|
1264
|
+
|| !portableProviderPluginTransports.includes(
|
|
1265
|
+
binding.transport as PortableProviderPluginTransport,
|
|
1266
|
+
)
|
|
1267
|
+
) {
|
|
1268
|
+
throw new Error("plugin binding transport is unsupported");
|
|
1269
|
+
}
|
|
1270
|
+
const transport = binding.transport as PortableProviderPluginTransport;
|
|
1271
|
+
const adapterId = safePluginId(binding.adapterId, "plugin binding adapterId");
|
|
1272
|
+
const surfaceId = safePluginId(binding.surfaceId, "plugin binding surfaceId");
|
|
1273
|
+
const origin = exactPublicHttpsOrigin(
|
|
1274
|
+
binding.origin,
|
|
1275
|
+
`plugin binding ${surfaceId} origin`,
|
|
1276
|
+
);
|
|
1277
|
+
const authKinds = boundedArray(
|
|
1278
|
+
binding.authKinds,
|
|
1279
|
+
`plugin binding ${surfaceId} authKinds`,
|
|
1280
|
+
portableProviderPluginAuthKinds.length,
|
|
1281
|
+
).map((kind) => {
|
|
1282
|
+
if (
|
|
1283
|
+
typeof kind !== "string"
|
|
1284
|
+
|| !portableProviderPluginAuthKinds.includes(
|
|
1285
|
+
kind as PortableProviderPluginAuthKind,
|
|
1286
|
+
)
|
|
1287
|
+
) {
|
|
1288
|
+
throw new Error(
|
|
1289
|
+
`plugin binding ${surfaceId} accepts an unsupported auth kind`,
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
return kind as PortableProviderPluginAuthKind;
|
|
1293
|
+
});
|
|
1294
|
+
if (new Set(authKinds).size !== authKinds.length) {
|
|
1295
|
+
throw new Error(`plugin binding ${surfaceId} repeats an auth kind`);
|
|
1296
|
+
}
|
|
1297
|
+
const sortedAuthKinds = [...authKinds].sort();
|
|
1298
|
+
if (sortedAuthKinds.some((kind, index) => kind !== authKinds[index])) {
|
|
1299
|
+
throw new Error(`plugin binding ${surfaceId} auth kinds must be sorted`);
|
|
1300
|
+
}
|
|
1301
|
+
if (
|
|
1302
|
+
transport === "provider-api"
|
|
1303
|
+
&& (
|
|
1304
|
+
sortedAuthKinds.length !== 1
|
|
1305
|
+
|| sortedAuthKinds[0] !== "oauth-token-file"
|
|
1306
|
+
)
|
|
1307
|
+
) {
|
|
1308
|
+
throw new Error(
|
|
1309
|
+
`plugin binding ${surfaceId} provider-api requires only oauth-token-file auth`,
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
if (
|
|
1313
|
+
transport === "linked-device"
|
|
1314
|
+
&& (
|
|
1315
|
+
sortedAuthKinds.length !== 1
|
|
1316
|
+
|| sortedAuthKinds[0] !== "linked-device-store"
|
|
1317
|
+
)
|
|
1318
|
+
) {
|
|
1319
|
+
throw new Error(
|
|
1320
|
+
`plugin binding ${surfaceId} linked-device requires only linked-device-store auth`,
|
|
1321
|
+
);
|
|
1322
|
+
}
|
|
1323
|
+
if (
|
|
1324
|
+
transport === "web-session-api"
|
|
1325
|
+
&& sortedAuthKinds.some(
|
|
1326
|
+
(kind) =>
|
|
1327
|
+
kind === "oauth-token-file"
|
|
1328
|
+
|| kind === "linked-device-store",
|
|
1329
|
+
)
|
|
1330
|
+
) {
|
|
1331
|
+
throw new Error(
|
|
1332
|
+
`plugin binding ${surfaceId} web-session-api requires browser-session auth`,
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
const operations = boundedArray(
|
|
1336
|
+
binding.operations,
|
|
1337
|
+
`plugin binding ${surfaceId} operations`,
|
|
1338
|
+
MAX_OPERATIONS_PER_BINDING,
|
|
1339
|
+
).map((operation) => parseOperation(operation, transport));
|
|
1340
|
+
if (
|
|
1341
|
+
transport === "linked-device"
|
|
1342
|
+
&& operations.some((operation) => operation.state !== "capture-required")
|
|
1343
|
+
) {
|
|
1344
|
+
throw new Error(
|
|
1345
|
+
`plugin binding ${surfaceId} linked-device operations must remain capture-required until a portable lifecycle protocol is available`,
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
const operationKeys = operations.map(
|
|
1349
|
+
(operation) => `${operation.name}@${operation.contractVersion}`,
|
|
1350
|
+
);
|
|
1351
|
+
if (new Set(operationKeys).size !== operationKeys.length) {
|
|
1352
|
+
throw new Error(
|
|
1353
|
+
`plugin binding ${surfaceId} repeats an operation contract`,
|
|
1354
|
+
);
|
|
1355
|
+
}
|
|
1356
|
+
if (
|
|
1357
|
+
new Set(operations.map((operation) => operation.name)).size
|
|
1358
|
+
!== operations.length
|
|
1359
|
+
) {
|
|
1360
|
+
throw new Error(
|
|
1361
|
+
`plugin binding ${surfaceId} v1 permits only one current contract version per operation name`,
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
1364
|
+
const sortedOperations = [...operations].sort(
|
|
1365
|
+
(left, right) => {
|
|
1366
|
+
const nameOrder = compareCanonicalText(left.name, right.name);
|
|
1367
|
+
return nameOrder === 0
|
|
1368
|
+
? left.contractVersion - right.contractVersion
|
|
1369
|
+
: nameOrder;
|
|
1370
|
+
},
|
|
1371
|
+
);
|
|
1372
|
+
if (
|
|
1373
|
+
sortedOperations.some(
|
|
1374
|
+
(operation, index) => operation !== operations[index],
|
|
1375
|
+
)
|
|
1376
|
+
) {
|
|
1377
|
+
throw new Error(
|
|
1378
|
+
`plugin binding ${surfaceId} operations must be sorted by name and contract version`,
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
const common = {
|
|
1382
|
+
adapterId,
|
|
1383
|
+
surfaceId,
|
|
1384
|
+
origin,
|
|
1385
|
+
authKinds: Object.freeze(sortedAuthKinds),
|
|
1386
|
+
subject: parseSubject(
|
|
1387
|
+
binding.subject,
|
|
1388
|
+
sortedOperations,
|
|
1389
|
+
transport,
|
|
1390
|
+
surfaceId,
|
|
1391
|
+
),
|
|
1392
|
+
};
|
|
1393
|
+
if (transport === "provider-api") {
|
|
1394
|
+
return Object.freeze({
|
|
1395
|
+
...common,
|
|
1396
|
+
transport,
|
|
1397
|
+
operations: Object.freeze(
|
|
1398
|
+
sortedOperations as readonly PortableProviderApiPluginOperationV1[],
|
|
1399
|
+
),
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
if (transport === "web-session-api") {
|
|
1403
|
+
return Object.freeze({
|
|
1404
|
+
...common,
|
|
1405
|
+
transport,
|
|
1406
|
+
operations: Object.freeze(
|
|
1407
|
+
sortedOperations as readonly PortableWebSessionPluginOperationV1[],
|
|
1408
|
+
),
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
return Object.freeze({
|
|
1412
|
+
...common,
|
|
1413
|
+
transport,
|
|
1414
|
+
operations: Object.freeze(
|
|
1415
|
+
sortedOperations as readonly PortableWebSessionPluginOperationV1[],
|
|
1416
|
+
),
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
function parseCapabilities(
|
|
1421
|
+
value: unknown,
|
|
1422
|
+
bindingOrigins: ReadonlySet<string>,
|
|
1423
|
+
bindings: readonly PortableProviderPluginBindingV1[],
|
|
1424
|
+
): PortableProviderPluginCapabilitiesV1 {
|
|
1425
|
+
const capabilities = record(value, "plugin capabilities");
|
|
1426
|
+
exactKeys(
|
|
1427
|
+
capabilities,
|
|
1428
|
+
["networkOrigins", "planFiles", "state", "sessionMaterial"],
|
|
1429
|
+
"plugin capabilities",
|
|
1430
|
+
);
|
|
1431
|
+
if (
|
|
1432
|
+
capabilities.planFiles !== "none"
|
|
1433
|
+
&& capabilities.planFiles !== "read"
|
|
1434
|
+
) {
|
|
1435
|
+
throw new Error("plugin capabilities planFiles must be none or read");
|
|
1436
|
+
}
|
|
1437
|
+
if (
|
|
1438
|
+
capabilities.state !== "none"
|
|
1439
|
+
&& capabilities.state !== "namespaced"
|
|
1440
|
+
) {
|
|
1441
|
+
throw new Error("plugin capabilities state must be none or namespaced");
|
|
1442
|
+
}
|
|
1443
|
+
if (
|
|
1444
|
+
!Array.isArray(capabilities.networkOrigins)
|
|
1445
|
+
|| capabilities.networkOrigins.length > MAX_NETWORK_ORIGINS
|
|
1446
|
+
) {
|
|
1447
|
+
throw new Error(
|
|
1448
|
+
`plugin capabilities networkOrigins must contain at most ${MAX_NETWORK_ORIGINS} entries`,
|
|
1449
|
+
);
|
|
1450
|
+
}
|
|
1451
|
+
const networkOrigins = capabilities.networkOrigins.map((origin, index) =>
|
|
1452
|
+
exactPublicHttpsOrigin(
|
|
1453
|
+
origin,
|
|
1454
|
+
`plugin capabilities networkOrigins[${index}]`,
|
|
1455
|
+
),
|
|
1456
|
+
);
|
|
1457
|
+
if (new Set(networkOrigins).size !== networkOrigins.length) {
|
|
1458
|
+
throw new Error("plugin capabilities repeat a network origin");
|
|
1459
|
+
}
|
|
1460
|
+
const sortedOrigins = [...networkOrigins].sort();
|
|
1461
|
+
if (sortedOrigins.some((origin, index) => origin !== networkOrigins[index])) {
|
|
1462
|
+
throw new Error("plugin capability network origins must be sorted");
|
|
1463
|
+
}
|
|
1464
|
+
for (const origin of bindingOrigins) {
|
|
1465
|
+
if (!networkOrigins.includes(origin as `https://${string}`)) {
|
|
1466
|
+
throw new Error(
|
|
1467
|
+
`plugin capability network origins omit binding origin ${origin}`,
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
if (
|
|
1472
|
+
!Array.isArray(capabilities.sessionMaterial)
|
|
1473
|
+
|| capabilities.sessionMaterial.length > MAX_SESSION_MATERIAL_NAMES
|
|
1474
|
+
) {
|
|
1475
|
+
throw new Error(
|
|
1476
|
+
`plugin capabilities sessionMaterial must contain at most ${MAX_SESSION_MATERIAL_NAMES} entries`,
|
|
1477
|
+
);
|
|
1478
|
+
}
|
|
1479
|
+
const sessionMaterial = capabilities.sessionMaterial.map((name, index) => {
|
|
1480
|
+
const normalized = boundedString(
|
|
1481
|
+
name,
|
|
1482
|
+
`plugin capabilities sessionMaterial[${index}]`,
|
|
1483
|
+
128,
|
|
1484
|
+
);
|
|
1485
|
+
if (!materialNamePattern.test(normalized)) {
|
|
1486
|
+
throw new Error(
|
|
1487
|
+
"plugin session material names must use lowercase dotted or dashed identifiers",
|
|
1488
|
+
);
|
|
1489
|
+
}
|
|
1490
|
+
if (
|
|
1491
|
+
!portableProviderPluginSessionMaterialNames.includes(
|
|
1492
|
+
normalized as PortableProviderPluginSessionMaterialName,
|
|
1493
|
+
)
|
|
1494
|
+
) {
|
|
1495
|
+
throw new Error(
|
|
1496
|
+
`plugin session material ${normalized} is unsupported by host API v1`,
|
|
1497
|
+
);
|
|
1498
|
+
}
|
|
1499
|
+
return normalized as PortableProviderPluginSessionMaterialName;
|
|
1500
|
+
});
|
|
1501
|
+
if (new Set(sessionMaterial).size !== sessionMaterial.length) {
|
|
1502
|
+
throw new Error("plugin capabilities repeat a session material name");
|
|
1503
|
+
}
|
|
1504
|
+
const sortedMaterial = [...sessionMaterial].sort();
|
|
1505
|
+
if (
|
|
1506
|
+
sortedMaterial.some((name, index) => name !== sessionMaterial[index])
|
|
1507
|
+
) {
|
|
1508
|
+
throw new Error("plugin session material names must be sorted");
|
|
1509
|
+
}
|
|
1510
|
+
const executableSessionMaterial = new Set<
|
|
1511
|
+
PortableProviderPluginSessionMaterialName
|
|
1512
|
+
>();
|
|
1513
|
+
for (const binding of bindings) {
|
|
1514
|
+
if (
|
|
1515
|
+
binding.transport === "provider-api"
|
|
1516
|
+
&& binding.authKinds.includes("oauth-token-file")
|
|
1517
|
+
) {
|
|
1518
|
+
executableSessionMaterial.add("oauth-access-token");
|
|
1519
|
+
}
|
|
1520
|
+
if (
|
|
1521
|
+
binding.transport === "web-session-api"
|
|
1522
|
+
&& binding.authKinds.some((kind) =>
|
|
1523
|
+
kind === "cookie-source"
|
|
1524
|
+
|| kind === "cookies-file"
|
|
1525
|
+
|| kind === "browser-profile"
|
|
1526
|
+
)
|
|
1527
|
+
) {
|
|
1528
|
+
executableSessionMaterial.add("cookie-jar");
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
for (const name of sortedMaterial) {
|
|
1532
|
+
if (!executableSessionMaterial.has(name)) {
|
|
1533
|
+
throw new Error(
|
|
1534
|
+
`plugin session material ${name} is not executable by any declared binding`,
|
|
1535
|
+
);
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
return Object.freeze({
|
|
1539
|
+
networkOrigins: Object.freeze(sortedOrigins),
|
|
1540
|
+
planFiles: capabilities.planFiles,
|
|
1541
|
+
state: capabilities.state,
|
|
1542
|
+
sessionMaterial: Object.freeze(sortedMaterial),
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
function parseFile(value: unknown): PortableProviderPluginFileV1 {
|
|
1547
|
+
const file = record(value, "plugin file");
|
|
1548
|
+
exactKeys(file, ["path", "kind", "bytes", "sha256"], "plugin file");
|
|
1549
|
+
const path = safePackagePath(file.path, "plugin file path");
|
|
1550
|
+
if (file.kind !== "runtime" && file.kind !== "data") {
|
|
1551
|
+
throw new Error(`plugin file ${path} kind must be runtime or data`);
|
|
1552
|
+
}
|
|
1553
|
+
if (
|
|
1554
|
+
!Number.isSafeInteger(file.bytes)
|
|
1555
|
+
|| (file.bytes as number) < 1
|
|
1556
|
+
|| (file.bytes as number) > MAX_PLUGIN_FILE_BYTES
|
|
1557
|
+
) {
|
|
1558
|
+
throw new Error(
|
|
1559
|
+
`plugin file ${path} bytes must be between 1 and ${MAX_PLUGIN_FILE_BYTES}`,
|
|
1560
|
+
);
|
|
1561
|
+
}
|
|
1562
|
+
const sha256 = safeSha256(file.sha256, `plugin file ${path} sha256`);
|
|
1563
|
+
return Object.freeze({
|
|
1564
|
+
path,
|
|
1565
|
+
kind: file.kind,
|
|
1566
|
+
bytes: file.bytes as number,
|
|
1567
|
+
sha256,
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
function runtimeRequireReference(node: ts.Node): boolean {
|
|
1572
|
+
if (ts.isIdentifier(node)) return node.text === "require";
|
|
1573
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
1574
|
+
return node.name.text === "require";
|
|
1575
|
+
}
|
|
1576
|
+
return (
|
|
1577
|
+
ts.isElementAccessExpression(node)
|
|
1578
|
+
&& node.argumentExpression !== undefined
|
|
1579
|
+
&& ts.isStringLiteralLike(node.argumentExpression)
|
|
1580
|
+
&& node.argumentExpression.text === "require"
|
|
1581
|
+
);
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
function assertSelfContainedPortableRuntime(
|
|
1585
|
+
path: string,
|
|
1586
|
+
bytes: Uint8Array,
|
|
1587
|
+
): void {
|
|
1588
|
+
let source: string;
|
|
1589
|
+
try {
|
|
1590
|
+
source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
1591
|
+
} catch {
|
|
1592
|
+
throw new Error(`portable plugin runtime ${path} must be valid UTF-8`);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
let imports: readonly {
|
|
1596
|
+
readonly kind: string;
|
|
1597
|
+
readonly path: string;
|
|
1598
|
+
}[];
|
|
1599
|
+
try {
|
|
1600
|
+
imports = portableRuntimeImportScanner.scanImports(source);
|
|
1601
|
+
} catch {
|
|
1602
|
+
throw new Error(
|
|
1603
|
+
`portable plugin runtime ${path} must contain valid JavaScript`,
|
|
1604
|
+
);
|
|
1605
|
+
}
|
|
1606
|
+
for (const imported of imports) {
|
|
1607
|
+
if (imported.kind === "dynamic-import") {
|
|
1608
|
+
throw new Error(
|
|
1609
|
+
`portable plugin runtime ${path} must not use dynamic import`,
|
|
1610
|
+
);
|
|
1611
|
+
}
|
|
1612
|
+
if (imported.kind === "require-call") {
|
|
1613
|
+
throw new Error(
|
|
1614
|
+
`portable plugin runtime ${path} must not use require`,
|
|
1615
|
+
);
|
|
1616
|
+
}
|
|
1617
|
+
if (
|
|
1618
|
+
imported.kind !== "import-statement"
|
|
1619
|
+
|| !allowedPortableRuntimeImports.has(imported.path)
|
|
1620
|
+
) {
|
|
1621
|
+
throw new Error(
|
|
1622
|
+
`portable plugin runtime ${path} imports unsupported module ${imported.path}`,
|
|
1623
|
+
);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
const sourceFile = ts.createSourceFile(
|
|
1628
|
+
path,
|
|
1629
|
+
source,
|
|
1630
|
+
ts.ScriptTarget.ESNext,
|
|
1631
|
+
true,
|
|
1632
|
+
ts.ScriptKind.JS,
|
|
1633
|
+
);
|
|
1634
|
+
let nonLiteralImport = false;
|
|
1635
|
+
let requireReference = false;
|
|
1636
|
+
const visit = (node: ts.Node): void => {
|
|
1637
|
+
if (nonLiteralImport || requireReference) return;
|
|
1638
|
+
if (
|
|
1639
|
+
ts.isCallExpression(node)
|
|
1640
|
+
&& node.expression.kind === ts.SyntaxKind.ImportKeyword
|
|
1641
|
+
) {
|
|
1642
|
+
nonLiteralImport = true;
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
if (runtimeRequireReference(node)) {
|
|
1646
|
+
requireReference = true;
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
ts.forEachChild(node, visit);
|
|
1650
|
+
};
|
|
1651
|
+
visit(sourceFile);
|
|
1652
|
+
if (nonLiteralImport) {
|
|
1653
|
+
throw new Error(
|
|
1654
|
+
`portable plugin runtime ${path} must not use dynamic import`,
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
if (requireReference) {
|
|
1658
|
+
throw new Error(
|
|
1659
|
+
`portable plugin runtime ${path} must not use require`,
|
|
1660
|
+
);
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
function parseProvenance(
|
|
1665
|
+
value: unknown,
|
|
1666
|
+
): PortableProviderPluginProvenanceV1 {
|
|
1667
|
+
const provenance = record(value, "plugin provenance");
|
|
1668
|
+
if (provenance.kind === "local") {
|
|
1669
|
+
exactKeys(provenance, ["kind"], "local plugin provenance");
|
|
1670
|
+
return Object.freeze({ kind: "local" });
|
|
1671
|
+
}
|
|
1672
|
+
if (provenance.kind !== "git") {
|
|
1673
|
+
throw new Error("plugin provenance kind must be local or git");
|
|
1674
|
+
}
|
|
1675
|
+
exactKeys(
|
|
1676
|
+
provenance,
|
|
1677
|
+
["kind", "repository", "revision"],
|
|
1678
|
+
"git plugin provenance",
|
|
1679
|
+
);
|
|
1680
|
+
const repository = exactHttpsRepository(provenance.repository);
|
|
1681
|
+
const repositoryHostname = new URL(repository).hostname.toLowerCase();
|
|
1682
|
+
if (
|
|
1683
|
+
!repository.endsWith(".git")
|
|
1684
|
+
&& repositoryHostname !== "github.com"
|
|
1685
|
+
&& repositoryHostname !== "gitlab.com"
|
|
1686
|
+
) {
|
|
1687
|
+
throw new Error(
|
|
1688
|
+
"plugin git provenance repository must identify an HTTPS repository",
|
|
1689
|
+
);
|
|
1690
|
+
}
|
|
1691
|
+
const revision = boundedString(
|
|
1692
|
+
provenance.revision,
|
|
1693
|
+
"plugin provenance revision",
|
|
1694
|
+
64,
|
|
1695
|
+
);
|
|
1696
|
+
if (!gitRevisionPattern.test(revision)) {
|
|
1697
|
+
throw new Error(
|
|
1698
|
+
"plugin git provenance revision must be a full lowercase commit digest",
|
|
1699
|
+
);
|
|
1700
|
+
}
|
|
1701
|
+
return Object.freeze({
|
|
1702
|
+
kind: "git",
|
|
1703
|
+
repository,
|
|
1704
|
+
revision,
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
function parseManifestOrThrow(
|
|
1709
|
+
value: unknown,
|
|
1710
|
+
): PortableProviderPluginManifestV1 {
|
|
1711
|
+
const manifest = record(value, "portable provider plugin manifest");
|
|
1712
|
+
exactKeys(
|
|
1713
|
+
manifest,
|
|
1714
|
+
[
|
|
1715
|
+
"schemaVersion",
|
|
1716
|
+
"hostApiVersion",
|
|
1717
|
+
"id",
|
|
1718
|
+
"version",
|
|
1719
|
+
"displayName",
|
|
1720
|
+
"runtime",
|
|
1721
|
+
"provenance",
|
|
1722
|
+
"capabilities",
|
|
1723
|
+
"bindings",
|
|
1724
|
+
"files",
|
|
1725
|
+
],
|
|
1726
|
+
"portable provider plugin manifest",
|
|
1727
|
+
);
|
|
1728
|
+
if (
|
|
1729
|
+
manifest.schemaVersion
|
|
1730
|
+
!== PORTABLE_PROVIDER_PLUGIN_MANIFEST_SCHEMA_VERSION
|
|
1731
|
+
) {
|
|
1732
|
+
throw new Error(
|
|
1733
|
+
`portable provider plugin schemaVersion must be ${PORTABLE_PROVIDER_PLUGIN_MANIFEST_SCHEMA_VERSION}`,
|
|
1734
|
+
);
|
|
1735
|
+
}
|
|
1736
|
+
if (manifest.hostApiVersion !== PORTABLE_PROVIDER_PLUGIN_HOST_API_VERSION) {
|
|
1737
|
+
throw new Error(
|
|
1738
|
+
`portable provider plugin hostApiVersion must be ${PORTABLE_PROVIDER_PLUGIN_HOST_API_VERSION}`,
|
|
1739
|
+
);
|
|
1740
|
+
}
|
|
1741
|
+
const id = safePluginId(manifest.id, "plugin ID");
|
|
1742
|
+
const version = safeVersion(manifest.version);
|
|
1743
|
+
const displayName = boundedString(manifest.displayName, "plugin displayName", 160);
|
|
1744
|
+
const runtime = record(manifest.runtime, "plugin runtime");
|
|
1745
|
+
exactKeys(runtime, ["kind", "entrypoint"], "plugin runtime");
|
|
1746
|
+
if (runtime.kind !== "bun-js") {
|
|
1747
|
+
throw new Error("plugin runtime kind must be bun-js");
|
|
1748
|
+
}
|
|
1749
|
+
const entrypoint = safePackagePath(
|
|
1750
|
+
runtime.entrypoint,
|
|
1751
|
+
"plugin runtime entrypoint",
|
|
1752
|
+
);
|
|
1753
|
+
if (!entrypoint.endsWith(".mjs")) {
|
|
1754
|
+
throw new Error("plugin runtime entrypoint must be a bundled .mjs file");
|
|
1755
|
+
}
|
|
1756
|
+
const provenance = parseProvenance(manifest.provenance);
|
|
1757
|
+
const bindings = boundedArray(
|
|
1758
|
+
manifest.bindings,
|
|
1759
|
+
"plugin bindings",
|
|
1760
|
+
MAX_BINDINGS,
|
|
1761
|
+
).map(parseBinding);
|
|
1762
|
+
const routeKeys = bindings.map(
|
|
1763
|
+
(binding) => `${binding.transport}/${binding.surfaceId}`,
|
|
1764
|
+
);
|
|
1765
|
+
if (new Set(routeKeys).size !== routeKeys.length) {
|
|
1766
|
+
throw new Error("portable provider plugin repeats a transport route");
|
|
1767
|
+
}
|
|
1768
|
+
const adapterIds = bindings.map((binding) => binding.adapterId);
|
|
1769
|
+
if (new Set(adapterIds).size !== adapterIds.length) {
|
|
1770
|
+
throw new Error("portable provider plugin repeats an adapter ID");
|
|
1771
|
+
}
|
|
1772
|
+
const sortedBindings = [...bindings].sort((left, right) =>
|
|
1773
|
+
compareCanonicalText(
|
|
1774
|
+
`${left.transport}/${left.surfaceId}`,
|
|
1775
|
+
`${right.transport}/${right.surfaceId}`,
|
|
1776
|
+
),
|
|
1777
|
+
);
|
|
1778
|
+
if (
|
|
1779
|
+
sortedBindings.some((binding, index) => binding !== bindings[index])
|
|
1780
|
+
) {
|
|
1781
|
+
throw new Error("plugin bindings must be sorted by transport and surface");
|
|
1782
|
+
}
|
|
1783
|
+
const bindingOrigins = new Set(bindings.map((binding) => binding.origin));
|
|
1784
|
+
const capabilities = parseCapabilities(
|
|
1785
|
+
manifest.capabilities,
|
|
1786
|
+
bindingOrigins,
|
|
1787
|
+
bindings,
|
|
1788
|
+
);
|
|
1789
|
+
const files = boundedArray(
|
|
1790
|
+
manifest.files,
|
|
1791
|
+
"plugin files",
|
|
1792
|
+
MAX_PLUGIN_FILES,
|
|
1793
|
+
).map(parseFile);
|
|
1794
|
+
const pathKeys = files.map((file) => file.path);
|
|
1795
|
+
const caseFoldedPathKeys = pathKeys.map((path) => path.toLowerCase());
|
|
1796
|
+
if (
|
|
1797
|
+
new Set(pathKeys).size !== pathKeys.length
|
|
1798
|
+
|| new Set(caseFoldedPathKeys).size !== caseFoldedPathKeys.length
|
|
1799
|
+
) {
|
|
1800
|
+
throw new Error("portable provider plugin repeats a file path");
|
|
1801
|
+
}
|
|
1802
|
+
const sortedFiles = [...files].sort((left, right) =>
|
|
1803
|
+
compareCanonicalText(left.path, right.path),
|
|
1804
|
+
);
|
|
1805
|
+
if (sortedFiles.some((file, index) => file !== files[index])) {
|
|
1806
|
+
throw new Error("plugin files must be sorted by path");
|
|
1807
|
+
}
|
|
1808
|
+
const payloadBytes = files.reduce((total, file) => total + file.bytes, 0);
|
|
1809
|
+
if (payloadBytes > MAX_PLUGIN_PAYLOAD_BYTES) {
|
|
1810
|
+
throw new Error(
|
|
1811
|
+
`plugin payload exceeds ${MAX_PLUGIN_PAYLOAD_BYTES} bytes`,
|
|
1812
|
+
);
|
|
1813
|
+
}
|
|
1814
|
+
const runtimeFiles = files.filter((file) => file.kind === "runtime");
|
|
1815
|
+
if (
|
|
1816
|
+
runtimeFiles.length !== 1
|
|
1817
|
+
|| runtimeFiles[0]?.path !== entrypoint
|
|
1818
|
+
) {
|
|
1819
|
+
throw new Error(
|
|
1820
|
+
"plugin package must declare exactly one runtime file, its entrypoint",
|
|
1821
|
+
);
|
|
1822
|
+
}
|
|
1823
|
+
return Object.freeze({
|
|
1824
|
+
schemaVersion: PORTABLE_PROVIDER_PLUGIN_MANIFEST_SCHEMA_VERSION,
|
|
1825
|
+
hostApiVersion: PORTABLE_PROVIDER_PLUGIN_HOST_API_VERSION,
|
|
1826
|
+
id,
|
|
1827
|
+
version,
|
|
1828
|
+
displayName,
|
|
1829
|
+
runtime: Object.freeze({ kind: "bun-js", entrypoint }),
|
|
1830
|
+
provenance,
|
|
1831
|
+
capabilities,
|
|
1832
|
+
bindings: Object.freeze(sortedBindings),
|
|
1833
|
+
files: Object.freeze(sortedFiles),
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
export function parsePortableProviderPluginManifest(
|
|
1838
|
+
value: unknown,
|
|
1839
|
+
): PortableProviderPluginManifestResult {
|
|
1840
|
+
try {
|
|
1841
|
+
return { ok: true, value: parseManifestOrThrow(value) };
|
|
1842
|
+
} catch (error) {
|
|
1843
|
+
return {
|
|
1844
|
+
ok: false,
|
|
1845
|
+
issues: Object.freeze([
|
|
1846
|
+
error instanceof Error ? error.message : "invalid portable provider plugin manifest",
|
|
1847
|
+
]),
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
export function renderPortableProviderPluginManifest(
|
|
1853
|
+
manifest: PortableProviderPluginManifestV1,
|
|
1854
|
+
): string {
|
|
1855
|
+
const parsed = parsePortableProviderPluginManifest(manifest);
|
|
1856
|
+
if (!parsed.ok) {
|
|
1857
|
+
throw new Error(`invalid portable provider plugin manifest: ${parsed.issues.join("; ")}`);
|
|
1858
|
+
}
|
|
1859
|
+
return `${JSON.stringify(parsed.value, null, 2)}\n`;
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
function sha256(bytes: Uint8Array | string): string {
|
|
1863
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
function sameRootStats(
|
|
1867
|
+
left: BigIntStats,
|
|
1868
|
+
right: BigIntStats,
|
|
1869
|
+
): boolean {
|
|
1870
|
+
return (
|
|
1871
|
+
left.dev === right.dev
|
|
1872
|
+
&& left.ino === right.ino
|
|
1873
|
+
&& left.size === right.size
|
|
1874
|
+
&& left.mtimeNs === right.mtimeNs
|
|
1875
|
+
&& left.ctimeNs === right.ctimeNs
|
|
1876
|
+
);
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
type BoundPathIdentity = {
|
|
1880
|
+
readonly device: string;
|
|
1881
|
+
readonly inode: string;
|
|
1882
|
+
};
|
|
1883
|
+
|
|
1884
|
+
type BoundPathDirectoryEntry = {
|
|
1885
|
+
readonly name: string;
|
|
1886
|
+
readonly kind: "file" | "directory" | "symbolic-link" | "other";
|
|
1887
|
+
readonly identity: BoundPathIdentity;
|
|
1888
|
+
readonly size: string;
|
|
1889
|
+
readonly mtimeNs: string;
|
|
1890
|
+
readonly ctimeNs: string;
|
|
1891
|
+
};
|
|
1892
|
+
|
|
1893
|
+
type BoundPathTreeEntry = Omit<BoundPathDirectoryEntry, "name"> & {
|
|
1894
|
+
readonly path: string;
|
|
1895
|
+
};
|
|
1896
|
+
|
|
1897
|
+
type BoundPathFileExpectation = Pick<
|
|
1898
|
+
BoundPathDirectoryEntry,
|
|
1899
|
+
"identity" | "size" | "mtimeNs" | "ctimeNs"
|
|
1900
|
+
>;
|
|
1901
|
+
|
|
1902
|
+
type BoundPathHelperOperation =
|
|
1903
|
+
| {
|
|
1904
|
+
readonly kind: "list-directory";
|
|
1905
|
+
readonly segments: readonly string[];
|
|
1906
|
+
readonly directoryExpectations: readonly BoundPathIdentity[];
|
|
1907
|
+
readonly maximumEntries: number;
|
|
1908
|
+
}
|
|
1909
|
+
| {
|
|
1910
|
+
readonly kind: "read-file";
|
|
1911
|
+
readonly segments: readonly string[];
|
|
1912
|
+
readonly directoryExpectations: readonly BoundPathIdentity[];
|
|
1913
|
+
readonly maximumBytes: number;
|
|
1914
|
+
readonly fileExpectation: BoundPathFileExpectation;
|
|
1915
|
+
}
|
|
1916
|
+
| {
|
|
1917
|
+
readonly kind: "snapshot-tree";
|
|
1918
|
+
readonly maximumEntries: number;
|
|
1919
|
+
readonly maximumDirectories: number;
|
|
1920
|
+
readonly maximumDepth: number;
|
|
1921
|
+
readonly maximumPathBytes: number;
|
|
1922
|
+
}
|
|
1923
|
+
| {
|
|
1924
|
+
readonly kind: "batch-read-files";
|
|
1925
|
+
readonly files: readonly {
|
|
1926
|
+
readonly segments: readonly string[];
|
|
1927
|
+
readonly directoryExpectations: readonly BoundPathIdentity[];
|
|
1928
|
+
readonly maximumBytes: number;
|
|
1929
|
+
readonly fileExpectation: BoundPathFileExpectation;
|
|
1930
|
+
}[];
|
|
1931
|
+
readonly maximumTotalBytes: number;
|
|
1932
|
+
};
|
|
1933
|
+
|
|
1934
|
+
function rootIdentity(
|
|
1935
|
+
stats: Pick<BigIntStats, "dev" | "ino">,
|
|
1936
|
+
): BoundPathIdentity {
|
|
1937
|
+
return {
|
|
1938
|
+
device: stats.dev.toString(),
|
|
1939
|
+
inode: stats.ino.toString(),
|
|
1940
|
+
};
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
function sameBoundPathIdentity(
|
|
1944
|
+
left: BoundPathIdentity,
|
|
1945
|
+
right: BoundPathIdentity,
|
|
1946
|
+
): boolean {
|
|
1947
|
+
return left.device === right.device && left.inode === right.inode;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
function parseBoundPathIdentity(
|
|
1951
|
+
value: unknown,
|
|
1952
|
+
label: string,
|
|
1953
|
+
): BoundPathIdentity {
|
|
1954
|
+
if (!isRecord(value)) throw new Error(`${label} must be an object`);
|
|
1955
|
+
exactKeys(value, ["device", "inode"], label);
|
|
1956
|
+
if (
|
|
1957
|
+
typeof value.device !== "string"
|
|
1958
|
+
|| !/^[0-9]{1,40}$/u.test(value.device)
|
|
1959
|
+
|| typeof value.inode !== "string"
|
|
1960
|
+
|| !/^[0-9]{1,40}$/u.test(value.inode)
|
|
1961
|
+
) throw new Error(`${label} is invalid`);
|
|
1962
|
+
return {
|
|
1963
|
+
device: value.device,
|
|
1964
|
+
inode: value.inode,
|
|
1965
|
+
};
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
function parseBoundPathTreeEntry(value: unknown): BoundPathTreeEntry {
|
|
1969
|
+
if (!isRecord(value)) throw new Error("path helper tree entry must be an object");
|
|
1970
|
+
exactKeys(
|
|
1971
|
+
value,
|
|
1972
|
+
["path", "kind", "identity", "size", "mtimeNs", "ctimeNs"],
|
|
1973
|
+
"path helper tree entry",
|
|
1974
|
+
);
|
|
1975
|
+
if (
|
|
1976
|
+
typeof value.path !== "string"
|
|
1977
|
+
|| value.path === ""
|
|
1978
|
+
|| value.path.startsWith("/")
|
|
1979
|
+
|| value.path.endsWith("/")
|
|
1980
|
+
|| value.path.includes("\\")
|
|
1981
|
+
|| value.path.includes("\u0000")
|
|
1982
|
+
|| Buffer.byteLength(value.path, "utf8") > 512
|
|
1983
|
+
|| value.path.split("/").some((segment) =>
|
|
1984
|
+
segment === "" || segment === "." || segment === "..")
|
|
1985
|
+
|| (
|
|
1986
|
+
value.kind !== "file"
|
|
1987
|
+
&& value.kind !== "directory"
|
|
1988
|
+
&& value.kind !== "symbolic-link"
|
|
1989
|
+
&& value.kind !== "other"
|
|
1990
|
+
)
|
|
1991
|
+
|| typeof value.size !== "string"
|
|
1992
|
+
|| !/^[0-9]{1,40}$/u.test(value.size)
|
|
1993
|
+
|| typeof value.mtimeNs !== "string"
|
|
1994
|
+
|| !/^-?[0-9]{1,40}$/u.test(value.mtimeNs)
|
|
1995
|
+
|| typeof value.ctimeNs !== "string"
|
|
1996
|
+
|| !/^-?[0-9]{1,40}$/u.test(value.ctimeNs)
|
|
1997
|
+
) throw new Error("path helper returned an invalid tree entry");
|
|
1998
|
+
return {
|
|
1999
|
+
path: value.path,
|
|
2000
|
+
kind: value.kind,
|
|
2001
|
+
identity: parseBoundPathIdentity(value.identity, "path helper tree entry identity"),
|
|
2002
|
+
size: value.size,
|
|
2003
|
+
mtimeNs: value.mtimeNs,
|
|
2004
|
+
ctimeNs: value.ctimeNs,
|
|
2005
|
+
};
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
function runBoundPathHelper(
|
|
2009
|
+
root: string,
|
|
2010
|
+
expectedRoot: BoundPathIdentity,
|
|
2011
|
+
operation: BoundPathHelperOperation,
|
|
2012
|
+
): unknown {
|
|
2013
|
+
pathHelperSpawnObserverForTest?.();
|
|
2014
|
+
const child = spawnSync(process.execPath, [
|
|
2015
|
+
"--no-env-file",
|
|
2016
|
+
"--no-install",
|
|
2017
|
+
"--no-macros",
|
|
2018
|
+
"--no-addons",
|
|
2019
|
+
`--config=${pathHelperConfigPath}`,
|
|
2020
|
+
pathHelperPath,
|
|
2021
|
+
], {
|
|
2022
|
+
cwd: root,
|
|
2023
|
+
encoding: "utf8",
|
|
2024
|
+
env: { NODE_ENV: "production" },
|
|
2025
|
+
input: JSON.stringify({
|
|
2026
|
+
schemaVersion: 1,
|
|
2027
|
+
requestId: randomUUID(),
|
|
2028
|
+
expected: expectedRoot,
|
|
2029
|
+
operation,
|
|
2030
|
+
}),
|
|
2031
|
+
maxBuffer: operation.kind === "read-file"
|
|
2032
|
+
? Math.max(1024 * 1024, Math.ceil(operation.maximumBytes / 3) * 4 + 64 * 1024)
|
|
2033
|
+
: operation.kind === "batch-read-files"
|
|
2034
|
+
? Math.ceil(operation.maximumTotalBytes / 3) * 4 + 1024 * 1024
|
|
2035
|
+
: 8 * 1024 * 1024,
|
|
2036
|
+
shell: false,
|
|
2037
|
+
timeout: 30_000,
|
|
2038
|
+
windowsHide: true,
|
|
2039
|
+
});
|
|
2040
|
+
let current: BigIntStats;
|
|
2041
|
+
try {
|
|
2042
|
+
current = lstatSync(root, { bigint: true });
|
|
2043
|
+
} catch {
|
|
2044
|
+
throw new Error("portable provider plugin package root disappeared during verification");
|
|
2045
|
+
}
|
|
2046
|
+
if (
|
|
2047
|
+
current.isSymbolicLink()
|
|
2048
|
+
|| !current.isDirectory()
|
|
2049
|
+
|| !sameBoundPathIdentity(rootIdentity(current), expectedRoot)
|
|
2050
|
+
) {
|
|
2051
|
+
throw new Error("portable provider plugin package root changed identity during verification");
|
|
2052
|
+
}
|
|
2053
|
+
if (child.error !== undefined) {
|
|
2054
|
+
throw new Error("bound package path helper failed to start", { cause: child.error });
|
|
2055
|
+
}
|
|
2056
|
+
if (child.status !== 0) {
|
|
2057
|
+
const detail = child.stderr.trim().slice(0, 512);
|
|
2058
|
+
throw new Error(
|
|
2059
|
+
detail === ""
|
|
2060
|
+
? "bound package path helper rejected the operation"
|
|
2061
|
+
: detail,
|
|
2062
|
+
);
|
|
2063
|
+
}
|
|
2064
|
+
try {
|
|
2065
|
+
return JSON.parse(child.stdout) as unknown;
|
|
2066
|
+
} catch (error) {
|
|
2067
|
+
throw new Error("bound package path helper returned invalid JSON", { cause: error });
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
function snapshotBoundPackageTree(
|
|
2072
|
+
root: string,
|
|
2073
|
+
expectedRoot: BoundPathIdentity,
|
|
2074
|
+
): readonly BoundPathTreeEntry[] {
|
|
2075
|
+
const response = runBoundPathHelper(root, expectedRoot, {
|
|
2076
|
+
kind: "snapshot-tree",
|
|
2077
|
+
maximumEntries: MAX_PLUGIN_PACKAGE_ENTRIES,
|
|
2078
|
+
maximumDirectories: MAX_PLUGIN_DIRECTORIES,
|
|
2079
|
+
maximumDepth: 15,
|
|
2080
|
+
maximumPathBytes: 512,
|
|
2081
|
+
});
|
|
2082
|
+
if (!isRecord(response)) throw new Error("path helper tree response must be an object");
|
|
2083
|
+
exactKeys(
|
|
2084
|
+
response,
|
|
2085
|
+
["ok", "identity", "treeEntries"],
|
|
2086
|
+
"path helper tree response",
|
|
2087
|
+
);
|
|
2088
|
+
if (response.ok !== true || !Array.isArray(response.treeEntries)) {
|
|
2089
|
+
throw new Error("path helper returned a malformed tree response");
|
|
2090
|
+
}
|
|
2091
|
+
const identity = parseBoundPathIdentity(response.identity, "path helper root identity");
|
|
2092
|
+
if (!sameBoundPathIdentity(identity, expectedRoot)) {
|
|
2093
|
+
throw new Error("path helper tree response came from the wrong package root");
|
|
2094
|
+
}
|
|
2095
|
+
if (response.treeEntries.length > MAX_PLUGIN_PACKAGE_ENTRIES) {
|
|
2096
|
+
throw new Error("path helper tree response exceeds its entry bound");
|
|
2097
|
+
}
|
|
2098
|
+
const entries = response.treeEntries.map(parseBoundPathTreeEntry);
|
|
2099
|
+
if (
|
|
2100
|
+
entries.some((entry, index) =>
|
|
2101
|
+
index > 0
|
|
2102
|
+
&& compareCanonicalText(entries[index - 1]!.path, entry.path) >= 0)
|
|
2103
|
+
) {
|
|
2104
|
+
throw new Error("path helper tree response is not strictly sorted");
|
|
2105
|
+
}
|
|
2106
|
+
return Object.freeze(entries);
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
function readBoundPackageFile(
|
|
2110
|
+
root: string,
|
|
2111
|
+
expectedRoot: BoundPathIdentity,
|
|
2112
|
+
relativePath: string,
|
|
2113
|
+
directoryExpectations: readonly BoundPathIdentity[],
|
|
2114
|
+
fileExpectation: BoundPathFileExpectation,
|
|
2115
|
+
maximumBytes: number,
|
|
2116
|
+
label: string,
|
|
2117
|
+
): Buffer {
|
|
2118
|
+
let response: unknown;
|
|
2119
|
+
try {
|
|
2120
|
+
response = runBoundPathHelper(root, expectedRoot, {
|
|
2121
|
+
kind: "read-file",
|
|
2122
|
+
segments: relativePath.split("/"),
|
|
2123
|
+
directoryExpectations,
|
|
2124
|
+
fileExpectation: {
|
|
2125
|
+
identity: fileExpectation.identity,
|
|
2126
|
+
size: fileExpectation.size,
|
|
2127
|
+
mtimeNs: fileExpectation.mtimeNs,
|
|
2128
|
+
ctimeNs: fileExpectation.ctimeNs,
|
|
2129
|
+
},
|
|
2130
|
+
maximumBytes,
|
|
2131
|
+
});
|
|
2132
|
+
} catch (error) {
|
|
2133
|
+
throw new Error(`${label} could not be safely read`, { cause: error });
|
|
2134
|
+
}
|
|
2135
|
+
if (!isRecord(response)) throw new Error("path helper read response must be an object");
|
|
2136
|
+
exactKeys(
|
|
2137
|
+
response,
|
|
2138
|
+
["ok", "identity", "contentBase64"],
|
|
2139
|
+
"path helper read response",
|
|
2140
|
+
);
|
|
2141
|
+
if (response.ok !== true || typeof response.contentBase64 !== "string") {
|
|
2142
|
+
throw new Error("path helper returned a malformed read response");
|
|
2143
|
+
}
|
|
2144
|
+
const identity = parseBoundPathIdentity(response.identity, "path helper root identity");
|
|
2145
|
+
if (!sameBoundPathIdentity(identity, expectedRoot)) {
|
|
2146
|
+
throw new Error("path helper read response came from the wrong package root");
|
|
2147
|
+
}
|
|
2148
|
+
const maximumEncodedBytes = Math.ceil(maximumBytes / 3) * 4;
|
|
2149
|
+
if (
|
|
2150
|
+
response.contentBase64.length > maximumEncodedBytes
|
|
2151
|
+
|| !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(
|
|
2152
|
+
response.contentBase64,
|
|
2153
|
+
)
|
|
2154
|
+
) throw new Error("path helper returned malformed bounded file content");
|
|
2155
|
+
const bytes = Buffer.from(response.contentBase64, "base64");
|
|
2156
|
+
if (
|
|
2157
|
+
bytes.byteLength < 1
|
|
2158
|
+
|| bytes.byteLength > maximumBytes
|
|
2159
|
+
|| bytes.toString("base64") !== response.contentBase64
|
|
2160
|
+
) {
|
|
2161
|
+
throw new Error(
|
|
2162
|
+
`${label} must be a regular file between 1 and ${maximumBytes} bytes`,
|
|
2163
|
+
);
|
|
2164
|
+
}
|
|
2165
|
+
return bytes;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
function readBoundPackageFiles(
|
|
2169
|
+
root: string,
|
|
2170
|
+
expectedRoot: BoundPathIdentity,
|
|
2171
|
+
files: readonly {
|
|
2172
|
+
readonly path: string;
|
|
2173
|
+
readonly directoryExpectations: readonly BoundPathIdentity[];
|
|
2174
|
+
readonly fileExpectation: BoundPathFileExpectation;
|
|
2175
|
+
readonly maximumBytes: number;
|
|
2176
|
+
}[],
|
|
2177
|
+
maximumTotalBytes: number,
|
|
2178
|
+
): readonly Buffer[] {
|
|
2179
|
+
const response = runBoundPathHelper(root, expectedRoot, {
|
|
2180
|
+
kind: "batch-read-files",
|
|
2181
|
+
files: files.map((file) => ({
|
|
2182
|
+
segments: file.path.split("/"),
|
|
2183
|
+
directoryExpectations: file.directoryExpectations,
|
|
2184
|
+
maximumBytes: file.maximumBytes,
|
|
2185
|
+
fileExpectation: {
|
|
2186
|
+
identity: file.fileExpectation.identity,
|
|
2187
|
+
size: file.fileExpectation.size,
|
|
2188
|
+
mtimeNs: file.fileExpectation.mtimeNs,
|
|
2189
|
+
ctimeNs: file.fileExpectation.ctimeNs,
|
|
2190
|
+
},
|
|
2191
|
+
})),
|
|
2192
|
+
maximumTotalBytes,
|
|
2193
|
+
});
|
|
2194
|
+
if (!isRecord(response)) throw new Error("path helper batch read response must be an object");
|
|
2195
|
+
exactKeys(
|
|
2196
|
+
response,
|
|
2197
|
+
["ok", "identity", "fileContentsBase64"],
|
|
2198
|
+
"path helper batch read response",
|
|
2199
|
+
);
|
|
2200
|
+
if (
|
|
2201
|
+
response.ok !== true
|
|
2202
|
+
|| !Array.isArray(response.fileContentsBase64)
|
|
2203
|
+
|| response.fileContentsBase64.length !== files.length
|
|
2204
|
+
) throw new Error("path helper returned a malformed batch read response");
|
|
2205
|
+
const identity = parseBoundPathIdentity(response.identity, "path helper root identity");
|
|
2206
|
+
if (!sameBoundPathIdentity(identity, expectedRoot)) {
|
|
2207
|
+
throw new Error("path helper batch read response came from the wrong package root");
|
|
2208
|
+
}
|
|
2209
|
+
let totalBytes = 0;
|
|
2210
|
+
return Object.freeze(response.fileContentsBase64.map((encoded, index) => {
|
|
2211
|
+
const file = files[index];
|
|
2212
|
+
if (file === undefined || typeof encoded !== "string") {
|
|
2213
|
+
throw new Error("path helper returned malformed batch file content");
|
|
2214
|
+
}
|
|
2215
|
+
const maximumEncodedBytes = Math.ceil(file.maximumBytes / 3) * 4;
|
|
2216
|
+
if (
|
|
2217
|
+
encoded.length > maximumEncodedBytes
|
|
2218
|
+
|| !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(
|
|
2219
|
+
encoded,
|
|
2220
|
+
)
|
|
2221
|
+
) throw new Error("path helper returned malformed bounded batch file content");
|
|
2222
|
+
const bytes = Buffer.from(encoded, "base64");
|
|
2223
|
+
totalBytes += bytes.byteLength;
|
|
2224
|
+
if (
|
|
2225
|
+
bytes.byteLength < 1
|
|
2226
|
+
|| bytes.byteLength > file.maximumBytes
|
|
2227
|
+
|| totalBytes > maximumTotalBytes
|
|
2228
|
+
|| bytes.toString("base64") !== encoded
|
|
2229
|
+
) throw new Error("path helper batch file content violates its byte bound");
|
|
2230
|
+
return bytes;
|
|
2231
|
+
}));
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
type WalkedPackageEntry = BoundPathTreeEntry & {
|
|
2235
|
+
readonly directoryExpectations: readonly BoundPathIdentity[];
|
|
2236
|
+
};
|
|
2237
|
+
|
|
2238
|
+
type WalkedPackage = {
|
|
2239
|
+
readonly files: readonly string[];
|
|
2240
|
+
readonly directories: readonly string[];
|
|
2241
|
+
readonly entries: readonly WalkedPackageEntry[];
|
|
2242
|
+
readonly entryByPath: ReadonlyMap<string, WalkedPackageEntry>;
|
|
2243
|
+
};
|
|
2244
|
+
|
|
2245
|
+
function walkPackage(
|
|
2246
|
+
root: string,
|
|
2247
|
+
expectedRoot: BoundPathIdentity,
|
|
2248
|
+
): WalkedPackage {
|
|
2249
|
+
const files: string[] = [];
|
|
2250
|
+
const directories: string[] = [];
|
|
2251
|
+
const entries: WalkedPackageEntry[] = [];
|
|
2252
|
+
const entryByPath = new Map<string, WalkedPackageEntry>();
|
|
2253
|
+
const treeEntries = snapshotBoundPackageTree(root, expectedRoot);
|
|
2254
|
+
for (const entry of treeEntries) {
|
|
2255
|
+
if (entryByPath.has(entry.path)) {
|
|
2256
|
+
throw new Error(
|
|
2257
|
+
`portable provider plugin package contains duplicate entry ${entry.path}`,
|
|
2258
|
+
);
|
|
2259
|
+
}
|
|
2260
|
+
const segments = entry.path.split("/");
|
|
2261
|
+
const directoryExpectations: BoundPathIdentity[] = [];
|
|
2262
|
+
for (let index = 1; index < segments.length; index += 1) {
|
|
2263
|
+
const parentPath = segments.slice(0, index).join("/");
|
|
2264
|
+
const parent = entryByPath.get(parentPath);
|
|
2265
|
+
if (parent === undefined || parent.kind !== "directory") {
|
|
2266
|
+
throw new Error(
|
|
2267
|
+
`portable provider plugin tree omits directory ${parentPath}`,
|
|
2268
|
+
);
|
|
2269
|
+
}
|
|
2270
|
+
directoryExpectations.push(parent.identity);
|
|
2271
|
+
}
|
|
2272
|
+
if (entry.kind === "symbolic-link") {
|
|
2273
|
+
throw new Error(
|
|
2274
|
+
`portable provider plugin package contains symlink ${entry.path}`,
|
|
2275
|
+
);
|
|
2276
|
+
}
|
|
2277
|
+
const walkedEntry = Object.freeze({
|
|
2278
|
+
...entry,
|
|
2279
|
+
directoryExpectations: Object.freeze(directoryExpectations),
|
|
2280
|
+
});
|
|
2281
|
+
entries.push(walkedEntry);
|
|
2282
|
+
entryByPath.set(entry.path, walkedEntry);
|
|
2283
|
+
if (entry.kind === "directory") {
|
|
2284
|
+
safePackagePath(`${entry.path}/placeholder`, "plugin directory path");
|
|
2285
|
+
directories.push(entry.path);
|
|
2286
|
+
if (directories.length > MAX_PLUGIN_DIRECTORIES) {
|
|
2287
|
+
throw new Error(
|
|
2288
|
+
`portable provider plugin package exceeds ${MAX_PLUGIN_DIRECTORIES} directories`,
|
|
2289
|
+
);
|
|
2290
|
+
}
|
|
2291
|
+
continue;
|
|
2292
|
+
}
|
|
2293
|
+
if (entry.kind !== "file") {
|
|
2294
|
+
throw new Error(
|
|
2295
|
+
`portable provider plugin package contains unsupported entry ${entry.path}`,
|
|
2296
|
+
);
|
|
2297
|
+
}
|
|
2298
|
+
if (!portableProviderPluginManifestNames.has(entry.path)) {
|
|
2299
|
+
safePackagePath(entry.path, "plugin package file path");
|
|
2300
|
+
}
|
|
2301
|
+
files.push(entry.path);
|
|
2302
|
+
if (files.length > MAX_PLUGIN_FILES + 1) {
|
|
2303
|
+
throw new Error(
|
|
2304
|
+
`portable provider plugin package exceeds ${MAX_PLUGIN_FILES} declared files`,
|
|
2305
|
+
);
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
return {
|
|
2309
|
+
files: Object.freeze(files.sort(compareCanonicalText)),
|
|
2310
|
+
directories: Object.freeze(directories.sort(compareCanonicalText)),
|
|
2311
|
+
entries: Object.freeze(entries),
|
|
2312
|
+
entryByPath,
|
|
2313
|
+
};
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
function sameWalkedPackage(
|
|
2317
|
+
left: WalkedPackage,
|
|
2318
|
+
right: WalkedPackage,
|
|
2319
|
+
): boolean {
|
|
2320
|
+
return (
|
|
2321
|
+
left.entries.length === right.entries.length
|
|
2322
|
+
&& left.entries.every((entry, index) => {
|
|
2323
|
+
const candidate = right.entries[index];
|
|
2324
|
+
return candidate !== undefined
|
|
2325
|
+
&& entry.path === candidate.path
|
|
2326
|
+
&& entry.kind === candidate.kind
|
|
2327
|
+
&& sameBoundPathIdentity(entry.identity, candidate.identity)
|
|
2328
|
+
&& entry.size === candidate.size
|
|
2329
|
+
&& entry.mtimeNs === candidate.mtimeNs
|
|
2330
|
+
&& entry.ctimeNs === candidate.ctimeNs;
|
|
2331
|
+
})
|
|
2332
|
+
);
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
function expectedDirectories(paths: readonly string[]): readonly string[] {
|
|
2336
|
+
const expected = new Set<string>();
|
|
2337
|
+
for (const path of paths) {
|
|
2338
|
+
const segments = path.split("/");
|
|
2339
|
+
for (let index = 1; index < segments.length; index += 1) {
|
|
2340
|
+
expected.add(segments.slice(0, index).join("/"));
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
return [...expected].sort();
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
export function verifyPortableProviderPluginPackageDirectory(
|
|
2347
|
+
path: string,
|
|
2348
|
+
): VerifiedPortableProviderPluginPackage {
|
|
2349
|
+
const root = resolve(path);
|
|
2350
|
+
let rootStats: BigIntStats;
|
|
2351
|
+
try {
|
|
2352
|
+
rootStats = lstatSync(root, { bigint: true });
|
|
2353
|
+
} catch {
|
|
2354
|
+
throw new Error("portable provider plugin package directory does not exist");
|
|
2355
|
+
}
|
|
2356
|
+
if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) {
|
|
2357
|
+
throw new Error(
|
|
2358
|
+
"portable provider plugin package root must be a regular non-symlink directory",
|
|
2359
|
+
);
|
|
2360
|
+
}
|
|
2361
|
+
const expectedRoot = rootIdentity(rootStats);
|
|
2362
|
+
const walked = walkPackage(root, expectedRoot);
|
|
2363
|
+
const manifestNames = walked.files.filter((file) =>
|
|
2364
|
+
portableProviderPluginManifestNames.has(file)
|
|
2365
|
+
);
|
|
2366
|
+
if (manifestNames.length === 0) {
|
|
2367
|
+
throw new Error(
|
|
2368
|
+
`portable provider plugin package is missing ${PORTABLE_PROVIDER_PLUGIN_MANIFEST_NAME}`,
|
|
2369
|
+
);
|
|
2370
|
+
}
|
|
2371
|
+
if (manifestNames.length > 1) {
|
|
2372
|
+
throw new Error(
|
|
2373
|
+
`portable provider plugin package must contain exactly one of ${[...portableProviderPluginManifestNames].join(" or ")}`,
|
|
2374
|
+
);
|
|
2375
|
+
}
|
|
2376
|
+
const manifestName = manifestNames[0];
|
|
2377
|
+
if (manifestName === undefined) {
|
|
2378
|
+
throw new Error("portable provider plugin package manifest selection failed");
|
|
2379
|
+
}
|
|
2380
|
+
const manifestEntry = walked.entryByPath.get(
|
|
2381
|
+
manifestName,
|
|
2382
|
+
);
|
|
2383
|
+
if (manifestEntry === undefined || manifestEntry.kind !== "file") {
|
|
2384
|
+
throw new Error(
|
|
2385
|
+
`portable provider plugin package is missing ${manifestName}`,
|
|
2386
|
+
);
|
|
2387
|
+
}
|
|
2388
|
+
const manifestBytes = readBoundPackageFile(
|
|
2389
|
+
root,
|
|
2390
|
+
expectedRoot,
|
|
2391
|
+
manifestName,
|
|
2392
|
+
manifestEntry.directoryExpectations,
|
|
2393
|
+
manifestEntry,
|
|
2394
|
+
MAX_MANIFEST_BYTES,
|
|
2395
|
+
"portable provider plugin manifest",
|
|
2396
|
+
);
|
|
2397
|
+
let manifestValue: unknown;
|
|
2398
|
+
try {
|
|
2399
|
+
manifestValue = JSON.parse(manifestBytes.toString("utf8")) as unknown;
|
|
2400
|
+
} catch {
|
|
2401
|
+
throw new Error("portable provider plugin manifest must contain valid UTF-8 JSON");
|
|
2402
|
+
}
|
|
2403
|
+
const parsed = parsePortableProviderPluginManifest(manifestValue);
|
|
2404
|
+
if (!parsed.ok) {
|
|
2405
|
+
throw new Error(
|
|
2406
|
+
`invalid portable provider plugin manifest: ${parsed.issues.join("; ")}`,
|
|
2407
|
+
);
|
|
2408
|
+
}
|
|
2409
|
+
const canonicalManifest = renderPortableProviderPluginManifest(parsed.value);
|
|
2410
|
+
if (!manifestBytes.equals(Buffer.from(canonicalManifest, "utf8"))) {
|
|
2411
|
+
throw new Error(
|
|
2412
|
+
"portable provider plugin manifest must use canonical rendered JSON",
|
|
2413
|
+
);
|
|
2414
|
+
}
|
|
2415
|
+
const expectedFiles = [
|
|
2416
|
+
manifestName,
|
|
2417
|
+
...parsed.value.files.map((file) => file.path),
|
|
2418
|
+
].sort();
|
|
2419
|
+
const actualFiles = [...walked.files].sort();
|
|
2420
|
+
if (
|
|
2421
|
+
expectedFiles.length !== actualFiles.length
|
|
2422
|
+
|| expectedFiles.some((file, index) => file !== actualFiles[index])
|
|
2423
|
+
) {
|
|
2424
|
+
throw new Error(
|
|
2425
|
+
"portable provider plugin package files differ from its exact manifest",
|
|
2426
|
+
);
|
|
2427
|
+
}
|
|
2428
|
+
const declaredDirectories = expectedDirectories(
|
|
2429
|
+
parsed.value.files.map((file) => file.path),
|
|
2430
|
+
);
|
|
2431
|
+
const actualDirectories = [...walked.directories].sort();
|
|
2432
|
+
if (
|
|
2433
|
+
declaredDirectories.length !== actualDirectories.length
|
|
2434
|
+
|| declaredDirectories.some(
|
|
2435
|
+
(directory, index) => directory !== actualDirectories[index],
|
|
2436
|
+
)
|
|
2437
|
+
) {
|
|
2438
|
+
throw new Error(
|
|
2439
|
+
"portable provider plugin package contains an undeclared or empty directory",
|
|
2440
|
+
);
|
|
2441
|
+
}
|
|
2442
|
+
const walkedFiles = parsed.value.files.map((file) => {
|
|
2443
|
+
const walkedEntry = walked.entryByPath.get(file.path);
|
|
2444
|
+
if (walkedEntry === undefined || walkedEntry.kind !== "file") {
|
|
2445
|
+
throw new Error(
|
|
2446
|
+
`portable provider plugin package no longer contains file ${file.path}`,
|
|
2447
|
+
);
|
|
2448
|
+
}
|
|
2449
|
+
return walkedEntry;
|
|
2450
|
+
});
|
|
2451
|
+
const payloadFileBytes = readBoundPackageFiles(
|
|
2452
|
+
root,
|
|
2453
|
+
expectedRoot,
|
|
2454
|
+
walkedFiles.map((walkedEntry) => ({
|
|
2455
|
+
path: walkedEntry.path,
|
|
2456
|
+
directoryExpectations: walkedEntry.directoryExpectations,
|
|
2457
|
+
fileExpectation: walkedEntry,
|
|
2458
|
+
maximumBytes: MAX_PLUGIN_FILE_BYTES,
|
|
2459
|
+
})),
|
|
2460
|
+
MAX_PLUGIN_PAYLOAD_BYTES,
|
|
2461
|
+
);
|
|
2462
|
+
let payloadBytes = 0;
|
|
2463
|
+
const files = parsed.value.files.map((file, index) => {
|
|
2464
|
+
const bytes = payloadFileBytes[index];
|
|
2465
|
+
if (bytes === undefined) {
|
|
2466
|
+
throw new Error(`portable provider plugin file ${file.path} was not read`);
|
|
2467
|
+
}
|
|
2468
|
+
payloadBytes += bytes.byteLength;
|
|
2469
|
+
if (bytes.byteLength !== file.bytes) {
|
|
2470
|
+
throw new Error(
|
|
2471
|
+
`portable provider plugin file ${file.path} byte length does not match its manifest`,
|
|
2472
|
+
);
|
|
2473
|
+
}
|
|
2474
|
+
const digest = sha256(bytes);
|
|
2475
|
+
if (digest !== file.sha256) {
|
|
2476
|
+
throw new Error(
|
|
2477
|
+
`portable provider plugin file ${file.path} digest does not match its manifest`,
|
|
2478
|
+
);
|
|
2479
|
+
}
|
|
2480
|
+
return Object.freeze({
|
|
2481
|
+
path: file.path,
|
|
2482
|
+
kind: file.kind,
|
|
2483
|
+
bytes,
|
|
2484
|
+
sha256: digest,
|
|
2485
|
+
});
|
|
2486
|
+
});
|
|
2487
|
+
const runtimeFile = files.find((file) => file.kind === "runtime");
|
|
2488
|
+
if (runtimeFile === undefined) {
|
|
2489
|
+
throw new Error("portable provider plugin runtime file was not read");
|
|
2490
|
+
}
|
|
2491
|
+
assertSelfContainedPortableRuntime(runtimeFile.path, runtimeFile.bytes);
|
|
2492
|
+
if (payloadBytes > MAX_PLUGIN_PAYLOAD_BYTES) {
|
|
2493
|
+
throw new Error(
|
|
2494
|
+
`portable provider plugin payload exceeds ${MAX_PLUGIN_PAYLOAD_BYTES} bytes`,
|
|
2495
|
+
);
|
|
2496
|
+
}
|
|
2497
|
+
const rootAfter = lstatSync(root, { bigint: true });
|
|
2498
|
+
const walkedAfter = walkPackage(root, expectedRoot);
|
|
2499
|
+
if (
|
|
2500
|
+
!sameRootStats(rootStats, rootAfter)
|
|
2501
|
+
|| !sameWalkedPackage(walked, walkedAfter)
|
|
2502
|
+
) {
|
|
2503
|
+
throw new Error(
|
|
2504
|
+
"portable provider plugin package changed while it was being verified",
|
|
2505
|
+
);
|
|
2506
|
+
}
|
|
2507
|
+
const bundleHash = createHash("sha256")
|
|
2508
|
+
.update("io-portable-provider-plugin-v1\0")
|
|
2509
|
+
.update(canonicalManifest);
|
|
2510
|
+
for (const file of files) {
|
|
2511
|
+
bundleHash
|
|
2512
|
+
.update("\0")
|
|
2513
|
+
.update(file.path)
|
|
2514
|
+
.update("\0")
|
|
2515
|
+
.update(file.kind)
|
|
2516
|
+
.update("\0")
|
|
2517
|
+
.update(file.sha256);
|
|
2518
|
+
}
|
|
2519
|
+
const verified = Object.freeze({
|
|
2520
|
+
root,
|
|
2521
|
+
manifest: parsed.value,
|
|
2522
|
+
manifestBytes: Buffer.from(manifestBytes),
|
|
2523
|
+
manifestSha256: sha256(manifestBytes),
|
|
2524
|
+
bundleSha256: bundleHash.digest("hex"),
|
|
2525
|
+
payloadBytes,
|
|
2526
|
+
files: Object.freeze(files),
|
|
2527
|
+
});
|
|
2528
|
+
verifiedPortableProviderPluginPackages.add(verified);
|
|
2529
|
+
return verified;
|
|
2530
|
+
}
|