From 06883c51b7e023107dbefe7ddbff9464f6e626e5 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 28 Oct 2026 12:15:35 +0900 Subject: [PATCH 1/2] docs: define Workspace Plugin API contracts --- resources/plugin/API.md | 344 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 resources/plugin/API.md diff --git a/resources/plugin/API.md b/resources/plugin/API.md new file mode 100644 index 000000000..ecd371b36 --- /dev/null +++ b/resources/plugin/API.md @@ -0,0 +1,344 @@ +# Workspace Plugin APIの設計契約 + +本書はWorkspace管理のPluginに採用する設計を定める。実行形式はWebAssembly Component Model、engineはWasmtime、guest/Host境界はWITとする。実装済みAPIの利用ガイドではない。既存の[v1 Tool world](wit/yoi-plugin-tool-v1.wit)、[v1 instance world](wit/yoi-plugin-instance-v1.wit)、[v1 Host imports](wit/deps/yoi-host/yoi-host-v1.wit)、PDK、templateは旧v1のoffline authoring資産であり、本書のv2実行契約をまだ提供していない。 + +管理、guest実行、Server上の拡張データを横断する判断をWIT資産の近くで一か所に保持する。実装時には各wire型と境界testを契約の機械的な根拠にし、ここには所有境界・失敗保証・採用理由を残す。作業順と進捗の正本はO-4、T-469、T-465、T-533とする。 + +## 1. 三つのAPIと所有境界 + +Pluginを管理する権限とPluginコードに渡す権限を分離する。Serverに保存するからServer operatorしか導入できない、とはしない。 + +| 境界 | 呼び出し方向 | 責任 | +| --- | --- | --- | +| Workspace管理API | 認可された管理client → Server | 導入・更新・失効・削除、設定、検証結果、診断 | +| Component exports | Runtime Host → guest | Tool呼び出し、Service/event処理 | +| Host imports | guest → Runtime Host → 必要なdomain owner | 外部通信、拡張データ、診断等の限定された作用 | + +ServerはWorkspace所属、設定、installation、artifact、永続操作の正本を持つ。Runtimeはplanを適用し、guestと接続・一時handleを所有する。公開HTTP DTOは`server-api`、domain認可と永続化は`workspace-server`、guest ABIは`resources/plugin/wit`とPDKの責任とする。Rust traitやServer RESTを汎用proxyとしてguestへ渡さない。 + +Workspace管理者は自Workspaceに導入・更新できる。読取りcallerにはWorkspace読取り権限、mutationにはWorkspace管理権限を要求し、既存の認証済みactorから判断する。operator権限は通常導入の前提にしない。Worker/model/repository/packageの自己申告は管理権限にならない。 + +Hostが実行する全操作の上限は、packageの要求、Workspaceの付与、Profileの縮小、呼出元から委譲可能な範囲、現在の対象資源・運用制約の積集合とする。Serviceはlaunch時に明示されたService用grantを使い、管理者のsession credentialを借用しない。SubWorkerへのPlugin継承も再解決された委譲範囲内のみとし、親のhandleを移送しない。 + +## 2. identityと保存するもの + +installationはWorkspaceに属し、artifactはimmutable bytesで識別する。同じpackage名やversionは権限でも実体の一意性の証拠でもない。 + +- `installation_id`はServerが発行するWorkspace内の導入identity。package作者の`id`は名前であり、このidentityを置換しない。 +- 一つのinstallationに複数のimmutable artifactを登録できる。updateは新しいdigestの登録であり既存bytesを置換しない。同じinstallation内のpackage id/versionに異なる内容を登録する場合はversion衝突として拒否する。他Workspaceの同名versionは検査・存在開示の対象にしない。 +- 同一package idの別installationは許すが、設定aliasを一意にする。同じpackageを別credentialで使う用途をversion名の改変で代替しない。 +- Tool公開名はinstallationの不変ID由来namespaceと宣言Tool名からHostが構成する。表示aliasの変更で既存planのTool名を変えない。衝突・長さ超過はplan検証時に拒否し、登録時に黙ってrenameしない。 +- artifact blobの共有は物理dedupeに限定する。他Workspaceのcatalog、設定、導入履歴、data、grantは共有されない。hashを知っているだけでは取得できない。 + +### package digestと配送digest + +既存`manifest::plugin`のdigestは、path順のfile集合をhashするpackage identityであり、ZIP全体のSHA-256ではない。両者を同じfieldとして検証しない。 + +- `package_digest`は既存のfile集合digestを維持する。schema/manifest/componentを含む論理内容の同一性を検証する。 +- `artifact_digest`はServerが保存・配送するcanonical archive bytesのSHA-256とする。`artifact_size`と共に配送・cacheを検証する。 +- Serverは検証済みfile集合から既存canonical pack規則でarchiveを生成し、そのbytesを一度保存する。取得時に再packしない。upload元ZIPのbytesと保存bytesが異なり得るため、両digestと保存sizeを応答する。 +- planは両digest、package schema、exact Component world/API versionを固定する。Runtimeは受信bytesのhashに加え、package validatorで論理digestを検証する。 + +## 3. Workspace管理API + +管理操作は既存のtyped `ServerApi`へ追加する。以下は採用するoperationの意味であり、別のPlugin専用HTTP frameworkは作らない。HTTP prefixは既存の`/api/w/{workspace}`とし、route/DTO/OpenAPI/clientは同じ宣言から生成する。 + +### 操作と競合 + +| 操作 | 契約 | +| --- | --- | +| `POST /plugin-uploads` | bounded binary `.yoi-plugin`を受け、caller/Workspaceに束縛した一時upload identityと検証結果を返す。install・enableはしない | +| `POST /plugins` | upload identity、Workspace内alias、operation keyでinstallationを作成。結果はinstallation/artifact identityと確定operationの識別子 | +| `GET /plugins` / `GET /plugins/{installation}` | Workspace-scoped bounded一覧・詳細。opaque cursor、page sizeの上限あり。guest codeは実行しない | +| `POST /plugins/{installation}/artifacts` | 新しい検証済みuploadからartifact追加。観測した最終catalog変更operationと新しいoperation keyが必要。既存plan/configは変更しない | +| `POST /plugins/{installation}/revoke` | 指定artifact digestの導入許可を失効。観測した最終catalog変更operation、理由、新しいoperation key。新規実行と既存実行の操作権限を撤回 | +| `POST /plugins/{installation}/uninstall` | 観測した最終catalog変更operationと新しいoperation keyで除去を要求。参照があれば拒否し、安全な阻害情報を返す | +| `GET /plugin-operations/{operation}` | 認可された同Workspace callerが保存結果を照合。結果不明のmutationを新しいkeyでやり直さない | + +uploadはmanagement operationと分ける。巨大bytesをJSON/idempotency記録に埋めず、検証に失敗したuploadからcatalogが生じないようにするためである。upload identityは24時間で失効する一時資源で、同じcontentの再uploadは可能。失効後も既にcommitしたinstallationを消さない。usage/quotaは未確定uploadも含む。 + +catalog mutationのoperation keyはWorkspace・認証主体・操作kindに束縛し、canonical request fingerprintと保存結果をdomain更新と同じtransactionで確定する。fingerprintは対象、観測した最終catalog変更operation、内容digest、alias、理由など意味を変える入力を含み、生成された結果IDや秘密値を含まない。同じkey/入力には元の結果、異なる入力にはconflictを返す。replay時も現在の読取り権限を確認し、権限失効後に以前の結果を漏らさない。保存済みreplayは最新stateの観測ではない。 + +catalogの追加・失効・除去では、そのinstallationの最終変更を確定したoperation identityをtransaction内で比較してから更新する。時刻やstateだけでは、同じstateのままartifactが追加された後の古い削除判断を拒否できないためである。設定内容はconfig treeの実内容digestで比較し、別の汎用更新カウンタは導入しない。 + +configのenable/disable、artifact選択、接続binding、grant変更は既存Workspace config Preview/Commit/CASへ統合する。別のenable REST endpointを作って設定正本を二重化しない。Preview後のinstallation/schema変更はcommit時にも照合する。 + +### install・disable・revoke・uninstall + +- installは導入可能なartifactを登録するだけで、Workerの開始やTool登録を行わない。 +- updateとconfig disableは新規/replacementのplanに反映する。既存Workerのpinをhot reloadしない。即時停止が必要なら明示revokeを使う。 +- revokeはinstallationとartifact digestの組に対して不可逆とする。同じ組を再許可して古い実行を復活させない。管理者が同じbytesを再利用する場合は新しいinstallationを明示作成し、新しいconfig/planを生成する。古いinstallationの失効tombstoneとpinは保持する。 +- revokeは新規Host操作を拒否し、稼働中guestの取消・接続closeを要求する。既に外部へ送った効果の取り消しは保証しない。Remote Runtimeへ失効通知を送るだけでは認可が撤回された証拠にならない。保護操作のadmissionはownerでlive確認し、確認不能なら拒否する。stream接続はHostの失効監視で閉じ、監視を維持できない場合も継続しない。 +- uninstallはconfig、active/pending/restorable Worker pinが参照する間は拒否する。revokeしてもpinを勝手に消さない。Worker retention解除・config変更・uninstallを別操作として扱う。 +- installationの拡張データが残る場合もuninstallは拒否する。後述の明示purgeで消すか、データを保持したdisabled installationとして残す。 +- artifact GCは全Workspaceの参照を確認する。DB参照より先に永続blobをpublishし、crash時の未参照blobは後で回収する。未publish blobをcatalogから参照させない。 + +参照検査・新規pin/config参照の作成・削除fenceは同じdomain transaction境界で競合を解決する。削除直前の単なるlist結果では判断しない。 + +HTTP errorは既存共通error envelopeへ合わせる。invalid=400、未認証=401、権限不足=403、不可視対象=404、最終catalog変更operation不一致/operation-key/参照競合=409、size=413、quota=429、owner unavailable=503を使う。他Workspace対象の存在をerror詳細から漏らさない。errorの`retryable`だけでmutationの新規再送を許可しない。 + +## 4. package宣言とABI version + +新しい実行契約は`yoi:plugin@2.0.0`と`yoi:host@2.0.0`にする。JSON文字列だけのHost呼出し、数値handle、guest側WebSocket recvを変えるため、v1の意味を黙って差し替えない。 + +- 新しいmanifest schemaはv2とし、required worlds/interfaces、Tool schema、接続slot、拡張data schema、event subscriptionを静的に宣言する。schema contributionを得るためにguestを実行しない。 +- Workspace設定はDCDLのclosed typed schemaで検証する。Toolのmodel-facing入力はJSON Schemaで定義する。JSON SchemaとDCDL schemaを同じものと仮定しない。 +- Componentの実imports/exportsを静的に検査してmanifestと照合する。manifestの申告だけで未公開importをlinkしない。 +- 初期実装はexact world/interface versionだけを受け入れる。semver rangeでHost APIを黙って置換しない。互換な追加は別versionとして明示サポートし、既存pinには元の契約を提供するかunsupportedで止める。 +- 旧v1 packageはofflineで読めても新Runtimeでは実行対象にしない。v2向けrebuild/explicit installを要求し、汎用JSON adapterで旧権限経路を復活させない。 +- PDK/template/validator/WITを一緒にv2へ更新する。今回の設計文書だけを根拠に既存v1 templateが新Runtimeで動くとは扱わない。 + +## 5. Component exports + +Tool payloadだけはPluginごとにschemaが異なるため、schema検証済みUTF-8 JSONを運ぶ。それ以外の共通context、error、event envelope、Host request/resultはWIT record/variant/resourceで定義する。 + +以下は採用するinterfaceの形を説明する最小例で、現行WITへ追加済みという意味ではない。 + +```wit +package yoi:plugin@2.0.0; + +interface tool { + record invocation { + invocation-id: string, + tool-name: string, + input-json: string, + config-json: string, + remaining-ms: u32, + } + record output { + summary: string, + content: option, + structured-json: option, + } + record tool-error { + code: string, + message: string, + } + invoke: func(input: invocation) -> result; +} + +world tool-plugin { + export tool; +} +``` + +`invocation-id`はHost生成の追跡identityであり権限ではない。contextにServer token、host path、任意Workspace selectorを含めない。configはplanのnon-secret snapshotで、secret値は含めない。structured outputには宣言されたoutput schemaを要求し、Hostが検証する。 + +Tool worldは呼出しごとにfresh Store/instanceとし、compiled Componentは共有できる。Toolのglobal/static memoryを永続化や他呼出しとの通信に使えない。v2初期Toolは応答一回のbounded callとし、guestがmodelへ直接streamを注入するAPIは設けない。 + +Service/event worldには`on-start(config) -> result`、`on-event(envelope) -> event-result`、`on-stop(reason) -> result`を設ける。いずれもfresh Storeで呼び、HostがWorker/installation/planに束縛したService resourceを保持する。ToolとServiceで共有する状態はHost経由の明示dataとし、guest memoryの残存を契約にしない。`status()`を呼んで診断のためにguestを実行せず、Hostが実行・接続・失敗の観測を返す。 + +`on-start`は設定検証とローカル初期化だけを行い、logging以外のHost作用を認めない。GETも副作用を持ち得るため、汎用HTTPや付随data書込みを初期化中に許可しない。外部event sourceの接続は全required Pluginの検証・導入成功後にHostが開始する。initialization失敗時に外部効果のrollbackを装わない。`on-stop`もloggingだけのbest effort通知で、resource解放の前提にしない。 + +## 6. Host importsとcapability lifetime + +公開するinterfaceは機能ごとに分ける。汎用`invoke(method, json)`、汎用Server HTTP proxy、DB connection、host path、任意process実行APIは提供しない。 + +| interface | 操作の意味 | +| --- | --- | +| `connections` | 宣言slot名から、その呼出しに許可されたconnection resourceを取得 | +| `http` | connectionに束縛されたbounded request/response | +| `extension-data` | Plugin所有schemaのdataを対象object capability経由でread/CAS update | +| `logging` | bounded structured診断。model contextへの注入ではない | +| `workdir` | 後続で既存attachment-scoped operationを型付きにadapt。ambient filesystemは不可 | +| `websocket` | 後続のHost-owned driverへのsend/close。recvはevent配送で行う | + +`connections.open(slot)`はWIT resourceを返す。slotはmanifestの宣言名で、Workspace設定が具体的な接続先とsecret参照にbindする。guestは任意credential IDや別Workspace IDを指定しない。resourceはStore/呼出し内だけで有効で、serialize・persist・別Workerへtransferできない。Serviceの長寿命接続自体はHost所有とし、各event処理時にfresh handleを発行する。 + +Hostは各importでcurrent invocation、installation、plan、付与範囲、対象資源、deadline/cancellationを照合する。lookup済みhandleを後続操作の無条件許可にしない。guestのComponent start sectionからの外部I/Oも禁止し、export呼出し用contextのないimportを拒否する。 + +planはconfig treeの内容digest、Profile projection digest、installation/artifact identities、接続binding identityとpolicyの内容digest、宣言schemaと実効grant、実行主体と委譲identityを固定する。管理操作を行った人の資格情報はguestの実行主体ではない。 + +| 変更・状態 | 既存plan/new export/restoreへの作用 | +| --- | --- | +| artifact追加、config enable/disable、Profileの選択・grant設定変更 | 新規/replacement planだけに適用。既存pinの集合や付与snapshotは変更しない | +| Workspace/対象資源のlive grant上限の縮小、明示的grant revoke、実行主体の委譲失効 | 既存snapshotとの積集合を直ちに縮小。次のadmission/Host操作を拒否し、進行中処理へ取消要求 | +| live grant拡大 | 古いsnapshotを広げない。追加利用には新規planが必要 | +| 接続binding失効、installation+artifactのrevoke | 新しいexport・接続・restoreを拒否。既存guest/driverを取消対象にする | +| 管理者の管理権限喪失 | 以後の管理操作を拒否。独立したWorker実行権限まで導入者の権限と同一視しない | +| ownerへ確認できない | fresh export、restore、保護操作・保存結果読取りを拒否。cached planを認可にしない | + +各fresh exportの開始とrestoreにはServer ownerでinstallation/plan/実行主体のlive admissionを行う。pure Toolも例外にしない。既にadmit済みのローカル計算は取消・deadlineまで進み得るが、失効後の結果公開と追加Host作用は拒否する。初期Toolにoffline実行leaseは設けない。設定でのgrant変更とlive security grantの失効を混同しないよう、管理画面/APIの結果も適用対象を明示する。 + +WASIを一括linkしない。v2 worldに宣言したimportだけをallowlistし、ambient filesystem/network/environment/stdin/stdoutを与えない。clock/random等を追加する場合も個別のversioned interfaceとして明示する。PDKはこのimport集合だけでcompileできるようにする。 + +Wasmtimeのasync Host実装を使って待機中のexecutor threadを占有しない。ただしguestにexperimental async Component ABIを要求せず、初期WITは通常のcall/resultとする。Storeを並行利用せず、同一Service consumerへのevent処理を直列化する。 + +## 7. 外部HTTP接続 + +HTTPはURLを自由に受けるAPIではなく、connection resourceに対する相対pathのrequestとする。Server/Runtimeの資格情報を外部requestへ転用しない。 + +requestはmethod enum、absolute-path形式の相対path、query pair、許可されたheader pair、body bytes、要求timeoutを持つ。responseはstatus、許可されたheader、body bytesを持つ。4xx/5xxもHTTP応答として返し、transport errorと区別する。binaryはbyte listで運び、base64やJSON stringで二重に包まない。 + +初期Host interfaceの型は次の形にする。Tool worldにはこれらの必要なinterfaceを明示importし、未使用の機能を一括importするworldにはしない。 + +```wit +package yoi:host@2.0.0; + +interface errors { + enum code { + invalid-input, denied, not-found, conflict, unsupported, + quota-exceeded, deadline-exceeded, cancelled, unavailable, + indeterminate, receipt-expired, + } + enum effect-observation { not-dispatched, response-observed, indeterminate } + record host-error { + code: code, + message: string, + diagnostic-id: string, + effect: effect-observation, + } +} + +interface connections { + use errors.{host-error}; + resource connection; + open: func(slot: string) -> result; +} + +interface http { + use connections.{connection}; + use errors.{host-error}; + enum method { get, head, post, put, patch, delete, options } + record pair { name: string, value: string } + record request { + method: method, + path: string, + query: list, + headers: list, + body: list, + timeout-ms: u32, + effect-key: string, + } + record response { + status: u16, + headers: list, + body: list, + } + send: func(target: borrow, input: request) -> result; +} +``` + +queryはHostがpercent-encodeして送る。path中への`?`埋込みを拒否し、query/headerの重複・順序は許可されたprotocol semanticsを保持する。guest指定の接続/転送制御headerは拒否し、Hostが必要なものを生成する。effect-keyは最大128 UTF-8 bytesの非秘密のopaque文字列とし、Hostのinvocationに束縛する。 + +- Workspace bindingはexact origin(scheme/host/port)、許可method、segment境界のpath範囲、secret参照、資源上限を持つ。packageはslotと要求範囲を宣言できるが、bindingを自己発行できない。 +- 初期APIはHTTPSのみ。TLS検証を無効にできない。raw socket、任意proxy、Unix socket、CONNECT、upgrade、redirect自動追従は提供しない。 +- pathはHostが一度canonicalizeし検査・送信に同じ値を使う。scheme/authority変更、userinfo、fragment、backslash、encoded separator/dot traversalを拒否する。prefixの文字列一致で`/api`に`/apix`を含めない。 +- HostがDNS解決した全候補を検査し、許可したaddressへ接続を固定する。接続時の再解決で検査を迂回しない。TLSの名前検証とSNIは許可hostnameを使う。 +- loopback、link-local、metadata endpoint、Server/Runtime管理endpointを拒否する。private LANはServer運用policyが許す範囲内でWorkspaceが明示bindingできる。Workspace管理権限だけで共有Hostの内部network全体を開かない。 +- `Authorization`、Cookie、Host、proxy/hop-by-hop header等はguestが指定できない。接続policyで許可したnon-secret headerだけを受け付ける。credentialはHostが指定origin/pathへだけ注入し、responseのSet-Cookie等を返さない。 +- credentialの値をguestへ返すAPIはない。ただし相手serverがsecretをechoする可能性まで完全に除去できるとは主張しない。credentialの配送先はWorkspaceが管理する信頼境界として扱う。 +- planはsecret参照と接続policyの内容digestを固定し、secret値は保存しない。通常のsecret rotationは同じbindingのHost解決で反映できるが、宛先/権限変更は新規planにする。live失効・policy縮小は古いplanにも効く。 +- request/responseはbuffered boundedとし、上限到達時は切断してlimit errorを返す。切断されたresponseを成功に見せない。圧縮展開後の上限も検査する。 + +### 外部副作用と再試行 + +HostはHTTP requestを自動再送しない。GETであってもprovider固有semanticsがあるため、transport層が勝手に再試行しない。redirectもguestにresponseとして返すだけで、別originへの追跡は新しい認可済みbindingが必要になる。 + +全HTTP requestに`(Workspace, installation, Worker, consumer-or-tool-call, logical-invocation, effect-key)`のscopeを使い、request fingerprint、送信admission、保存結果を記録する。consumerはServiceの場合の固定identity、Toolの場合は永続Tool-call identityであり、別consumerで同じevent-idを処理してもreceiptを共有しない。GETも例外にせず、methodだけから副作用なしを推定しない。同じkey/内容は保存結果を返し、異なる内容はconflict。guestがkeyを変えれば新しい作用なので、package内の再試行も自動的に安全になるわけではない。 + +fingerprintはinstallation/plan、接続binding identityとslotとpolicyの内容digest、method、正規化path、順序を保つquery/header、body hashを含む。secretの値、残りdeadline、attemptは含めない。saved responseはboundedでWorkspace保護データとして保持し、diagnostic logとは分離する。request body/secret headerの生値をreceiptへ保存しない。同一requestの保存結果を返す際にも現在のinstallation/接続/読取り権限を検証する。 + +invocation identityはHostの永続Tool-call/event記録に結び付け、同じTool-callの回復や同じconsumer/eventの再配送では固定する。guestがidentityを指定・変更できない。新しいユーザーの呼出しは新identityとする。返却済みTool結果は再実行せず既存結果を使い、未完了の回復を行う場合だけ同じeffect-key領域を使う。 + +送信前にdurable admissionを記録し、記録不能なら送らない。送信した可能性があるままtimeout/切断/crashした場合は`indeterminate`とし、同じkeyでも汎用Hostは再送しない。provider固有の照合・idempotency APIがある場合だけ、そのadapterの契約で回復できる。Tool結果が失敗でも外部効果が取り消されたとは表示しない。 + +保持期限を過ぎたeffect keyは未使用keyとして扱わず`receipt-expired`で拒否する。古いinvocation/eventの再開も同様で、再実行は明示的な新しい依頼にする。invocationの受理時にHostがreceipt照合期限を固定し、期限の過ぎたidentityには新しいeffect-keyを追加できない。期限情報/tombstoneは関連Worker/eventの保持期間中残し、bodyを削除しても未実行扱いに戻さない。receipt保持とevent再配送可能期間をHostで連動させ、exactly-onceを約束しない。 + +## 8. 共通error、出力、診断 + +Host errorは閉じたWIT variantで返し、少なくともinvalid-input、denied、not-found、conflict、unsupported、quota-exceeded、deadline-exceeded、cancelled、unavailable、indeterminate、receipt-expiredを区別する。guest business errorは宣言されたcodeとbounded messageで返す。未知guest codeはplugin-failedへ正規化し、Host errorの権限をguestが偽装できないようにする。 + +Host errorには安全なmessage、診断ID、外部効果の観測(not-dispatched / response-observed / indeterminate)を付ける。`response-observed`はbusiness成功や外部transaction commitの証明ではない。再試行の可否はerror文字列でなくoperation契約で判断する。trap、不正output、resource超過はHostが実行失敗として記録する。 + +Tool outputは通常のWorker Tool結果・履歴経路へ流す。Pluginがhidden prompt、System instruction、任意Session entryを直接差し込むAPIはない。structured JSONはschema検証し、不正・超過時に切り詰めたJSONを返さない。textの表示短縮はHost projectionで明示する。 + +loggingはlevel、event code、安全なmessageとscalar fieldsに限定する。raw request/response、secret、prompt、host pathを標準診断に保存しない。Pluginが受け取った外部データ自体はuntrusted contentであり、命令や権限として扱わない。 + +## 9. 付随データの契約 + +Server拡張はdomain objectに関連付けるPlugin所有のtyped dataとし、既存tableへの任意column追加では実現しない。物理保存schemaはHostが管理する。`feature-storage`のnative migration/SQLite handleは信頼済みHost用でありguestへ公開しない。 + +addressはHost contextのWorkspace/installationと、認可済みobject resource、宣言collection、schema id/version、keyから決まる。schema id/versionも物理・論理namespaceに含め、新旧artifactが同じcellを別schemaで読み書きしない。guestにWorkspace selectorやraw table名を与えない。最初のobject kindはTicketとし、Hostが存在・Workspace・callerのread/write権限を検証したTicket resourceだけを使う。一般objectへの拡張はadapterと権限を追加して行う。 + +後続の`objects.resolve-ticket(ticket-ref, access)`はreadまたはextension-writeを要求し、現在のWorker主体・呼出元からの委譲とPlugin grantを検証して呼出し内だけ有効なWIT `ticket` resourceを返す。Tool入力のTicket refもevent envelopeのrefも単なる選択子であり、どちらも同じ解決を通す。ServiceにはService用grantを適用し、eventを受けたことだけでwriteを許可しない。削除済み・不可視・別Workspaceの対象はnot-foundとする。 + +`ticket-read.read(borrow)`はread権限を再検査し、title、case state、content digest、bounded bodyとtruncated flagだけを返す専用read APIとする。thread、添付、認証・管理情報、将来増えるfieldを汎用serializeで公開しない。上限は64 KiB、完全本文が必要なら本APIではunsupportedとする。eventの削除通知自体は安全なmetadataとして配送できるが、削除済みTicket resourceをresolve/readできる意味ではない。 + +- packageはcollectionごとにschema id/versionとclosed typed schemaを宣言する。data valueのJSONはそのschemaで検証する。 +- APIは`get`、`put(expected_last_write | never_written, operation_key, value)`、`delete(expected_last_write, operation_key)`、bounded `list(cursor, limit)`。任意query、SQL、cross-object transactionは公開しない。 +- 同じcellへのput/deleteを確定した最終operation identityを、書込みtransaction内で観測値と比較する。CAS成功とoperation receiptを同じtransactionで保存し、自動読直しによるlast-write-winsへ変えない。同じ内容の再書込みや削除・再作成を時刻/内容hashだけで区別できないため、このoperationを比較する。 +- deleteは最終delete operationのtombstoneを保持する。再作成はそのoperationを期待値にし、`never_written`を通さない。これによりABAを防ぐ。`get`は対象objectの認可成功後に限り、present(value, last-write)、never-written、deleted(last-delete)を区別する。不可視objectと不存在は同じnot-foundにする。 +- data mutationのreceiptはHTTPと同じ実行scope(Workspace/installation/Worker/consumer-or-tool-call/logical-invocation)とguest operation-keyに束縛する。fingerprintに完全なcell address、put/delete種別、期待last-write、schemaとvalueのdigestを含める。異入力でkeyを再利用すればconflictとする。現在の対象認可確認後にreceipt照合をCAS検査より先に行い、成功済み同一要求を最新cellとの差で失敗にしない。照合期限・expired・tombstone保持はHTTPと同じinvocation規則を使う。 +- `lookup-mutation(operation-key)`で同じinvocationの保存結果(committed: mutation identityと結果digest / not-recorded / expired)を現在の対象認可の下で照合できる。event再配送では先にreceiptを照合し、成功済みなら新しい値を読み直して同じkeyで別のputを作らない。ownerがnot-recordedを返した場合だけ現在のcellから要求を作れる。照合通信失敗はnot-recordedではない。外部HTTP・data・event ackを跨ぐ原子性は保証せず、handlerは段階ごとの保存結果で回復する。 +- listはstable key順のbounded探索でありsnapshot全体の一貫性は保証しない。cursorはWorkspace/installation/object/queryに束縛し、不正・失効cursorを最初のpageに黙って戻さない。 +- Ticketの通常fieldやworkflow判断をこのdata APIで変更できない。必要ならそのdomainの専用Host APIを別に設計する。 +- 親object削除とPlugin data書込みはowner側の削除fenceで競合を解決する。削除後に新しい子dataを作らず、retention後にHostが回収する。 +- schemaの破壊的変更は新しいschema version/namespaceとする。既存dataをguest起動時にmigrationしない。移行は新旧schemaを明示した認可付き操作として後続で設計し、初期APIでは自動変換しない。 +- rollbackで旧artifactを使う場合も元schemaだけを読む。最新schemaへ読み替えない。 +- revoke/disableではdataを消さない。Workspace管理者の明示purgeをoperation keyと期待data-generationで行い、先に実行をrevoke/fenceする。purge receiptを残し、uninstallとの一操作への暗黙統合をしない。 + +## 10. commit済みeventとService + +最初のhookはcommit済みeventの観察とする。PluginをServer transaction内で実行せず、元操作の成功をPluginの応答待ちにしない。domain ownerがtransactional outboxへeventを記録し、Hostが認可・投影して配送する。共有の無制限event busは提供しない。 + +最初のeventはTicket変更通知とし、envelopeはevent-id、event-type/version、occurred-at、対象resource reference、変更を確定したdomain eventのidentityと対象内容digest、correlation/causation identity、bounded typed payloadを持つ。全文や秘密fieldは載せず、必要な詳細は認可付きAPIで取得する。削除eventは削除済みreferenceと安全なmetadataのみとし、resource accessを復活させない。 + +subscriptionはmanifest宣言・Workspace grant・Profile選択から確定し、guestが任意event名を動的subscribeしない。consumerはWorker/installation/plan内のservice単位であり、別Workerに同じPluginを入れると別consumerになる。Workspace-wide singletonや分散consumer groupは暗黙に作らない。 + +- event-idは再配送でも固定、attemptはHost生成の観測値。consumer内はHost保存順に一件ずつ処理する。Workspace全体や別consumer間の全順序は約束しない。 +- `event-result`はack、retry-after、rejectのvariant。ackはHostのcursor commitで確定し、guestのreturnだけでは確定しない。 +- deliveryは保持期間内のat-least-once。crash/timeoutで再配送され得るため、外部作用はevent-id由来invocationとstable effect-keyを使う。payload/keyを変えた再送を同一作用として扱わない。 +- retry-afterはeffectが結果不明でない場合のみ再配送を予約できる。indeterminateな外部効果を含むeventは要確認でconsumerを止め、汎用retryで再送しない。 +- reject、繰返し失敗、queue上限、retention gapではconsumerをpausedとして可視化する。silent drop、無限retry、詰まったeventの無断skipはしない。復旧・明示skipはWorkspace管理操作として監査する。 +- cursorが保持範囲から外れたらgapを返し、最新位置へ自動復旧しない。payload取得時にも対象への現在のread権限を再検査する。 +- disableは新規planに作用し、既存consumerの即時停止はrevoke/Worker stopで行う。restoreは同じconsumer identity/pin/cursorを使い、replacementは新しいconsumerとして開始する。開始時点以前のeventを自動backfillしない。 +- 同じconsumerの旧・新Runtimeが同時に処理しないようHost実行leaseでfenceする。receipt/cursor mutationもleaseを検証する。 + +WebSocketは別のHost-owned source driverがeventへ変換する。guestの`recv(timeout)` loopは復活させない。network frame自体にdurable delivery保証はないため、domain outbox eventと同じ保証を表示しない。必要なら外部protocolのsequence/resumeをbridge側で使う。 + +処理前の検証・拒否hookは初期APIに含めない。将来導入時は対象操作ごとに入力/返却値/timeout/failure policy/再入・副作用を契約化し、汎用interceptorとしてServer内部を公開しない。 + +## 11. 資源上限と終了 + +以下は新APIの初期defaultとして採用する。既存実装値の転載ではない。Hostは実効limitを起動前に固定して診断へ公開し、運用上限は縮小できる。guestから無制限や上限引上げを要求できない。 + +| 資源 | 初期default | +| --- | --- | +| Tool input / output、event payload | 各64 KiB(UTF-8/serialized bytes) | +| config、付随dataの1 cell | 各64 KiB | +| guest linear memory | 256 MiB / Store | +| Wasmtime fuel | 10,000,000 / export call | +| Tool/event export wall deadline | 30秒、上位caller deadlineが短ければそちら | +| start / stop hook | 5秒 / 2秒 | +| HTTP request body / response body | 各1 MiB | +| HTTP header総量 | 16 KiB、64 pairまで | +| HTTP request deadline | 15秒、残りcall deadline以下 | +| guest発行Host操作 | 同時4、累計32 / call | +| log総量 | 16 KiB / call、超過はdrop件数のみ記録 | +| Service待機queue | 256 event、満杯時は配送をpause | +| event再試行 | 最大5 attempt、待機上限5分、その後pause | +| event自動再配送・effect receipt照合 | 最低7日、期限後は明示expired/gap | +| 管理list | default 50、上限100件 | + +fuelはCPU時間の精密尺度ではない。wall deadline、memory/table/instance/resource数のWasmtime limiterと併用する。HTTPとguest実行で別々に30秒ずつ延長せず、同じcall deadlineから残り時間を計算する。package最大size/file数は既存validator上限を再利用し、値の第二正本を作らない。Workspace全体の保存/queue/実行同時数はHostの有限quotaでadmissionし、個別上限だけで総量を無制限にしない。 + +終了順は新規admission停止、invocation取消、接続/driver停止、期限付きon-stop、登録lease解除、Store/一時resource解放とする。取消中に届いた結果で終了済みinvocationへ追加作用を発行しない。失効後のon-stopは外部write禁止。guestがstopに応答しなくてもHostはresourceを回収する。既に送信された外部効果はrollbackしない。 + +## 12. 初期範囲と契約検証 + +最初の実行APIはTool、connection-bound HTTPS、loggingに絞る。付随データとcommit済みeventは本書の設計を採用する後続interfaceであり、初期worldへ動かないstubを登録しない。WebSocket、Workdir、MCP transport、判断hookも未対応importとして明示拒否する。MCP専用のprocess/credential APIを先行して作らず、必要なHost capabilityを個別に追加する。 + +最初のvertical contract testは、Server operatorでないWorkspace管理者によるupload/install/config、plan生成、Remote/Embedded双方のWasmtime Tool実行、Host HTTPS、通常Tool履歴への結果、同一pin restoreを通す。mock guestだけでHost境界の証明を代替しない。 + +境界testでは少なくとも次を固定する。 + +- 同じblobでも別Workspaceのcatalog/plan/secret/dataを読めず、digestだけのfetchができない。 +- v1/未対応world、manifestと実importの不一致、未宣言Tool、invalid input/outputを実行前または結果公開前に拒否する。 +- path canonicalization、DNS rebinding、redirect、credential header、private/control-plane宛先を実際のHost adapterで検証する。 +- upload/publish/DB commitの部分失敗、CAS競合、同一key再送、異入力key reuse、uninstallとpin作成の競合を検証する。 +- effectの送信前失敗、送信後応答喪失、receipt期限、cancel/revokeを区別し、indeterminateから自動再送しない。 +- fuel/memory/wall deadline、Host待機中の取消、trap、不応答on-stopでもresourceが回収される。 +- 後続data/eventではCAS/ABA、親削除fence、schema rollback、再配送、retention gap、lease競合、外部効果不明時pauseを検証する。 + +採用する契約と実装手段を区別する。DBのtable名、内部trait名、物理blob配置、adapterのmodule分割は実装裁量だが、Workspace分離、pin、APIの型・失敗保証・再試行・拡張境界はこの契約を満たす必要がある。 From 2d195f405881068ad969960739d6edf93a95753d Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 28 Oct 2026 13:31:47 +0900 Subject: [PATCH 2/2] feat: provide v2 Plugin Component contracts and offline validation Validate exact Component interfaces and static Tool/DCDL schemas without guest execution. Share canonical archive packing and independent package/artifact verification; build real pure and HTTPS PDK fixtures and retire v1/Service authoring stubs. --- Cargo.lock | 65 +- crates/manifest/Cargo.toml | 6 + crates/manifest/src/plugin.rs | 1224 ++++++++++------- crates/manifest/src/plugin/component.rs | 572 ++++++++ crates/manifest/src/plugin/schema.rs | 510 +++++++ crates/plugin-pdk/Cargo.toml | 3 + crates/plugin-pdk/README.md | 8 +- crates/plugin-pdk/src/lib.rs | 828 +++-------- .../tests/fixtures/component-tool-source.txt | 38 - .../fixtures/https-tool-v2.component.wasm | Bin 0 -> 149406 bytes .../tests/fixtures/tool-v2.component.wasm | Bin 0 -> 144431 bytes crates/plugin-pdk/tests/template.rs | 355 +++-- crates/plugin-pdk/tests/wit_bindgen_probe.rs | 44 +- crates/yoi/src/main.rs | 2 +- crates/yoi/src/plugin_cli.rs | 455 ++++-- devshell.nix | 1 + package.nix | 2 +- resources/plugin/API.md | 2 +- resources/plugin/generate-fixtures.sh | 13 + .../wit/deps/yoi-host/yoi-host-v1.wit | 0 .../wit/yoi-plugin-instance-v1.wit | 0 .../{ => legacy}/wit/yoi-plugin-tool-v1.wit | 0 .../Cargo.toml | 3 +- .../templates/rust-component-https/README.md | 5 + .../rust-component-https/plugin.toml | 28 + .../templates/rust-component-https/src/lib.rs | 61 + .../wit/deps/yoi-host/yoi-host-v2.wit | 61 + .../wit/yoi-plugin-v2.wit | 49 + .../rust-component-instance/README.md | 5 - .../plugin.component.wasm | 3 - .../rust-component-instance/plugin.toml | 42 - .../rust-component-instance/src/lib.rs | 85 -- .../templates/rust-component-tool/README.md | 6 +- .../rust-component-tool/plugin.component.wasm | 1 - .../templates/rust-component-tool/plugin.toml | 13 +- .../templates/rust-component-tool/src/lib.rs | 8 +- .../wit/deps/yoi-host/yoi-host-v2.wit | 61 + .../rust-component-tool/wit/yoi-plugin-v2.wit | 49 + .../plugin/wit/deps/yoi-host/yoi-host-v2.wit | 61 + resources/plugin/wit/yoi-plugin-v2.wit | 49 + 40 files changed, 3056 insertions(+), 1662 deletions(-) create mode 100644 crates/manifest/src/plugin/component.rs create mode 100644 crates/manifest/src/plugin/schema.rs delete mode 100644 crates/plugin-pdk/tests/fixtures/component-tool-source.txt create mode 100644 crates/plugin-pdk/tests/fixtures/https-tool-v2.component.wasm create mode 100644 crates/plugin-pdk/tests/fixtures/tool-v2.component.wasm create mode 100644 resources/plugin/generate-fixtures.sh rename resources/plugin/{ => legacy}/wit/deps/yoi-host/yoi-host-v1.wit (100%) rename resources/plugin/{ => legacy}/wit/yoi-plugin-instance-v1.wit (100%) rename resources/plugin/{ => legacy}/wit/yoi-plugin-tool-v1.wit (100%) rename resources/plugin/templates/{rust-component-instance => rust-component-https}/Cargo.toml (91%) create mode 100644 resources/plugin/templates/rust-component-https/README.md create mode 100644 resources/plugin/templates/rust-component-https/plugin.toml create mode 100644 resources/plugin/templates/rust-component-https/src/lib.rs create mode 100644 resources/plugin/templates/rust-component-https/wit/deps/yoi-host/yoi-host-v2.wit create mode 100644 resources/plugin/templates/rust-component-https/wit/yoi-plugin-v2.wit delete mode 100644 resources/plugin/templates/rust-component-instance/README.md delete mode 100644 resources/plugin/templates/rust-component-instance/plugin.component.wasm delete mode 100644 resources/plugin/templates/rust-component-instance/plugin.toml delete mode 100644 resources/plugin/templates/rust-component-instance/src/lib.rs delete mode 100644 resources/plugin/templates/rust-component-tool/plugin.component.wasm create mode 100644 resources/plugin/templates/rust-component-tool/wit/deps/yoi-host/yoi-host-v2.wit create mode 100644 resources/plugin/templates/rust-component-tool/wit/yoi-plugin-v2.wit create mode 100644 resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit create mode 100644 resources/plugin/wit/yoi-plugin-v2.wit diff --git a/Cargo.lock b/Cargo.lock index 061aa366b..aae036296 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1853,6 +1853,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "foldhash 0.1.5", + "serde", ] [[package]] @@ -2614,7 +2615,9 @@ dependencies = [ "agen", "arc-swap", "builtin-source", + "crc32fast", "decodal", + "jsonschema", "protocol", "secrets", "serde", @@ -2626,6 +2629,9 @@ dependencies = [ "thiserror 2.0.18", "toml", "tracing", + "wasmparser 0.244.0", + "wit-component", + "wit-parser", ] [[package]] @@ -5822,7 +5828,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", - "wasmparser", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f64765e43c000fae008a48db8b288e175e237fc9ce20a26586836878c1bee89f" +dependencies = [ + "leb128fmt", + "wasmparser 0.258.3", ] [[package]] @@ -5833,8 +5849,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap", - "wasm-encoder", - "wasmparser", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", ] [[package]] @@ -5860,6 +5876,39 @@ dependencies = [ "hashbrown 0.15.5", "indexmap", "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe9269dd817e0e7021bf534ccd9761bc2d64778b6708e41228b8439d75eaa0b1" +dependencies = [ + "bitflags 2.11.0", + "indexmap", +] + +[[package]] +name = "wast" +version = "258.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97f7defc7ecca8b19ac7f824598eadd0c53985ee00c74060d65051e9da5b58a1" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder 0.258.3", +] + +[[package]] +name = "wat" +version = "1.258.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7555c008cca87f2ac58d9f83ccda7e7b44611093ce28eb28f052e7c78024b9bf" +dependencies = [ + "wast", ] [[package]] @@ -6471,9 +6520,10 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder", + "wasm-encoder 0.244.0", "wasm-metadata", - "wasmparser", + "wasmparser 0.244.0", + "wat", "wit-parser", ] @@ -6492,7 +6542,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser", + "wasmparser 0.244.0", ] [[package]] @@ -6739,7 +6789,10 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "toml", + "wasmparser 0.244.0", "wit-bindgen", + "wit-component", + "wit-parser", ] [[package]] diff --git a/crates/manifest/Cargo.toml b/crates/manifest/Cargo.toml index abdc916a2..a8cf30a79 100644 --- a/crates/manifest/Cargo.toml +++ b/crates/manifest/Cargo.toml @@ -15,11 +15,17 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_ignored = "0.1.14" sha2 = "0.10" +crc32fast = "1" +wasmparser = "0.244.0" +wit-parser = "0.244.0" +wit-component = "0.244.0" +jsonschema = { version = "0.33.0", default-features = false } secrets = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } tracing = { workspace = true } [dev-dependencies] +wit-component = { version = "0.244.0", features = ["dummy-module"] } serial_test = "3.4.0" tempfile = { workspace = true } diff --git a/crates/manifest/src/plugin.rs b/crates/manifest/src/plugin.rs index 9686f3979..33eb85330 100644 --- a/crates/manifest/src/plugin.rs +++ b/crates/manifest/src/plugin.rs @@ -8,10 +8,14 @@ //! //! Archives use a restricted stored-ZIP subset. Unsafe paths, links, unsupported //! entries, size/count limit violations, and inconsistent headers are rejected; -//! the file set is digested in order. Directory inputs also reject internal links -//! and undergo the same validation, so distribution formats share the package +//! the file set is digested in order. Canonical ZIP32 output stores files in +//! path order with fixed metadata, CRC32, no compression, extras or comments. +//! Directory inputs also reject internal links and undergo the same validation, +//! so distribution formats share the package //! contract. Component Model metadata is required; legacy core-Wasm runtime -//! declarations are rejected. Validation covers the offline format, not safety +//! declarations are rejected. Tool schemas use the offline 2020-12 subset in +//! [`schema`]; non-secret config uses closed static DCDL syntax, never JSON Schema. +//! Validation covers the offline format, not safety //! established by running components. use serde::{Deserialize, Serialize}; @@ -19,14 +23,17 @@ use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::fs; -use std::io; +use std::io::{self, Read}; use std::path::{Path, PathBuf}; -const SUPPORTED_PLUGIN_API_VERSION: u32 = 1; +pub const SUPPORTED_PLUGIN_API_VERSION: u32 = 2; + +pub mod component; +pub mod schema; const ZIP_EOCD: u32 = 0x0605_4b50; const ZIP_CENTRAL_DIRECTORY: u32 = 0x0201_4b50; const ZIP_LOCAL_FILE: u32 = 0x0403_4b50; -const ZIP_FLAG_ENCRYPTED: u16 = 0x0001; + const ZIP_COMPRESSION_STORED: u16 = 0; const ZIP_UNIX_SYMLINK_TYPE: u32 = 0o120000; const ZIP_UNIX_FILE_TYPE_MASK: u32 = 0o170000; @@ -40,9 +47,17 @@ pub struct PluginTemplateResource { /// Embedded starter template for Rust Component Model Tool Plugins. /// /// The template is data only: it performs no filesystem/network operations and -/// grants no authority. Future authoring CLI commands can materialize these -/// files into a chosen destination after applying their own overwrite policy. +/// grants no authority. Authoring commands materialize these files only into an +/// explicitly chosen empty destination. pub const RUST_COMPONENT_TOOL_TEMPLATE: &[PluginTemplateResource] = &[ + PluginTemplateResource { + path: "wit/yoi-plugin-v2.wit", + contents: include_str!("../../../resources/plugin/wit/yoi-plugin-v2.wit"), + }, + PluginTemplateResource { + path: "wit/deps/yoi-host/yoi-host-v2.wit", + contents: include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit"), + }, PluginTemplateResource { path: "Cargo.toml", contents: include_str!( @@ -61,240 +76,90 @@ pub const RUST_COMPONENT_TOOL_TEMPLATE: &[PluginTemplateResource] = &[ "../../../resources/plugin/templates/rust-component-tool/plugin.toml" ), }, - PluginTemplateResource { - path: "plugin.component.wasm", - contents: include_str!( - "../../../resources/plugin/templates/rust-component-tool/plugin.component.wasm" - ), - }, PluginTemplateResource { path: "README.md", contents: include_str!("../../../resources/plugin/templates/rust-component-tool/README.md"), }, ]; -/// Embedded starter template for Rust Component Model instance Plugins. -pub const RUST_COMPONENT_INSTANCE_TEMPLATE: &[PluginTemplateResource] = &[ +/// HTTPS authoring imports typed resources; it does not implement Host networking. +pub const RUST_COMPONENT_HTTPS_TOOL_TEMPLATE: &[PluginTemplateResource] = &[ + PluginTemplateResource { + path: "wit/yoi-plugin-v2.wit", + contents: include_str!("../../../resources/plugin/wit/yoi-plugin-v2.wit"), + }, + PluginTemplateResource { + path: "wit/deps/yoi-host/yoi-host-v2.wit", + contents: include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit"), + }, PluginTemplateResource { path: "Cargo.toml", contents: include_str!( - "../../../resources/plugin/templates/rust-component-instance/Cargo.toml" + "../../../resources/plugin/templates/rust-component-https/Cargo.toml" ), }, PluginTemplateResource { path: "src/lib.rs", contents: include_str!( - "../../../resources/plugin/templates/rust-component-instance/src/lib.rs" + "../../../resources/plugin/templates/rust-component-https/src/lib.rs" ), }, PluginTemplateResource { path: "plugin.toml", contents: include_str!( - "../../../resources/plugin/templates/rust-component-instance/plugin.toml" - ), - }, - PluginTemplateResource { - path: "plugin.component.wasm", - contents: include_str!( - "../../../resources/plugin/templates/rust-component-instance/plugin.component.wasm" + "../../../resources/plugin/templates/rust-component-https/plugin.toml" ), }, PluginTemplateResource { path: "README.md", contents: include_str!( - "../../../resources/plugin/templates/rust-component-instance/README.md" + "../../../resources/plugin/templates/rust-component-https/README.md" ), }, ]; -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] -pub enum PluginPermission { - Surface { surface: PluginSurface }, - Tool { name: String }, - ToolNamespace { namespace: String }, - Service { name: String }, - Ingress { name: String }, - ExternalWrite, - HostApi { api: PluginHostApi }, -} - -#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct PluginRequestGrant { - /// Exact URL scheme allowed by this target, for example `https` or `http`; `*` is broad. - pub scheme: String, - /// Exact request host allowed by this target. `*` is broad and must be surfaced in diagnostics. - pub host: String, - /// Optional exact port. `None` means the scheme default or any explicit port for that host. - pub port: Option, - /// Uppercase HTTP methods allowed for this target, for example `GET` or `POST`. - pub methods: Vec, - /// Optional path prefixes allowed for this target. Empty means any absolute path on the host. - pub path_prefixes: Vec, -} - -impl PluginRequestGrant { - pub fn label(&self) -> String { - let scheme = if self.scheme.trim().is_empty() { - "" - } else { - self.scheme.as_str() - }; - let host = if self.host.trim().is_empty() { - "" - } else { - self.host.as_str() - }; - let port = self.port.map(|port| format!(":{port}")).unwrap_or_default(); - let methods = if self.methods.is_empty() { - "".to_string() - } else { - self.methods.join(",") - }; - let paths = if self.path_prefixes.is_empty() { - "*".to_string() - } else { - self.path_prefixes.join(",") - }; - let broad = if self.is_broad() { - " [broad-request]" - } else { - "" - }; - format!("{scheme}://{host}{port} {methods} {paths}{broad}") - } - - pub fn is_broad(&self) -> bool { - self.scheme.trim() == "*" || self.host.trim() == "*" || self.path_prefixes.is_empty() - } -} - -#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct PluginWebSocketGrant { - /// Exact URL scheme allowed by this WebSocket target: `wss` or `ws`; `*` is broad. - pub scheme: String, - /// Exact WebSocket host allowed by this target. `*` is broad and must be surfaced in diagnostics. - pub host: String, - /// Optional exact port. `None` means the scheme default or any explicit port for that host. - pub port: Option, - /// Optional path prefixes allowed for this target. Empty means any absolute path on the host. - pub path_prefixes: Vec, -} - -impl PluginWebSocketGrant { - pub fn label(&self) -> String { - let scheme = if self.scheme.trim().is_empty() { - "" - } else { - self.scheme.as_str() - }; - let host = if self.host.trim().is_empty() { - "" - } else { - self.host.as_str() - }; - let port = self.port.map(|port| format!(":{port}")).unwrap_or_default(); - let paths = if self.path_prefixes.is_empty() { - "*".to_string() - } else { - self.path_prefixes.join(",") - }; - let broad = if self.is_broad() { - " [broad-websocket]" - } else { - "" - }; - format!("{scheme}://{host}{port} {paths}{broad}") - } - - pub fn is_broad(&self) -> bool { - self.scheme.trim() == "*" || self.host.trim() == "*" || self.path_prefixes.is_empty() - } -} - -impl PluginPermission { - pub fn label(&self) -> String { - match self { - Self::Surface { surface } => format!("surfaces.{surface}"), - Self::Tool { name } => format!("tool.{name}"), - Self::ToolNamespace { namespace } => format!("tool_namespace.{namespace}"), - Self::Service { name } => format!("service.{name}"), - Self::Ingress { name } => format!("ingress.{name}"), - Self::ExternalWrite => "external_write".to_string(), - Self::HostApi { api } => format!("host_api.{api}"), - } - } - - pub fn surface(surface: PluginSurface) -> Self { - Self::Surface { surface } - } - - pub fn tool(name: impl Into) -> Self { - Self::Tool { name: name.into() } - } - - pub fn tool_namespace(namespace: impl Into) -> Self { - Self::ToolNamespace { - namespace: namespace.into(), - } - } - - pub fn service(name: impl Into) -> Self { - Self::Service { name: name.into() } - } - - pub fn ingress(name: impl Into) -> Self { - Self::Ingress { name: name.into() } - } - - pub fn host_api(api: PluginHostApi) -> Self { - Self::HostApi { api } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PluginHostApi { - Request, - #[serde(rename = "websocket")] - WebSocket, - Fs, -} - -impl fmt::Display for PluginHostApi { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Request => f.write_str("request"), - Self::WebSocket => f.write_str("websocket"), - Self::Fs => f.write_str("fs"), - } - } -} - +/// The only supported v2 surface; future surfaces are not success stubs. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PluginSurface { - Hook, Tool, - Service, - Ingress, - Wasm, } impl fmt::Display for PluginSurface { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - PluginSurface::Hook => f.write_str("hook"), - PluginSurface::Tool => f.write_str("tool"), - PluginSurface::Service => f.write_str("service"), - PluginSurface::Ingress => f.write_str("ingress"), - PluginSurface::Wasm => f.write_str("wasm"), - } + f.write_str("tool") } } +/// Capability requests are metadata, never effective grants. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum PluginRequestedGrant { + Logging, + Https { slot: String }, +} + +/// A slot requests HTTPS protocol bounds, not credentials or concrete bindings. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginConnectionSlot { + pub name: String, + pub methods: Vec, + pub path_prefixes: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum PluginHttpMethod { + Get, + Head, + Post, + Put, + Patch, + Delete, + Options, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PluginSourceKind { @@ -366,82 +231,38 @@ pub struct PluginPackageManifest { pub name: String, pub version: String, pub description: Option, - #[serde(default)] pub surfaces: Vec, - #[serde(default)] - pub runtime: Option, - #[serde(default)] - pub hooks: Vec, - #[serde(default)] + pub runtime: PluginRuntimeManifest, pub tools: Vec, + /// Closed static DCDL type expression, not JSON Schema or guest code. + pub config_schema: String, #[serde(default)] - pub services: Vec, + pub connections: Vec, #[serde(default)] - pub ingresses: Vec, - /// Permission declarations stored in the offline package. They grant no authority - /// and are not consumed by the current Worker runtime. - #[serde(default)] - pub permissions: Vec, - /// URL target metadata retained in the offline package for a future installer. - #[serde(default)] - pub request: Vec, - /// WebSocket target metadata retained in the offline package for a future installer. - #[serde(default)] - pub websocket: Vec, + pub requested_grants: Vec, } impl PluginPackageManifest { pub fn declared_surfaces(&self) -> BTreeSet { - let mut surfaces: BTreeSet<_> = self.surfaces.iter().copied().collect(); - if !self.hooks.is_empty() { - surfaces.insert(PluginSurface::Hook); - } - if !self.tools.is_empty() { - surfaces.insert(PluginSurface::Tool); - } - if !self.services.is_empty() { - surfaces.insert(PluginSurface::Service); - } - if !self.ingresses.is_empty() { - surfaces.insert(PluginSurface::Ingress); - } - if self.runtime.is_some() { - surfaces.insert(PluginSurface::Wasm); - } - surfaces + self.surfaces.iter().copied().collect() } } -const LEGACY_PLUGIN_RUNTIME_WASM_KIND: &str = "wasm"; -const LEGACY_PLUGIN_RUNTIME_WASM_ABI: &str = "yoi-plugin-wasm-1"; -/// Component Model declaration kind accepted in offline packages. -/// -/// Validates component paths and corresponding worlds; rejects legacy core-Wasm -/// declarations. This constant implies neither runtime registration nor verified -/// component execution. pub const PLUGIN_RUNTIME_COMPONENT_KIND: &str = "wasm-component"; -pub const PLUGIN_COMPONENT_TOOL_WORLD: &str = "yoi:plugin/tool@1.0.0"; -pub const PLUGIN_COMPONENT_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0"; +pub const PLUGIN_COMPONENT_TOOL_WORLD: &str = "yoi:plugin/tool-plugin@2.0.0"; +pub const PLUGIN_COMPONENT_HTTPS_TOOL_WORLD: &str = "yoi:plugin/https-tool-plugin@2.0.0"; +pub const PLUGIN_COMPONENT_LOGGED_TOOL_WORLD: &str = "yoi:plugin/logged-tool-plugin@2.0.0"; +pub const PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD: &str = + "yoi:plugin/https-logged-tool-plugin@2.0.0"; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PluginRuntimeManifest { pub kind: String, - #[serde(default)] - pub entry: Option, - #[serde(default)] - pub abi: Option, - #[serde(default)] - pub component: Option, - #[serde(default)] - pub world: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct PluginHookManifest { - pub id: String, - pub file: String, + pub component: String, + pub world: String, + /// Exact imports, including type interfaces, without semver ranges. + pub required_imports: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -450,40 +271,13 @@ pub struct PluginToolManifest { pub name: String, pub description: String, pub input_schema: serde_json::Value, - /// Declares possible external side effects for a future installer. This flag - /// grants no authority and is not consumed by the current Worker runtime. + pub output_schema: serde_json::Value, + #[serde(default)] + pub error_codes: Vec, #[serde(default)] pub external_write: bool, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct PluginServiceManifest { - pub name: String, - pub description: String, - #[serde(default)] - pub lifecycle: String, - #[serde(default)] - pub status_schema: Option, - #[serde(default)] - pub side_effects: Vec, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct PluginIngressManifest { - pub name: String, - pub description: String, - #[serde(default)] - pub event_kinds: Vec, - #[serde(default)] - pub input_schema: Option, - #[serde(default)] - pub sources: Vec, - #[serde(default)] - pub side_effects: Vec, -} - #[derive(Clone, Debug, PartialEq, Eq)] pub struct PluginPackageLimits { pub max_package_size_bytes: u64, @@ -510,7 +304,8 @@ pub struct InspectedPluginPackage { pub identity: SourceQualifiedPluginId, pub input_path: PathBuf, pub package_label: String, - pub digest: String, + pub package_digest: String, + pub component: component::ComponentMetadata, pub manifest: PluginPackageManifest, pub entries: BTreeSet, } @@ -524,12 +319,26 @@ pub struct InspectedPluginPackage { pub struct MaterializedPluginPackage { pub package: InspectedPluginPackage, pub files: BTreeMap>, + /// Digest and size of canonical archive bytes, not the upload ZIP. + pub artifact_digest: String, + pub artifact_size: u64, } #[derive(Clone, Debug, PartialEq)] pub struct PackedPluginPackage { pub output_path: PathBuf, pub package: InspectedPluginPackage, + pub artifact_digest: String, + pub artifact_size: u64, +} + +/// Immutable candidate bytes to publish once; fetch must return these exact bytes. +#[derive(Clone, Debug, PartialEq)] +pub struct CanonicalPluginArtifact { + pub bytes: Vec, + pub package_digest: String, + pub artifact_digest: String, + pub artifact_size: u64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -636,7 +445,7 @@ pub fn read_plugin_package_file( .with_source(source) .with_package(label)); } - let bytes = fs::read(path).map_err(|error| { + let bytes = read_bounded_file(path, limits.max_package_size_bytes).map_err(|error| { PluginDiagnostic::new( PluginDiagnosticKind::Io, PluginDiagnosticPhase::Inspection, @@ -701,14 +510,102 @@ pub fn write_plugin_package_file( output_path: &Path, limits: &PluginPackageLimits, ) -> Result { - write_stored_zip_file(output_path, &materialized.files, limits)?; + let artifact = pack_plugin_package(materialized, limits)?; + if let Some(parent) = output_path.parent().filter(|p| !p.as_os_str().is_empty()) { + fs::create_dir_all(parent).map_err(|_| { + malformed_zip( + "output", + materialized.package.source(), + "cannot create output directory", + ) + })?; + } + fs::write(output_path, &artifact.bytes).map_err(|_| { + malformed_zip( + "output", + materialized.package.source(), + "cannot write package", + ) + })?; let package = read_plugin_package_file(output_path, materialized.package.source(), limits)?; Ok(PackedPluginPackage { output_path: output_path.to_path_buf(), package: package.package, + artifact_digest: artifact.artifact_digest, + artifact_size: artifact.artifact_size, }) } +/// Shared Server installer/authoring canonicalization. Revalidates mutable file data. +pub fn pack_plugin_package( + materialized: &MaterializedPluginPackage, + limits: &PluginPackageLimits, +) -> Result { + let bytes = canonical_archive_bytes(&materialized.files, limits)?; + let checked = read_plugin_package_bytes(&bytes, materialized.package.source(), limits)?; + Ok(CanonicalPluginArtifact { + package_digest: checked.package.package_digest, + artifact_size: bytes.len() as u64, + artifact_digest: artifact_digest(&bytes), + bytes, + }) +} + +/// Offline upload inspection; accepts bounded stored ZIP and returns canonical metadata. +pub fn read_plugin_package_bytes( + bytes: &[u8], + source: PluginSourceKind, + limits: &PluginPackageLimits, +) -> Result { + materialize_archive( + Path::new("explicit.yoi-plugin"), + "explicit.yoi-plugin", + source, + bytes, + limits, + ) +} + +/// Runtime/cache verification of exact canonical bytes and both independent digests. +/// This function confers no access, admission or execution authority. +pub fn verify_plugin_artifact( + bytes: &[u8], + expected_package_digest: &str, + expected_artifact_digest: &str, + expected_size: u64, + limits: &PluginPackageLimits, +) -> Result { + if bytes.len() as u64 > limits.max_package_size_bytes { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Bounds, + PluginDiagnosticPhase::Inspection, + "artifact exceeds size limit", + )); + } + if expected_size != bytes.len() as u64 || artifact_digest(bytes) != expected_artifact_digest { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Digest, + PluginDiagnosticPhase::Inspection, + "artifact digest/size mismatch", + )); + } + let package = read_plugin_package_bytes(bytes, PluginSourceKind::Explicit, limits)?; + if package.package.package_digest != expected_package_digest + || canonical_archive_bytes(&package.files, limits)? != bytes + { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Digest, + PluginDiagnosticPhase::Inspection, + "package digest mismatch or noncanonical artifact", + )); + } + Ok(package) +} + +fn artifact_digest(bytes: &[u8]) -> String { + format!("sha256:{}", hex_lower(&Sha256::digest(bytes))) +} + impl InspectedPluginPackage { pub fn source(&self) -> PluginSourceKind { self.identity.source @@ -722,6 +619,13 @@ fn materialize_archive( bytes: &[u8], limits: &PluginPackageLimits, ) -> Result { + if bytes.len() as u64 > limits.max_package_size_bytes { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Bounds, + PluginDiagnosticPhase::Inspection, + "archive exceeds size limit", + )); + } let archive = parse_stored_zip(bytes, label, source, limits)?; materialize_files(path, label.to_string(), source, archive.files, limits) } @@ -772,18 +676,25 @@ fn materialize_files( .with_source(source) .with_package(label.clone()) })?; - validate_manifest(&manifest, &archive, &label, source)?; + let component = validate_manifest(&manifest, &archive, &label, source)?; let digest = deterministic_digest(&archive.files); let identity = SourceQualifiedPluginId::new(source, manifest.id.clone()); let package = InspectedPluginPackage { identity, input_path: path.to_path_buf(), package_label: label, - digest, + package_digest: digest, + component, manifest, entries: archive.files.keys().cloned().collect(), }; - Ok(MaterializedPluginPackage { package, files }) + let canonical = canonical_archive_bytes(&files, limits)?; + Ok(MaterializedPluginPackage { + package, + files, + artifact_digest: artifact_digest(&canonical), + artifact_size: canonical.len() as u64, + }) } fn collect_directory_files( @@ -807,8 +718,9 @@ fn collect_directory_files( .with_source(source) .with_package(label.to_string()) })? - .filter_map(|entry| entry.ok().map(|entry| entry.path())) - .collect::>(); + .map(|entry| entry.map(|entry| entry.path())) + .collect::, _>>() + .map_err(|_| malformed_zip(label, source, "cannot read directory entry"))?; entries.sort(); for path in entries { @@ -893,7 +805,7 @@ fn collect_directory_files( .with_source(source) .with_package(label.to_string()) })?; - let content = fs::read(&path).map_err(|error| { + let content = read_bounded_file(&path, limits.max_file_size_bytes).map_err(|error| { PluginDiagnostic::new( PluginDiagnosticKind::Io, PluginDiagnosticPhase::Inspection, @@ -932,11 +844,10 @@ fn collect_directory_files( Ok(()) } -fn write_stored_zip_file( - output_path: &Path, +fn canonical_archive_bytes( files: &BTreeMap>, limits: &PluginPackageLimits, -) -> Result<(), PluginDiagnostic> { +) -> Result, PluginDiagnostic> { if files.len() > limits.max_entries_per_package { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Bounds, @@ -944,6 +855,32 @@ fn write_stored_zip_file( "plugin package contains more entries than the configured bound", )); } + let expanded = files + .values() + .try_fold(0u64, |sum, v| sum.checked_add(v.len() as u64)); + if files.len() > u16::MAX as usize + || expanded + .is_none_or(|n| n > limits.max_expanded_size_bytes || n > limits.max_package_size_bytes) + { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Bounds, + PluginDiagnosticPhase::Inspection, + "archive exceeds count/expanded bounds", + )); + } + let archive_size = files.iter().try_fold(22u64, |total, (name, content)| { + total + .checked_add(76)? + .checked_add((name.len() as u64).checked_mul(2)?)? + .checked_add(content.len() as u64) + }); + if archive_size.is_none_or(|n| n > limits.max_package_size_bytes || n > u32::MAX as u64) { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Bounds, + PluginDiagnosticPhase::Inspection, + "archive exceeds ZIP32/package bounds", + )); + } let mut bytes = Vec::new(); let mut central = Vec::new(); for (name, content) in files { @@ -961,6 +898,17 @@ fn write_stored_zip_file( "plugin package entry exceeds the configured per-file bound", )); } + if name.len() > u16::MAX as usize + || content.len() > u32::MAX as usize + || bytes.len() > u32::MAX as usize + { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Bounds, + PluginDiagnosticPhase::Inspection, + "ZIP32 bounds exceeded", + )); + } + let crc = crc32fast::hash(content); let local_offset = bytes.len() as u32; write_u32_vec(&mut bytes, ZIP_LOCAL_FILE); write_u16_vec(&mut bytes, 20); @@ -968,7 +916,7 @@ fn write_stored_zip_file( write_u16_vec(&mut bytes, ZIP_COMPRESSION_STORED); write_u16_vec(&mut bytes, 0); write_u16_vec(&mut bytes, 0); - write_u32_vec(&mut bytes, 0); + write_u32_vec(&mut bytes, crc); write_u32_vec(&mut bytes, content.len() as u32); write_u32_vec(&mut bytes, content.len() as u32); write_u16_vec(&mut bytes, name.len() as u16); @@ -983,7 +931,7 @@ fn write_stored_zip_file( write_u16_vec(&mut central, ZIP_COMPRESSION_STORED); write_u16_vec(&mut central, 0); write_u16_vec(&mut central, 0); - write_u32_vec(&mut central, 0); + write_u32_vec(&mut central, crc); write_u32_vec(&mut central, content.len() as u32); write_u32_vec(&mut central, content.len() as u32); write_u16_vec(&mut central, name.len() as u16); @@ -1012,32 +960,7 @@ fn write_stored_zip_file( "plugin package exceeds the configured package size bound", )); } - if let Some(parent) = output_path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - fs::create_dir_all(parent).map_err(|error| { - PluginDiagnostic::new( - PluginDiagnosticKind::Io, - PluginDiagnosticPhase::Inspection, - format!( - "plugin package output directory could not be created: {}", - safe_io_error(&error) - ), - ) - })?; - } - fs::write(output_path, bytes).map_err(|error| { - PluginDiagnostic::new( - PluginDiagnosticKind::Io, - PluginDiagnosticPhase::Inspection, - format!( - "plugin package output could not be written: {}", - safe_io_error(&error) - ), - ) - })?; - Ok(()) + Ok(bytes) } fn write_u16_vec(out: &mut Vec, value: u16) { @@ -1053,154 +976,151 @@ fn validate_manifest( archive: &StoredArchive, label: &str, source: PluginSourceKind, -) -> Result<(), PluginDiagnostic> { +) -> Result { + let reject = |kind, message: String| { + PluginDiagnostic::new(kind, PluginDiagnosticPhase::Manifest, message) + .with_source(source) + .with_package(label) + .with_identity(SourceQualifiedPluginId::new(source, &manifest.id)) + }; if manifest.schema_version != SUPPORTED_PLUGIN_API_VERSION { - return Err(PluginDiagnostic::new( + return Err(reject( PluginDiagnosticKind::Api, - PluginDiagnosticPhase::Manifest, - "plugin schema/API version is unsupported", - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); + "unsupported schema: v2 rebuild required".into(), + )); } - if !is_safe_id(&manifest.id) { - return Err(PluginDiagnostic::new( + if !is_safe_id(&manifest.id) + || manifest.name.trim().is_empty() + || manifest.version.trim().is_empty() + { + return Err(reject( PluginDiagnosticKind::Malformed, - PluginDiagnosticPhase::Manifest, - "plugin manifest id is not a safe local id", - ) - .with_source(source) - .with_package(label)); + "safe id, name and version are required".into(), + )); } - if manifest.name.trim().is_empty() || manifest.version.trim().is_empty() { - return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Malformed, - PluginDiagnosticPhase::Manifest, - "plugin manifest name and version are required", - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); - } - if let Some(runtime) = &manifest.runtime { - match runtime.kind.as_str() { - LEGACY_PLUGIN_RUNTIME_WASM_KIND => { - return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Api, - PluginDiagnosticPhase::Manifest, - format!( - "legacy raw wasm plugin runtime `{LEGACY_PLUGIN_RUNTIME_WASM_KIND}` / `{}` is retired; use `{PLUGIN_RUNTIME_COMPONENT_KIND}`", - runtime - .abi - .as_deref() - .unwrap_or(LEGACY_PLUGIN_RUNTIME_WASM_ABI) - ), - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); - } - PLUGIN_RUNTIME_COMPONENT_KIND => { - if runtime.abi.is_some() || runtime.entry.is_some() { - return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Malformed, - PluginDiagnosticPhase::Manifest, - "plugin component runtime must not declare raw WASM ABI metadata", - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); - } - if !matches!( - runtime.world.as_deref(), - Some(PLUGIN_COMPONENT_TOOL_WORLD) | Some(PLUGIN_COMPONENT_INSTANCE_WORLD) - ) { - return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Api, - PluginDiagnosticPhase::Manifest, - "plugin component world is unsupported", - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); - } - let Some(component) = runtime.component.as_deref() else { - return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Missing, - PluginDiagnosticPhase::Manifest, - "plugin component runtime artifact is required", - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); - }; - validate_manifest_path(component, archive, label, source, &manifest.id)?; - } - _ => { - return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Api, - PluginDiagnosticPhase::Manifest, - "plugin runtime kind is unsupported", - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); - } - } - } - let instance_capable = manifest.runtime.as_ref().is_some_and(|runtime| { - runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND - && runtime.world.as_deref() == Some(PLUGIN_COMPONENT_INSTANCE_WORLD) - }); - if (!manifest.services.is_empty() || !manifest.ingresses.is_empty()) && !instance_capable { - return Err(PluginDiagnostic::new( + if manifest.surfaces != [PluginSurface::Tool] || manifest.tools.is_empty() { + return Err(reject( PluginDiagnosticKind::Surface, - PluginDiagnosticPhase::Manifest, - "plugin service/ingress declarations require the yoi:plugin/instance@1.0.0 component world", + "only nonempty Tool packages are supported".into(), + )); + } + let runtime = &manifest.runtime; + if runtime.kind != PLUGIN_RUNTIME_COMPONENT_KIND { + return Err(reject( + PluginDiagnosticKind::Api, + "only wasm-component v2 is supported".into(), + )); + } + validate_manifest_path(&runtime.component, archive, label, source, &manifest.id)?; + schema::validate_config_schema(&manifest.config_schema).map_err(|e| { + reject( + PluginDiagnosticKind::Malformed, + format!("invalid config schema: {e}"), ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); - } - for service in &manifest.services { - if !is_safe_id(&service.name) { - return Err(PluginDiagnostic::new( + })?; + let mut names = BTreeSet::new(); + for tool in &manifest.tools { + if !is_safe_id(&tool.name) + || tool.description.trim().is_empty() + || !names.insert(&tool.name) + { + return Err(reject( + PluginDiagnosticKind::Duplicate, + "Tool names must be safe, unique and described".into(), + )); + } + for value in [&tool.input_schema, &tool.output_schema] { + schema::validate_tool_schema(value).map_err(|e| { + reject( + PluginDiagnosticKind::Malformed, + format!("invalid Tool schema: {e}"), + ) + })?; + } + let errors: BTreeSet<_> = tool.error_codes.iter().collect(); + if errors.len() != tool.error_codes.len() || tool.error_codes.iter().any(|v| !is_safe_id(v)) + { + return Err(reject( PluginDiagnosticKind::Malformed, - PluginDiagnosticPhase::Manifest, - "plugin service name is not safe", - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); + "business error codes must be safe and unique".into(), + )); } } - for ingress in &manifest.ingresses { - if !is_safe_id(&ingress.name) { - return Err(PluginDiagnostic::new( + let mut slots = BTreeSet::new(); + for slot in &manifest.connections { + if !is_safe_id(&slot.name) + || !slots.insert(&slot.name) + || slot.methods.is_empty() + || slot.methods.iter().collect::>().len() != slot.methods.len() + || slot.path_prefixes.is_empty() + || slot + .path_prefixes + .iter() + .any(|p| !valid_http_path_prefix(p)) + || slot.path_prefixes.iter().collect::>().len() != slot.path_prefixes.len() + { + return Err(reject( PluginDiagnosticKind::Malformed, - PluginDiagnosticPhase::Manifest, - "plugin ingress name is not safe", - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); + "invalid or duplicate HTTPS slot/request bounds".into(), + )); } } - for hook in &manifest.hooks { - if !is_safe_id(&hook.id) { - return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Malformed, - PluginDiagnosticPhase::Manifest, - "plugin hook id is not safe", - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); + let mut requested_slots = BTreeSet::new(); + let mut logging = false; + for grant in &manifest.requested_grants { + match grant { + PluginRequestedGrant::Logging if !logging => logging = true, + PluginRequestedGrant::Https { slot } + if slots.contains(slot) && requested_slots.insert(slot) => {} + _ => { + return Err(reject( + PluginDiagnosticKind::Malformed, + "undeclared or duplicate requested grant".into(), + )); + } } - validate_manifest_path(&hook.file, archive, label, source, &manifest.id)?; } - Ok(()) + if slots != requested_slots { + return Err(reject( + PluginDiagnosticKind::Malformed, + "each connection slot requires an HTTPS grant request".into(), + )); + } + let http = runtime + .required_imports + .iter() + .any(|v| v == "yoi:host/http@2.0.0"); + let connections = runtime + .required_imports + .iter() + .any(|v| v == "yoi:host/connections@2.0.0"); + let logs = runtime + .required_imports + .iter() + .any(|v| v == "yoi:host/logging@2.0.0"); + if http != !slots.is_empty() || http != connections || logging != logs { + return Err(reject( + PluginDiagnosticKind::Api, + "Host imports and requested grants/slots disagree".into(), + )); + } + component::validate_component( + &archive.files[&runtime.component], + &runtime.world, + &runtime.required_imports, + ) + .map_err(|e| reject(PluginDiagnosticKind::Api, format!("Component ABI: {e}"))) +} + +fn valid_http_path_prefix(path: &str) -> bool { + path.starts_with('/') + && !path.starts_with("//") + && !path.bytes().any(|b| { + b.is_ascii_control() + || b.is_ascii_whitespace() + || matches!(b, b'?' | b'#' | b'\\' | b'%' | b':') + }) + && !path.split('/').any(|v| matches!(v, "." | "..")) } fn validate_manifest_path( @@ -1241,6 +1161,7 @@ struct StoredArchive { #[derive(Clone, Debug)] struct CentralDirectoryEntry { name: String, + crc: u32, compressed_size: u32, uncompressed_size: u32, local_header_offset: u32, @@ -1272,6 +1193,17 @@ fn parse_stored_zip( let central_offset = read_u32(eocd, 16) .ok_or_else(|| malformed_zip(label, source, "zip end record is truncated"))? as usize; + let comment_len = read_u16(eocd, 20).unwrap_or(u16::MAX) as usize; + if eocd_offset.checked_add(22 + comment_len) != Some(bytes.len()) + || read_u16(eocd, 8) != Some(entry_count as u16) + || central_offset.checked_add(central_size) != Some(eocd_offset) + { + return Err(malformed_zip( + label, + source, + "inconsistent ZIP directory/end bounds", + )); + } if disk_number != 0 || central_disk != 0 { return Err(malformed_zip( label, @@ -1364,7 +1296,15 @@ fn parse_stored_zip( .ok_or_else(|| { malformed_zip(label, source, "zip central directory entry is too large") })?; + if cursor > eocd_offset || read_u16(bytes, name_start - 12) != Some(0) { + return Err(malformed_zip( + label, + source, + "ZIP directory entry outside bounds or multi-disk entry", + )); + } entries.push(CentralDirectoryEntry { + crc: read_u32(bytes, name_start - 30).unwrap(), name, compressed_size, uncompressed_size, @@ -1375,15 +1315,15 @@ fn parse_stored_zip( }); } + if cursor != eocd_offset { + return Err(malformed_zip(label, source, "ZIP directory size mismatch")); + } let mut files = BTreeMap::new(); let mut expanded_size = 0u64; + let mut ranges = Vec::new(); for entry in entries { - if entry.flags & ZIP_FLAG_ENCRYPTED != 0 { - return Err(malformed_zip( - label, - source, - "encrypted zip packages are unsupported", - )); + if entry.flags & !0x0800 != 0 { + return Err(malformed_zip(label, source, "unsupported ZIP flags")); } if is_zip_symlink(entry.external_attributes) { return Err(PluginDiagnostic::new( @@ -1425,7 +1365,19 @@ fn parse_stored_zip( .with_source(source) .with_package(label)); } - let data = read_stored_entry(bytes, &entry, label, source)?; + let (data, end) = read_stored_entry(bytes, &entry, label, source)?; + if end > central_offset + || ranges + .iter() + .any(|&(start, stop)| (entry.local_header_offset as usize) < stop && end > start) + { + return Err(malformed_zip( + label, + source, + "overlapping ZIP entries or directory data", + )); + } + ranges.push((entry.local_header_offset as usize, end)); if data.len() != entry.uncompressed_size as usize || data.len() != entry.compressed_size as usize { @@ -1454,7 +1406,7 @@ fn read_stored_entry( entry: &CentralDirectoryEntry, label: &str, source: PluginSourceKind, -) -> Result, PluginDiagnostic> { +) -> Result<(Vec, usize), PluginDiagnostic> { let cursor = entry.local_header_offset as usize; if read_u32(bytes, cursor) != Some(ZIP_LOCAL_FILE) { return Err(malformed_zip( @@ -1473,7 +1425,14 @@ fn read_stored_entry( let extra_len = read_u16(bytes, cursor + 28) .ok_or_else(|| malformed_zip(label, source, "zip local file header is truncated"))? as usize; - if local_flags != entry.flags || local_method != entry.compression_method { + let name_start = cursor + 30; + if bytes.get(name_start..name_start.saturating_add(name_len)) != Some(entry.name.as_bytes()) + || read_u32(bytes, cursor + 14) != Some(entry.crc) + || read_u32(bytes, cursor + 18) != Some(entry.compressed_size) + || read_u32(bytes, cursor + 22) != Some(entry.uncompressed_size) + || local_flags != entry.flags + || local_method != entry.compression_method + { return Err(malformed_zip( label, source, @@ -1495,7 +1454,11 @@ fn read_stored_entry( "zip entry data points outside the package", )); } - Ok(bytes[data_start..data_end].to_vec()) + let data = &bytes[data_start..data_end]; + if crc32fast::hash(data) != entry.crc { + return Err(malformed_zip(label, source, "ZIP CRC mismatch")); + } + Ok((data.to_vec(), data_end)) } fn find_eocd(bytes: &[u8]) -> Option { @@ -1596,6 +1559,17 @@ fn package_label(path: &Path) -> String { .to_string() } +fn read_bounded_file(path: &Path, max: u64) -> io::Result> { + let mut bytes = Vec::new(); + fs::File::open(path)? + .take(max.saturating_add(1)) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > max { + return Err(io::Error::from(io::ErrorKind::InvalidData)); + } + Ok(bytes) +} + fn safe_io_error(error: &io::Error) -> &'static str { match error.kind() { io::ErrorKind::NotFound => "not found", @@ -1644,14 +1618,72 @@ mod tests { use super::*; use tempfile::tempdir; - fn materialize_template(root: &Path) { - for resource in RUST_COMPONENT_TOOL_TEMPLATE { - let path = root.join(resource.path); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).unwrap(); - } - fs::write(path, resource.contents).unwrap(); - } + fn fixture_files() -> BTreeMap> { + let mut resolve = wit_parser::Resolve::default(); + resolve + .push_str( + "host.wit", + include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit"), + ) + .unwrap(); + let package = resolve + .push_str( + "plugin.wit", + include_str!("../../../resources/plugin/wit/yoi-plugin-v2.wit"), + ) + .unwrap(); + let world = resolve + .select_world(&[package], Some("tool-plugin")) + .unwrap(); + let mut module = + wit_component::dummy_module(&resolve, world, wit_parser::ManglingAndAbi::Standard32); + wit_component::embed_component_metadata( + &mut module, + &resolve, + world, + wit_component::StringEncoding::UTF8, + ) + .unwrap(); + let component = wit_component::ComponentEncoder::default() + .module(&module) + .unwrap() + .validate(true) + .encode() + .unwrap(); + let manifest = r#" +schema_version = 2 +id = "example.pure" +name = "Pure" +version = "0.1.0" +surfaces = ["tool"] +config_schema = "{}" +[runtime] +kind = "wasm-component" +component = "plugin.component.wasm" +world = "yoi:plugin/tool-plugin@2.0.0" +required_imports = [] +[[tools]] +name = "echo" +description = "Echo input" +input_schema = { type = "object", additionalProperties = false } +output_schema = { type = "object", additionalProperties = false } +"#; + BTreeMap::from([ + ("plugin.toml".into(), manifest.as_bytes().to_vec()), + ("plugin.component.wasm".into(), component), + ]) + } + + fn inspect( + files: BTreeMap>, + ) -> Result { + materialize_files( + Path::new("fixture"), + "fixture".into(), + PluginSourceKind::Explicit, + files, + &PluginPackageLimits::default(), + ) } #[test] @@ -1662,17 +1694,233 @@ mod tests { fs::create_dir_all(&source).unwrap(); fs::create_dir_all(ambient.parent().unwrap()).unwrap(); fs::write(&ambient, b"malformed ambient package").unwrap(); - materialize_template(&source); - + for (name, content) in fixture_files() { + fs::write(source.join(name), content).unwrap(); + } let limits = PluginPackageLimits::default(); let package = read_plugin_directory(&source, PluginSourceKind::Explicit, &limits).unwrap(); let output = dir.path().join("explicit.yoi-plugin"); - write_plugin_package_file(&package, &output, &limits).unwrap(); + let packed = write_plugin_package_file(&package, &output, &limits).unwrap(); let reread = read_plugin_package_file(&output, PluginSourceKind::Explicit, &limits).unwrap(); - assert_eq!(reread.package.identity.source, PluginSourceKind::Explicit); - assert_eq!(reread.package.digest, package.package.digest); + assert_eq!( + reread.package.package_digest, + package.package.package_digest + ); + assert_eq!(reread.artifact_digest, packed.artifact_digest); + assert_eq!(reread.artifact_size, fs::metadata(output).unwrap().len()); + } + + #[test] + fn canonical_pack_is_reproducible_and_digests_have_distinct_meanings() { + let package = inspect(fixture_files()).unwrap(); + let limits = PluginPackageLimits::default(); + let a = pack_plugin_package(&package, &limits).unwrap(); + let b = pack_plugin_package(&package, &limits).unwrap(); + assert_eq!(a, b); + assert_eq!(a.package_digest, deterministic_digest(&package.files)); + assert_eq!(a.artifact_digest, artifact_digest(&a.bytes)); + assert_ne!(a.package_digest, a.artifact_digest); + verify_plugin_artifact( + &a.bytes, + &a.package_digest, + &a.artifact_digest, + a.artifact_size, + &limits, + ) + .unwrap(); + let mut noncanonical = a.bytes.clone(); + // A ZIP comment changes transport bytes, not the logical file set. + let end = noncanonical.len(); + noncanonical[end - 2..].copy_from_slice(&4u16.to_le_bytes()); + noncanonical.extend_from_slice(b"note"); + let uploaded = + read_plugin_package_bytes(&noncanonical, PluginSourceKind::Explicit, &limits).unwrap(); + assert_eq!(uploaded.package.package_digest, a.package_digest); + assert_eq!(pack_plugin_package(&uploaded, &limits).unwrap(), a); + assert!( + verify_plugin_artifact( + &noncanonical, + &a.package_digest, + &artifact_digest(&noncanonical), + noncanonical.len() as u64, + &limits + ) + .is_err() + ); + } + + #[test] + fn artifact_verification_rejects_each_digest_size_and_changed_bytes() { + let artifact = pack_plugin_package( + &inspect(fixture_files()).unwrap(), + &PluginPackageLimits::default(), + ) + .unwrap(); + for (package, transport, size) in [ + ( + "sha256:wrong", + artifact.artifact_digest.as_str(), + artifact.artifact_size, + ), + ( + artifact.package_digest.as_str(), + "sha256:wrong", + artifact.artifact_size, + ), + ( + artifact.package_digest.as_str(), + artifact.artifact_digest.as_str(), + artifact.artifact_size + 1, + ), + ] { + assert_eq!( + verify_plugin_artifact( + &artifact.bytes, + package, + transport, + size, + &PluginPackageLimits::default() + ) + .unwrap_err() + .kind, + PluginDiagnosticKind::Digest + ); + } + let mut bytes = artifact.bytes.clone(); + bytes[40] ^= 1; + assert!( + verify_plugin_artifact( + &bytes, + &artifact.package_digest, + &artifact.artifact_digest, + artifact.artifact_size, + &PluginPackageLimits::default() + ) + .is_err() + ); + } + + #[test] + fn version_surface_schema_slot_and_component_mismatches_are_rejected() { + let base = fixture_files(); + let text = String::from_utf8(base["plugin.toml"].clone()).unwrap(); + for (from, to) in [ + ("schema_version = 2", "schema_version = 1"), + ("schema_version = 2", "schema_version = 999"), + ("surfaces = [\"tool\"]", "surfaces = [\"service\"]"), + ("tool-plugin@2.0.0", "tool-plugin@2.0.1"), + ("tool-plugin@2.0.0", "https-tool-plugin@2.0.0"), + ("type = \"object\"", "type = \"nonexistent\""), + ( + "config_schema = \"{}\"", + "config_schema = \"{ ...String }\"", + ), + ( + "required_imports = []", + "required_imports = [\"yoi:host/http@2.0.0\"]", + ), + ] { + let mut files = base.clone(); + let changed = text.replace(from, to); + assert_ne!(changed, text); + files.insert("plugin.toml".into(), changed.into_bytes()); + assert!(inspect(files).is_err(), "mutation {from} -> {to}"); + } + for extra in [ + "\n[[connections]]\nname = \"api\"\nmethods = [\"GET\"]\npath_prefixes = [\"/\"]", + "\n[[requested_grants]]\nkind = \"https\"\nslot = \"missing\"", + "\n[[requested_grants]]\nkind = \"logging\"", + ] { + let mut files = base.clone(); + files.insert("plugin.toml".into(), format!("{text}{extra}").into_bytes()); + assert!(inspect(files).is_err()); + } + let mut files = base; + files.insert("plugin.component.wasm".into(), b"not a component".to_vec()); + assert!(inspect(files).is_err()); + } + + #[test] + fn stored_archive_rejects_traversal_duplicates_corruption_and_size_bounds() { + let limits = PluginPackageLimits::default(); + let mut files = fixture_files(); + files.insert("../escape".into(), vec![]); + assert_eq!( + canonical_archive_bytes(&files, &limits).unwrap_err().kind, + PluginDiagnosticKind::Traversal + ); + // Equal-length paths let both local and central names be changed coherently. + let bytes = canonical_archive_bytes( + &BTreeMap::from([("aa".into(), vec![1]), ("bb".into(), vec![2])]), + &limits, + ) + .unwrap(); + for (name, replacement, kind) in [ + (b"bb", b"aa", PluginDiagnosticKind::Duplicate), + (b"bb", b"..", PluginDiagnosticKind::Traversal), + ] { + let mut mutated = bytes.clone(); + for index in 0..mutated.len() - 1 { + if &mutated[index..index + 2] == name { + mutated[index..index + 2].copy_from_slice(replacement); + } + } + assert_eq!( + parse_stored_zip(&mutated, "fixture", PluginSourceKind::Explicit, &limits) + .unwrap_err() + .kind, + kind + ); + } + let mut corrupt = bytes.clone(); + corrupt[32] ^= 1; + assert!( + parse_stored_zip(&corrupt, "fixture", PluginSourceKind::Explicit, &limits).is_err() + ); + for small in [ + PluginPackageLimits { + max_package_size_bytes: 1, + ..limits.clone() + }, + PluginPackageLimits { + max_entries_per_package: 1, + ..limits.clone() + }, + PluginPackageLimits { + max_file_size_bytes: 0, + ..limits.clone() + }, + PluginPackageLimits { + max_expanded_size_bytes: 1, + ..limits.clone() + }, + ] { + assert!(read_plugin_package_bytes(&bytes, PluginSourceKind::Explicit, &small).is_err()); + } + } + + #[test] + fn pack_revalidates_mutated_files_and_directory_rejects_symlinks() { + let mut package = inspect(fixture_files()).unwrap(); + package.files.insert("plugin.component.wasm".into(), vec![]); + assert!(pack_plugin_package(&package, &PluginPackageLimits::default()).is_err()); + #[cfg(unix)] + { + let dir = tempdir().unwrap(); + std::os::unix::fs::symlink("outside", dir.path().join("link")).unwrap(); + assert_eq!( + read_plugin_directory( + dir.path(), + PluginSourceKind::Explicit, + &PluginPackageLimits::default() + ) + .unwrap_err() + .kind, + PluginDiagnosticKind::Traversal + ); + } } #[test] diff --git a/crates/manifest/src/plugin/component.rs b/crates/manifest/src/plugin/component.rs new file mode 100644 index 000000000..56babc532 --- /dev/null +++ b/crates/manifest/src/plugin/component.rs @@ -0,0 +1,572 @@ +//! Static validation of the exact, supported Plugin Component ABI. +//! +//! Component metadata is inferred from validated bytes, never trusted from a +//! producer's custom sections. This module does not instantiate guest code. + +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use wit_component::{DecodedWasm, WitPrinter}; +use wit_parser::{InterfaceId, Resolve, WorldId, WorldItem, WorldKey}; + +const PLUGIN_WIT: &str = include_str!("../../../../resources/plugin/wit/yoi-plugin-v2.wit"); +const HOST_WIT: &str = + include_str!("../../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit"); + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ComponentMetadata { + pub world: String, + pub imports: Vec, + pub exports: Vec, +} + +/// Validates an executable Component against the embedded exact-version world. +/// +/// `required_imports` is a set (duplicates are rejected); it must equal both the +/// selected world's imports and the actual Component imports. Returned names are +/// fully qualified and sorted. The world name comes from the supported contract, +/// not a producer-supplied custom section: executable Components do not encode +/// the original WIT world name. +pub fn validate_component( + bytes: &[u8], + world: &str, + required_imports: &[String], +) -> Result { + let world_name = match world { + "yoi:plugin/tool-plugin@2.0.0" => "tool-plugin", + "yoi:plugin/https-tool-plugin@2.0.0" => "https-tool-plugin", + "yoi:plugin/logged-tool-plugin@2.0.0" => "logged-tool-plugin", + "yoi:plugin/https-logged-tool-plugin@2.0.0" => "https-logged-tool-plugin", + _ => return Err(format!("unsupported Component world `{world}`")), + }; + if !wasmparser::Parser::is_component(bytes) { + return Err("expected a WebAssembly Component, not a core module".into()); + } + wasmparser::Validator::new() + .validate_all(bytes) + .map_err(|error| format!("invalid WebAssembly Component: {error}"))?; + let (actual, actual_world) = match wit_component::decode(bytes) + .map_err(|error| format!("cannot decode Component interface: {error:#}"))? + { + DecodedWasm::Component(resolve, world) => (resolve, world), + DecodedWasm::WitPackage(..) => { + return Err("expected an executable Component, not a WIT type package".into()); + } + }; + let (expected, expected_world) = contract(world_name)?; + let expected_imports = surface(&expected, expected_world, true)?; + let expected_exports = surface(&expected, expected_world, false)?; + let actual_imports = surface(&actual, actual_world, true)?; + let actual_exports = surface(&actual, actual_world, false)?; + let required: BTreeSet<_> = required_imports.iter().cloned().collect(); + if required.len() != required_imports.len() { + return Err("duplicate required Component import in manifest".into()); + } + let imports: Vec<_> = expected_imports.keys().cloned().collect(); + let exports: Vec<_> = expected_exports.keys().cloned().collect(); + if required != expected_imports.keys().cloned().collect() { + return Err(format!( + "manifest required_imports do not match world `{world}`: expected {imports:?}" + )); + } + if actual_imports.keys().ne(expected_imports.keys()) { + return Err(format!( + "Component imports do not match world `{world}`: expected {imports:?}, found {:?}", + actual_imports.keys().collect::>() + )); + } + if actual_exports.keys().ne(expected_exports.keys()) { + return Err(format!( + "Component exports do not match world `{world}`: expected {exports:?}, found {:?}", + actual_exports.keys().collect::>() + )); + } + + // Compare all decoded interfaces, including those owning referenced types, + // not only function names or the world surface. Printing preserves the + // qualified owner of type aliases and resource identity, borrow/own, field + // names/order, and nested signatures. Resolve::merge is not an ABI check. + let expected_interfaces = named_interfaces(&expected)?; + for (name, id) in named_interfaces(&actual)? { + let expected_id = expected_interfaces + .get(&name) + .ok_or_else(|| format!("unsupported Component interface `{name}`"))?; + if interface_text(&actual, id)? != interface_text(&expected, *expected_id)? { + return Err(format!("Component interface `{name}` has an incorrect ABI")); + } + } + + Ok(ComponentMetadata { + world: world.to_string(), + imports, + exports, + }) +} + +fn contract(world_name: &str) -> Result<(Resolve, WorldId), String> { + let mut resolve = Resolve::default(); + resolve + .push_str("yoi-host-v2.wit", HOST_WIT) + .map_err(|error| format!("invalid embedded Host WIT: {error:#}"))?; + let package = resolve + .push_str("yoi-plugin-v2.wit", PLUGIN_WIT) + .map_err(|error| format!("invalid embedded Plugin WIT: {error:#}"))?; + let world = resolve + .select_world(&[package], Some(world_name)) + .map_err(|error| format!("invalid embedded Plugin world: {error:#}"))?; + Ok((resolve, world)) +} + +fn surface( + resolve: &Resolve, + world: WorldId, + imports: bool, +) -> Result, String> { + let items = if imports { + &resolve.worlds[world].imports + } else { + &resolve.worlds[world].exports + }; + let mut names = BTreeMap::new(); + for (key, item) in items { + let (WorldKey::Interface(key_id), WorldItem::Interface { id, .. }) = (key, item) else { + return Err( + "Component world contains an unsupported bare function/type or named interface" + .into(), + ); + }; + if key_id != id { + return Err("Component interface identity does not match its world key".into()); + } + let name = resolve + .id_of(*id) + .ok_or_else(|| "Component interface has no exact package identity".to_string())?; + if names.insert(name.clone(), *id).is_some() { + return Err(format!("duplicate Component interface `{name}`")); + } + } + Ok(names) +} + +fn named_interfaces(resolve: &Resolve) -> Result, String> { + let mut names = BTreeMap::new(); + for (id, _) in resolve.interfaces.iter() { + let name = resolve + .id_of(id) + .ok_or_else(|| "Component interface has no exact package identity".to_string())?; + if names.insert(name.clone(), id).is_some() { + return Err(format!("duplicate Component interface `{name}`")); + } + } + Ok(names) +} + +fn interface_text(resolve: &Resolve, id: InterfaceId) -> Result { + // Declaration order is not ABI; record fields, variant cases, parameters, + // and results remain ordered. Sorting maps also canonicalizes use aliases + // and resource methods without changing their nominal owners. + let mut resolve = resolve.clone(); + resolve.interfaces[id].types.sort_keys(); + resolve.interfaces[id].functions.sort_keys(); + let mut printer = WitPrinter::default(); + printer.emit_docs(false); + printer + .print_interface(&resolve, id) + .map_err(|error| format!("cannot inspect Component ABI: {error:#}"))?; + Ok(printer.output.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use wit_component::{ComponentEncoder, StringEncoding, dummy_module, embed_component_metadata}; + use wit_parser::ManglingAndAbi; + + const PURE: &str = "yoi:plugin/tool-plugin@2.0.0"; + const HTTPS: &str = "yoi:plugin/https-tool-plugin@2.0.0"; + const LOGGED: &str = "yoi:plugin/logged-tool-plugin@2.0.0"; + const HTTPS_LOGGED: &str = "yoi:plugin/https-logged-tool-plugin@2.0.0"; + + fn fixture(host: &str, plugin: &str, world_name: &str) -> Vec { + let mut resolve = Resolve::default(); + resolve.push_str("host.wit", host).unwrap(); + let package = resolve.push_str("plugin.wit", plugin).unwrap(); + let world = resolve.select_world(&[package], Some(world_name)).unwrap(); + encode(&resolve, world) + } + + fn encode(resolve: &Resolve, world: WorldId) -> Vec { + let mut module = dummy_module(resolve, world, ManglingAndAbi::Standard32); + embed_component_metadata(&mut module, resolve, world, StringEncoding::UTF8).unwrap(); + ComponentEncoder::default() + .module(&module) + .unwrap() + .validate(true) + .encode() + .unwrap() + } + + fn https_imports() -> Vec { + [ + "yoi:host/connections@2.0.0", + "yoi:host/errors@2.0.0", + "yoi:host/http@2.0.0", + ] + .map(str::to_string) + .into() + } + + fn logged_imports(https: bool) -> Vec { + let mut imports = if https { + https_imports() + } else { + vec!["yoi:host/errors@2.0.0".into()] + }; + imports.push("yoi:host/logging@2.0.0".into()); + imports + } + + #[test] + fn exact_world_components_return_sorted_metadata_without_running_guest() { + for (name, world, imports) in [ + ("tool-plugin", PURE, vec![]), + ("https-tool-plugin", HTTPS, https_imports()), + ("logged-tool-plugin", LOGGED, logged_imports(false)), + ( + "https-logged-tool-plugin", + HTTPS_LOGGED, + logged_imports(true), + ), + ] { + let component = fixture(HOST_WIT, PLUGIN_WIT, name); + assert_eq!( + validate_component(&component, world, &imports).unwrap(), + ComponentMetadata { + world: world.into(), + imports, + exports: vec!["yoi:plugin/tool@2.0.0".into()], + } + ); + } + } + + #[test] + fn logging_worlds_require_exact_manifest_and_actual_import_sets() { + for (name, world, imports) in [ + ("logged-tool-plugin", LOGGED, logged_imports(false)), + ( + "https-logged-tool-plugin", + HTTPS_LOGGED, + logged_imports(true), + ), + ] { + let component = fixture(HOST_WIT, PLUGIN_WIT, name); + let mut missing = imports.clone(); + missing.retain(|name| !name.contains("/logging@")); + assert!( + validate_component(&component, world, &missing) + .unwrap_err() + .contains("manifest required_imports") + ); + let plugin = PLUGIN_WIT.replace("import yoi:host/logging@2.0.0;", ""); + let component = fixture(HOST_WIT, &plugin, name); + assert!( + validate_component(&component, world, &imports) + .unwrap_err() + .contains("Component imports") + ); + } + } + + #[test] + fn logging_world_and_interface_versions_are_exact() { + let logging = HOST_WIT + .split_once("interface logging {") + .unwrap() + .1 + .replace( + "use errors.{host-error};", + "use yoi:host/errors@2.0.0.{host-error};", + ); + for (name, world, imports) in [ + ("logged-tool-plugin", LOGGED, logged_imports(false)), + ( + "https-logged-tool-plugin", + HTTPS_LOGGED, + logged_imports(true), + ), + ] { + let supported = fixture(HOST_WIT, PLUGIN_WIT, name); + for version in ["1.0.0", "2.0.1", "9.0.0"] { + let unsupported_world = world.replace("@2.0.0", &format!("@{version}")); + assert!( + validate_component(&supported, &unsupported_world, &imports) + .unwrap_err() + .contains("unsupported Component world") + ); + let host = format!( + "{HOST_WIT}\npackage yoi:host@{version} {{ interface logging {{ {logging} }}" + ); + let plugin = PLUGIN_WIT.replace("logging@2.0.0", &format!("logging@{version}")); + let component = fixture(&host, &plugin, name); + assert!( + validate_component(&component, world, &imports) + .unwrap_err() + .contains("Component imports") + ); + } + } + } + + #[test] + fn typed_logging_nested_payload_and_signature_changes_are_rejected() { + for (from, to) in [ + ( + "trace, debug, info, warn, error", + "debug, trace, info, warn, error", + ), + ("number(f64)", "number(f32)"), + ("signed(s64)", "signed(u64)"), + ("value: scalar", "value: string"), + ("fields: list", "fields: option"), + ("event-code: string", "event-name: string"), + ( + "message: string,\n fields:", + "message: string, extra: bool,\n fields:", + ), + ("log: func(input: entry)", "log: func(input: string)"), + ( + "log: func(input: entry) -> result<_, host-error>", + "log: func(input: entry) -> result", + ), + ("log: func", "extra: func(); log: func"), + ] { + assert!(HOST_WIT.contains(from)); + let host = HOST_WIT.replace(from, to); + for (name, world, imports) in [ + ("logged-tool-plugin", LOGGED, logged_imports(false)), + ( + "https-logged-tool-plugin", + HTTPS_LOGGED, + logged_imports(true), + ), + ] { + let component = fixture(&host, PLUGIN_WIT, name); + let error = validate_component(&component, world, &imports).unwrap_err(); + assert!( + error.contains("yoi:host/logging@2.0.0") && error.contains("incorrect ABI"), + "mutation: {from} -> {to}: {error}" + ); + } + } + } + + #[test] + fn validated_metadata_has_serializable_public_fields() { + let component = fixture(HOST_WIT, PLUGIN_WIT, "logged-tool-plugin"); + let metadata = validate_component(&component, LOGGED, &logged_imports(false)).unwrap(); + let json = serde_json::to_value(&metadata).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "world": LOGGED, + "imports": ["yoi:host/errors@2.0.0", "yoi:host/logging@2.0.0"], + "exports": ["yoi:plugin/tool@2.0.0"], + }) + ); + assert_eq!( + serde_json::from_value::(json).unwrap(), + metadata + ); + } + + #[test] + fn v1_unknown_and_wrong_manifest_worlds_are_rejected() { + let component = fixture(HOST_WIT, PLUGIN_WIT, "tool-plugin"); + for world in [ + "yoi:plugin/tool-plugin@1.0.0", + "yoi:plugin/tool-plugin@2.0.1", + ] { + assert!( + validate_component(&component, world, &[]) + .unwrap_err() + .contains("unsupported Component world") + ); + } + assert!( + validate_component(&component, HTTPS, &https_imports()) + .unwrap_err() + .contains("Component imports") + ); + for version in ["1.0.0", "9.0.0"] { + let changed = PLUGIN_WIT.replace("2.0.0", version); + let changed_host = HOST_WIT.replace("2.0.0", version); + let component = fixture(&changed_host, &changed, "tool-plugin"); + assert!( + validate_component(&component, PURE, &[]) + .unwrap_err() + .contains("Component exports") + ); + } + } + + #[test] + fn wrong_host_interface_versions_are_not_semver_compatible() { + for version in ["1.0.0", "2.0.1", "9.0.0"] { + let host = HOST_WIT.replace("@2.0.0", &format!("@{version}")); + let plugin = PLUGIN_WIT + .replace("errors@2.0.0", &format!("errors@{version}")) + .replace("connections@2.0.0", &format!("connections@{version}")) + .replace("http@2.0.0", &format!("http@{version}")) + .replace("logging@2.0.0", &format!("logging@{version}")); + let component = fixture(&host, &plugin, "https-tool-plugin"); + assert!( + validate_component(&component, HTTPS, &https_imports()) + .unwrap_err() + .contains("Component imports") + ); + } + } + + #[test] + fn manifest_import_set_must_equal_actual_and_supported_world() { + let component = fixture(HOST_WIT, PLUGIN_WIT, "https-tool-plugin"); + let mut reordered = https_imports(); + reordered.reverse(); + assert!(validate_component(&component, HTTPS, &reordered).is_ok()); + let mut duplicate = https_imports(); + duplicate.push(duplicate[0].clone()); + for imports in [ + vec![], + vec!["wasi:filesystem/types@0.2.0".into()], + duplicate, + ] { + assert!(validate_component(&component, HTTPS, &imports).is_err()); + } + } + + #[test] + fn unadvertised_imports_and_extra_exports_are_rejected() { + for (from, to) in [ + ("export tool;", ""), + ( + "world tool-plugin {", + "world tool-plugin { import surprise: func();", + ), + ( + "world tool-plugin {", + "world tool-plugin { export surprise: func();", + ), + ( + "world tool-plugin {", + "world tool-plugin { import yoi:host/logging@2.0.0;", + ), + ] { + let plugin = PLUGIN_WIT.replace(from, to); + let component = fixture(HOST_WIT, &plugin, "tool-plugin"); + assert!(validate_component(&component, PURE, &[]).is_err()); + } + let plugin = PLUGIN_WIT.replace( + "world tool-plugin {", + "world tool-plugin { import wasi:unpublished/io@0.2.0;", + ); + let host = format!( + "{HOST_WIT}\npackage wasi:unpublished@0.2.0 {{ interface io {{ read: func(); }} }}" + ); + let component = fixture(&host, &plugin, "tool-plugin"); + assert!(validate_component(&component, PURE, &[]).is_err()); + } + + #[test] + fn same_interface_name_with_changed_nested_signature_is_rejected() { + for (from, to) in [ + ("remaining-ms: u32", "remaining-ms: u64"), + ("remaining-ms: u32", "time-left: u32"), + ("content: option", "content: list"), + ("summary: string", "summary: string, extra: bool"), + ("input: invocation", "input: output"), + ("result", "result"), + ("invoke: func", "extra: func(); invoke: func"), + ] { + assert!(PLUGIN_WIT.contains(from)); + let plugin = PLUGIN_WIT.replace(from, to); + let component = fixture(HOST_WIT, &plugin, "tool-plugin"); + assert!( + validate_component(&component, PURE, &[]) + .unwrap_err() + .contains("incorrect ABI"), + "mutation: {from} -> {to}" + ); + } + } + + #[test] + fn host_resource_identity_ownership_and_nested_records_are_exact() { + for (from, to) in [ + ("borrow", "own"), + ("use connections.{connection};", "resource connection;"), + ("status: u16", "status: u32"), + ("value: string", "value: list"), + ( + "resource connection;", + "resource connection; resource other;", + ), + ("target: borrow", "target: borrow"), + ("diagnostic-id: string", "diagnostic-id: u64"), + ] { + let mut host = HOST_WIT.replace(from, to); + if to == "target: borrow" { + host = host.replace("interface http {", "interface http { resource other;"); + } + assert_ne!(host, HOST_WIT); + let component = fixture(&host, PLUGIN_WIT, "https-tool-plugin"); + assert!( + validate_component(&component, HTTPS, &https_imports()) + .unwrap_err() + .contains("incorrect ABI"), + "mutation: {from} -> {to}" + ); + } + } + + #[test] + fn interface_declaration_order_is_not_part_of_the_abi() { + let (mut resolve, world) = contract("https-tool-plugin").unwrap(); + for (_, interface) in resolve.interfaces.iter_mut() { + interface.types.reverse(); + interface.functions.reverse(); + } + let component = encode(&resolve, world); + assert!(validate_component(&component, HTTPS, &https_imports()).is_ok()); + } + + #[test] + fn docs_are_not_part_of_the_abi() { + let plugin = PLUGIN_WIT.replace( + "interface tool {", + "/// Different documentation.\ninterface tool {", + ); + let component = fixture(HOST_WIT, &plugin, "tool-plugin"); + assert!(validate_component(&component, PURE, &[]).is_ok()); + } + + #[test] + fn core_module_wit_type_package_and_invalid_component_are_rejected() { + let (resolve, world) = contract("tool-plugin").unwrap(); + let module = dummy_module(&resolve, world, ManglingAndAbi::Standard32); + assert!( + validate_component(&module, PURE, &[]) + .unwrap_err() + .contains("core module") + ); + let package = resolve.worlds[world].package.unwrap(); + let types = wit_component::encode(&resolve, package).unwrap(); + assert!( + validate_component(&types, PURE, &[]) + .unwrap_err() + .contains("WIT type package") + ); + let mut component = encode(&resolve, world); + component.pop(); + assert!(validate_component(&component, PURE, &[]).is_err()); + } +} diff --git a/crates/manifest/src/plugin/schema.rs b/crates/manifest/src/plugin/schema.rs new file mode 100644 index 000000000..cb64c24bf --- /dev/null +++ b/crates/manifest/src/plugin/schema.rs @@ -0,0 +1,510 @@ +//! Offline schema admission shared by package consumers. No guest, DCDL evaluator, +//! import loader, or ambient JSON Schema retriever participates in validation. + +use std::collections::{BTreeMap, BTreeSet}; + +use decodal::{Ast, Expr, ExprId, Literal, SyntaxTokenKind as Token}; +use serde_json::Value; + +const MAX_SCHEMA_BYTES: usize = 64 * 1024; +const MAX_DEPTH: usize = 32; +const MAX_NODES: usize = 4096; +const TOOL_DIALECT: &str = "https://json-schema.org/draft/2020-12/schema"; + +/// Admit a bounded, offline JSON Schema 2020-12 document (the default dialect). +/// +/// Unknown keywords/dialects, resource IDs, dynamic/anchor/external references, +/// dangling references and recursive reference graphs are unsupported and rejected. +/// Local JSON Pointer references must target schema positions, not annotation data. +/// This checks the schema, not a Tool payload; callers must still validate payloads. +pub fn validate_tool_schema(schema: &Value) -> Result<(), String> { + bound_json(schema)?; + let mut graph = BTreeMap::new(); + inspect_tool_schema(schema, "", &mut graph)?; + let mut complete = BTreeMap::new(); + for path in graph.keys() { + check_reference_graph(path, &graph, &mut BTreeSet::new(), &mut complete, 0)?; + } + // Use the fixed built-in meta-schema, never schema-directed dialect retrieval. + jsonschema::draft202012::meta::validate(schema) + .map_err(|error| format!("invalid Tool JSON Schema: {error}"))?; + // The compiler normally skips unreferenced $defs. Compile every admitted + // schema position through one in-memory resource, so invalid regexes/formats + // cannot hide in an unused definition. These URNs are generated, not input. + const RESOURCE: &str = "urn:yoi:plugin:tool-schema"; + let resource = jsonschema::Resource::from_contents(schema.clone()) + .map_err(|error| format!("invalid Tool JSON Schema resource: {error}"))?; + let all_schemas = serde_json::json!({ + "allOf": graph.keys().map(|path| { + serde_json::json!({"$ref": format!("{RESOURCE}#{}", uri_fragment(path))}) + }).collect::>() + }); + jsonschema::options() + .with_draft(jsonschema::Draft::Draft202012) + .with_resource(RESOURCE, resource) + .with_retriever(NoRetrieval) + .should_validate_formats(true) + .should_ignore_unknown_formats(false) + .build(&all_schemas) + .map_err(|error| format!("invalid Tool JSON Schema: {error}"))?; + Ok(()) +} + +struct NoRetrieval; + +impl jsonschema::Retrieve for NoRetrieval { + fn retrieve( + &self, + _uri: &jsonschema::Uri, + ) -> Result> { + Err("external schema retrieval is forbidden".into()) + } +} + +fn bound_json(value: &Value) -> Result<(), String> { + let mut stack = vec![(value, 0)]; + let mut count = 0; + while let Some((value, depth)) = stack.pop() { + count += 1; + if count > MAX_NODES || depth > MAX_DEPTH { + return Err("Tool JSON Schema exceeds static complexity limits".into()); + } + match value { + Value::Array(values) => stack.extend(values.iter().map(|v| (v, depth + 1))), + Value::Object(values) => stack.extend(values.values().map(|v| (v, depth + 1))), + _ => {} + } + } + if serde_json::to_vec(value).map_err(|e| e.to_string())?.len() > MAX_SCHEMA_BYTES { + return Err("Tool JSON Schema exceeds static byte limit".into()); + } + Ok(()) +} + +fn pointer_child(parent: &str, key: &str) -> String { + format!("{parent}/{}", key.replace('~', "~0").replace('/', "~1")) +} + +fn uri_fragment(pointer: &str) -> String { + let mut encoded = String::new(); + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + for byte in pointer.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/') { + encoded.push(char::from(byte)); + } else { + encoded.push('%'); + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 15)])); + } + } + encoded +} + +fn inspect_tool_schema( + schema: &Value, + path: &str, + graph: &mut BTreeMap>, +) -> Result<(), String> { + graph.insert(path.into(), Vec::new()); + if schema.is_boolean() { + return Ok(()); + } + let object = schema + .as_object() + .ok_or_else(|| format!("schema at #{path} must be an object or boolean"))?; + let mut children = Vec::new(); + let mut references = Vec::new(); + for (key, value) in object { + let location = pointer_child(path, key); + match key.as_str() { + "$schema" => { + if !path.is_empty() || value.as_str() != Some(TOOL_DIALECT) { + return Err(format!("unsupported Tool schema dialect at #{location}")); + } + } + "$ref" => { + let reference = value + .as_str() + .ok_or_else(|| format!("$ref at #{location} must be a string"))?; + let target = reference.strip_prefix('#').ok_or_else(|| { + format!("external Tool schema reference at #{location} is forbidden") + })?; + if (!target.is_empty() && !target.starts_with('/')) || target.contains('%') { + return Err(format!( + "only local JSON Pointer $ref is supported at #{location}" + )); + } + references.push(target.to_owned()); + } + "$defs" | "properties" | "patternProperties" | "dependentSchemas" => { + let entries = value + .as_object() + .ok_or_else(|| format!("schema map at #{location} must be an object"))?; + for (name, child) in entries { + children.push((pointer_child(&location, name), child)); + } + } + "allOf" | "anyOf" | "oneOf" | "prefixItems" => { + let entries = value + .as_array() + .ok_or_else(|| format!("schema list at #{location} must be an array"))?; + for (index, child) in entries.iter().enumerate() { + children.push((pointer_child(&location, &index.to_string()), child)); + } + } + "items" + | "contains" + | "additionalProperties" + | "unevaluatedProperties" + | "unevaluatedItems" + | "propertyNames" + | "not" + | "if" + | "then" + | "else" => { + children.push((location, value)); + } + // These contain data, not subschemas. A property name or literal value + // resembling a keyword must not acquire schema semantics. + "type" | "enum" | "const" | "multipleOf" | "maximum" | "exclusiveMaximum" + | "minimum" | "exclusiveMinimum" | "maxLength" | "minLength" | "pattern" | "format" + | "maxItems" | "minItems" | "uniqueItems" | "maxContains" | "minContains" + | "maxProperties" | "minProperties" | "required" | "dependentRequired" | "title" + | "description" | "default" | "examples" | "deprecated" | "readOnly" | "writeOnly" + | "$comment" => {} + _ => return Err(format!("unsupported Tool schema keyword at #{location}")), + } + } + references.extend(children.iter().map(|(path, _)| path.clone())); + graph.insert(path.into(), references); + for (path, child) in children { + inspect_tool_schema(child, &path, graph)?; + } + Ok(()) +} + +fn check_reference_graph( + path: &str, + graph: &BTreeMap>, + active: &mut BTreeSet, + complete: &mut BTreeMap, + depth: usize, +) -> Result { + if active.contains(path) { + return Err(format!( + "recursive Tool schema reference at #{path} is unsupported" + )); + } + if depth > MAX_DEPTH { + return Err("Tool schema reference graph exceeds static depth limit".into()); + } + let children = graph + .get(path) + .ok_or_else(|| format!("Tool schema reference does not target a schema: #{path}"))?; + if let Some(height) = complete.get(path) { + if depth + height > MAX_DEPTH { + return Err("Tool schema reference graph exceeds static depth limit".into()); + } + return Ok(*height); + } + active.insert(path.into()); + let mut height = 0; + for child in children { + height = height.max(1 + check_reference_graph(child, graph, active, complete, depth + 1)?); + } + active.remove(path); + complete.insert(path.into(), height); + Ok(height) +} + +/// Admit a closed static subset of actual DCDL syntax using decodal's parser/AST. +/// +/// The root must be an object. Nested closed objects, homogeneous array constraints +/// (`[...String]`), primitive types (`String`, `Bool`, `Int`, `Float`) and scalar +/// singleton literals are supported. Object fields must have unique single-part +/// paths. Imports, open objects/maps, Unknown, defaults, references, expressions, +/// calls and user functions are rejected, not evaluated. This does not validate a +/// config instance or establish that its values contain no secrets. +pub fn validate_config_schema(source: &str) -> Result<(), String> { + if source.len() > MAX_SCHEMA_BYTES { + return Err("DCDL config schema exceeds static byte limit".into()); + } + let tokens = decodal::tokenize_source(source) + .map_err(|error| format!("invalid DCDL config schema: {}", error.message))?; + if tokens.len() > MAX_NODES { + return Err("DCDL config schema exceeds static token limit".into()); + } + // Guard parser recursion before parsing untrusted source. Forbid operators + // outside the subset here as well, so long right-associative expressions + // cannot consume the parser stack before the AST admission check. + let mut depth = 0usize; + for (index, token) in tokens.iter().enumerate() { + match &token.kind { + Token::LBrace | Token::LBracket | Token::LParen => { + depth += 1; + if depth > MAX_DEPTH { + return Err("DCDL config schema exceeds static depth limit".into()); + } + } + Token::RBrace | Token::RBracket | Token::RParen => { + depth = depth.saturating_sub(1); + } + Token::Minus + if tokens + .get(index + 1) + .is_some_and(|next| matches!(next.kind, Token::Int(_) | Token::Float(_))) => {} + Token::Ident(_) + | Token::Int(_) + | Token::Float(_) + | Token::String(_) + | Token::True + | Token::False + | Token::Semicolon + | Token::Comma + | Token::Ellipsis + | Token::Colon + | Token::Equal + | Token::Comment + | Token::Eof => {} + _ => return Err("unsupported expression in static DCDL config schema".into()), + } + } + let parsed = decodal::parse_source(source) + .map_err(|error| format!("invalid DCDL config schema: {}", error.message))?; + let root = unparenthesized(&parsed.ast, parsed.root); + if !matches!(parsed.ast.get(root).expr, Expr::Object { .. }) { + return Err("DCDL config schema root must be a closed object".into()); + } + inspect_config_schema(&parsed.ast, root) +} + +fn unparenthesized(ast: &Ast, mut id: ExprId) -> ExprId { + while let Expr::Parenthesized { expr } = ast.get(id).expr { + id = expr; + } + id +} + +fn inspect_config_schema(ast: &Ast, id: ExprId) -> Result<(), String> { + match &ast.get(unparenthesized(ast, id)).expr { + Expr::Object { fields, rest } => { + if rest.is_some() { + return Err( + "DCDL config schema objects must be closed (no rest constraint)".into(), + ); + } + let mut names = BTreeSet::new(); + for field in fields { + if field.path.len() != 1 || !names.insert(&field.path[0]) { + return Err("DCDL config schema requires unique single-part field paths".into()); + } + inspect_config_schema(ast, field.value)?; + } + Ok(()) + } + Expr::ArrayConstraint { item } => inspect_config_schema(ast, *item), + Expr::Ident(name) if matches!(name.as_str(), "String" | "Bool" | "Int" | "Float") => Ok(()), + Expr::Literal(Literal::Float(value)) if !value.is_finite() => { + Err("DCDL config schema requires finite scalar literals".into()) + } + Expr::Literal(_) => Ok(()), + Expr::Unary { + op: decodal::ast::UnaryOp::Neg, + expr, + } if matches!(ast.get(*expr).expr, Expr::Literal(Literal::Int(_))) + || matches!(ast.get(*expr).expr, Expr::Literal(Literal::Float(v)) if v.is_finite()) => + { + Ok(()) + } + _ => Err("unsupported node in closed static DCDL config schema".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn tool_schema_accepts_typed_objects_and_local_definitions() { + let schema = json!({ + "$schema": TOOL_DIALECT, + "$defs": {"entry/type": {"type": "string", "minLength": 1}}, + "type": "object", + "properties": { + "label": {"$ref": "#/$defs/entry~1type"}, + "values": {"type": "array", "items": {"type": "integer"}} + }, + "required": ["label"], + "additionalProperties": false + }); + validate_tool_schema(&schema).unwrap(); + for schema in [json!({}), json!(true), json!(false)] { + validate_tool_schema(&schema).unwrap(); + } + } + + #[test] + fn tool_schema_rejects_invalid_keyword_shapes_and_types() { + for schema in [ + json!(null), + json!([]), + json!({"type": "unknown"}), + json!({"required": "label"}), + json!({"properties": []}), + json!({"properties": {"nested": {"type": 3}}}), + json!({"allOf": []}), + json!({"items": []}), + json!({"minLength": -1}), + json!({"pattern": "["}), + json!({"required": ["x", "x"]}), + json!({"format": "unknown-format"}), + ] { + assert!(validate_tool_schema(&schema).is_err(), "accepted {schema}"); + } + } + + #[test] + fn tool_schema_rejects_unknown_dialects_keywords_and_nested_extensions() { + for schema in [ + json!({"$schema": "https://example.invalid/schema"}), + json!({"$schema": "http://json-schema.org/draft-07/schema#"}), + json!({"typo": true}), + json!({"$id": "https://example.invalid/base"}), + json!({"$dynamicRef": "#node"}), + json!({"$anchor": "node"}), + json!({"$defs": {"unused": {"typo": true}}}), + json!({"properties": {"x": {"$schema": TOOL_DIALECT}}}), + json!({"contentSchema": {"type": "string"}}), + ] { + assert!(validate_tool_schema(&schema).is_err(), "accepted {schema}"); + } + } + + #[test] + fn tool_schema_rejects_external_dangling_and_recursive_references() { + for reference in [ + "https://example.invalid/schema", + "file:///etc/passwd", + "other.json", + "//example.invalid/schema", + "urn:example:schema", + "#missing", + "#/$defs/missing", + "#", + "#/%24defs/x", + ] { + assert!( + validate_tool_schema(&json!({"$ref": reference})).is_err(), + "{reference}" + ); + } + for schema in [ + json!({"$defs": {"unused": {"$ref": "https://example.invalid/schema"}}}), + json!({"default": {"type": "string"}, "$ref": "#/default"}), + json!({"$defs": {"a": {"$ref": "#/$defs/b"}, "b": {"$ref": "#/$defs/a"}}}), + ] { + assert!(validate_tool_schema(&schema).is_err(), "accepted {schema}"); + } + } + + #[test] + fn tool_schema_rejects_invalid_unreferenced_definitions() { + for schema in [ + json!({"$defs": {"unused": {"pattern": "["}}}), + json!({"$defs": {"unused": {"format": "unknown-format"}}}), + json!({"$defs": {"unused": {"properties": {"x": {"pattern": "["}}}}}), + ] { + assert!(validate_tool_schema(&schema).is_err(), "accepted {schema}"); + } + } + + #[test] + fn tool_schema_treats_annotations_and_property_names_as_data() { + validate_tool_schema(&json!({ + "properties": { + "$ref": {"type": "string"}, + "unknown": {"const": {"typo": 1}}, + "space # % ~ / 日本語": {"type": "string"} + }, + "default": {"$ref": "https://example.invalid/data", "typo": 3}, + "examples": [{"$schema": "not a schema"}] + })) + .unwrap(); + } + + #[test] + fn config_schema_accepts_closed_dcdl_types_and_singletons() { + for source in [ + "{}", + "label = String; enabled = Bool; retries = Int; ratio = Float", + "{ nested = { label = String }; tags = [...String]; rows = [...{ id = Int }] }", + "{ mode = \"safe\"; enabled = true; count = 3; ratio = 0.5; offset = -2; delta = -1.5 }", + "# DCDL comment\n { label = (String) }", + ] { + validate_config_schema(source).unwrap_or_else(|error| panic!("{source}: {error}")); + } + } + + #[test] + fn config_schema_rejects_open_untyped_and_dynamic_dcdl() { + for source in [ + "Unknown", + "String", + "[...String]", + "{ x = Unknown }", + "{ ...String }", + "{ x = String; ...Unknown }", + "{ nested = { ...Int } }", + "{ x = [...Unknown] }", + "import \"remote.dcdl\"", + "{ x = import \"/etc/passwd\" }", + "let T = String in { x = T }", + "{ x = MissingType }", + "{ x = env(\"TOKEN\") }", + "{ x = String default \"hello\" }", + "{ x = (v) => v }", + "{ x = 1 + 1 }", + "{ x = [] }", + "{ x = [String] }", + "{ x = _ }", + "{ x = String; x = Int }", + "{ x.y = String }", + "{ x = String", + "{\"type\": \"object\"}", + "{ x = ---1 }", + ] { + assert!(validate_config_schema(source).is_err(), "accepted {source}"); + } + } + + #[test] + fn schemas_reject_oversize_and_deep_inputs_without_evaluation() { + assert!(validate_config_schema(&" ".repeat(MAX_SCHEMA_BYTES + 1)).is_err()); + assert!( + validate_config_schema(&format!("{}String{}", "(".repeat(1000), ")".repeat(1000))) + .is_err() + ); + assert!( + validate_tool_schema(&json!({"description": "x".repeat(MAX_SCHEMA_BYTES)})).is_err() + ); + let mut nested = json!({}); + for _ in 0..MAX_DEPTH + 1 { + nested = json!({"not": nested}); + } + assert!(validate_tool_schema(&nested).is_err()); + // Each suffix is visited and cached before the next, longer chain. + let mut definitions = serde_json::Map::new(); + definitions.insert("n00".into(), json!({"type": "string"})); + for index in 1..MAX_DEPTH + 2 { + definitions.insert( + format!("n{index:02}"), + json!({ + "$ref": format!("#/$defs/n{:02}", index - 1) + }), + ); + } + let error = validate_tool_schema(&json!({"$defs": definitions})).unwrap_err(); + assert!(error.contains("depth limit"), "{error}"); + } +} diff --git a/crates/plugin-pdk/Cargo.toml b/crates/plugin-pdk/Cargo.toml index 99202a5b2..2c04cbb86 100644 --- a/crates/plugin-pdk/Cargo.toml +++ b/crates/plugin-pdk/Cargo.toml @@ -15,3 +15,6 @@ wit-bindgen = "0.51.0" [dev-dependencies] tempfile.workspace = true toml.workspace = true +wasmparser = "0.244.0" +wit-component = "0.244.0" +wit-parser = "0.244.0" diff --git a/crates/plugin-pdk/README.md b/crates/plugin-pdk/README.md index 50e2ee049..59a6fed99 100644 --- a/crates/plugin-pdk/README.md +++ b/crates/plugin-pdk/README.md @@ -1,9 +1,5 @@ # plugin-pdk -Guest-side JSON/WIT helpers for offline Component Model packages. Without depending on host/runtime crates, it converts Tool and Service inputs/outputs and bounds Tool output size. Using the PDK or validating a package does not authorize installation or execution in a Worker. +Guest-side Rust helpers for the versioned Component Model Tool contract. [`ToolContext`] decodes only non-secret invocation data; [`ToolOutput`] rejects oversized data and separates structured output from presentation text. [`export_component_tool!`] adapts generated WIT bindings and preserves the result's error arm. -## Main entry points - -- [`run_json_tool`] and [`export_component_tool!`] — typed Tool input/output boundary -- [`ToolOutput`] and [`ToolError`] — bounded output and errors -- [`ServiceOutput`] and [`export_plugin_instance!`] — instance output contracts +Host authorization, schema validation, effect receipts and execution belong to the Host, not this crate. The PDK does not import WASI, implement a Host, or provide Service/event/Workdir/WebSocket stubs. Old v1 packages require an explicit v2 rebuild rather than a compatibility adapter. diff --git a/crates/plugin-pdk/src/lib.rs b/crates/plugin-pdk/src/lib.rs index 592a45687..364833a44 100644 --- a/crates/plugin-pdk/src/lib.rs +++ b/crates/plugin-pdk/src/lib.rs @@ -2,146 +2,126 @@ use serde::Serialize; use serde::de::DeserializeOwned; -use serde_json::Value; pub use wit_bindgen; pub type Result = std::result::Result; -/// Current Yoi Component Model Tool world targeted by this PDK. -pub const TOOL_WORLD: &str = "yoi:plugin/tool@1.0.0"; - -/// Repository WIT for the current Tool world, exposed for authoring tools and -/// tests. Runtime components should still generate bindings at compile time. -pub const TOOL_WIT: &str = include_str!("../../../resources/plugin/wit/yoi-plugin-tool-v1.wit"); - -/// Repository WIT for the grant-bound host APIs importable by Tool components. +pub const TOOL_WORLD: &str = "yoi:plugin/tool-plugin@2.0.0"; +pub const HTTPS_TOOL_WORLD: &str = "yoi:plugin/https-tool-plugin@2.0.0"; +pub const TOOL_WIT: &str = include_str!("../../../resources/plugin/wit/yoi-plugin-v2.wit"); pub const HOST_WIT: &str = - include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v1.wit"); - -/// Serialized JSON limit defined by the offline Tool world's output contract. + include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit"); pub const MAX_TOOL_OUTPUT_BYTES: usize = 64 * 1024; -/// Byte limit for summaries in the offline Tool world. -pub const MAX_SUMMARY_BYTES: usize = 1024; -/// Conservative content cap that leaves room for JSON framing and escaping. -pub const MAX_CONTENT_BYTES: usize = MAX_TOOL_OUTPUT_BYTES - MAX_SUMMARY_BYTES - 4096; -/// Maximum structured error message bytes retained by the PDK. +pub const MAX_TOOL_INPUT_BYTES: usize = 64 * 1024; +pub const MAX_CONFIG_BYTES: usize = 64 * 1024; pub const MAX_ERROR_MESSAGE_BYTES: usize = 4096; -/// Per-call context passed to a typed JSON handler. +/// Non-secret call snapshot. Invocation identity tracks effects; it grants no authority. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ToolContext { + invocation_id: String, tool_name: String, + config_json: String, + remaining_ms: u32, } impl ToolContext { - /// Create context for the manifest-declared Tool name selected by the host. - pub fn new(tool_name: impl Into) -> Self { + pub fn new( + invocation_id: String, + tool_name: String, + config_json: String, + remaining_ms: u32, + ) -> Self { Self { - tool_name: bounded_text(tool_name.into(), MAX_SUMMARY_BYTES), + invocation_id, + tool_name, + config_json, + remaining_ms, } } - /// Manifest-declared Tool name supplied by the host runtime. + pub fn invocation_id(&self) -> &str { + &self.invocation_id + } pub fn tool_name(&self) -> &str { &self.tool_name } + pub fn remaining_ms(&self) -> u32 { + self.remaining_ms + } + + /// Decode the plan's non-secret config. Host validation remains authoritative. + pub fn config(&self) -> Result { + parse_bounded_json(&self.config_json, MAX_CONFIG_BYTES) + } } -/// Bounded Tool output formatted on the guest side. -/// -/// Shorten text at character boundaries and recheck the serialized JSON limit, including escapes. -/// Typed JSON through [`Self::json`] errors on overflow rather than truncating and breaking -/// its structure. Providing this output format does not provide Plugin execution by Workers. +/// Output crosses WIT as a record, never as a success envelope hiding an error. +/// Oversized output is rejected, not silently truncated into invalid JSON. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct ToolOutput { summary: String, - #[serde(skip_serializing_if = "Option::is_none")] content: Option, + structured_json: Option, } impl ToolOutput { - /// Create an ordinary Tool output. - pub fn new(summary: impl Into, content: Option) -> Self { - let mut output = Self { - summary: normalize_summary(summary.into()), - content: content.map(|value| bounded_text(value, MAX_CONTENT_BYTES)), - }; - output.bound_serialized_json(); - output + pub fn new(summary: impl Into, content: Option) -> Result { + Self::checked(summary.into(), content, None) } - /// Create a Tool output whose content is typed JSON. - pub fn json( - summary: impl Into, - value: impl Serialize, - ) -> std::result::Result { - let content = serde_json::to_string(&value).map_err(ToolError::serialization)?; + pub fn summary(summary: impl Into) -> Result { + Self::new(summary, None) + } + + /// Structured JSON is separate from presentation text and needs a manifest output schema. + pub fn json(summary: impl Into, value: impl Serialize) -> Result { + let json = serde_json::to_string(&value).map_err(ToolError::serialization)?; + Self::checked(summary.into(), None, Some(json)) + } + + fn checked( + summary: String, + content: Option, + structured_json: Option, + ) -> Result { let output = Self { - summary: normalize_summary(summary.into()), - content: Some(content), + summary, + content, + structured_json, }; - let serialized = serde_json::to_string(&output).map_err(ToolError::serialization)?; - if serialized.len() > MAX_TOOL_OUTPUT_BYTES { - return Err(ToolError::invalid_output(format!( - "serialized ToolOutput JSON exceeds {MAX_TOOL_OUTPUT_BYTES} bytes" - ))); + let bytes = serde_json::to_vec(&output).map_err(ToolError::serialization)?; + if bytes.len() > MAX_TOOL_OUTPUT_BYTES { + return Err(ToolError::invalid_output( + "serialized output exceeds 64 KiB", + )); } Ok(output) } - /// Create a summary-only Tool output. - pub fn summary(summary: impl Into) -> Self { - Self::new(summary, None) - } - - /// Return the bounded summary. pub fn summary_text(&self) -> &str { &self.summary } - - /// Return optional detailed content. pub fn content(&self) -> Option<&str> { self.content.as_deref() } - - /// Serialize to the ToolOutput JSON string returned by the WIT `call` export. - pub fn to_json_string(&self) -> String { - serde_json::to_string(self).unwrap_or_else(|_| { - r#"{"summary":"tool error: serialization","content":"{\"error\":{\"code\":\"serialization\",\"message\":\"failed to serialize ToolOutput\"}}"}"#.to_string() - }) + pub fn structured_json(&self) -> Option<&str> { + self.structured_json.as_deref() } - fn bound_serialized_json(&mut self) { - loop { - let Ok(serialized) = serde_json::to_string(self) else { - return; - }; - if serialized.len() <= MAX_TOOL_OUTPUT_BYTES { - return; - } - let Some(content) = self.content.take() else { - return; - }; - if content.is_empty() { - return; - } - - let overflow = serialized.len() - MAX_TOOL_OUTPUT_BYTES; - let target = content.len().saturating_sub(overflow + "…".len()); - self.content = Some(bounded_text(content, target)); - } + #[doc(hidden)] + pub fn into_parts(self) -> (String, Option, Option) { + (self.summary, self.content, self.structured_json) } } -/// Stable, low-cardinality error codes for PDK-produced Tool errors. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] +/// Guest business errors are not Host authorization decisions or effect receipts. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ToolErrorCode { InvalidInput, InvalidOutput, Serialization, - Denied, UnsupportedTool, Failed, } @@ -152,640 +132,200 @@ impl ToolErrorCode { Self::InvalidInput => "invalid_input", Self::InvalidOutput => "invalid_output", Self::Serialization => "serialization", - Self::Denied => "denied", Self::UnsupportedTool => "unsupported_tool", Self::Failed => "failed", } } } -/// Structured, bounded error that is rendered as ordinary ToolOutput JSON. -#[derive(Clone, Debug, PartialEq, Serialize, thiserror::Error)] +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] #[error("{code:?}: {message}")] pub struct ToolError { code: ToolErrorCode, message: String, - #[serde(skip_serializing_if = "Option::is_none")] - details: Option, } impl ToolError { pub fn new(code: ToolErrorCode, message: impl Into) -> Self { - Self { - code, - message: bounded_text(message.into(), MAX_ERROR_MESSAGE_BYTES), - details: None, + let mut message = message.into(); + if message.len() > MAX_ERROR_MESSAGE_BYTES { + let mut end = MAX_ERROR_MESSAGE_BYTES; + while !message.is_char_boundary(end) { + end -= 1; + } + message.truncate(end); } + Self { code, message } } - pub fn invalid_input(message: impl Into) -> Self { Self::new(ToolErrorCode::InvalidInput, message) } - pub fn invalid_output(message: impl Into) -> Self { Self::new(ToolErrorCode::InvalidOutput, message) } - - pub fn serialization(error: impl std::fmt::Display) -> Self { - Self::new(ToolErrorCode::Serialization, error.to_string()) + /// Serialization diagnostics must not include the original payload. + pub fn serialization(_error: impl std::fmt::Display) -> Self { + Self::new(ToolErrorCode::Serialization, "output serialization failed") } - - pub fn denied(message: impl Into) -> Self { - Self::new(ToolErrorCode::Denied, message) + pub fn unsupported_tool(_name: impl AsRef) -> Self { + Self::new(ToolErrorCode::UnsupportedTool, "unsupported Tool name") } - - pub fn unsupported_tool(tool_name: impl Into) -> Self { - Self::new( - ToolErrorCode::UnsupportedTool, - format!("unsupported tool `{}`", tool_name.into()), - ) - } - pub fn failed(message: impl Into) -> Self { Self::new(ToolErrorCode::Failed, message) } - - pub fn with_details(mut self, details: impl Serialize) -> Self { - self.details = serde_json::to_value(details).ok(); - self - } - pub fn code(&self) -> ToolErrorCode { self.code } - pub fn code_str(&self) -> &'static str { self.code.as_str() } - pub fn message(&self) -> &str { &self.message } +} - /// Render this error as ordinary ToolOutput JSON content. - pub fn into_tool_output(self) -> ToolOutput { - #[derive(Serialize)] - struct ErrorBody<'a> { - error: ErrorPayload<'a>, - } - - #[derive(Serialize)] - struct ErrorPayload<'a> { - code: &'static str, - message: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - details: Option<&'a Value>, - } - - let summary = format!("tool error: {}", self.code.as_str()); - let payload = ErrorBody { - error: ErrorPayload { - code: self.code.as_str(), - message: &self.message, - details: self.details.as_ref(), - }, - }; - let mut content = serde_json::to_string(&payload).unwrap_or_else(|_| { - format!( - r#"{{"error":{{"code":"{}","message":"failed to serialize error details"}}}}"#, - ToolErrorCode::Serialization.as_str() - ) - }); - if content.len() > MAX_CONTENT_BYTES { - let payload = ErrorBody { - error: ErrorPayload { - code: self.code.as_str(), - message: &self.message, - details: None, - }, - }; - content = serde_json::to_string(&payload).unwrap_or_else(|_| { - r#"{"error":{"code":"serialization","message":"failed to serialize error"}}"# - .to_string() - }); - } - ToolOutput::new(summary, Some(content)) +fn parse_bounded_json(json: &str, limit: usize) -> Result { + if json.len() > limit { + return Err(ToolError::invalid_input("JSON exceeds 64 KiB")); } + serde_json::from_str(json) + .map_err(|_| ToolError::invalid_input("JSON does not match the expected schema")) } -/// Parse the WIT `input-json` string into a typed input value. -pub fn parse_json_input(input_json: &str) -> std::result::Result -where - T: DeserializeOwned, -{ - serde_json::from_str(input_json).map_err(|error| { - ToolError::invalid_input(format!( - "tool input is not valid JSON for this schema: {error}" - )) - }) +pub fn parse_json_input(json: &str) -> Result { + parse_bounded_json(json, MAX_TOOL_INPUT_BYTES) } -/// Guest-side boundary that runs a typed JSON handler and converts the result to ToolOutput JSON. -/// -/// Convert parse, handler, and serialization failures to the normal output form. These errors -/// are not host authorization decisions, and catching handler panics is not guaranteed. -/// The PDK does not depend on host/runtime crates or grant permissions through input conversion. -pub fn run_json_tool(tool_name: &str, input_json: &str, handler: F) -> String -where - I: DeserializeOwned, - F: FnOnce(ToolContext, I) -> std::result::Result, -{ - let result = parse_json_input::(input_json).and_then(|input| { - let context = ToolContext::new(tool_name); - handler(context, input) - }); - match result { - Ok(output) => output.to_json_string(), - Err(error) => error.into_tool_output().to_json_string(), - } +/// Host performs schema and admission checks; this boundary only decodes guest data. +/// Failures remain the WIT result's error arm. No retry or success stub is introduced. +pub fn run_json_tool( + context: ToolContext, + input_json: &str, + handler: impl FnOnce(ToolContext, I) -> Result, +) -> Result { + let _: serde_json::Value = context.config()?; + let input = parse_json_input(input_json)?; + handler(context, input) } -/// Connect a typed JSON handler to a WIT `Guest` implementation and component export. -/// -/// Use guest-generated bindings and centralize handler conversion in [`run_json_tool`]. -/// This macro grants no host API permissions and is not a mechanism for registering or -/// executing dynamic Plugins in the current Worker. +/// Adapt bindings generated from any supported v2 Tool world. The generated glue +/// owns the canonical ABI; callers never implement pointer/length exports. #[macro_export] macro_rules! export_component_tool { ($adapter:ident, $handler:path) => { struct $adapter; - - impl Guest for $adapter { - fn call( - tool_name: ::std::string::String, - input_json: ::std::string::String, - ) -> ::std::string::String { - $crate::run_json_tool(&tool_name, &input_json, $handler) + impl exports::yoi::plugin::tool::Guest for $adapter { + fn invoke( + input: exports::yoi::plugin::tool::Invocation, + ) -> ::std::result::Result< + exports::yoi::plugin::tool::Output, + exports::yoi::plugin::tool::ToolError, + > { + let context = $crate::ToolContext::new( + input.invocation_id, + input.tool_name, + input.config_json, + input.remaining_ms, + ); + $crate::run_json_tool(context, &input.input_json, $handler) + .map(|output| { + let (summary, content, structured_json) = output.into_parts(); + exports::yoi::plugin::tool::Output { + summary, + content, + structured_json, + } + }) + .map_err(|error| exports::yoi::plugin::tool::ToolError { + code: error.code_str().to_owned(), + message: error.message().to_owned(), + }) } } - export!($adapter); }; } -fn normalize_summary(summary: String) -> String { - let summary = bounded_text(summary, MAX_SUMMARY_BYTES); - if summary.trim().is_empty() { - "tool completed".to_string() - } else { - summary - } -} - -fn bounded_text(mut value: String, max_bytes: usize) -> String { - if max_bytes == 0 { - return String::new(); - } - - value = value - .chars() - .map(|ch| { - if ch.is_control() && ch != '\n' && ch != '\t' { - ' ' - } else { - ch - } - }) - .collect(); - if value.len() <= max_bytes { - return value; - } - - let suffix = "…"; - let budget = max_bytes.saturating_sub(suffix.len()); - let mut cut = budget.min(value.len()); - while cut > 0 && !value.is_char_boundary(cut) { - cut -= 1; - } - value.truncate(cut); - value.push_str(suffix); - value -} - #[cfg(test)] mod tests { use super::*; - use serde::{Deserialize, Serialize}; + use serde::Deserialize; use serde_json::json; - #[derive(Debug, Deserialize)] - struct EchoInput { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Input { text: String, } - #[derive(Debug, Serialize)] - struct EchoOutput<'a> { - tool: &'a str, - text: String, + fn context(config: &str) -> ToolContext { + ToolContext::new("call-1".into(), "echo".into(), config.into(), 500) } #[test] - fn run_json_tool_parses_input_and_serializes_output() { - let output = run_json_tool( - "example_echo", - r#"{"text":"hello"}"#, - |ctx, input: EchoInput| { - ToolOutput::json( - format!("{} ok", ctx.tool_name()), - EchoOutput { - tool: ctx.tool_name(), - text: input.text, - }, - ) - }, - ); - - let value: Value = serde_json::from_str(&output).unwrap(); - assert_eq!(value["summary"], "example_echo ok"); - let content: Value = serde_json::from_str(value["content"].as_str().unwrap()).unwrap(); - assert_eq!(content, json!({"tool":"example_echo","text":"hello"})); - assert!(output.len() <= MAX_TOOL_OUTPUT_BYTES); - } - - #[test] - fn run_json_tool_renders_error_output() { - let output = - run_json_tool::("example_echo", r#"{"text":1}"#, |_ctx, _input| { - Ok(ToolOutput::summary("unreachable")) - }); - - let value: Value = serde_json::from_str(&output).unwrap(); - assert_eq!(value["summary"], "tool error: invalid_input"); - let content: Value = serde_json::from_str(value["content"].as_str().unwrap()).unwrap(); - assert_eq!(content["error"]["code"], "invalid_input"); - assert!(content["error"]["message"].as_str().unwrap().len() <= MAX_ERROR_MESSAGE_BYTES); - assert!(output.len() <= MAX_TOOL_OUTPUT_BYTES); - } - - #[test] - fn json_output_rejects_oversized_serialized_tool_output() { - let too_large = vec!["quoted \" text"; MAX_TOOL_OUTPUT_BYTES / 4]; - let error = ToolOutput::json("too large", too_large).unwrap_err(); - - assert_eq!(error.code(), ToolErrorCode::InvalidOutput); - } - - #[test] - fn tool_error_bounds_message_and_control_characters() { - let message = format!("bad\u{0007}{}", "x".repeat(MAX_ERROR_MESSAGE_BYTES * 2)); - let error = ToolError::failed(message); - - assert_eq!(error.code_str(), "failed"); - assert!(!error.message().contains('\u{0007}')); - assert!(error.message().len() <= MAX_ERROR_MESSAGE_BYTES + "…".len()); - } - - #[test] - fn service_output_helper_builds_runtime_command_envelope() { - let event = PluginIngressEvent { - kind: "websocket_text".to_string(), - source: "websocket:wss://example.test/socket".to_string(), - ingress_name: "example_ws".to_string(), - payload: json!({"text":"ping"}), - created_at: "2026-06-25T00:00:00Z".to_string(), - attempt: 1, - correlation_id: "event-1".to_string(), - }; - - assert_eq!(event.websocket_text(), Some("ping")); - let output = - ServiceOutput::websocket_send(&event, "reply-1", "wss://example.test/socket", "pong") - .unwrap(); - let value = serde_json::to_value(output).unwrap(); - - assert_eq!(value["accepted"], true); - assert_eq!(value["output_commands"][0]["source_event_id"], "event-1"); - assert_eq!(value["output_commands"][0]["command_id"], "reply-1"); - assert_eq!(value["output_commands"][0]["kind"], "websocket_send"); - assert_eq!(value["output_commands"][0]["payload"]["text"], "pong"); + fn typed_handler_keeps_structured_output_separate_from_text() { + let output = run_json_tool(context("{}"), r#"{"text":"hello"}"#, |ctx, input: Input| { + assert_eq!(ctx.invocation_id(), "call-1"); + assert_eq!(ctx.remaining_ms(), 500); + ToolOutput::json( + "echoed", + json!({"text": input.text, "tool": ctx.tool_name()}), + ) + }) + .unwrap(); + assert_eq!(output.content(), None); assert_eq!( - value["output_commands"][0]["requested_at"], - "2026-06-25T00:00:00Z" + serde_json::from_str::(output.structured_json().unwrap()).unwrap(), + json!({"text":"hello", "tool":"echo"}) ); } #[test] - fn wit_constants_match_current_world() { - assert!(TOOL_WIT.contains("package yoi:plugin@1.0.0")); - assert!(TOOL_WIT.contains("world tool")); - assert!(TOOL_WIT.contains("export call")); - assert_eq!(TOOL_WORLD, "yoi:plugin/tool@1.0.0"); - assert!(HOST_WIT.contains("interface request")); - assert!(HOST_WIT.contains("interface websocket")); - assert!(HOST_WIT.contains("interface fs")); - assert!(HOST_WIT.contains("%list: func")); - assert!(INSTANCE_WIT.contains("world instance")); - assert!(INSTANCE_WIT.contains("export handle-ingress")); - assert!(INSTANCE_WIT.contains("websocket_send")); - } -} - -/// Versioned Component Model instance world handled by the host-managed -/// PluginInstanceRegistry. -pub const PLUGIN_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0"; - -/// Repository WIT for the current instance world. -pub const INSTANCE_WIT: &str = - include_str!("../../../resources/plugin/wit/yoi-plugin-instance-v1.wit"); - -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct PluginIngressEvent { - pub kind: String, - pub source: String, - #[serde(default)] - pub ingress_name: String, - #[serde(default)] - pub payload: Value, - #[serde(default)] - pub created_at: String, - #[serde(default = "default_attempt")] - pub attempt: u32, - #[serde(default)] - pub correlation_id: String, -} - -impl PluginIngressEvent { - /// Return the text payload carried by a host-owned WebSocket ingress event. - pub fn websocket_text(&self) -> Option<&str> { - self.payload.get("text").and_then(Value::as_str) - } - - /// Build a `websocket_send` output command that replies through the - /// host-owned Service WebSocket driver. The host still validates the target - /// URL and matching grants before sending. - pub fn websocket_send( - &self, - command_id: impl Into, - url: impl Into, - text: impl Into, - ) -> Result { - ServiceOutputCommand::new( - self, - command_id, - ServiceOutputCommandKind::WebSocketSend, - serde_json::json!({ "url": url.into(), "text": text.into() }), - ) - } -} - -fn default_attempt() -> u32 { - 1 -} - -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -pub struct PluginStatus { - pub state: String, - #[serde(default)] - pub data: Value, -} - -impl PluginStatus { - pub fn ready(data: Value) -> Self { - Self { - state: "ready".to_string(), - data, + fn invalid_input_or_config_never_runs_handler_or_returns_success() { + for (config, input) in [ + ("{}", r#"{"text":1}"#), + ("broken", r#"{"text":"ok"}"#), + ("{}", r#"{"text":"ok","extra":true}"#), + ] { + let error = run_json_tool::(context(config), input, |_, _| { + panic!("handler must not run") + }) + .unwrap_err(); + assert_eq!(error.code(), ToolErrorCode::InvalidInput); } } - pub fn stopped() -> Self { - Self { - state: "stopped".to_string(), - data: Value::Null, + #[test] + fn payload_limits_reject_overflow_including_json_escaping() { + for text in [ + "x".repeat(MAX_TOOL_OUTPUT_BYTES), + "\"".repeat(MAX_TOOL_OUTPUT_BYTES / 2), + "語".repeat(MAX_TOOL_OUTPUT_BYTES / 3), + ] { + assert_eq!( + ToolOutput::json("large", &text).unwrap_err().code(), + ToolErrorCode::InvalidOutput + ); } + assert!( + parse_json_input::(&format!( + "\"{}\"", + "x".repeat(MAX_TOOL_INPUT_BYTES) + )) + .is_err() + ); + let large = format!("\"{}\"", "x".repeat(MAX_CONFIG_BYTES)); + assert!(context(&large).config::().is_err()); } -} -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ServiceOutputCommandKind { - DiagnosticStatusUpdate, - HostRequestDispatch, - #[serde(rename = "websocket_send")] - WebSocketSend, -} - -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct ServiceOutputCommand { - pub correlation_id: String, - pub source_event_id: String, - pub command_id: String, - pub kind: ServiceOutputCommandKind, - pub payload: Value, - pub requested_at: String, -} - -impl ServiceOutputCommand { - pub fn new( - event: &PluginIngressEvent, - command_id: impl Into, - kind: ServiceOutputCommandKind, - payload: impl Serialize, - ) -> Result { - let command_id = sanitize_command_id(command_id.into()); - if command_id.is_empty() { - return Err(ToolError::invalid_output( - "service output command_id must not be empty", - )); - } - let source_event_id = event.correlation_id.clone(); - if source_event_id.is_empty() { - return Err(ToolError::invalid_output( - "service output command requires ingress event correlation_id", - )); - } - let requested_at = if event.created_at.is_empty() { - "1970-01-01T00:00:00Z".to_string() - } else { - event.created_at.clone() - }; - Ok(Self { - correlation_id: sanitize_command_id(format!("{source_event_id}:{command_id}")), - source_event_id, - command_id, - kind, - payload: serde_json::to_value(payload).map_err(ToolError::serialization)?, - requested_at, + #[test] + fn business_failure_is_preserved_and_message_is_utf8_bounded() { + let error = run_json_tool(context("{}"), "null", |_, _: ()| { + Err(ToolError::failed("語".repeat(MAX_ERROR_MESSAGE_BYTES))) }) + .unwrap_err(); + assert_eq!(error.code_str(), "failed"); + assert!(error.message().len() <= MAX_ERROR_MESSAGE_BYTES); + assert!(error.message().ends_with('語')); } } - -/// Instance processing results and output commands requested from the host. -/// -/// The guest does not send anything here; it returns commands as data tied to the original -/// ingress event. Command creation/serialization and host authorization/execution are separate -/// boundaries. This type provides neither network permissions nor the current Worker execution mechanism. -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct ServiceOutput { - #[serde(default)] - pub accepted: bool, - #[serde(default, skip_serializing_if = "Value::is_null")] - pub data: Value, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub output_commands: Vec, -} - -impl ServiceOutput { - pub fn accepted(data: impl Serialize) -> Result { - Ok(Self { - accepted: true, - data: serde_json::to_value(data).map_err(ToolError::serialization)?, - output_commands: Vec::new(), - }) - } - - pub fn empty() -> Self { - Self { - accepted: true, - data: Value::Null, - output_commands: Vec::new(), - } - } - - pub fn with_command(mut self, command: ServiceOutputCommand) -> Self { - self.output_commands.push(command); - self - } - - pub fn websocket_send( - event: &PluginIngressEvent, - command_id: impl Into, - url: impl Into, - text: impl Into, - ) -> Result { - Ok(Self::empty().with_command(event.websocket_send(command_id, url, text)?)) - } -} - -fn sanitize_command_id(value: String) -> String { - bounded_text( - value - .chars() - .map(|ch| if ch.is_control() { '-' } else { ch }) - .collect(), - 128, - ) -} - -/// Rust-facing instance Plugin contract. Hosts call `start` once, then route -/// Tool/Ingress surfaces through the same mutable instance. -pub trait Plugin: Sized + 'static { - fn start(config: Value) -> Result; - fn handle_tool(&mut self, name: &str, input: Value) -> Result; - fn handle_ingress(&mut self, name: &str, event: PluginIngressEvent) -> Result; - fn status(&self) -> Result { - Ok(PluginStatus::ready(Value::Null)) - } - fn stop(&mut self) -> Result { - Ok(PluginStatus::stopped()) - } -} - -#[doc(hidden)] -pub fn plugin_instance_error(message: impl Into) -> String { - serde_json::json!({ "error": { "message": message.into() } }).to_string() -} - -#[doc(hidden)] -pub fn plugin_instance_status(status: &PluginStatus) -> String { - serde_json::to_string(status).unwrap_or_else(|error| plugin_instance_error(error.to_string())) -} - -/// Implement the generated Component Model `Guest` trait for an instance Plugin -/// and export it with the `wit-bindgen` generated `export!` macro. -/// -/// The caller must invoke `wit_bindgen::generate!` for the `instance` world -/// first, with `runtime_path: "yoi_plugin_pdk::wit_bindgen::rt"`. That defines -/// the `Guest` trait and `export!` macro in the current module. -#[macro_export] -macro_rules! export_plugin_instance { - ($adapter:ident, $plugin:ty) => { - struct $adapter; - - thread_local! { - static YOI_PLUGIN_INSTANCE: ::std::cell::RefCell<::std::option::Option<$plugin>> = const { ::std::cell::RefCell::new(None) }; - } - - impl Guest for $adapter { - fn start(config_json: ::std::string::String) -> ::std::string::String { - let config = serde_json::from_str(&config_json).unwrap_or(serde_json::Value::Null); - match <$plugin as $crate::Plugin>::start(config) { - Ok(plugin) => { - YOI_PLUGIN_INSTANCE.with(|slot| *slot.borrow_mut() = Some(plugin)); - $crate::plugin_instance_status(&$crate::PluginStatus::ready(serde_json::Value::Null)) - } - Err(error) => $crate::plugin_instance_error(error.to_string()), - } - } - - fn handle_tool( - name: ::std::string::String, - input_json: ::std::string::String, - ) -> ::std::string::String { - let input = serde_json::from_str(&input_json).unwrap_or(serde_json::Value::Null); - YOI_PLUGIN_INSTANCE.with(|slot| { - let mut slot = slot.borrow_mut(); - let Some(plugin) = slot.as_mut() else { - return $crate::plugin_instance_error("plugin instance has not been started"); - }; - match plugin.handle_tool(&name, input) { - Ok(output) => output.to_json_string(), - Err(error) => error.into_tool_output().to_json_string(), - } - }) - } - - fn handle_ingress( - name: ::std::string::String, - event_json: ::std::string::String, - ) -> ::std::string::String { - let event = match serde_json::from_str::<$crate::PluginIngressEvent>(&event_json) { - Ok(event) => event, - Err(error) => return $crate::plugin_instance_error(error.to_string()), - }; - YOI_PLUGIN_INSTANCE.with(|slot| { - let mut slot = slot.borrow_mut(); - let Some(plugin) = slot.as_mut() else { - return $crate::plugin_instance_error("plugin instance has not been started"); - }; - match plugin.handle_ingress(&name, event) { - Ok(output) => serde_json::to_string(&output) - .unwrap_or_else(|error| $crate::plugin_instance_error(error.to_string())), - Err(error) => $crate::plugin_instance_error(error.to_string()), - } - }) - } - - fn status() -> ::std::string::String { - YOI_PLUGIN_INSTANCE.with(|slot| { - let slot = slot.borrow(); - let Some(plugin) = slot.as_ref() else { - return $crate::plugin_instance_error("plugin instance has not been started"); - }; - match plugin.status() { - Ok(status) => $crate::plugin_instance_status(&status), - Err(error) => $crate::plugin_instance_error(error.to_string()), - } - }) - } - - fn stop() -> ::std::string::String { - YOI_PLUGIN_INSTANCE.with(|slot| { - let mut slot = slot.borrow_mut(); - let Some(plugin) = slot.as_mut() else { - return $crate::plugin_instance_error("plugin instance has not been started"); - }; - match plugin.stop() { - Ok(status) => { - let output = $crate::plugin_instance_status(&status); - *slot = None; - output - } - Err(error) => $crate::plugin_instance_error(error.to_string()), - } - }) - } - } - - export!($adapter); - }; -} diff --git a/crates/plugin-pdk/tests/fixtures/component-tool-source.txt b/crates/plugin-pdk/tests/fixtures/component-tool-source.txt deleted file mode 100644 index 4311cc2f0..000000000 --- a/crates/plugin-pdk/tests/fixtures/component-tool-source.txt +++ /dev/null @@ -1,38 +0,0 @@ -//! Minimal Component Model Tool plugin authoring sketch using `yoi-plugin-pdk`. -//! -//! Build this as a `wasm32-unknown-unknown` cdylib with Component Model tooling -//! and package the adapted component as `plugin.component.wasm`. - -use serde::{Deserialize, Serialize}; -use yoi_plugin_pdk::wit_bindgen; -use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput}; - -wit_bindgen::generate!({ - world: "tool", - path: "../../../resources/plugin/wit", - generate_all, - runtime_path: "yoi_plugin_pdk::wit_bindgen::rt", -}); - -#[derive(Debug, Deserialize)] -struct EchoInput { - text: String, -} - -#[derive(Debug, Serialize)] -struct EchoOutput<'a> { - tool: &'a str, - text: String, -} - -fn handle_echo(ctx: ToolContext, input: EchoInput) -> Result { - ToolOutput::json( - format!("{} ok", ctx.tool_name()), - EchoOutput { - tool: ctx.tool_name(), - text: input.text, - }, - ) -} - -yoi_plugin_pdk::export_component_tool!(Plugin, handle_echo); diff --git a/crates/plugin-pdk/tests/fixtures/https-tool-v2.component.wasm b/crates/plugin-pdk/tests/fixtures/https-tool-v2.component.wasm new file mode 100644 index 0000000000000000000000000000000000000000..0012b1af24bbb96796d83cf22ca6314798cb7ffc GIT binary patch literal 149406 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 Gi2nlxy^Uc2 literal 0 HcmV?d00001 diff --git a/crates/plugin-pdk/tests/fixtures/tool-v2.component.wasm b/crates/plugin-pdk/tests/fixtures/tool-v2.component.wasm new file mode 100644 index 0000000000000000000000000000000000000000..792a4e6b2ffc6de268e3da8f09f46ea2ca65fc7e GIT binary patch literal 144431 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 z

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 core Wasm -> Component coverage for the shipped starters. +//! Only filesystem paths are relocated; WIT and guest source are unchanged. +//! Requires the wasm32-unknown-unknown Rust std target and cached Cargo dependencies. +//! No guest is instantiated and no HTTPS Host is faked by this suite. use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; use toml::Value; +use wit_parser::WorldItem; -const TEMPLATE_CARGO: &str = - include_str!("../../../resources/plugin/templates/rust-component-tool/Cargo.toml"); -const TEMPLATE_LIB: &str = - include_str!("../../../resources/plugin/templates/rust-component-tool/src/lib.rs"); -const TEMPLATE_PLUGIN: &str = - include_str!("../../../resources/plugin/templates/rust-component-tool/plugin.toml"); -const SERVICE_TEMPLATE_CARGO: &str = - include_str!("../../../resources/plugin/templates/rust-component-instance/Cargo.toml"); -const SERVICE_TEMPLATE_LIB: &str = - include_str!("../../../resources/plugin/templates/rust-component-instance/src/lib.rs"); -const SERVICE_TEMPLATE_PLUGIN: &str = - include_str!("../../../resources/plugin/templates/rust-component-instance/plugin.toml"); -// Preserve the removed document's source as a byte-identical fixture to keep the test input unchanged. -const SAMPLE_LIB: &str = include_str!("fixtures/component-tool-source.txt"); -const PDK_CARGO: &str = include_str!("../Cargo.toml"); +const TEMPLATES: [&str; 2] = ["rust-component-tool", "rust-component-https"]; -#[test] -fn rust_component_tool_template_has_expected_files() { - let cargo: Value = toml::from_str(TEMPLATE_CARGO).expect("template Cargo.toml parses"); - assert_eq!(cargo["package"]["edition"].as_str(), Some("2024")); - assert!(cargo["workspace"].is_table()); - assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib")); - assert_eq!( - cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(), - Some("../../../../crates/plugin-pdk") - ); - assert!(TEMPLATE_CARGO.contains("rev = \"\"")); - - let plugin: Value = toml::from_str(TEMPLATE_PLUGIN).expect("template plugin.toml parses"); - assert_eq!(plugin["schema_version"].as_integer(), Some(1)); - assert_eq!(plugin["runtime"]["kind"].as_str(), Some("wasm-component")); - assert_eq!( - plugin["runtime"]["world"].as_str(), - Some("yoi:plugin/tool@1.0.0") - ); - assert!(plugin["tools"].as_array().expect("tools array").len() == 1); - - assert!(TEMPLATE_LIB.contains("use yoi_plugin_pdk::wit_bindgen")); - assert!(TEMPLATE_LIB.contains("wit_bindgen::generate!")); - assert!(TEMPLATE_LIB.contains("generate_all")); - assert!(TEMPLATE_LIB.contains("runtime_path: \"yoi_plugin_pdk::wit_bindgen::rt\"")); - assert!(TEMPLATE_LIB.contains("yoi_plugin_pdk::export_component_tool!")); - assert!(TEMPLATE_LIB.contains("ToolOutput::json")); +fn root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .unwrap() } #[test] -fn rust_component_service_template_has_event_output_pattern() { - let cargo: Value = toml::from_str(SERVICE_TEMPLATE_CARGO).expect("service Cargo.toml parses"); - assert_eq!(cargo["package"]["edition"].as_str(), Some("2024")); - assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib")); - assert_eq!( - cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(), - Some("../../../../crates/plugin-pdk") - ); - assert!(SERVICE_TEMPLATE_CARGO.contains("rev = \"\"")); - - let plugin: Value = - toml::from_str(SERVICE_TEMPLATE_PLUGIN).expect("service plugin.toml parses"); - assert_eq!(plugin["schema_version"].as_integer(), Some(1)); - assert_eq!(plugin["runtime"]["kind"].as_str(), Some("wasm-component")); - assert_eq!( - plugin["runtime"]["world"].as_str(), - Some("yoi:plugin/instance@1.0.0") - ); - assert!( - plugin["permissions"] - .as_array() - .expect("permissions array") - .iter() - .any(|permission| permission["kind"].as_str() == Some("host_api") - && permission["api"].as_str() == Some("websocket")) - ); - assert_eq!( - plugin["services"].as_array().expect("services array").len(), - 1 - ); - let ingress = &plugin["ingresses"].as_array().expect("ingresses array")[0]; - assert!( - ingress["event_kinds"] - .as_array() - .expect("event kinds") - .iter() - .any(|kind| kind.as_str() == Some("websocket_text")) - ); - assert!( - ingress["sources"] - .as_array() - .expect("sources") - .iter() - .any(|source| source.as_str() == Some("websocket:wss://example.com/socket")) - ); - let websocket = &plugin["websocket"].as_array().expect("websocket targets")[0]; - assert_eq!(websocket["scheme"].as_str(), Some("wss")); - assert_eq!(websocket["host"].as_str(), Some("example.com")); - assert!( - websocket["path_prefixes"] - .as_array() - .expect("websocket path prefixes") - .iter() - .any(|prefix| prefix.as_str() == Some("/socket")) - ); - - assert!(SERVICE_TEMPLATE_LIB.contains("world: \"instance\"")); - assert!(SERVICE_TEMPLATE_LIB.contains("PluginIngressEvent")); - assert!(SERVICE_TEMPLATE_LIB.contains("ServiceOutput::websocket_send")); - assert!(!SERVICE_TEMPLATE_LIB.contains("recv(timeout")); - assert!(!SERVICE_TEMPLATE_PLUGIN.contains("kind = \"wasm\"")); +fn template_wit_copies_match_the_versioned_contract() { + let root = root(); + for template in TEMPLATES { + for file in ["yoi-plugin-v2.wit", "deps/yoi-host/yoi-host-v2.wit"] { + assert_eq!( + fs::read(root.join("resources/plugin/wit").join(file)).unwrap(), + fs::read( + root.join("resources/plugin/templates") + .join(template) + .join("wit") + .join(file) + ) + .unwrap(), + "{template}/{file} must be regenerated from the canonical WIT" + ); + } + } } #[test] -fn component_tool_fixture_uses_pdk_bindings() { - assert!(SAMPLE_LIB.contains("use yoi_plugin_pdk::wit_bindgen")); - assert!(SAMPLE_LIB.contains("wit_bindgen::generate!")); - assert!(SAMPLE_LIB.contains("generate_all")); - assert!(SAMPLE_LIB.contains("runtime_path: \"yoi_plugin_pdk::wit_bindgen::rt\"")); - assert!(SAMPLE_LIB.contains("yoi_plugin_pdk::export_component_tool!")); - assert!(!SAMPLE_LIB.contains("export_name")); +fn template_manifest_worlds_use_exact_versions_and_schemas() { + for template in TEMPLATES { + let path = root().join("resources/plugin/templates").join(template); + let cargo: Value = + toml::from_str(&fs::read_to_string(path.join("Cargo.toml")).unwrap()).unwrap(); + assert!(cargo["workspace"].is_table()); + assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib")); + let manifest: Value = + toml::from_str(&fs::read_to_string(path.join("plugin.toml")).unwrap()).unwrap(); + assert_eq!(manifest["schema_version"].as_integer(), Some(2)); + assert_eq!(manifest["runtime"]["kind"].as_str(), Some("wasm-component")); + assert!( + manifest["runtime"]["world"] + .as_str() + .unwrap() + .ends_with("@2.0.0") + ); + for tool in manifest["tools"].as_array().unwrap() { + for schema in ["input_schema", "output_schema"] { + assert_eq!(tool[schema]["type"].as_str(), Some("object")); + assert_eq!(tool[schema]["additionalProperties"].as_bool(), Some(false)); + } + } + } } #[test] -fn embedded_template_cargo_checks_for_wasm_target() { - cargo_check_template("rust-component-tool"); +fn pure_starter_builds_real_component_without_imports() { + build_component( + "rust-component-tool", + "yoi_rust_component_tool_template", + &[], + ); } #[test] -fn embedded_service_template_cargo_checks_for_wasm_target() { - cargo_check_template("rust-component-instance"); +fn https_starter_builds_real_component_with_only_explicit_host_imports() { + build_component( + "rust-component-https", + "yoi_rust_component_https_template", + &[ + "yoi:host/connections@2.0.0", + "yoi:host/errors@2.0.0", + "yoi:host/http@2.0.0", + ], + ); } -fn cargo_check_template(template: &str) { - let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let templates_dir = crate_dir.join("../../resources/plugin/templates"); - let template_dir = templates_dir.join(template); - - // A sibling fixture preserves the shipped relative PDK and WIT paths verbatim. - // Never create or remove a lockfile in the shipped template: another invocation - // may be checking it at the same time, or a developer may own that lockfile. +fn build_component(template: &str, library: &str, expected_imports: &[&str]) { + let root = root(); + let sdk = root.join("target/sdk"); + fs::create_dir_all(&sdk).unwrap(); + let source = root.join("resources/plugin/templates").join(template); let fixture = tempfile::Builder::new() - .prefix(".cargo-check-") - .tempdir_in(&templates_dir) - .expect("temporary template fixture"); - copy_template_tree(&template_dir, fixture.path(), true); - - // Both templates share the PDK dependencies. A separate persistent target - // reuses those artifacts without sharing source or the outer Cargo build lock. - // Cargo fingerprints and its target lock handle changes and concurrent checks. - let executable = std::env::current_exe().expect("current test executable"); - let target_dir = executable - .parent() - .and_then(|deps| deps.parent()) - .expect("test executable has a profile directory") - .join("compile-tests/plugin-pdk-templates"); + .prefix("build-") + .tempdir_in(&sdk) + .unwrap(); + copy_tree(&source, fixture.path()); + let cargo_path = fixture.path().join("Cargo.toml"); + let cargo = fs::read_to_string(&cargo_path).unwrap().replace( + "../../../../crates/plugin-pdk", + root.join("crates/plugin-pdk").to_str().unwrap(), + ); + fs::write(&cargo_path, cargo).unwrap(); + let target = sdk.join("guest"); let output = Command::new(env!("CARGO")) - .arg("check") + .args([ + "build", + "--release", + "--offline", + "--target", + "wasm32-unknown-unknown", + "--target-dir", + ]) + .arg(&target) .arg("--manifest-path") - .arg(fixture.path().join("Cargo.toml")) - .arg("--target") - .arg("wasm32-unknown-unknown") - .arg("--offline") - .arg("--target-dir") - .arg(target_dir) + .arg(&cargo_path) .current_dir(fixture.path()) .env("CARGO_TERM_COLOR", "never") .output() - .expect("spawn cargo check for embedded template"); - + .expect("run guest Cargo build"); + let logs = sdk.join("logs"); + fs::create_dir_all(&logs).unwrap(); + fs::write(logs.join(format!("{template}.stdout.log")), &output.stdout).unwrap(); + fs::write(logs.join(format!("{template}.stderr.log")), &output.stderr).unwrap(); assert!( output.status.success(), - "template {template} cargo check failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}", - output.status, + "{template}: cargo build failed\n{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); + let core = + fs::read(target.join(format!("wasm32-unknown-unknown/release/{library}.wasm"))).unwrap(); + let component = wit_component::ComponentEncoder::default() + .module(&core) + .unwrap() + .validate(true) + .encode() + .unwrap(); + wasmparser::Validator::new_with_features(wasmparser::WasmFeatures::all()) + .validate_all(&component) + .unwrap(); + let wit_parser::decoding::DecodedWasm::Component(resolve, world) = + wit_component::decode(&component).unwrap() + else { + panic!("expected Component, not WIT package") + }; + let world = &resolve.worlds[world]; + let mut imports: Vec<_> = world + .imports + .keys() + .map(|key| resolve.name_world_key(key)) + .collect(); + imports.sort(); + assert_eq!(imports, expected_imports); + let exports: Vec<_> = world + .exports + .keys() + .map(|key| resolve.name_world_key(key)) + .collect(); + assert_eq!(exports, ["yoi:plugin/tool@2.0.0"]); + let WorldItem::Interface { id, .. } = world.exports.values().next().unwrap() else { + panic!("Tool interface export required") + }; + assert_eq!( + resolve.interfaces[*id].functions.keys().collect::>(), + [&"invoke".to_string()] + ); + + // Retain actual package inputs for the shared validator/pack owner, not a mock .wasm. + let package = sdk.join("fixtures").join(template); + fs::create_dir_all(&package).unwrap(); + fs::copy(source.join("plugin.toml"), package.join("plugin.toml")).unwrap(); + fs::write(package.join("plugin.component.wasm"), component).unwrap(); } -fn copy_template_tree(source: &Path, destination: &Path, is_root: bool) { - let metadata = fs::symlink_metadata(source).expect("read template directory metadata"); - assert!( - metadata.is_dir() && !metadata.file_type().is_symlink(), - "template input must be a real directory: {}", - source.display() - ); - for entry in fs::read_dir(source).expect("read template directory") { - let entry = entry.expect("read template entry"); - let path = entry.path(); - let kind = entry.file_type().expect("read template entry type"); - assert!( - !kind.is_symlink() && (kind.is_dir() || kind.is_file()), - "unsupported template input (including symlinks): {}", - path.display() - ); - +fn copy_tree(source: &Path, destination: &Path) { + fs::create_dir_all(destination).unwrap(); + for entry in fs::read_dir(source).unwrap() { + let entry = entry.unwrap(); + let kind = entry.file_type().unwrap(); + assert!(!kind.is_symlink(), "template may not contain symlinks"); let name = entry.file_name(); - // Preserve all compiler inputs, including build scripts and nested assets. - // Only omit the root build output and lockfile: each isolated consumer must - // resolve its own lockfile, as the original unlocked template check did. - if is_root - && ((kind.is_dir() && name == "target") || (kind.is_file() && name == "Cargo.lock")) - { + if name == "target" || name == "Cargo.lock" { continue; } let copied = destination.join(name); if kind.is_dir() { - fs::create_dir(&copied).expect("create template fixture directory"); - copy_template_tree(&path, &copied, false); + copy_tree(&entry.path(), &copied); } else { - fs::copy(&path, &copied).expect("copy unmodified template input"); + fs::copy(entry.path(), copied).unwrap(); } } } #[test] fn pdk_runtime_dependencies_are_guest_side_only() { - let cargo: Value = toml::from_str(PDK_CARGO).expect("PDK Cargo.toml parses"); - let dependencies = cargo["dependencies"] - .as_table() - .expect("dependencies table"); - let forbidden = [ - "worker", - "yoi-worker", - "agen", - "tui", - "yoi-tui", - "client", - "yoi-client", - "manifest", - "yoi-manifest", - "ticket", - "yoi-ticket", - ]; - - for name in forbidden { + let cargo: Value = toml::from_str(include_str!("../Cargo.toml")).unwrap(); + for dependency in cargo["dependencies"].as_table().unwrap().keys() { assert!( - !dependencies.contains_key(name), - "PDK must not depend on host/runtime crate `{name}`" + ["serde", "serde_json", "thiserror", "wit-bindgen"].contains(&dependency.as_str()), + "unexpected Host dependency {dependency}" ); } } diff --git a/crates/plugin-pdk/tests/wit_bindgen_probe.rs b/crates/plugin-pdk/tests/wit_bindgen_probe.rs index 041e3a290..92b4d81e4 100644 --- a/crates/plugin-pdk/tests/wit_bindgen_probe.rs +++ b/crates/plugin-pdk/tests/wit_bindgen_probe.rs @@ -1,25 +1,47 @@ +//! Compile-pass coverage for generated v2 bindings and the public adapter macro. use yoi_plugin_pdk::wit_bindgen; wit_bindgen::generate!({ - world: "tool", + world: "tool-plugin", path: "../../resources/plugin/wit", generate_all, runtime_path: "yoi_plugin_pdk::wit_bindgen::rt", }); -struct Probe; +fn handle( + ctx: yoi_plugin_pdk::ToolContext, + input: serde_json::Value, +) -> yoi_plugin_pdk::Result { + if ctx.tool_name() != "probe" { + return Err(yoi_plugin_pdk::ToolError::unsupported_tool(ctx.tool_name())); + } + yoi_plugin_pdk::ToolOutput::json("probe ok", input) +} -impl Guest for Probe { - fn call(tool_name: String, input_json: String) -> String { - yoi_plugin_pdk::run_json_tool(&tool_name, &input_json, |_ctx, input: serde_json::Value| { - yoi_plugin_pdk::ToolOutput::json("probe ok", input) - }) +yoi_plugin_pdk::export_component_tool!(Probe, handle); + +fn invocation(input_json: &str) -> exports::yoi::plugin::tool::Invocation { + exports::yoi::plugin::tool::Invocation { + invocation_id: "call-1".into(), + tool_name: "probe".into(), + input_json: input_json.into(), + config_json: "{}".into(), + remaining_ms: 1000, } } #[test] -fn wit_bindgen_generates_current_tool_world() { - let output = ::call("probe".to_string(), r#"{"ok":true}"#.to_string()); - let value: serde_json::Value = serde_json::from_str(&output).unwrap(); - assert_eq!(value["summary"], "probe ok"); +fn generated_adapter_returns_record_and_keeps_business_error_in_error_arm() { + use exports::yoi::plugin::tool::Guest; + let output = Probe::invoke(invocation(r#"{"ok":true}"#)).unwrap(); + assert_eq!(output.summary, "probe ok"); + assert_eq!(output.content, None); + assert_eq!(output.structured_json.as_deref(), Some(r#"{"ok":true}"#)); + assert_eq!( + Probe::invoke(invocation("broken")).unwrap_err().code, + "invalid_input" + ); + let mut input = invocation("{}"); + input.tool_name = "unknown".into(); + assert_eq!(Probe::invoke(input).unwrap_err().code, "unsupported_tool"); } diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 12871d0ec..1d3a5128e 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -1847,7 +1847,7 @@ fn parse_plugin_pack_args( } fn plugin_usage() -> &'static str { - "usage: yoi plugin new [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]" + "usage: yoi plugin new [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]" } fn parse_mcp_args(args: &[String]) -> Result { diff --git a/crates/yoi/src/plugin_cli.rs b/crates/yoi/src/plugin_cli.rs index 9b93dc263..ee82cdc68 100644 --- a/crates/yoi/src/plugin_cli.rs +++ b/crates/yoi/src/plugin_cli.rs @@ -5,9 +5,10 @@ use std::io; use std::path::{Path, PathBuf}; use manifest::plugin::{ - MaterializedPluginPackage, PluginDiagnostic, PluginDiagnosticKind, PluginDiagnosticPhase, - PluginPackageLimits, PluginPermission, PluginSourceKind, PluginTemplateResource, - RUST_COMPONENT_INSTANCE_TEMPLATE, RUST_COMPONENT_TOOL_TEMPLATE, SourceQualifiedPluginId, + InspectedPluginPackage, MaterializedPluginPackage, PluginConnectionSlot, PluginDiagnostic, + PluginDiagnosticKind, PluginDiagnosticPhase, PluginPackageLimits, PluginRequestedGrant, + PluginRuntimeManifest, PluginSourceKind, PluginTemplateResource, + RUST_COMPONENT_HTTPS_TOOL_TEMPLATE, RUST_COMPONENT_TOOL_TEMPLATE, SourceQualifiedPluginId, read_plugin_directory, read_plugin_package_file, write_plugin_package_file, }; use serde::Serialize; @@ -69,18 +70,16 @@ pub(crate) fn run(command: PluginCliCommand) -> Result<()> { fn render_new(template: &str, destination: &Path, args: &PluginCliArgs) -> Result { let (template_name, resources) = embedded_template_resources(template)?; materialize_template(destination, resources)?; - let mut next_steps = vec![ - "Review plugin.toml and generated Rust source.".to_string(), - "Replace the placeholder plugin.component.wasm with a real built component before offline package verification.".to_string(), - "Run `yoi plugin check ` and then `yoi plugin pack `.".to_string(), + let core_name = match template_name { + "rust-component-https-tool" => "yoi_rust_component_https_template", + _ => "yoi_rust_component_tool_template", + }; + let next_steps = vec![ + "Review plugin.toml and generated Rust source. In Cargo.toml, replace the repository-relative yoi-plugin-pdk dependency path with an absolute local crates/plugin-pdk path or a pinned Yoi Git commit.".to_string(), + "In the generated directory, install the wasm32-unknown-unknown Rust target and wasm-tools, then run `cargo build --release --target wasm32-unknown-unknown --target-dir ../guest-target`. Keep build output outside the package input directory so check/pack do not include build files.".to_string(), + format!("Run `wasm-tools component new ../guest-target/wasm32-unknown-unknown/release/{core_name}.wasm -o plugin.component.wasm` to create the actual Component; cargo check alone does not build it."), + "Run `yoi plugin check ` and then `yoi plugin pack `; static validation does not establish Host implementation or execution support.".to_string(), ]; - if template == "rust-component-service" { - next_steps.insert( - 1, - "Implement the proposed Service ingress output shape for offline package validation." - .to_string(), - ); - } let report = NewReport { command: "new", template: template_name, @@ -103,9 +102,12 @@ fn embedded_template_resources( ) -> Result<(&'static str, &'static [PluginTemplateResource])> { match template { "rust-component-tool" => Ok(("rust-component-tool", RUST_COMPONENT_TOOL_TEMPLATE)), - "rust-component-service" => Ok(("rust-component-service", RUST_COMPONENT_INSTANCE_TEMPLATE)), + "rust-component-https-tool" => Ok(( + "rust-component-https-tool", + RUST_COMPONENT_HTTPS_TOOL_TEMPLATE, + )), _ => Err(format!( - "unsupported plugin template `{template}` (supported: rust-component-tool, rust-component-service)" + "unsupported plugin template `{template}` (supported: rust-component-tool, rust-component-https-tool); Service and other surfaces are not supported" ) .into()), } @@ -195,10 +197,11 @@ fn render_pack(input: &Path, output: Option<&Path>, args: &PluginCliArgs) -> Res status: "packed", input_path: input.display().to_string(), output_path: packed.output_path.display().to_string(), - package: PackageReport::from_materialized(&MaterializedPluginPackage { - package: packed.package, - files: materialized.files, - }), + package: PackageReport::from_package( + &packed.package, + &packed.artifact_digest, + packed.artifact_size, + ), safety: AuthoringSafetyReport::default(), }; if args.json { @@ -229,25 +232,19 @@ fn build_check_report(input: &Path) -> CheckReport { read_plugin_package_file(input, PluginSourceKind::Explicit, &limits) }; match result { - Ok(materialized) => { - let placeholder_diagnostic = placeholder_component_diagnostic(&materialized); - let status = if placeholder_diagnostic.is_some() { - "partial" - } else { - "verified" - }; - let diagnostics = placeholder_diagnostic.into_iter().collect(); - CheckReport { - command: "check", - status, - input_path: input.display().to_string(), - input_kind, - package: Some(PackageReport::from_materialized(&materialized)), - diagnostics, - safety: AuthoringSafetyReport::default(), - next_steps: check_next_steps(status), - } - } + Ok(materialized) => CheckReport { + command: "check", + status: "verified", + input_path: input.display().to_string(), + input_kind, + package: Some(PackageReport::from_materialized(&materialized)), + diagnostics: Vec::new(), + safety: AuthoringSafetyReport::default(), + next_steps: vec![ + "Package schemas, actual Component imports/exports, and canonical archive metadata are statically valid; no Plugin code was executed.".to_string(), + "Static validation does not establish Host implementation, HTTP availability, installation, or Worker execution support.".to_string(), + ], + }, Err(diagnostic) => CheckReport { command: "check", status: "rejected", @@ -263,46 +260,6 @@ fn build_check_report(input: &Path) -> CheckReport { } } -fn placeholder_component_diagnostic( - materialized: &MaterializedPluginPackage, -) -> Option { - let runtime = materialized.package.manifest.runtime.as_ref()?; - let component = runtime.component.as_deref()?; - let component_bytes = materialized.files.get(component)?; - let placeholder_bytes = RUST_COMPONENT_TOOL_TEMPLATE - .iter() - .find(|resource| resource.path == "plugin.component.wasm")? - .contents - .as_bytes(); - if component_bytes != placeholder_bytes { - return None; - } - Some(PluginDiagnosticReport { - kind: "placeholder".to_string(), - phase: "runtime".to_string(), - message: format!( - "plugin component runtime artifact `{component}` is the generated placeholder; replace it with a real built component before package verification" - ), - }) -} - -fn check_next_steps(status: &str) -> Vec { - match status { - "verified" => vec![ - "Package metadata and archive contents are valid without executing Plugin code." - .to_string(), - "Keep this artifact for explicit offline inspection; Worker Plugin installation is not supported." - .to_string(), - ], - "partial" => vec![ - "Replace the generated placeholder component artifact with a real built component." - .to_string(), - "Run `yoi plugin check ` again to verify the offline package.".to_string(), - ], - _ => vec!["Fix the reported diagnostics before packing this Plugin.".to_string()], - } -} - fn plugin_diagnostic_error(context: &str, diagnostic: PluginDiagnostic) -> String { format!("{context} failed: {}", diagnostic.message) } @@ -339,18 +296,19 @@ fn render_check_human(report: &CheckReport) -> Result { if let Some(package) = &report.package { writeln!( out, - "package: {} version={} digest={} entries={} source={} surfaces={} tools={}", + "package: {} version={} package_digest={} entries={} source={} surfaces={} tools={}", package.reference, package.version, - package.digest, + package.package_digest, package.entries.len(), package.source, join_or_none(&package.surfaces), package.tools.len() )?; + render_artifact_human(&mut out, package)?; writeln!( out, - "installation: unsupported; this command only inspects the explicit input path" + "scope: static validation of the explicit input path only; no Host implementation or execution support is established" )?; } if report.diagnostics.is_empty() { @@ -385,12 +343,13 @@ fn render_pack_human(report: &PackReport) -> Result { )?; writeln!( out, - "package: {} version={} digest={} entries={}", + "package: {} version={} package_digest={} entries={}", report.package.reference, report.package.version, - report.package.digest, + report.package.package_digest, report.package.entries.len() )?; + render_artifact_human(&mut out, &report.package)?; writeln!( out, "safety: deterministic stored .yoi-plugin archive; no Plugin execution; no config mutation" @@ -398,6 +357,26 @@ fn render_pack_human(report: &PackReport) -> Result { Ok(out) } +fn render_artifact_human(out: &mut String, package: &PackageReport) -> Result<()> { + writeln!( + out, + "canonical archive: artifact_digest={} artifact_size={} bytes", + package.artifact_digest, package.artifact_size + )?; + writeln!(out, "component world: {}", package.component.world)?; + writeln!( + out, + "component imports: {}", + join_or_none(&package.component.imports) + )?; + writeln!( + out, + "component exports: {}", + join_or_none(&package.component.exports) + )?; + Ok(()) +} + #[derive(Serialize)] struct AuthoringSafetyReport { no_network: bool, @@ -456,50 +435,75 @@ struct PackageReport { source: String, version: String, schema_version: u32, - digest: String, + package_digest: String, + artifact_digest: String, + artifact_size: u64, input_path: String, entries: Vec, surfaces: Vec, tools: Vec, - permissions: Vec, + runtime: PluginRuntimeManifest, + component: ComponentReport, + connections: Vec, + requested_grants: Vec, } impl PackageReport { fn from_materialized(materialized: &MaterializedPluginPackage) -> Self { + Self::from_package( + &materialized.package, + &materialized.artifact_digest, + materialized.artifact_size, + ) + } + + fn from_package( + package: &InspectedPluginPackage, + artifact_digest: &str, + artifact_size: u64, + ) -> Self { Self { - reference: package_reference(&materialized.package.identity), - package: materialized.package.manifest.id.clone(), - source: materialized.package.identity.source.to_string(), - version: materialized.package.manifest.version.clone(), - schema_version: materialized.package.manifest.schema_version, - digest: materialized.package.digest.clone(), - input_path: materialized.package.input_path.display().to_string(), - entries: materialized.package.entries.iter().cloned().collect(), - surfaces: materialized - .package + reference: package_reference(&package.identity), + package: package.manifest.id.clone(), + source: package.identity.source.to_string(), + version: package.manifest.version.clone(), + schema_version: package.manifest.schema_version, + package_digest: package.package_digest.clone(), + artifact_digest: artifact_digest.to_string(), + artifact_size, + input_path: package.input_path.display().to_string(), + entries: package.entries.iter().cloned().collect(), + surfaces: package .manifest .surfaces .iter() .map(ToString::to_string) .collect(), - tools: materialized - .package + tools: package .manifest .tools .iter() .map(|tool| tool.name.clone()) .collect(), - permissions: materialized - .package - .manifest - .permissions - .iter() - .map(|permission| permission_name(permission.clone()).to_string()) - .collect(), + runtime: package.manifest.runtime.clone(), + component: ComponentReport { + world: package.component.world.clone(), + imports: package.component.imports.clone(), + exports: package.component.exports.clone(), + }, + connections: package.manifest.connections.clone(), + requested_grants: package.manifest.requested_grants.clone(), } } } +#[derive(Serialize)] +struct ComponentReport { + world: String, + imports: Vec, + exports: Vec, +} + #[derive(Serialize)] struct PluginDiagnosticReport { kind: String, @@ -528,10 +532,6 @@ fn package_reference(identity: &SourceQualifiedPluginId) -> String { identity.to_string() } -fn permission_name(permission: PluginPermission) -> String { - permission.label() -} - fn join_or_none(values: &[String]) -> String { if values.is_empty() { "(none)".to_string() @@ -571,23 +571,240 @@ mod tests { use super::*; use tempfile::tempdir; + fn built_tool_package(destination: &Path) { + render_new( + "rust-component-tool", + destination, + &PluginCliArgs::default(), + ) + .unwrap(); + fs::write( + destination.join("plugin.component.wasm"), + include_bytes!("../../plugin-pdk/tests/fixtures/tool-v2.component.wasm"), + ) + .unwrap(); + } + #[test] - fn explicit_template_check_and_pack_remain_offline_authoring_operations() { + fn tool_templates_explain_actual_component_build_without_shipping_a_placeholder() { + let dir = tempdir().unwrap(); + for template in ["rust-component-tool", "rust-component-https-tool"] { + let destination = dir.path().join(template); + let rendered = + render_new(template, &destination, &PluginCliArgs { json: true }).unwrap(); + let report: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!(report["template"], template); + assert!(destination.join("plugin.toml").is_file()); + assert!(destination.join("src/lib.rs").is_file()); + assert!(!destination.join("plugin.component.wasm").exists()); + let steps = report["next_steps"].to_string(); + assert!(steps.contains("yoi-plugin-pdk")); + assert!(steps.contains( + "cargo build --release --target wasm32-unknown-unknown --target-dir ../guest-target" + )); + assert!(steps.contains("wasm-tools component new")); + let cargo: toml::Value = + toml::from_str(&fs::read_to_string(destination.join("Cargo.toml")).unwrap()) + .unwrap(); + let core_name = cargo["package"]["name"].as_str().unwrap().replace('-', "_"); + assert!(steps.contains(&format!( + "wasm-tools component new ../guest-target/wasm32-unknown-unknown/release/{core_name}.wasm -o plugin.component.wasm" + ))); + assert!(steps.contains("does not establish Host implementation")); + assert_eq!(report["safety"]["no_plugin_execution"], true); + } + } + + #[test] + fn unsupported_templates_are_rejected_before_creating_a_destination() { + let dir = tempdir().unwrap(); + let destination = dir.path().join("unsupported"); + for template in [ + "rust-component-service", + "rust-component-instance", + "unknown", + ] { + let error = render_new(template, &destination, &PluginCliArgs::default()).unwrap_err(); + assert!(error.to_string().contains("unsupported plugin template")); + assert!(!destination.exists()); + } + } + + #[test] + fn unbuilt_and_noncomponent_templates_are_rejected_not_partially_verified_or_packed() { + let dir = tempdir().unwrap(); + let source = dir.path().join("unbuilt"); + let output = dir.path().join("rejected.yoi-plugin"); + let args = PluginCliArgs::default(); + render_new("rust-component-tool", &source, &args).unwrap(); + let missing = build_check_report(&source); + assert_eq!(missing.status, "rejected"); + assert!(missing.package.is_none()); + assert!(!missing.diagnostics.is_empty()); + assert!(render_pack(&source, Some(&output), &args).is_err()); + assert!(!output.exists()); + + for bytes in [b"placeholder".as_slice(), b"\0asm\x01\0\0\0".as_slice()] { + fs::write(source.join("plugin.component.wasm"), bytes).unwrap(); + let rejected = build_check_report(&source); + assert_eq!(rejected.status, "rejected"); + assert!(rejected.package.is_none()); + assert!(!rejected.diagnostics.is_empty()); + let human = render_check_report(&rejected, &args).unwrap(); + assert!(human.contains("[rejected]")); + assert!(human.contains("diagnostics:")); + assert!(render_pack(&source, Some(&output), &args).is_err()); + assert!(!output.exists()); + } + } + + #[test] + fn actual_component_check_pack_and_readback_report_both_digests_and_canonical_size() { let dir = tempdir().unwrap(); let source = dir.path().join("example"); let output = dir.path().join("example.yoi-plugin"); - let args = PluginCliArgs::default(); + let second_output = dir.path().join("second.yoi-plugin"); + let args = PluginCliArgs { json: true }; + built_tool_package(&source); - let created = render_new("rust-component-tool", &source, &args).unwrap(); - assert!(created.contains("created plugin template")); + let checked: serde_json::Value = + serde_json::from_str(&render_check(&source, &args).unwrap()).unwrap(); + assert_eq!(checked["status"], "verified"); + assert_eq!(checked["package"]["schema_version"], 2); + assert_eq!(checked["safety"]["no_network"], true); + assert_eq!(checked["safety"]["no_plugin_execution"], true); + assert!(checked["package"].get("digest").is_none()); + assert!(checked["package"].get("permissions").is_none()); + assert_eq!( + checked["package"]["component"]["imports"], + serde_json::json!([]) + ); + assert_eq!( + checked["package"]["component"]["exports"], + serde_json::json!(["yoi:plugin/tool@2.0.0"]) + ); + assert_eq!( + checked["package"]["runtime"]["world"], + "yoi:plugin/tool-plugin@2.0.0" + ); + assert_ne!( + checked["package"]["package_digest"], + checked["package"]["artifact_digest"] + ); + for field in ["package_digest", "artifact_digest"] { + assert!( + checked["package"][field] + .as_str() + .unwrap() + .starts_with("sha256:") + ); + } - let checked = render_check(&source, &args).unwrap(); - assert!(checked.contains("[partial]")); - assert!(checked.contains("explicit input path")); + let packed: serde_json::Value = + serde_json::from_str(&render_pack(&source, Some(&output), &args).unwrap()).unwrap(); + assert_eq!(packed["status"], "packed"); + let readback: serde_json::Value = + serde_json::from_str(&render_check(&output, &args).unwrap()).unwrap(); + assert_eq!(readback["status"], "verified"); + for field in [ + "package_digest", + "artifact_digest", + "artifact_size", + "component", + ] { + assert_eq!(checked["package"][field], packed["package"][field]); + assert_eq!(checked["package"][field], readback["package"][field]); + } + assert_eq!( + packed["package"]["artifact_size"].as_u64().unwrap(), + fs::metadata(&output).unwrap().len() + ); + render_pack(&source, Some(&second_output), &args).unwrap(); + assert_eq!( + fs::read(&output).unwrap(), + fs::read(&second_output).unwrap() + ); - let packed = render_pack(&source, Some(&output), &args).unwrap(); - assert!(packed.contains("[packed]")); - assert!(output.is_file()); + let human = render_check(&output, &PluginCliArgs::default()).unwrap(); + assert!(human.contains("package_digest=sha256:")); + assert!(human.contains("artifact_digest=sha256:")); + assert!(human.contains("artifact_size=")); + assert!(human.contains("static validation of the explicit input path only")); + let human_pack = render_pack(&source, Some(&output), &PluginCliArgs::default()).unwrap(); + assert!(human_pack.contains("package_digest=sha256:")); + assert!(human_pack.contains("artifact_digest=sha256:")); + assert!(human_pack.contains("artifact_size=")); + } + + #[test] + fn https_component_reports_static_requests_not_host_execution_support() { + let dir = tempdir().unwrap(); + let source = dir.path().join("https"); + let output = dir.path().join("https.yoi-plugin"); + let args = PluginCliArgs { json: true }; + render_new("rust-component-https-tool", &source, &args).unwrap(); + fs::write( + source.join("plugin.component.wasm"), + include_bytes!("../../plugin-pdk/tests/fixtures/https-tool-v2.component.wasm"), + ) + .unwrap(); + let report: serde_json::Value = + serde_json::from_str(&render_check(&source, &args).unwrap()).unwrap(); + assert_eq!(report["status"], "verified"); + assert_eq!( + report["package"]["component"]["world"], + "yoi:plugin/https-tool-plugin@2.0.0" + ); + assert_eq!( + report["package"]["component"]["imports"], + serde_json::json!([ + "yoi:host/connections@2.0.0", + "yoi:host/errors@2.0.0", + "yoi:host/http@2.0.0" + ]) + ); + assert_eq!(report["package"]["connections"][0]["name"], "api"); + assert_eq!( + report["package"]["requested_grants"], + serde_json::json!([ + { "kind": "https", "slot": "api" } + ]) + ); + assert!( + report["next_steps"] + .to_string() + .contains("does not establish Host implementation, HTTP availability") + ); + assert_eq!(report["safety"]["no_network"], true); + assert_eq!(report["safety"]["no_plugin_execution"], true); + render_pack(&source, Some(&output), &args).unwrap(); + assert_eq!(build_check_report(&output).status, "verified"); + } + + #[test] + fn unsupported_surface_and_legacy_schema_diagnostics_reach_cli_reports() { + let dir = tempdir().unwrap(); + let source = dir.path().join("invalid"); + built_tool_package(&source); + let manifest_path = source.join("plugin.toml"); + let manifest = fs::read_to_string(&manifest_path).unwrap(); + for invalid in [ + manifest.replace("schema_version = 2", "schema_version = 1"), + manifest.replace("surfaces = [\"tool\"]", "surfaces = [\"service\"]"), + ] { + assert_ne!(manifest, invalid); + fs::write(&manifest_path, invalid).unwrap(); + let rendered = render_check(&source, &PluginCliArgs { json: true }).unwrap(); + let report: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!(report["status"], "rejected"); + assert!(report["package"].is_null()); + assert!( + !report["diagnostics"][0]["message"] + .as_str() + .unwrap() + .is_empty() + ); + } } #[test] diff --git a/devshell.nix b/devshell.nix index 28ff25612..e348a500b 100644 --- a/devshell.nix +++ b/devshell.nix @@ -7,6 +7,7 @@ pkgs.mkShell { playwright-driver.browsers rustc cargo + lld rustfmt pkgs.sccache ]; diff --git a/package.nix b/package.nix index 3f035b2f3..004642674 100644 --- a/package.nix +++ b/package.nix @@ -44,7 +44,7 @@ rustPlatform.buildRustPackage rec { }; # All four WIP SDK crates resolve from crates.io 0.2.0 via Cargo.lock checksums. - cargoHash = "sha256-L1RR+zdcVxMGvEonGxERgcvzjxzXro4n2j2syBeR/m0="; + cargoHash = "sha256-cLDfqFu6H9YjaL5EpTs5NE2GzkeS0qYcj5Ys/6U5u40="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint, diff --git a/resources/plugin/API.md b/resources/plugin/API.md index ecd371b36..6c1734386 100644 --- a/resources/plugin/API.md +++ b/resources/plugin/API.md @@ -1,6 +1,6 @@ # Workspace Plugin APIの設計契約 -本書はWorkspace管理のPluginに採用する設計を定める。実行形式はWebAssembly Component Model、engineはWasmtime、guest/Host境界はWITとする。実装済みAPIの利用ガイドではない。既存の[v1 Tool world](wit/yoi-plugin-tool-v1.wit)、[v1 instance world](wit/yoi-plugin-instance-v1.wit)、[v1 Host imports](wit/deps/yoi-host/yoi-host-v1.wit)、PDK、templateは旧v1のoffline authoring資産であり、本書のv2実行契約をまだ提供していない。 +本書はWorkspace管理のPluginに採用する設計を定める。実行形式はWebAssembly Component Model、engineはWasmtime、guest/Host境界はWITとする。実装済みAPIの利用ガイドではない。旧[v1 Tool world](legacy/wit/yoi-plugin-tool-v1.wit)、[v1 instance world](legacy/wit/yoi-plugin-instance-v1.wit)、[v1 Host imports](legacy/wit/deps/yoi-host/yoi-host-v1.wit)は歴史資料として隔離する。v2の機械的契約は[Tool worlds](wit/yoi-plugin-v2.wit)と[Host imports](wit/deps/yoi-host/yoi-host-v2.wit)で定める。PDKやtemplateのbuild成功はHost実装やWorker実行経路の完成を意味しない。 管理、guest実行、Server上の拡張データを横断する判断をWIT資産の近くで一か所に保持する。実装時には各wire型と境界testを契約の機械的な根拠にし、ここには所有境界・失敗保証・採用理由を残す。作業順と進捗の正本はO-4、T-469、T-465、T-533とする。 diff --git a/resources/plugin/generate-fixtures.sh b/resources/plugin/generate-fixtures.sh new file mode 100644 index 000000000..d7d836d27 --- /dev/null +++ b/resources/plugin/generate-fixtures.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Regenerate authoring copies and real, never-instantiated Components from Rust. +# Prerequisites: Cargo/Rust with wasm32-unknown-unknown std and lld on PATH. +set -euo pipefail +root="$(cd -- "$(dirname -- "$0")/../.." && pwd)" +cd "$root" +for template in rust-component-tool rust-component-https; do + cp resources/plugin/wit/yoi-plugin-v2.wit "resources/plugin/templates/$template/wit/yoi-plugin-v2.wit" + cp resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit "resources/plugin/templates/$template/wit/deps/yoi-host/yoi-host-v2.wit" +done +cargo test --locked -p yoi-plugin-pdk --target-dir target/sdk/test --test template +cp target/sdk/fixtures/rust-component-tool/plugin.component.wasm crates/plugin-pdk/tests/fixtures/tool-v2.component.wasm +cp target/sdk/fixtures/rust-component-https/plugin.component.wasm crates/plugin-pdk/tests/fixtures/https-tool-v2.component.wasm diff --git a/resources/plugin/wit/deps/yoi-host/yoi-host-v1.wit b/resources/plugin/legacy/wit/deps/yoi-host/yoi-host-v1.wit similarity index 100% rename from resources/plugin/wit/deps/yoi-host/yoi-host-v1.wit rename to resources/plugin/legacy/wit/deps/yoi-host/yoi-host-v1.wit diff --git a/resources/plugin/wit/yoi-plugin-instance-v1.wit b/resources/plugin/legacy/wit/yoi-plugin-instance-v1.wit similarity index 100% rename from resources/plugin/wit/yoi-plugin-instance-v1.wit rename to resources/plugin/legacy/wit/yoi-plugin-instance-v1.wit diff --git a/resources/plugin/wit/yoi-plugin-tool-v1.wit b/resources/plugin/legacy/wit/yoi-plugin-tool-v1.wit similarity index 100% rename from resources/plugin/wit/yoi-plugin-tool-v1.wit rename to resources/plugin/legacy/wit/yoi-plugin-tool-v1.wit diff --git a/resources/plugin/templates/rust-component-instance/Cargo.toml b/resources/plugin/templates/rust-component-https/Cargo.toml similarity index 91% rename from resources/plugin/templates/rust-component-instance/Cargo.toml rename to resources/plugin/templates/rust-component-https/Cargo.toml index 2ba3b8fc4..086861eb6 100644 --- a/resources/plugin/templates/rust-component-instance/Cargo.toml +++ b/resources/plugin/templates/rust-component-https/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "yoi-rust-component-service-template" +name = "yoi-rust-component-https-template" version = "0.1.0" edition = "2024" license = "MIT" @@ -14,7 +14,6 @@ crate-type = ["cdylib"] [dependencies] serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" yoi-plugin-pdk = { path = "../../../../crates/plugin-pdk" } # Out-of-tree Plugin packages should replace the local path with a pinned diff --git a/resources/plugin/templates/rust-component-https/README.md b/resources/plugin/templates/rust-component-https/README.md new file mode 100644 index 000000000..640227935 --- /dev/null +++ b/resources/plugin/templates/rust-component-https/README.md @@ -0,0 +1,5 @@ +# Connection-bound HTTPS Tool雛形 + +接続先とsecretをguestが選べないよう、宣言slotからcall-local resourceを取得する。副作用の観測を失敗時にも保持し、自動再送やrollback成功を装わない。HTTP error statusはresponseとして扱う。 + +このソースをComponentへbuildできることは、HTTPS Hostの実装・認可・receipt保存を提供した証拠にはならない。packageの要求grantはWorkspace bindingや実行権限を自己発行しない。 diff --git a/resources/plugin/templates/rust-component-https/plugin.toml b/resources/plugin/templates/rust-component-https/plugin.toml new file mode 100644 index 000000000..8c95aa80f --- /dev/null +++ b/resources/plugin/templates/rust-component-https/plugin.toml @@ -0,0 +1,28 @@ +schema_version = 2 +id = "example.rust_component_https" +name = "Rust HTTPS Component Tool Template" +version = "0.1.0" +surfaces = ["tool"] +config_schema = "{}" +requested_grants = [{ kind = "https", slot = "api" }] + +[runtime] +kind = "wasm-component" +component = "plugin.component.wasm" +world = "yoi:plugin/https-tool-plugin@2.0.0" +required_imports = ["yoi:host/errors@2.0.0", "yoi:host/connections@2.0.0", "yoi:host/http@2.0.0"] + +[[tools]] +name = "example_status" +description = "Observe the status response through an explicitly bound HTTPS slot." +input_schema = { type = "object", properties = {}, additionalProperties = false } +output_schema = { type = "object", properties = { status = { type = "integer" }, body_bytes = { type = "integer" } }, required = ["status", "body_bytes"], additionalProperties = false } +# Even GET may have side effects. Host receipt/admission policy still applies. +error_codes = ["invalid_input", "invalid_output", "serialization", "unsupported_tool", "failed"] +external_write = true + +[[connections]] +name = "api" +# Package requests HTTPS bounds; Workspace binding supplies origin and secrets. +methods = ["GET"] +path_prefixes = ["/status"] diff --git a/resources/plugin/templates/rust-component-https/src/lib.rs b/resources/plugin/templates/rust-component-https/src/lib.rs new file mode 100644 index 000000000..0f1607a4a --- /dev/null +++ b/resources/plugin/templates/rust-component-https/src/lib.rs @@ -0,0 +1,61 @@ +use serde::{Deserialize, Serialize}; +use yoi_plugin_pdk::wit_bindgen; +use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput}; + +wit_bindgen::generate!({ + world: "https-tool-plugin", + path: "wit", + generate_all, + runtime_path: "yoi_plugin_pdk::wit_bindgen::rt", +}); + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Input {} + +#[derive(Serialize)] +struct Output { + status: u16, + body_bytes: usize, +} + +fn handle_status(ctx: ToolContext, _input: Input) -> Result { + use yoi::host::{connections, http}; + if ctx.tool_name() != "example_status" { + return Err(ToolError::unsupported_tool(ctx.tool_name())); + } + // This resource is call-local; the slot has no URL or credential value. + let target = connections::open("api").map_err(host_failure)?; + let response = http::send( + &target, + &http::Request { + method: http::Method::Get, + path: "/status".into(), + query: Vec::new(), + headers: Vec::new(), + body: Vec::new(), + timeout_ms: ctx.remaining_ms().min(15_000), + // Stable within this logical invocation. Never automatically retry a failure. + effect_key: "status-v1".into(), + }, + ) + .map_err(host_failure)?; + // HTTP 4xx/5xx remain responses, not transport failures. Do not log raw bodies. + ToolOutput::json( + "HTTP response observed", + Output { + status: response.status, + body_bytes: response.body.len(), + }, + ) +} + +fn host_failure(error: yoi::host::errors::HostError) -> ToolError { + // Preserve the effect observation for the caller without claiming rollback or retry safety. + ToolError::failed(format!( + "Host {:?}; effect {:?}; diagnostic {}", + error.code, error.effect, error.diagnostic_id + )) +} + +yoi_plugin_pdk::export_component_tool!(Plugin, handle_status); diff --git a/resources/plugin/templates/rust-component-https/wit/deps/yoi-host/yoi-host-v2.wit b/resources/plugin/templates/rust-component-https/wit/deps/yoi-host/yoi-host-v2.wit new file mode 100644 index 000000000..c6f40d2c7 --- /dev/null +++ b/resources/plugin/templates/rust-component-https/wit/deps/yoi-host/yoi-host-v2.wit @@ -0,0 +1,61 @@ +package yoi:host@2.0.0; + +interface errors { + enum code { + invalid-input, denied, not-found, conflict, unsupported, + quota-exceeded, deadline-exceeded, cancelled, unavailable, + indeterminate, receipt-expired, + } + enum effect-observation { not-dispatched, response-observed, indeterminate } + record host-error { + code: code, + message: string, + diagnostic-id: string, + effect: effect-observation, + } +} + +interface connections { + use errors.{host-error}; + /// Call-local resource. No serialization, persistence or cross-Worker transfer. + resource connection; + open: func(slot: string) -> result; +} + +interface http { + use connections.{connection}; + use errors.{host-error}; + enum method { get, head, post, put, patch, delete, options } + record pair { name: string, value: string } + record request { + method: method, + path: string, + query: list, + headers: list, + body: list, + timeout-ms: u32, + effect-key: string, + } + record response { + status: u16, + headers: list, + body: list, + } + /// Host authorizes each operation and does not automatically retry or follow redirects. + send: func(target: borrow, input: request) -> result; +} + +interface logging { + use errors.{host-error}; + enum level { trace, debug, info, warn, error } + variant scalar { text(string), boolean(bool), signed(s64), unsigned(u64), number(f64) } + record field { name: string, value: scalar } + record entry { + level: level, + event-code: string, + message: string, + fields: list, + } + /// Bounded diagnostic data, not model context. Never include secrets or raw payloads. + log: func(input: entry) -> result<_, host-error>; +} diff --git a/resources/plugin/templates/rust-component-https/wit/yoi-plugin-v2.wit b/resources/plugin/templates/rust-component-https/wit/yoi-plugin-v2.wit new file mode 100644 index 000000000..9c7f3b02d --- /dev/null +++ b/resources/plugin/templates/rust-component-https/wit/yoi-plugin-v2.wit @@ -0,0 +1,49 @@ +package yoi:plugin@2.0.0; + +/// Only plugin-specific payloads use schema-validated JSON. Context is not authority. +interface tool { + record invocation { + invocation-id: string, + tool-name: string, + input-json: string, + config-json: string, + remaining-ms: u32, + } + record output { + summary: string, + content: option, + structured-json: option, + } + record tool-error { + code: string, + message: string, + } + invoke: func(input: invocation) -> result; +} + +/// Pure computation: no ambient WASI or unused Host capabilities. +world tool-plugin { + export tool; +} + +world https-tool-plugin { + import yoi:host/errors@2.0.0; + import yoi:host/connections@2.0.0; + import yoi:host/http@2.0.0; + export tool; +} + +/// Logging is an explicit capability, never an ambient console. +world logged-tool-plugin { + import yoi:host/errors@2.0.0; + import yoi:host/logging@2.0.0; + export tool; +} + +world https-logged-tool-plugin { + import yoi:host/errors@2.0.0; + import yoi:host/connections@2.0.0; + import yoi:host/http@2.0.0; + import yoi:host/logging@2.0.0; + export tool; +} diff --git a/resources/plugin/templates/rust-component-instance/README.md b/resources/plugin/templates/rust-component-instance/README.md deleted file mode 100644 index 7d0c2fd01..000000000 --- a/resources/plugin/templates/rust-component-instance/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Rust Component Instance Plugin雛形 - -提案段階の`yoi:plugin/instance@1.0.0`を対象にしたオフラインpackageの雛形である。YoiはPluginのインストールやWorker内での実行を提供していない。 - -Tool・Serviceの要求と応答は将来の実行境界を検討するための例である。host APIやgrantのmetadataは現在の製品では権限を付与せず、オフライン検査の成功も実行の許可を意味しない。 diff --git a/resources/plugin/templates/rust-component-instance/plugin.component.wasm b/resources/plugin/templates/rust-component-instance/plugin.component.wasm deleted file mode 100644 index 66d02db0a..000000000 --- a/resources/plugin/templates/rust-component-instance/plugin.component.wasm +++ /dev/null @@ -1,3 +0,0 @@ -# Build with: -# cargo component build --release -# cp target/wasm32-wasip1/release/example_yoi_instance_plugin.wasm plugin.component.wasm diff --git a/resources/plugin/templates/rust-component-instance/plugin.toml b/resources/plugin/templates/rust-component-instance/plugin.toml deleted file mode 100644 index 59495d374..000000000 --- a/resources/plugin/templates/rust-component-instance/plugin.toml +++ /dev/null @@ -1,42 +0,0 @@ -schema_version = 1 -id = "example.rust_service_plugin" -name = "Rust Service Plugin Template" -version = "0.1.0" -description = "Example Component Model Plugin with Tool and Service ingress handlers." -surfaces = ["tool", "service", "ingress"] -permissions = [ - { kind = "surface", surface = "tool" }, - { kind = "tool", name = "example_echo" }, - { kind = "surface", surface = "service" }, - { kind = "service", name = "example_service" }, - { kind = "surface", surface = "ingress" }, - { kind = "ingress", name = "example_ws" }, - { kind = "host_api", api = "websocket" }, -] - -[runtime] -kind = "wasm-component" -world = "yoi:plugin/instance@1.0.0" -component = "plugin.component.wasm" - -[[tools]] -name = "example_echo" -description = "Echo input text through the shared Plugin instance." -input_schema = { type = "object" } - -[[services]] -name = "example_service" -description = "Host-managed service instance for bounded ingress events." -lifecycle = "host-managed" - -[[ingresses]] -name = "example_ws" -description = "Handles host-owned WebSocket text events and returns websocket_send output commands." -event_kinds = ["websocket_text"] -sources = ["websocket:wss://example.com/socket"] -input_schema = { type = "object" } - -[[websocket]] -scheme = "wss" -host = "example.com" -path_prefixes = ["/socket"] diff --git a/resources/plugin/templates/rust-component-instance/src/lib.rs b/resources/plugin/templates/rust-component-instance/src/lib.rs deleted file mode 100644 index 1a2750ca0..000000000 --- a/resources/plugin/templates/rust-component-instance/src/lib.rs +++ /dev/null @@ -1,85 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use yoi_plugin_pdk::wit_bindgen; -use yoi_plugin_pdk::{ - export_plugin_instance, Plugin, PluginIngressEvent, PluginStatus, ServiceOutput, ToolError, - ToolOutput, -}; - -wit_bindgen::generate!({ - world: "instance", - path: "../../../../resources/plugin/wit", - generate_all, - runtime_path: "yoi_plugin_pdk::wit_bindgen::rt", -}); - -#[derive(Default)] -struct ExamplePlugin { - count: u64, -} - -#[derive(Deserialize)] -struct EchoInput { - text: String, -} - -#[derive(Serialize)] -struct EchoOutput { - text: String, - count: u64, -} - -impl Plugin for ExamplePlugin { - fn start(config: Value) -> Result { - Ok(Self { - count: config.get("start_count").and_then(Value::as_u64).unwrap_or(0), - }) - } - - fn handle_tool(&mut self, name: &str, input: Value) -> Result { - if name != "example_echo" { - return Err(ToolError::invalid_input(format!("unknown tool: {name}"))); - } - let input: EchoInput = - serde_json::from_value(input).map_err(|err| ToolError::invalid_input(err.to_string()))?; - self.count += 1; - ToolOutput::json( - format!("echoed {} bytes", input.text.len()), - EchoOutput { - text: input.text, - count: self.count, - }, - ) - } - - fn handle_ingress( - &mut self, - name: &str, - event: PluginIngressEvent, - ) -> Result { - if name != "example_ws" { - return Ok(ServiceOutput::accepted(json!({ "ignored": name }))?); - } - - let Some(text) = event.websocket_text() else { - return Ok(ServiceOutput::accepted(json!({ - "accepted": true, - "kind": event.kind, - }))?); - }; - - self.count += 1; - ServiceOutput::websocket_send( - &event, - format!("example-reply-{}", self.count), - event.source.strip_prefix("websocket:").unwrap_or(&event.source), - format!("echo({}): {text}", self.count), - ) - } - - fn status(&self) -> Result { - Ok(PluginStatus::ready(json!({ "count": self.count }))) - } -} - -export_plugin_instance!(ExamplePluginComponent, ExamplePlugin); diff --git a/resources/plugin/templates/rust-component-tool/README.md b/resources/plugin/templates/rust-component-tool/README.md index 506ff64ee..91bd6bcbb 100644 --- a/resources/plugin/templates/rust-component-tool/README.md +++ b/resources/plugin/templates/rust-component-tool/README.md @@ -1,5 +1,5 @@ -# Rust Component Plugin雛形 +# Rust Tool Component雛形 -提案段階の`yoi:plugin/tool@1.0.0`を対象にしたオフラインpackageの雛形である。YoiはPluginのインストールやWorker内での実行を提供していない。 +guest境界は生成されたWIT bindingsに任せ、手書きpointer ABIとWASI依存を持たせない。呼出し間の永続状態やServiceを成功stubとして公開しない。 -guest側の型付き入出力とエラー表現はRust PDKが担当し、ファイル・ネットワーク・環境への権限は付与しない。`plugin.component.wasm`はpackage検査用のテキストplaceholderであり、実行可能なcomponentの証拠にはならない。 +`plugin.component.wasm`はソースからbuildして配置する成果物であり、placeholderをpackageへ混入させない。PDKからのbuild成功と、Hostの認可・実行経路の完成は別の判断である。 diff --git a/resources/plugin/templates/rust-component-tool/plugin.component.wasm b/resources/plugin/templates/rust-component-tool/plugin.component.wasm deleted file mode 100644 index 6193fb382..000000000 --- a/resources/plugin/templates/rust-component-tool/plugin.component.wasm +++ /dev/null @@ -1 +0,0 @@ -placeholder component artifact for authoring-template checks; replace with a built wasm component before enabling. diff --git a/resources/plugin/templates/rust-component-tool/plugin.toml b/resources/plugin/templates/rust-component-tool/plugin.toml index 9c5192d0a..7bd09c91b 100644 --- a/resources/plugin/templates/rust-component-tool/plugin.toml +++ b/resources/plugin/templates/rust-component-tool/plugin.toml @@ -1,20 +1,21 @@ -schema_version = 1 +schema_version = 2 id = "example.rust_component_tool" name = "Rust Component Tool Template" version = "0.1.0" surfaces = ["tool"] -permissions = [ - { kind = "surface", surface = "tool" }, - { kind = "tool", name = "example_echo" }, -] +config_schema = "{}" +requested_grants = [] [runtime] kind = "wasm-component" component = "plugin.component.wasm" -world = "yoi:plugin/tool@1.0.0" +world = "yoi:plugin/tool-plugin@2.0.0" +required_imports = [] [[tools]] name = "example_echo" description = "Echo input text using the Rust PDK." input_schema = { type = "object", properties = { text = { type = "string" } }, required = ["text"], additionalProperties = false } +output_schema = { type = "object", properties = { tool = { type = "string" }, text = { type = "string" } }, required = ["tool", "text"], additionalProperties = false } +error_codes = ["invalid_input", "invalid_output", "serialization", "unsupported_tool", "failed"] external_write = false diff --git a/resources/plugin/templates/rust-component-tool/src/lib.rs b/resources/plugin/templates/rust-component-tool/src/lib.rs index c954aedc5..f5ff9d116 100644 --- a/resources/plugin/templates/rust-component-tool/src/lib.rs +++ b/resources/plugin/templates/rust-component-tool/src/lib.rs @@ -3,13 +3,14 @@ use yoi_plugin_pdk::wit_bindgen; use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput}; wit_bindgen::generate!({ - world: "tool", - path: "../../../../resources/plugin/wit", + world: "tool-plugin", + path: "wit", generate_all, runtime_path: "yoi_plugin_pdk::wit_bindgen::rt", }); #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct EchoInput { text: String, } @@ -21,6 +22,9 @@ struct EchoOutput<'a> { } fn handle_echo(ctx: ToolContext, input: EchoInput) -> Result { + if ctx.tool_name() != "example_echo" { + return Err(ToolError::unsupported_tool(ctx.tool_name())); + } if input.text.trim().is_empty() { return Err(ToolError::invalid_input("`text` must not be empty")); } diff --git a/resources/plugin/templates/rust-component-tool/wit/deps/yoi-host/yoi-host-v2.wit b/resources/plugin/templates/rust-component-tool/wit/deps/yoi-host/yoi-host-v2.wit new file mode 100644 index 000000000..c6f40d2c7 --- /dev/null +++ b/resources/plugin/templates/rust-component-tool/wit/deps/yoi-host/yoi-host-v2.wit @@ -0,0 +1,61 @@ +package yoi:host@2.0.0; + +interface errors { + enum code { + invalid-input, denied, not-found, conflict, unsupported, + quota-exceeded, deadline-exceeded, cancelled, unavailable, + indeterminate, receipt-expired, + } + enum effect-observation { not-dispatched, response-observed, indeterminate } + record host-error { + code: code, + message: string, + diagnostic-id: string, + effect: effect-observation, + } +} + +interface connections { + use errors.{host-error}; + /// Call-local resource. No serialization, persistence or cross-Worker transfer. + resource connection; + open: func(slot: string) -> result; +} + +interface http { + use connections.{connection}; + use errors.{host-error}; + enum method { get, head, post, put, patch, delete, options } + record pair { name: string, value: string } + record request { + method: method, + path: string, + query: list, + headers: list, + body: list, + timeout-ms: u32, + effect-key: string, + } + record response { + status: u16, + headers: list, + body: list, + } + /// Host authorizes each operation and does not automatically retry or follow redirects. + send: func(target: borrow, input: request) -> result; +} + +interface logging { + use errors.{host-error}; + enum level { trace, debug, info, warn, error } + variant scalar { text(string), boolean(bool), signed(s64), unsigned(u64), number(f64) } + record field { name: string, value: scalar } + record entry { + level: level, + event-code: string, + message: string, + fields: list, + } + /// Bounded diagnostic data, not model context. Never include secrets or raw payloads. + log: func(input: entry) -> result<_, host-error>; +} diff --git a/resources/plugin/templates/rust-component-tool/wit/yoi-plugin-v2.wit b/resources/plugin/templates/rust-component-tool/wit/yoi-plugin-v2.wit new file mode 100644 index 000000000..9c7f3b02d --- /dev/null +++ b/resources/plugin/templates/rust-component-tool/wit/yoi-plugin-v2.wit @@ -0,0 +1,49 @@ +package yoi:plugin@2.0.0; + +/// Only plugin-specific payloads use schema-validated JSON. Context is not authority. +interface tool { + record invocation { + invocation-id: string, + tool-name: string, + input-json: string, + config-json: string, + remaining-ms: u32, + } + record output { + summary: string, + content: option, + structured-json: option, + } + record tool-error { + code: string, + message: string, + } + invoke: func(input: invocation) -> result; +} + +/// Pure computation: no ambient WASI or unused Host capabilities. +world tool-plugin { + export tool; +} + +world https-tool-plugin { + import yoi:host/errors@2.0.0; + import yoi:host/connections@2.0.0; + import yoi:host/http@2.0.0; + export tool; +} + +/// Logging is an explicit capability, never an ambient console. +world logged-tool-plugin { + import yoi:host/errors@2.0.0; + import yoi:host/logging@2.0.0; + export tool; +} + +world https-logged-tool-plugin { + import yoi:host/errors@2.0.0; + import yoi:host/connections@2.0.0; + import yoi:host/http@2.0.0; + import yoi:host/logging@2.0.0; + export tool; +} diff --git a/resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit b/resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit new file mode 100644 index 000000000..c6f40d2c7 --- /dev/null +++ b/resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit @@ -0,0 +1,61 @@ +package yoi:host@2.0.0; + +interface errors { + enum code { + invalid-input, denied, not-found, conflict, unsupported, + quota-exceeded, deadline-exceeded, cancelled, unavailable, + indeterminate, receipt-expired, + } + enum effect-observation { not-dispatched, response-observed, indeterminate } + record host-error { + code: code, + message: string, + diagnostic-id: string, + effect: effect-observation, + } +} + +interface connections { + use errors.{host-error}; + /// Call-local resource. No serialization, persistence or cross-Worker transfer. + resource connection; + open: func(slot: string) -> result; +} + +interface http { + use connections.{connection}; + use errors.{host-error}; + enum method { get, head, post, put, patch, delete, options } + record pair { name: string, value: string } + record request { + method: method, + path: string, + query: list, + headers: list, + body: list, + timeout-ms: u32, + effect-key: string, + } + record response { + status: u16, + headers: list, + body: list, + } + /// Host authorizes each operation and does not automatically retry or follow redirects. + send: func(target: borrow, input: request) -> result; +} + +interface logging { + use errors.{host-error}; + enum level { trace, debug, info, warn, error } + variant scalar { text(string), boolean(bool), signed(s64), unsigned(u64), number(f64) } + record field { name: string, value: scalar } + record entry { + level: level, + event-code: string, + message: string, + fields: list, + } + /// Bounded diagnostic data, not model context. Never include secrets or raw payloads. + log: func(input: entry) -> result<_, host-error>; +} diff --git a/resources/plugin/wit/yoi-plugin-v2.wit b/resources/plugin/wit/yoi-plugin-v2.wit new file mode 100644 index 000000000..9c7f3b02d --- /dev/null +++ b/resources/plugin/wit/yoi-plugin-v2.wit @@ -0,0 +1,49 @@ +package yoi:plugin@2.0.0; + +/// Only plugin-specific payloads use schema-validated JSON. Context is not authority. +interface tool { + record invocation { + invocation-id: string, + tool-name: string, + input-json: string, + config-json: string, + remaining-ms: u32, + } + record output { + summary: string, + content: option, + structured-json: option, + } + record tool-error { + code: string, + message: string, + } + invoke: func(input: invocation) -> result; +} + +/// Pure computation: no ambient WASI or unused Host capabilities. +world tool-plugin { + export tool; +} + +world https-tool-plugin { + import yoi:host/errors@2.0.0; + import yoi:host/connections@2.0.0; + import yoi:host/http@2.0.0; + export tool; +} + +/// Logging is an explicit capability, never an ambient console. +world logged-tool-plugin { + import yoi:host/errors@2.0.0; + import yoi:host/logging@2.0.0; + export tool; +} + +world https-logged-tool-plugin { + import yoi:host/errors@2.0.0; + import yoi:host/connections@2.0.0; + import yoi:host/http@2.0.0; + import yoi:host/logging@2.0.0; + export tool; +}