diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bf43aa..fe1888e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,19 +23,41 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy + # 插件沙箱里的 QuickJS 编成这个目标(crates/tw-plugin/build.rs) + targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 + # 编插件沙箱要一个能出 wasm 的 clang:这里是 Homebrew 的 llvm,由 build.rs + # 自己找到(顺便测了「装了 brew 的 llvm 就能编」这条路) + - name: Toolchain for the plugin sandbox + run: bash scripts/wasm-toolchain.sh + - name: Format - run: cargo fmt --all -- --check + run: | + cargo fmt --all -- --check + # 沙箱里的那个小工程不在工作区里,上面一行管不到它 + cargo fmt --manifest-path crates/tw-plugin/guest/Cargo.toml -- --check - name: clippy run: cargo clippy --workspace --all-targets -- -D warnings + # 沙箱里的胶水(编成 wasm32 的 no_std 小工程)。build.rs 编它时不带 -D warnings + # —— 那是给外层工作区的 —— 所以它的告警只在这一步当错误 + - name: clippy (plugin sandbox guest) + run: | + LLVM="$(brew --prefix llvm)/bin" + CC_wasm32_unknown_unknown="$LLVM/clang" AR_wasm32_unknown_unknown="$LLVM/llvm-ar" \ + cargo clippy --manifest-path crates/tw-plugin/guest/Cargo.toml --target wasm32-unknown-unknown --target-dir target/tw-plugin-guest -- -D warnings + # 默认不跑打真实网络的那些(它们标了 #[ignore])—— CI 上的网络 # 抖动会变成一条和代码无关的红,而那种红看几次就没人看了。 - name: Test run: cargo test --workspace + # 编进这一版的沙箱是哪个 clang 编的、wasm 的哈希 + - name: Which compiler built the plugin sandbox + run: bash scripts/wasm-toolchain.sh record target + # 桌面端从 tw-api 导出前端类型(`ts` feature)。**默认关**,上面几步一行都 # 不编它 —— 而它坏掉的表现是桌面端接下一个 tag 时才发现导不出来。导出来 # 的文件再过一遍 tsc:ts-rs 生成的东西本身也可能不是合法的 TypeScript @@ -158,17 +180,25 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: components: clippy + targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 with: # 理由见下面 windows 那一段 cache-on-failure: true + # 镜像预装的 clang-15,显式指定(见脚本) + - name: Toolchain for the plugin sandbox + run: bash scripts/wasm-toolchain.sh + - name: clippy run: cargo clippy --workspace --all-targets -- -D warnings - name: Test run: cargo test --workspace + - name: Which compiler built the plugin sandbox + run: bash scripts/wasm-toolchain.sh record target + # 真二进制、真 socket、真数据面,和 macOS 那一步是同一个脚本 - name: Smoke (real binary, real socket, real data plane) run: ./scripts/smoke.sh @@ -185,6 +215,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: components: clippy + targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 with: # **失败也存。**这个 action 默认只在任务成功时保存缓存,而一个 @@ -196,12 +227,21 @@ jobs: # target 目录是安全的,它只是省掉那些与失败无关的部分。 cache-on-failure: true + # 镜像预装的 LLVM(C:\Program Files\LLVM),由 build.rs 自己找到 + - name: Toolchain for the plugin sandbox + shell: bash + run: bash scripts/wasm-toolchain.sh + - name: clippy run: cargo clippy --workspace --all-targets -- -D warnings - name: Test run: cargo test --workspace + - name: Which compiler built the plugin sandbox + shell: bash + run: bash scripts/wasm-toolchain.sh record target + - name: Build run: cargo build --release -p twcore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e25ec67..3f51e23 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,11 +44,27 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: - targets: aarch64-apple-darwin + # wasm32:插件沙箱里的 QuickJS(crates/tw-plugin/build.rs) + targets: aarch64-apple-darwin, wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 + # 编插件沙箱要一个能出 wasm 的 clang:Homebrew 的 llvm + - name: Toolchain for the plugin sandbox + run: bash scripts/wasm-toolchain.sh + + # `-p tw-plugin`:网关还没接上插件之前,它不在 twcore 的依赖里,不点名就 + # 没人编它 —— 而这条流水线要证明的正是每个目标平台都编得出沙箱 - name: Build - run: cargo build --release -p twcore --target aarch64-apple-darwin + run: cargo build --release -p twcore -p tw-plugin --target aarch64-apple-darwin + + # 这个平台的沙箱是哪个 clang 编的、wasm 的哈希:进摘要,也交给 publish + - name: Which compiler built the plugin sandbox + run: bash scripts/wasm-toolchain.sh record target/aarch64-apple-darwin/release guest-build/aarch64-apple-darwin.txt + - uses: actions/upload-artifact@v4 + with: + name: guest-build-aarch64-apple-darwin + path: guest-build/ + if-no-files-found: error # **产物自检。**一个编得过但起不来的二进制,从文件列表上看不出 # 任何问题 —— 而它会一路发到用户手里。 @@ -98,13 +114,31 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: - targets: x86_64-pc-windows-msvc, aarch64-pc-windows-msvc + targets: x86_64-pc-windows-msvc, aarch64-pc-windows-msvc, wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 + # 镜像预装的 LLVM(C:\Program Files\LLVM);没有就装官方发行版 + - name: Toolchain for the plugin sandbox + shell: bash + run: bash scripts/wasm-toolchain.sh + + # arm64 的沙箱机器码在 x64 上交叉编:build.rs 里的 Cranelift 直接编给目标平台 - name: Build run: | - cargo build --release -p twcore --target x86_64-pc-windows-msvc - cargo build --release -p twcore --target aarch64-pc-windows-msvc + cargo build --release -p twcore -p tw-plugin --target x86_64-pc-windows-msvc + cargo build --release -p twcore -p tw-plugin --target aarch64-pc-windows-msvc + + - name: Which compiler built the plugin sandbox + shell: bash + run: | + for t in x86_64-pc-windows-msvc aarch64-pc-windows-msvc; do + bash scripts/wasm-toolchain.sh record "target/$t/release" "guest-build/$t.txt" + done + - uses: actions/upload-artifact@v4 + with: + name: guest-build-windows + path: guest-build/ + if-no-files-found: error # **产物自检。**一个编得过但起不来的二进制,从文件列表上看不出任何 # 问题 —— 而它会一路发到用户手里。 @@ -202,13 +236,25 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: - targets: ${{ matrix.target }} + targets: ${{ matrix.target }}, wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 with: key: ${{ matrix.target }} + # 两种 runner 都预装 clang-15,两个架构用同一个版本(见脚本) + - name: Toolchain for the plugin sandbox + run: bash scripts/wasm-toolchain.sh + - name: Build - run: cargo build --release -p twcore --target ${{ matrix.target }} + run: cargo build --release -p twcore -p tw-plugin --target ${{ matrix.target }} + + - name: Which compiler built the plugin sandbox + run: bash scripts/wasm-toolchain.sh record "target/${{ matrix.target }}/release" "guest-build/${{ matrix.target }}.txt" + - uses: actions/upload-artifact@v4 + with: + name: guest-build-${{ matrix.target }} + path: guest-build/ + if-no-files-found: error # **产物自检。**一个编得过但起不来的二进制,从文件列表上看不出任何 # 问题 —— 而它会一路发到用户手里。 @@ -332,8 +378,33 @@ jobs: - uses: actions/download-artifact@v4 with: path: dist + pattern: twcore-* merge-multiple: true + # 每个平台的插件沙箱是哪个 clang 编的、wasm 的哈希。**只记录,不卡发版**: + # macOS 用 Homebrew 的 llvm、Linux 用 clang-15、Windows 用镜像里的 LLVM, + # 编译器不同,哈希本来就不同;同一个编译器编的(两个 Linux)应当相同 + - uses: actions/download-artifact@v4 + with: + path: guest-build + pattern: guest-build-* + merge-multiple: true + - name: Which compiler built each plugin sandbox + run: | + set -euo pipefail + { + echo "### Plugin sandbox" + echo + echo "| target | guest.wasm sha256 | clang |" + echo "|---|---|---|" + for f in guest-build/*.txt; do + t=$(basename "$f" .txt) + sha=$(awk '/^guest.wasm sha256 /{print $3}' "$f") + cc=$(sed -n 's/^clang //p' "$f" | head -n 1) + echo "| $t | \`$sha\` | $cc |" + done + } | tee -a "$GITHUB_STEP_SUMMARY" + # 排练也跑这一步:构建 job 交来的正好是清单上的文件,每个都对得上 # 它的校验和 - name: Every file is here, nothing else is, and each matches its checksum diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e93c0b..60bfca8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,6 +62,19 @@ cargo test --workspace ./scripts/smoke.sh ``` +Building `tw-plugin` needs an LLVM clang that targets WebAssembly; the README's +"Build and test" says how to install one. If you changed +`crates/tw-plugin/guest`, which is not a workspace member, check it on its own +as well (on macOS with Homebrew's LLVM): + +```bash +cargo fmt --manifest-path crates/tw-plugin/guest/Cargo.toml -- --check +CC_wasm32_unknown_unknown="$(brew --prefix llvm)/bin/clang" \ +AR_wasm32_unknown_unknown="$(brew --prefix llvm)/bin/llvm-ar" \ + cargo clippy --manifest-path crates/tw-plugin/guest/Cargo.toml --target wasm32-unknown-unknown \ + --target-dir target/tw-plugin-guest -- -D warnings +``` + Warnings are errors, and relaxing that on CI is the same as removing it. The toolchain is `stable`, so a newer stable than your local one can surface lints you cannot reproduce — `rustup update stable` before @@ -105,6 +118,33 @@ clean the diff is: connections to the same handshake before HTTP. The control key never leaves through the control plane and cannot be changed through it. +## The plugin sandbox + +Script plugins run in `tw-plugin`: QuickJS-ng, from the pinned `rquickjs-sys` +crate, compiled to `wasm32-unknown-unknown` and run by Wasmtime. Its +`build.rs` does three things on every build, and nothing is committed or +downloaded: + +1. **Compile the guest** (`crates/tw-plugin/guest`, outside the workspace, + with its own `Cargo.lock`) with the clang it finds. The module may import + two functions, a log line and the clock; the build fails if it imports + anything else. +2. **Snapshot it.** It runs the bridge script (`src/bridge.js`) once inside the + module and writes the initialized memory back into it, so every sandbox + starts with QuickJS already set up. +3. **Precompile it** with Cranelift for the target being built, cross targets + included. The binary embeds the result and contains only Wasmtime's + runtime, no compiler. + +Wasmtime is pinned to one exact version both as a dependency and as a +build-dependency: a precompiled module only loads in the Wasmtime version, and +with the settings (`src/engine.rs`), it was compiled with. Upgrade both +together. + +Only `tw-gateway` and `twcore` may depend on `tw-plugin`. +`crates/tw-plugin/tests/boundary.rs` fails when a crate that Lite or +Enterprise builds reaches it, because their builds would suddenly need clang. + ## The configuration reference `docs/config.md` and `docs/config.zh-CN.md` are written by hand, except the diff --git a/Cargo.lock b/Cargo.lock index e4a784f..fd9bf94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -27,6 +36,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.6" @@ -92,6 +107,12 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + [[package]] name = "arc-swap" version = "1.9.2" @@ -135,6 +156,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -198,7 +230,7 @@ dependencies = [ "hmac", "http", "percent-encoding", - "sha2", + "sha2 0.11.0", "time", "tracing", ] @@ -429,6 +461,9 @@ name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] [[package]] name = "bytes" @@ -585,6 +620,15 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -629,6 +673,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpp_demangle" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0667304c32ea56cb4cd6d2d7c0cfe9a2f8041229db8c033af7f8d69492429def" +dependencies = [ + "cfg-if", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -647,6 +700,152 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-assembler-x64" +version = "0.136.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8a495ae3e4d638712a0c0378719e526e448d04d7ebbbe1745712e12c5feb4c3" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.136.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eb2e299e844a27d799f43988e4415a37696fdb738cb463312ef5d06cae15b2a" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.136.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4710c34489bc844e45c1c0005d3e44d3d2b29cee77187507ba8d0bb400c621f" +dependencies = [ + "cranelift-entity", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-bitset" +version = "0.136.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76b63a4b0cfea80610150fffda11b1a9c3dc5a835403d9f381bf7d1a2874b4d7" +dependencies = [ + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen" +version = "0.136.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1af881f3b3392433e6f4f9b172b8d99270c538db4effea62b28bc48f02f0833" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.17.1", + "libm", + "log", + "postcard", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "serde_derive", + "sha2 0.10.9", + "smallvec", + "target-lexicon", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.136.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ce99c30dc7a692294613241119eea0de3c3cb4cf10ac2570ed72adf9a8c2eb" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.136.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34c2a529b638d95c347c2337403dacec86197ad6503cedc63caf53466ae5c4de" + +[[package]] +name = "cranelift-control" +version = "0.136.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "570eb4e02cca190896beb3c8a0a67583dca8b6965517a9f3e04cc89e17c9a986" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.136.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e73c65538d460b1062cb9a97428ee6bb4b616f5cb17be3b3ac0a3e4f1f789166" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-frontend" +version = "0.136.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ed70ab5849184973c42b57366f57611dc81c3e355d5b612c68a2900bea92242" +dependencies = [ + "cranelift-codegen", + "hashbrown 0.17.1", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.136.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "379f10a451c9b1bf4f59e4ce38061c53ca2454783041a3c1fafa6deef3c37108" + +[[package]] +name = "cranelift-native" +version = "0.136.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f496865b1a14bc85bcc9fb51a9539ca93b1d1f44fb92bd6a091fba02d6ab4a7" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.136.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89643801d9192df80c294f174d5fe0416395e7edb0bb3024784ba10fc933e76f" + [[package]] name = "crc32fast" version = "1.5.1" @@ -656,6 +855,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "crypto-common" version = "0.1.7" @@ -767,6 +991,18 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "equivalent" version = "1.0.2" @@ -997,6 +1233,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" +dependencies = [ + "fnv", + "hashbrown 0.16.1", + "indexmap", + "stable_deref_trait", +] + [[package]] name = "h2" version = "0.4.19" @@ -1032,6 +1280,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "foldhash", + "serde", + "serde_core", ] [[package]] @@ -1314,6 +1564,8 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -1357,6 +1609,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1459,12 +1720,24 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libsqlite3-sys" version = "0.38.2" @@ -1500,6 +1773,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + [[package]] name = "matchers" version = "0.2.0" @@ -1521,6 +1800,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memfd" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57804b2c9b69967f1536a56f86297e367a33b19e98852ed624b84551cdbc0d90" +dependencies = [ + "rustix", +] + [[package]] name = "mime" version = "0.3.17" @@ -1609,6 +1897,18 @@ dependencies = [ "autocfg", ] +[[package]] +name = "object" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c" +dependencies = [ + "crc32fast", + "hashbrown 0.17.1", + "indexmap", + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1674,6 +1974,18 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -1707,6 +2019,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulley-interpreter" +version = "49.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0091857bc410bd7117eb6ac042ced7714c69f994a8b9c2ae30add2f73e3a25ac" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-core", +] + +[[package]] +name = "pulley-macros" +version = "49.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8798ebc6bf0ef227e204fa3fad05b04970599a85f456fd28365450fd679b2600" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "quinn" version = "0.11.11" @@ -1840,6 +2175,41 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regalloc2" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757712e8e61590d6d4f5d563483755538b5aa13467837a3b41cd9832509a7f85" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.17.1", + "log", + "rustc-hash", + "serde", + "smallvec", +] + [[package]] name = "regex" version = "1.13.1" @@ -1950,6 +2320,12 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2122,6 +2498,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -2213,6 +2593,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.0" @@ -2282,6 +2673,9 @@ name = "smallvec" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +dependencies = [ + "serde", +] [[package]] name = "snow" @@ -2379,6 +2773,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" @@ -2767,7 +3167,7 @@ dependencies = [ "percent-encoding", "reqwest", "serde_json", - "sha2", + "sha2 0.11.0", "tokio", "tw-dialect", ] @@ -2823,7 +3223,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "serde_yaml_ng", - "sha2", + "sha2 0.11.0", "tempfile", "thiserror", "tokio", @@ -2892,7 +3292,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", - "sha2", + "sha2 0.11.0", "tempfile", "thiserror", "tokio", @@ -2951,6 +3351,21 @@ dependencies = [ "tw-api", ] +[[package]] +name = "tw-plugin" +version = "0.57.1" +dependencies = [ + "libc", + "rand 0.10.2", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror", + "wasm-encoder", + "wasmparser", + "wasmtime", +] + [[package]] name = "tw-pricing" version = "0.57.1" @@ -3036,7 +3451,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", - "sha2", + "sha2 0.11.0", "tempfile", "tokio", "tracing", @@ -3244,6 +3659,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.258.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e974fe6821a8cf64575d51ea2194e2c8f77e7b66e9afe7419ce8a97f9ee0d251" +dependencies = [ + "leb128fmt", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -3257,6 +3682,218 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.258.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9a61719f93a87b16d325921e251800c4833f8fab50fa21c7de73aed50086313" +dependencies = [ + "bitflags", + "hashbrown 0.17.1", + "indexmap", + "semver", + "serde", +] + +[[package]] +name = "wasmprinter" +version = "0.258.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a65e30fb2cd3cc5cb7761a6064787b2abf017e6566d8d0dedf2bb7f55ad5383" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser", +] + +[[package]] +name = "wasmtime" +version = "49.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "942ddd2fc5800ec01d4b11c28ad62ab00517dcd416aff728f7d73fddf75872c1" +dependencies = [ + "addr2line", + "async-trait", + "bitflags", + "bumpalo", + "cc", + "futures", + "libc", + "log", + "mach2", + "memfd", + "object", + "once_cell", + "postcard", + "pulley-interpreter", + "rayon", + "rustix", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasmparser", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "wasmtime-internal-winch", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-environ" +version = "49.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98f2ca21b23a51c1b1944297e9671a628c368e92aa6250e4c38f48e2dc251f0c" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "hashbrown 0.17.1", + "indexmap", + "log", + "object", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "sha2 0.10.9", + "smallvec", + "target-lexicon", + "wasm-encoder", + "wasmparser", + "wasmprinter", + "wasmtime-internal-component-util", + "wasmtime-internal-core", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "49.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "294c7f12f062ceb148cd26c77170ff739552db76d1f203f6127f249684988a70" + +[[package]] +name = "wasmtime-internal-core" +version = "49.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64b67114739063bb72bb8ed2b4994b8b22809d354432db68553ad529850f9536" +dependencies = [ + "hashbrown 0.17.1", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "49.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d19868a30cc20df6f6648c9a4b3f700fcd1ddd0a63af9a98cb75f2b58dc874" +dependencies = [ + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools", + "log", + "object", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror", + "wasmparser", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "49.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f1999aee268c3f625666b12ba0ded55a6ac2c800b60b426af7d164d63411908" +dependencies = [ + "cc", + "libc", + "rustix", + "wasmtime-environ", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "49.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca2499301c6f11cc9eb42c2beafc8efae396ae4cb91077e3ac080dc5d765e4" +dependencies = [ + "cc", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "49.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7162d829c56265e183d23b17d67afc5c6876433daaed54c137388a8078a1bb6b" +dependencies = [ + "libc", + "wasmtime-internal-core", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-unwinder" +version = "49.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e167cf144f3cee40838f7663e3a589f93bba65151efa084a2d58b2bd47469f1" +dependencies = [ + "cranelift-codegen", + "log", + "object", + "wasmtime-environ", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "49.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d09e038ad69c741dae29da4606ccfc0df66f156e62cb7f0664c8d7d1272a36d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "wasmtime-internal-winch" +version = "49.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "102d1de94626080bf1a3c4876942bf14c0547293361a7d855875dc1801609113" +dependencies = [ + "cranelift-codegen", + "gimli", + "log", + "object", + "target-lexicon", + "wasmparser", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "winch-codegen", +] + [[package]] name = "web-sys" version = "0.3.105" @@ -3295,6 +3932,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winch-codegen" +version = "49.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85699ac36c223c99afafdb65f461f2c42fe86a14f1c1df8540bdfd477b48522" +dependencies = [ + "cranelift-assembler-x64", + "cranelift-codegen", + "gimli", + "regalloc2", + "smallvec", + "target-lexicon", + "thiserror", + "wasmparser", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 28b9266..d46da33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,10 @@ members = [ "crates/tw-engine", "crates/tw-observe", "crates/tw-gateway", + # 脚本插件的沙箱(QuickJS 跑在 Wasmtime 里)。编它要一个能出 wasm 的 clang, + # 所以**只有 tw-gateway 和 twcore 能依赖它** —— 桌面端从 git 编的那几个 + # crate、企业版用的第一层都不能沾上,tw-plugin 的 tests/boundary.rs 守着 + "crates/tw-plugin", "crates/tw-control", # 控制通道的握手与加密。桌面端按 tag 依赖它,所以只依赖契约层和 tw-yaml "crates/tw-link", @@ -71,6 +75,7 @@ tw-breaker = { path = "crates/tw-breaker" } tw-dialect = { path = "crates/tw-dialect" } tw-bedrock = { path = "crates/tw-bedrock" } tw-link = { path = "crates/tw-link" } +tw-plugin = { path = "crates/tw-plugin" } # ── 版本与企业版 workspace 对齐,便于反向依赖时不打架 ──── tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync", "io-util", "signal"] } diff --git a/README.md b/README.md index 62cbdad..f34bf03 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ a time, and cannot stop core, take the diagnostic bundle or change | `tw-store` | Request history and runtime state on SQLite | | `tw-observe` | Event bus | | `tw-gateway` | Data plane: the life of a request | +| `tw-plugin` | Script-plugin sandbox: QuickJS compiled to WebAssembly, run by Wasmtime | | `tw-control` | Control-plane server | ThinkWatch Enterprise depends only on the first four, which depend only on one @@ -156,11 +157,32 @@ ThinkWatch Lite pins `tw-api`, `tw-types`, `tw-yaml`, `tw-guard`, `tw-watch` and `tw-link` to a release tag and bundles the `twcore` of the same release. Setting up AI clients and scanning their configuration happen in Lite, on the machine it runs on; `twcore` issues each client its own gateway key. The binary lives -in `bin/twcore`. +in `bin/twcore`. Only `tw-gateway` and `twcore` may depend on `tw-plugin`, the +one crate whose build needs more than Rust (see below), so building Lite or +Enterprise against these crates never does. ## Build and test -Requires a recent stable Rust toolchain (1.94.1 or later). +Requires a recent stable Rust toolchain (1.94.1 or later), plus an LLVM `clang` +that can compile C to WebAssembly and the `llvm-ar` that comes with it. The +plugin sandbox (`tw-plugin`) compiles QuickJS to WebAssembly while it builds; +Apple's clang cannot target WebAssembly. + +| System | Install | +|---|---| +| macOS | `brew install llvm` (found where Homebrew puts it; it does not need to be on `PATH`) | +| Debian, Ubuntu | `sudo apt install clang llvm` | +| Fedora | `sudo dnf install clang llvm` | +| Windows | the LLVM installer from [LLVM's releases](https://github.com/llvm/llvm-project/releases), or `winget install LLVM.LLVM` | + +The build tries Homebrew's LLVM, then `clang` and `clang-N` (`clang-19`, +`clang-18`, …) on `PATH`, and uses the first that really produces WebAssembly. +To pick one yourself, set `TW_WASM_CLANG`, and `TW_WASM_AR` when its `llvm-ar` +is not next to it. Rust's `wasm32-unknown-unknown` target is listed in +`rust-toolchain.toml`, so rustup installs it; the linking is done by the +`rust-lld` that ships with Rust. Nothing is downloaded during the build. Each +build records which clang it used and the SHA-256 of the WebAssembly module +(`tw_plugin::GUEST_CLANG`, `tw_plugin::GUEST_WASM_SHA256`). ```sh cargo build --release -p twcore # target/release/twcore diff --git a/README.zh-CN.md b/README.zh-CN.md index c00a554..2aa7044 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -91,13 +91,23 @@ twcore control-key --rotate # 更换密钥;用旧密钥建立的连接随 | `tw-store` | 基于 SQLite 的请求记录与运行时状态 | | `tw-observe` | 事件总线 | | `tw-gateway` | 数据面:一个请求的完整生命周期 | +| `tw-plugin` | 脚本插件的沙箱:编成 WebAssembly 的 QuickJS,由 Wasmtime 运行 | | `tw-control` | 控制面服务 | -ThinkWatch 企业版只依赖前四个 crate,这四个 crate 也只相互依赖;CI 会针对它们的每一次改动检查企业版能否编译。ThinkWatch Lite 把 `tw-api`、`tw-types`、`tw-yaml`、`tw-guard`、`tw-watch` 和 `tw-link` 固定在某个 Release 的 tag 上,并打包同一 Release 的 `twcore`。接管 AI 客户端和扫描其配置在 Lite 中完成,作用于应用所在的机器;`twcore` 只为每个客户端签发专用的网关密钥。二进制的源码位于 `bin/twcore`。 +ThinkWatch 企业版只依赖前四个 crate,这四个 crate 也只相互依赖;CI 会针对它们的每一次改动检查企业版能否编译。ThinkWatch Lite 把 `tw-api`、`tw-types`、`tw-yaml`、`tw-guard`、`tw-watch` 和 `tw-link` 固定在某个 Release 的 tag 上,并打包同一 Release 的 `twcore`。接管 AI 客户端和扫描其配置在 Lite 中完成,作用于应用所在的机器;`twcore` 只为每个客户端签发专用的网关密钥。二进制的源码位于 `bin/twcore`。只有 `tw-gateway` 和 `twcore` 可以依赖 `tw-plugin`——它是唯一一个构建时除了 Rust 还需要别的工具的 crate(见下文),因此用这些 crate 构建 Lite 或企业版时都不需要。 ## 构建与测试 -需要较新的 Rust 稳定版工具链(1.94.1 或更新)。 +需要较新的 Rust 稳定版工具链(1.94.1 或更新),以及一个能把 C 编译成 WebAssembly 的 LLVM `clang` 和与之配套的 `llvm-ar`。插件沙箱(`tw-plugin`)在构建时把 QuickJS 编译成 WebAssembly;Apple 自带的 clang 不支持 WebAssembly。 + +| 系统 | 安装 | +|---|---| +| macOS | `brew install llvm`(构建时会在 Homebrew 的安装位置找到它,不必加入 `PATH`) | +| Debian、Ubuntu | `sudo apt install clang llvm` | +| Fedora | `sudo dnf install clang llvm` | +| Windows | [LLVM 发布页](https://github.com/llvm/llvm-project/releases)上的安装包,或 `winget install LLVM.LLVM` | + +构建时依次尝试 Homebrew 的 LLVM、`PATH` 上的 `clang` 和 `clang-N`(`clang-19`、`clang-18`……),使用第一个确实能产出 WebAssembly 的。要指定某一个,设置 `TW_WASM_CLANG`;它的 `llvm-ar` 不在同一目录时,再设置 `TW_WASM_AR`。Rust 的 `wasm32-unknown-unknown` 目标已写在 `rust-toolchain.toml` 中,rustup 会自动安装;链接使用 Rust 自带的 `rust-lld`。构建过程中不下载任何东西。每次构建都会记录所用的 clang 和 WebAssembly 模块的 SHA-256(`tw_plugin::GUEST_CLANG`、`tw_plugin::GUEST_WASM_SHA256`)。 ```sh cargo build --release -p twcore # 生成 target/release/twcore diff --git a/crates/tw-plugin/Cargo.toml b/crates/tw-plugin/Cargo.toml new file mode 100644 index 0000000..d28c455 --- /dev/null +++ b/crates/tw-plugin/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "tw-plugin" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true +description = "Runs one script plugin's hooks inside a WebAssembly sandbox (QuickJS in Wasmtime)" + +# **只有 tw-gateway 和 twcore 能依赖这个 crate。**编它要一个能出 wasm 的 clang +# (见 build.rs),而桌面端从 git 编 tw-api、tw-types 这些、企业版编第一层时都 +# 不该被拖着要 clang —— tests/boundary.rs 守着这条。 + +[dependencies] +# **只有运行时**:没有 Cranelift,没有池分配器。沙箱的机器码在 build.rs 里预编译 +# 好、嵌进二进制。版本钉死,并且和下面构建依赖里的是同一个:预编译产物只肯在 +# 同一版本的 Wasmtime 里加载 +wasmtime = { version = "=49.0.1", default-features = false, features = ["runtime", "std"] } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +rand = { workspace = true } + +[target.'cfg(unix)'.dependencies] +# 量线程的 CPU 时间(CLOCK_THREAD_CPUTIME_ID) +libc = { workspace = true } + +[build-dependencies] +# 构建时的编译器:Cranelift 把快照编成目标平台的机器码;all-arch 让交叉编译 +# (比如在 x64 上编 aarch64 的 Windows)也能编 +wasmtime = { version = "=49.0.1", default-features = false, features = ["runtime", "std", "cranelift", "all-arch", "parallel-compilation"] } +# 改写快照的数据段。和 Wasmtime 49 自己用的是同一版 +wasmparser = "0.258" +wasm-encoder = "0.258" +sha2 = { workspace = true } +# Windows 上问 cargo metadata 要 rquickjs-sys 的位置(见 build.rs) +serde_json = { workspace = true } diff --git a/crates/tw-plugin/build.rs b/crates/tw-plugin/build.rs new file mode 100644 index 0000000..476650c --- /dev/null +++ b/crates/tw-plugin/build.rs @@ -0,0 +1,761 @@ +//! 编出插件沙箱:QuickJS-ng → wasm → 快照 → 目标平台的机器码,嵌进二进制。 +//! +//! 1. **wasm**:用一个能出 wasm32 的 clang 把 `guest/`(QuickJS-ng 源码来自钉死的 +//! rquickjs-sys)编成 `wasm32-unknown-unknown` 模块。C 由 clang 编,链接用 rustc +//! 自带的 rust-lld —— 所以外部只要 clang 和 llvm-ar 两样。这里**不下载任何 +//! 东西**:找不到就报错,告诉人怎么装。 +//! 2. **快照**:在构建机上跑一遍 `tw_init(bridge.js)`,把初始化好的整块内存写回 +//! 模块的数据段(Wizer 的做法)。之后每个实例都从初始化完的状态起步。 +//! 3. **预编译**:用 Cranelift 把快照编成目标平台的 `.cwasm`。交叉编译时就编给 +//! 目标平台(`Config::target`),也就不会带上构建机 CPU 的特性。运行时只有 +//! Wasmtime 的运行时部分,没有编译器。 +//! +//! 产物只在 `OUT_DIR` 里,仓库里不放任何二进制。用的 clang 版本和 wasm 的 +//! SHA-256 记进二进制(`tw_plugin::GUEST_CLANG`、`GUEST_WASM_SHA256`)和 +//! `OUT_DIR/guest-build.txt`,发出去的每一版都能对上是哪个工具链编的。 + +use std::env; +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use sha2::{Digest, Sha256}; + +#[path = "src/engine.rs"] +mod engine; + +/// 沙箱模块唯一允许的导入。多一个都不编:多出来的就是一条通往宿主的路 +const ALLOWED_IMPORTS: &[(&str, &str)] = &[("tw", "log"), ("env", "__rquickjs_host_now_us")]; + +const WASM_TARGET: &str = "wasm32-unknown-unknown"; + +fn main() { + let manifest_dir = + PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR")); + let target = env::var("TARGET").expect("TARGET"); + + for p in [ + "build.rs", + "src/engine.rs", + "src/bridge.js", + "guest/Cargo.toml", + "guest/Cargo.lock", + "guest/src", + ] { + println!("cargo:rerun-if-changed={p}"); + } + for v in ["TW_WASM_CLANG", "TW_WASM_AR"] { + println!("cargo:rerun-if-env-changed={v}"); + } + + let tools = find_tools(&out_dir); + check_rust_wasm_target(); + let wasm = build_guest(&manifest_dir, &out_dir, &tools); + check_imports(&wasm); + + let sha = hex(&Sha256::digest(&wasm)); + fs::write(out_dir.join("guest.wasm"), &wasm).expect("write guest.wasm"); + fs::write( + out_dir.join("guest-build.txt"), + format!( + "guest.wasm sha256 {sha}\nguest.wasm bytes {}\nclang {}\nclang path {}\nllvm-ar path {}\n", + wasm.len(), + tools.version, + tools.clang.display(), + tools.ar.display() + ), + ) + .expect("write guest-build.txt"); + println!("cargo:rustc-env=TW_PLUGIN_GUEST_SHA256={sha}"); + println!("cargo:rustc-env=TW_PLUGIN_GUEST_CLANG={}", tools.version); + + let bridge = fs::read(manifest_dir.join("src/bridge.js")).expect("read src/bridge.js"); + let snapshot = snapshot(&wasm, &bridge); + let cwasm = precompile(&snapshot, &target); + fs::write(out_dir.join("guest.cwasm"), cwasm).expect("write guest.cwasm"); +} + +// ── 工具链 ─────────────────────────────────────────────────────── + +struct Tools { + clang: PathBuf, + ar: PathBuf, + /// `clang --version` 的第一行 + version: String, +} + +/// 依次找:显式指定 → Homebrew 的 llvm(macOS)→ PATH 上的 clang / clang-N。 +/// 每个候选都真的编一个 wasm32 的目标文件试过才算数:Apple 自带的 clang 就编不了。 +fn find_tools(out_dir: &Path) -> Tools { + let mut tried: Vec = Vec::new(); + + if let Some(clang) = env::var_os("TW_WASM_CLANG") { + let clang = PathBuf::from(clang); + let ar = match env::var_os("TW_WASM_AR") { + Some(ar) => PathBuf::from(ar), + None => ar_for(&clang).unwrap_or_else(|| { + fail(&format!( + "TW_WASM_CLANG is set to {} but no llvm-ar was found next to it or on PATH; \ + set TW_WASM_AR as well", + clang.display() + )) + }), + }; + match probe(&clang, &ar, out_dir) { + Ok(version) => return Tools { clang, ar, version }, + Err(e) => fail(&format!( + "TW_WASM_CLANG={} cannot build for wasm32: {e}", + clang.display() + )), + } + } + + for c in candidates() { + let Some(ar) = ar_for(&c) else { + tried.push(format!("{} (no matching llvm-ar)", c.display())); + continue; + }; + match probe(&c, &ar, out_dir) { + Ok(version) => { + return Tools { + clang: c, + ar, + version, + }; + } + Err(e) => tried.push(format!("{}: {e}", c.display())), + } + } + + fail(&missing_clang_message(&tried)) +} + +fn exe(name: &str) -> String { + if cfg!(windows) { + format!("{name}.exe") + } else { + name.to_string() + } +} + +/// 候选的 clang,按优先级排好 +fn candidates() -> Vec { + let mut out = Vec::new(); + // Homebrew 的 llvm 是 keg-only,默认不在 PATH 上;macOS 自带的 clang 编不了 wasm + if cfg!(target_os = "macos") { + for prefix in ["/opt/homebrew/opt/llvm", "/usr/local/opt/llvm"] { + let p = Path::new(prefix).join("bin/clang"); + if p.is_file() { + out.push(p); + } + } + if let Some(prefix) = brew_prefix_llvm() { + let p = prefix.join("bin/clang"); + if p.is_file() && !out.contains(&p) { + out.push(p); + } + } + } + if let Some(p) = which(&exe("clang")) { + out.push(p); + } + // 发行版常见的带版本号的名字(clang-18 配 llvm-ar-18),新的优先 + for n in (13..=30).rev() { + if let Some(p) = which(&exe(&format!("clang-{n}"))) { + out.push(p); + } + } + // Windows 上 LLVM 安装包的默认位置(安装时不一定加进 PATH) + if cfg!(windows) { + for base in [env::var_os("ProgramFiles"), env::var_os("ProgramW6432")] + .into_iter() + .flatten() + { + let p = PathBuf::from(base) + .join("LLVM") + .join("bin") + .join("clang.exe"); + if p.is_file() && !out.contains(&p) { + out.push(p); + } + } + } + out +} + +fn brew_prefix_llvm() -> Option { + let out = Command::new("brew") + .args(["--prefix", "llvm"]) + .stderr(Stdio::null()) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8(out.stdout).ok()?; + let s = s.trim(); + (!s.is_empty()).then(|| PathBuf::from(s)) +} + +/// 和这个 clang 配套的 llvm-ar:`TW_WASM_AR`,或者它旁边的(顺着符号链接再找 +/// 一次),`clang-N` 配 `llvm-ar-N`,最后才是 PATH 上的。BSD 的 `ar` 给 wasm +/// 目标文件建不了符号索引,不用它 +fn ar_for(clang: &Path) -> Option { + if let Some(ar) = env::var_os("TW_WASM_AR") { + return Some(PathBuf::from(ar)); + } + let suffix = clang + .file_stem() + .and_then(|s| s.to_str()) + .and_then(|s| s.strip_prefix("clang")) + .unwrap_or("") + .to_string(); + let names = if suffix.is_empty() { + vec![exe("llvm-ar")] + } else { + vec![exe(&format!("llvm-ar{suffix}")), exe("llvm-ar")] + }; + let mut dirs: Vec = Vec::new(); + if let Some(d) = clang.parent() { + dirs.push(d.to_path_buf()); + } + if let Some(d) = fs::canonicalize(clang) + .ok() + .and_then(|real| real.parent().map(Path::to_path_buf)) + && !dirs.contains(&d) + { + dirs.push(d); + } + for d in &dirs { + for n in &names { + let p = d.join(n); + if p.is_file() { + return Some(p); + } + } + } + names.iter().find_map(|n| which(n)) +} + +fn which(name: &str) -> Option { + let path = env::var_os("PATH")?; + env::split_paths(&path) + .map(|d| d.join(name)) + .find(|p| p.is_file()) +} + +/// 真的编一个 wasm32 的目标文件、真的打一个包。返回 `clang --version` 的第一行 +fn probe(clang: &Path, ar: &Path, out_dir: &Path) -> Result { + let dir = out_dir.join("probe"); + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let src = dir.join("probe.c"); + let obj = dir.join("probe.o"); + let lib = dir.join("libprobe.a"); + let _ = fs::remove_file(&obj); + let _ = fs::remove_file(&lib); + fs::write(&src, "int tw_probe(int x) { return x * 2; }\n").map_err(|e| e.to_string())?; + let out = Command::new(clang) + .arg(format!("--target={WASM_TARGET}")) + .args(["-O2", "-c"]) + .arg(&src) + .arg("-o") + .arg(&obj) + .output() + .map_err(|e| format!("cannot run it ({e})"))?; + if !out.status.success() { + let err = String::from_utf8_lossy(&out.stderr); + let first = err + .lines() + .find(|l| !l.trim().is_empty()) + .unwrap_or("") + .trim(); + return Err(format!("it cannot target wasm32 ({first})")); + } + let head = fs::read(&obj).map_err(|e| e.to_string())?; + if !head.starts_with(b"\0asm") { + return Err("its output for wasm32 is not a wasm object".into()); + } + let out = Command::new(ar) + .arg("crs") + .arg(&lib) + .arg(&obj) + .output() + .map_err(|e| format!("cannot run {} ({e})", ar.display()))?; + if !out.status.success() { + return Err(format!("{} cannot archive a wasm object", ar.display())); + } + let out = Command::new(clang) + .arg("--version") + .output() + .map_err(|e| e.to_string())?; + let version = String::from_utf8_lossy(&out.stdout) + .lines() + .next() + .unwrap_or("unknown clang") + .trim() + .to_string(); + Ok(version) +} + +fn missing_clang_message(tried: &[String]) -> String { + let how = if cfg!(target_os = "macos") { + " macOS: brew install llvm\n (Apple's clang cannot target wasm; Homebrew's is found automatically)" + } else if cfg!(windows) { + " Windows: install LLVM from https://github.com/llvm/llvm-project/releases\n (the LLVM--win64.exe installer), or `winget install LLVM.LLVM`" + } else { + " Debian/Ubuntu: sudo apt install clang llvm\n Fedora: sudo dnf install clang llvm" + }; + let mut msg = String::from( + "Building tw-plugin needs a clang that can compile C to WebAssembly, plus the matching llvm-ar.\n\ + None was found.\n\nInstall one:\n", + ); + msg.push_str(how); + msg.push_str( + "\n\nOr point the build at one explicitly:\n TW_WASM_CLANG=/path/to/clang TW_WASM_AR=/path/to/llvm-ar\n", + ); + if !tried.is_empty() { + msg.push_str("\nTried:\n"); + for t in tried { + msg.push_str(" "); + msg.push_str(t); + msg.push('\n'); + } + } + msg +} + +/// rustc 要有 wasm32-unknown-unknown 的标准库(core)。根目录的 rust-toolchain.toml +/// 列了这个目标,rustup 会自己装;不走 rustup 的要手动装 +fn check_rust_wasm_target() { + let rustc = env::var_os("RUSTC").unwrap_or_else(|| OsString::from("rustc")); + let out = Command::new(&rustc) + .args(["--print", "target-libdir", "--target", WASM_TARGET]) + .output(); + let ok = match out { + Ok(o) if o.status.success() => { + let dir = PathBuf::from(String::from_utf8_lossy(&o.stdout).trim().to_string()); + fs::read_dir(&dir) + .map(|rd| { + rd.flatten().any(|e| { + let n = e.file_name(); + let n = n.to_string_lossy(); + n.starts_with("libcore-") && n.ends_with(".rlib") + }) + }) + .unwrap_or(false) + } + _ => false, + }; + if !ok { + fail( + "Building tw-plugin needs Rust's wasm32-unknown-unknown target.\n\ + Install it with:\n rustup target add wasm32-unknown-unknown\n\ + (rustup does this by itself for this repository: rust-toolchain.toml lists the target.)", + ); + } +} + +// ── 编 guest ───────────────────────────────────────────────────── + +fn build_guest(manifest_dir: &Path, out_dir: &Path, tools: &Tools) -> Vec { + let guest = manifest_dir.join("guest"); + let target_dir = out_dir.join("guest-target"); + let cargo_home = env::var_os("CARGO_HOME").map(PathBuf::from).or_else(|| { + env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }) + .map(|h| PathBuf::from(h).join(".cargo")) + }); + + let mut cmd = nested_cargo(); + cmd.args([ + "build", + "--release", + "--locked", + "--target", + WASM_TARGET, + "--manifest-path", + ]) + .arg(guest.join("Cargo.toml")) + .arg("--target-dir") + .arg(&target_dir); + + // 编出来的东西不带构建机的路径:同一套工具链在哪台机器上编都一样 + let mut remap = vec![ + format!("--remap-path-prefix={}=/guest", guest.display()), + format!("--remap-path-prefix={}=/target", target_dir.display()), + ]; + if let Some(h) = &cargo_home { + remap.push(format!("--remap-path-prefix={}=/cargo", h.display())); + } + cmd.env("CARGO_ENCODED_RUSTFLAGS", remap.join("\u{1f}")); + + // C 那一半交给探测到的 clang / llvm-ar。断言里的 __FILE__ 固定成一个名字: + // 否则它是构建目录下的绝对路径 + let mut cflags = vec![ + "-Wno-builtin-macro-redefined".to_string(), + "-D__FILE__=\"quickjs\"".to_string(), + ]; + if cfg!(windows) { + // rquickjs-sys 把它带的 libc 头文件目录 canonicalize 成 `\\?\C:\…` 交给 + // clang。这种写法里 `/` 不算分隔符,于是头文件里的 + // `#include ` 找不到。同一个目录换普通写法再给一遍没用: + // clang 认出是同一个目录,把后给的那个去掉了。所以拷一份到 OUT_DIR, + // 当作另一个目录给它 —— 前一个找不到时就找到这里 + let include = rquickjs_sys_dir(&guest) + .map(|d| d.join("vendor").join("wasi-libc").join("include")) + .unwrap_or_else(|e| fail(&format!("cannot locate rquickjs-sys: {e}"))); + let copy = out_dir.join("wasi-libc-include"); + copy_dir(&include, ©) + .unwrap_or_else(|e| fail(&format!("cannot copy {}: {e}", include.display()))); + cflags.push("-isystem".into()); + cflags.push(copy.display().to_string()); + } + let triple = WASM_TARGET.replace('-', "_"); + // 按 shell 的规则拆:路径里可以有空格 + let quoted: Vec = cflags.iter().map(|f| sh_quote(f)).collect(); + cmd.env(format!("CC_{triple}"), &tools.clang) + .env(format!("AR_{triple}"), &tools.ar) + .env("CC_SHELL_ESCAPED_FLAGS", "1") + .env(format!("CFLAGS_{triple}"), quoted.join(" ")); + + let status = cmd + .status() + .unwrap_or_else(|e| fail(&format!("cannot run cargo: {e}"))); + if !status.success() { + fail(&format!( + "building the QuickJS guest for {WASM_TARGET} failed (clang: {})", + tools.clang.display() + )); + } + let wasm_path = target_dir + .join(WASM_TARGET) + .join("release") + .join("tw_plugin_guest.wasm"); + fs::read(&wasm_path) + .unwrap_or_else(|e| fail(&format!("cannot read {}: {e}", wasm_path.display()))) +} + +/// 一个干净的 cargo:外层 cargo 给构建脚本的环境里有它自己的编译选项(CI 的 +/// `-D warnings`、clippy 的包装器、用户的 profile 覆盖、给本机用的 C 编译器和 +/// 选项),都不该落到这个独立的小工程上 +fn nested_cargo() -> Command { + let cargo = env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo")); + let mut cmd = Command::new(cargo); + for (key, _) in env::vars_os() { + let Some(key) = key.to_str() else { continue }; + let drop = matches!( + key, + "RUSTFLAGS" + | "CARGO_ENCODED_RUSTFLAGS" + | "CARGO_BUILD_RUSTFLAGS" + | "RUSTDOCFLAGS" + | "CARGO_ENCODED_RUSTDOCFLAGS" + | "RUSTC_WRAPPER" + | "RUSTC_WORKSPACE_WRAPPER" + | "CARGO_BUILD_RUSTC_WRAPPER" + | "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER" + | "CARGO_TARGET_DIR" + | "CARGO_BUILD_TARGET_DIR" + | "CARGO_BUILD_TARGET" + | "CARGO_INCREMENTAL" + | "CARGO_BUILD_INCREMENTAL" + | "CC" + | "CFLAGS" + | "AR" + | "TARGET_CC" + | "TARGET_CFLAGS" + | "TARGET_AR" + | "CC_SHELL_ESCAPED_FLAGS" + ) || key.starts_with("CARGO_PROFILE_") + || key.starts_with("CARGO_TARGET_"); + if drop { + cmd.env_remove(key); + } + } + cmd +} + +/// guest 用的那份 rquickjs-sys 在哪(问 cargo,源码可能在注册表缓存里,也可能 +/// 是 vendor 出来的) +fn rquickjs_sys_dir(guest: &Path) -> Result { + let out = nested_cargo() + .args([ + "metadata", + "--format-version", + "1", + "--locked", + "--manifest-path", + ]) + .arg(guest.join("Cargo.toml")) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).into_owned()); + } + let meta: serde_json::Value = serde_json::from_slice(&out.stdout).map_err(|e| e.to_string())?; + meta["packages"] + .as_array() + .into_iter() + .flatten() + .find(|p| p["name"] == "rquickjs-sys") + .and_then(|p| p["manifest_path"].as_str()) + .and_then(|m| Path::new(m).parent().map(Path::to_path_buf)) + .ok_or_else(|| "rquickjs-sys is not in the guest's dependency graph".into()) +} + +fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> { + fs::create_dir_all(to)?; + for entry in fs::read_dir(from)? { + let entry = entry?; + let target = to.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_dir(&entry.path(), &target)?; + } else { + fs::copy(entry.path(), &target)?; + } + } + Ok(()) +} + +/// 给 cc 的 `CC_SHELL_ESCAPED_FLAGS` 用的单引号括起来的写法 +fn sh_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +/// 导入表必须正好是允许的那几个,内存必须是模块自己的、导出出来的 +fn check_imports(wasm: &[u8]) { + use wasmparser::{Parser, Payload, TypeRef}; + for payload in Parser::new(0).parse_all(wasm) { + let payload = + payload.unwrap_or_else(|e| fail(&format!("the guest wasm does not parse: {e}"))); + if let Payload::ImportSection(reader) = payload { + for import in reader.into_imports() { + let import = import.unwrap_or_else(|e| fail(&format!("bad import: {e}"))); + let allowed = matches!(import.ty, TypeRef::Func(_)) + && ALLOWED_IMPORTS + .iter() + .any(|(m, n)| *m == import.module && *n == import.name); + if !allowed { + fail(&format!( + "the guest wasm imports {}.{} ({:?}); only {:?} are allowed", + import.module, import.name, import.ty, ALLOWED_IMPORTS + )); + } + } + } + } +} + +// ── 快照 ───────────────────────────────────────────────────────── + +/// 在构建机上实例化一次、跑 `tw_init(bridge.js)`,把那一刻的整块内存写回数据段 +fn snapshot(wasm: &[u8], bridge: &[u8]) -> Vec { + use wasmtime::{Engine, Linker, Module, Store}; + + let engine = Engine::new(&engine::config()).unwrap_or_else(|e| fail(&format!("wasmtime: {e}"))); + let module = + Module::new(&engine, wasm).unwrap_or_else(|e| fail(&format!("compile the guest: {e}"))); + let mut linker: Linker<()> = Linker::new(&engine); + // 初始化时不该有日志;时钟给 0,快照里就不会冻进构建那一刻的时间 + linker + .func_wrap("tw", "log", |_: u32, _: u32, _: u32| {}) + .and_then(|l| l.func_wrap("env", "__rquickjs_host_now_us", || -> f64 { 0.0 })) + .unwrap_or_else(|e| fail(&format!("linker: {e}"))); + let mut store = Store::new(&engine, ()); + // 纪元不会推进(没有计时线程),截止时间给多远都行 + store.set_epoch_deadline(u64::MAX / 2); + let instance = linker + .instantiate(&mut store, &module) + .unwrap_or_else(|e| fail(&format!("instantiate the guest: {e}"))); + let memory = instance + .get_memory(&mut store, "memory") + .unwrap_or_else(|| fail("the guest exports no memory")); + + let alloc = instance + .get_typed_func::(&mut store, "tw_alloc") + .unwrap_or_else(|e| fail(&format!("tw_alloc: {e}"))); + let init = instance + .get_typed_func::<(u32, u32), u32>(&mut store, "tw_init") + .unwrap_or_else(|e| fail(&format!("tw_init: {e}"))); + let out_ptr = instance + .get_typed_func::<(), u32>(&mut store, "tw_out_ptr") + .unwrap_or_else(|e| fail(&format!("tw_out_ptr: {e}"))); + let out_len = instance + .get_typed_func::<(), u32>(&mut store, "tw_out_len") + .unwrap_or_else(|e| fail(&format!("tw_out_len: {e}"))); + + let len = u32::try_from(bridge.len()).expect("bridge.js is small"); + let ptr = alloc + .call(&mut store, len) + .unwrap_or_else(|e| fail(&format!("tw_alloc: {e}"))); + let mut src = bridge.to_vec(); + src.push(0); + memory + .write(&mut store, ptr as usize, &src) + .unwrap_or_else(|e| fail(&format!("write bridge.js: {e}"))); + let rc = init + .call(&mut store, (ptr, len)) + .unwrap_or_else(|e| fail(&format!("tw_init trapped: {e:?}"))); + if rc != 0 { + let p = out_ptr.call(&mut store, ()).unwrap_or(0) as usize; + let n = out_len.call(&mut store, ()).unwrap_or(0) as usize; + let msg = memory + .data(&store) + .get(p..p + n) + .map(|b| String::from_utf8_lossy(b).into_owned()) + .unwrap_or_default(); + fail(&format!("bridge.js failed to initialize: {msg}")); + } + let image = memory.data(&store).to_vec(); + rewrite(wasm, &image) +} + +/// 把模块的数据段换成 `image`,初始内存页数改成快照时的页数。 +/// +/// 只对这一类模块成立:唯一可变的全局是影子栈指针,而它在 `tw_init` 返回时已经 +/// 回到初值;表在初始化期间没有变;数据段全是主动段。不满足就不编,不猜 +fn rewrite(wasm: &[u8], image: &[u8]) -> Vec { + use wasm_encoder as we; + use wasmparser::{DataKind, Parser, Payload}; + + const PAGE: usize = 65536; + let pages = (image.len() / PAGE) as u64; + let segments = segments(image); + + let mut out = we::Module::new(); + let mut saw_data = false; + for payload in Parser::new(0).parse_all(wasm) { + let payload = payload.unwrap_or_else(|e| fail(&format!("parse the guest: {e}"))); + match &payload { + Payload::Version { .. } | Payload::End(_) => {} + Payload::MemorySection(reader) => { + let mut section = we::MemorySection::new(); + let mut n = 0; + for m in reader.clone() { + let m = m.unwrap_or_else(|e| fail(&format!("memory section: {e}"))); + n += 1; + section.memory(we::MemoryType { + minimum: pages, + maximum: m.maximum, + memory64: m.memory64, + shared: m.shared, + page_size_log2: m.page_size_log2, + }); + } + if n != 1 { + fail(&format!("the guest has {n} memories; expected one")); + } + out.section(§ion); + } + Payload::GlobalSection(reader) => { + let mutable = reader + .clone() + .into_iter() + .filter(|g| g.as_ref().is_ok_and(|g| g.ty.mutable)) + .count(); + if mutable > 1 { + fail(&format!( + "the guest has {mutable} mutable globals; the snapshot only knows how to keep the stack pointer" + )); + } + raw(&mut out, &payload, wasm); + } + Payload::StartSection { .. } => { + fail("the guest has a start function; a snapshot would run it twice") + } + Payload::DataCountSection { .. } => { + out.section(&we::DataCountSection { + count: segments.len() as u32, + }); + } + Payload::DataSection(reader) => { + saw_data = true; + for d in reader.clone() { + let d = d.unwrap_or_else(|e| fail(&format!("data section: {e}"))); + if !matches!( + d.kind, + DataKind::Active { + memory_index: 0, + .. + } + ) { + fail("the guest has a passive data segment; the snapshot cannot keep it"); + } + } + let mut section = we::DataSection::new(); + for (offset, bytes) in &segments { + section.active( + 0, + &we::ConstExpr::i32_const(*offset as i32), + bytes.iter().copied(), + ); + } + out.section(§ion); + } + // 名字、producers 之类的自定义段运行时用不着 + Payload::CustomSection(_) => {} + _ => raw(&mut out, &payload, wasm), + } + } + if !saw_data { + fail("the guest has no data section"); + } + out.finish() +} + +fn raw(out: &mut wasm_encoder::Module, payload: &wasmparser::Payload<'_>, wasm: &[u8]) { + if let Some((id, range)) = payload.as_section() { + out.section(&wasm_encoder::RawSection { + id, + data: &wasm[range.start as usize..range.end as usize], + }); + } +} + +/// 内存里非零的连续片段;中间夹着不到 1 KiB 的零就并进同一段,段数少一些 +fn segments(mem: &[u8]) -> Vec<(usize, &[u8])> { + let mut segs = Vec::new(); + let mut i = 0; + while i < mem.len() { + if mem[i] == 0 { + i += 1; + continue; + } + let start = i; + let mut last = i; + while i < mem.len() && i - last < 1024 { + if mem[i] != 0 { + last = i; + } + i += 1; + } + segs.push((start, &mem[start..=last])); + i = last + 1; + } + segs +} + +// ── 预编译 ─────────────────────────────────────────────────────── + +fn precompile(wasm: &[u8], target: &str) -> Vec { + let mut config = engine::config(); + config + .target(target) + .unwrap_or_else(|e| fail(&format!("Wasmtime cannot compile for {target}: {e}"))); + let engine = wasmtime::Engine::new(&config).unwrap_or_else(|e| fail(&format!("wasmtime: {e}"))); + engine + .precompile_module(wasm) + .unwrap_or_else(|e| fail(&format!("precompile the guest for {target}: {e:?}"))) +} + +// ── 杂项 ───────────────────────────────────────────────────────── + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn fail(msg: &str) -> ! { + eprintln!("\nerror: tw-plugin: {msg}\n"); + std::process::exit(1); +} diff --git a/crates/tw-plugin/examples/latency.rs b/crates/tw-plugin/examples/latency.rs new file mode 100644 index 0000000..4197200 --- /dev/null +++ b/crates/tw-plugin/examples/latency.rs @@ -0,0 +1,124 @@ +//! 沙箱的开销:建实例 + 调一次钩子要多久。 +//! +//! ```sh +//! cargo run --release -p tw-plugin --example latency +//! ``` +//! +//! 默认上限(`Limits::default()`)下量。打印每一项的中位数和 p90/p99。 + +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use tw_plugin::{Limits, RequestOutcome, Runtime}; + +fn main() { + println!("guest wasm sha256 {}", tw_plugin::GUEST_WASM_SHA256); + println!("built with {}", tw_plugin::GUEST_CLANG); + + let t = Instant::now(); + let rt = Runtime::new(Limits::default()).expect("runtime"); + println!( + "Runtime::new {:>10.1?}", + t.elapsed() + ); + + let small = r#"export const manifest = { name: "date", api: 1, permissions: ["system", "reply.text"] }; + export function onRequest(req, ctx) { req.system = (req.system || "") + "\nToday: " + new Date().toISOString().slice(0, 10); return req; } + export function onReplyText(t) { return t.replaceAll("widget", "gadget"); }"#; + let t = Instant::now(); + let p = rt.load(small.as_bytes()).expect("load"); + println!( + "Runtime::load (small plugin) {:>10.1?}", + t.elapsed() + ); + + let ctx = json!({ "client": "claude-code", "model": "m", "format": "anthropic", "upstream": "anthropic", "settings": {} }); + let view = json!({ "format": "anthropic", "model": "m", "system": "be brief", + "messages": [ { "key": "m0", "role": "user", "parts": [ { "key": "p0", "type": "text", "text": "hello" } ] } ] }); + + report("on_request, small view (fresh instance)", 2000, || { + let inv = p.on_request(view.clone(), ctx.clone()); + assert!(matches!(inv.result, Ok(RequestOutcome::Changed(_)))); + inv.cpu + }); + report("reply() (fresh instance + ctx)", 2000, || { + let t = Instant::now(); + let r = p.reply(ctx.clone()).expect("reply"); + drop(r); + t.elapsed() + }); + let mut r = p.reply(ctx.clone()).expect("reply"); + report("on_text on a live reply instance", 20000, || { + let inv = r.on_text("a small widget delta"); + assert!(inv.result.is_ok()); + inv.cpu + }); + + let edit = rt + .load( + br#"export const manifest = { name: "words", api: 1, permissions: ["messages"] }; + export function onRequest(req) { + for (const m of req.messages) for (const p of m.parts) if (p.type === "text") p.text = p.text.replaceAll("widget", "gadget"); + return req; + }"#, + ) + .expect("load"); + for (label, size, n) in [("100 KB", 100usize << 10, 200), ("1 MB", 1 << 20, 30)] { + let v = big_view(size); + let bytes = serde_json::to_vec(&v).unwrap().len(); + report( + &format!("edit a {label} view ({bytes} B), sandbox CPU"), + n, + || { + let inv = edit.on_request(v.clone(), ctx.clone()); + assert!(matches!(inv.result, Ok(RequestOutcome::Changed(_)))); + inv.cpu + }, + ); + report( + &format!("edit a {label} view, wall incl. JSON in/out"), + n, + || { + let t = Instant::now(); + let inv = edit.on_request(v.clone(), ctx.clone()); + assert!(matches!(inv.result, Ok(RequestOutcome::Changed(_)))); + t.elapsed() + }, + ); + } +} + +fn report(label: &str, n: usize, mut f: impl FnMut() -> Duration) { + for _ in 0..(n / 10).max(3) { + f(); + } + let mut v: Vec = (0..n).map(|_| f()).collect(); + v.sort(); + let q = |p: f64| v[((v.len() as f64 - 1.0) * p) as usize]; + println!( + "{label:<46} n={n:<6} p50 {:>9.1?} p90 {:>9.1?} p99 {:>9.1?}", + q(0.5), + q(0.9), + q(0.99) + ); +} + +fn big_view(target: usize) -> Value { + let words = [ + "the", "function", "returns", "a", "value", "widget", "请求", "🙂", "\"q\"", "a\nb", + ]; + let mut messages = Vec::new(); + let mut size = 0; + let mut i = 0usize; + while size < target { + let text: String = (0..200) + .map(|j| words[(i * 7 + j * 13) % words.len()]) + .collect::>() + .join(" "); + size += text.len() + 100; + messages.push(json!({ "key": format!("m{i}"), "role": if i % 2 == 0 { "user" } else { "assistant" }, + "parts": [ { "key": format!("p{i}"), "type": "text", "text": text } ] })); + i += 1; + } + json!({ "format": "anthropic", "model": "m", "system": "s", "messages": messages }) +} diff --git a/crates/tw-plugin/guest/.gitignore b/crates/tw-plugin/guest/.gitignore new file mode 100644 index 0000000..fddc1db --- /dev/null +++ b/crates/tw-plugin/guest/.gitignore @@ -0,0 +1,2 @@ +# 在这里直接跑 cargo 时的产物(平时它由 tw-plugin 的 build.rs 编在 OUT_DIR 里) +/target diff --git a/crates/tw-plugin/guest/Cargo.lock b/crates/tw-plugin/guest/Cargo.lock new file mode 100644 index 0000000..2986d2e --- /dev/null +++ b/crates/tw-plugin/guest/Cargo.lock @@ -0,0 +1,41 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "rquickjs-sys" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cee271d0eeba64f0915b846cb7ae02e16faf3dfdffdca91731101d9d30fe3423" +dependencies = [ + "cc", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "tw-plugin-guest" +version = "0.0.0" +dependencies = [ + "rquickjs-sys", +] diff --git a/crates/tw-plugin/guest/Cargo.toml b/crates/tw-plugin/guest/Cargo.toml new file mode 100644 index 0000000..54b3e8a --- /dev/null +++ b/crates/tw-plugin/guest/Cargo.toml @@ -0,0 +1,32 @@ +# 插件沙箱里跑的那个 QuickJS:编成 wasm32-unknown-unknown,没有 WASI。 +# +# **它不是工作区成员**,平时的 `cargo build` / `cargo test` 不编它 —— 编它要一个 +# 能出 wasm 的 clang,而从源码编 core 只该需要 Rust。编好的 `qjs.wasm` 提交在 +# 旁边,`build.sh` 用钉死的工具链重编,CI 核对重编出来的和提交的逐字节相同。 +# 见 `build.sh` 和 `qjs.wasm.manifest`。 +[package] +name = "tw-plugin-guest" +version = "0.0.0" +edition = "2024" +license = "MIT" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +# QuickJS-ng 0.16.2 的 C 源码和它的绑定。只用绑定(没有 std,见 src/lib.rs)。 +# wasm32-unknown-unknown 下它用自己带的 wasi-libc(libc.a 与头文件)加一层垫片, +# 只留一个时钟导入。**钉死版本**:换版本就是换引擎。 +rquickjs-sys = "=0.14.0" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +panic = "abort" +strip = true +debug = false + +# 自成一个工作区,不被外面的 core 工作区收进去 +[workspace] diff --git a/crates/tw-plugin/guest/src/lib.rs b/crates/tw-plugin/guest/src/lib.rs new file mode 100644 index 0000000..bd6930a --- /dev/null +++ b/crates/tw-plugin/guest/src/lib.rs @@ -0,0 +1,519 @@ +//! 插件沙箱里的 QuickJS,和宿主(`tw-plugin`)之间的那层胶水。 +//! +//! **它只导入两个函数**:`tw.log`(插件的 `console.*`)和 +//! `env.__rquickjs_host_now_us`(rquickjs-sys 垫片里的时钟,`Date` 用)。没有 +//! WASI,没有文件、网络、环境变量、进程。宿主在实例化前核对这张导入表。 +//! +//! 宿主只通过下面这些导出驱动它: +//! +//! 1. 构建时:`tw_init(bridge.js)` 建运行时和上下文、求值桥脚本;之后整块内存 +//! 做成快照,每个实例都从那里开始(`tw-plugin` 的 `build.rs`)。 +//! 2. 加载插件:`tw_compile(源码)` 出字节码;再在新实例里走一遍 3 读出清单。 +//! 3. 每个实例:`tw_seed` → `tw_load(字节码)`(求值模块顶层)→ `tw_set_ctx`。 +//! 4. 每次钩子:`tw_call` —— 调钩子、跑完 Promise 任务、取结果。 +//! +//! 结果放在一块输出缓冲里,宿主用 `tw_out_ptr` / `tw_out_len` 读。输入由宿主用 +//! `tw_alloc` 要一块内存写进来,交给导出函数之后归这边释放。 +//! +//! **没有 std,也没有 `alloc`**:内存一律走 libc 的 `malloc`(QuickJS 用的同一个 +//! 堆)。这里的代码刻意不留任何会 panic 的写法(下标、`unwrap`)—— panic 会把 +//! 源文件路径编进 wasm,而路径的写法随构建机器变(Windows 是反斜杠),同一份源码 +//! 在不同机器上就编不出逐字节相同的 wasm 了。`tw-plugin` 有测试守着这一条。 + +#![no_std] +// 这些导出只由宿主调用,约定(谁分配、谁释放、指针指向哪里)就是上面那一段; +// 每个函数再写一遍「# Safety」只是重复 +#![allow(clippy::missing_safety_doc)] + +use core::ffi::{CStr, c_char, c_int, c_void}; +use core::ptr::null_mut; + +use rquickjs_sys as q; +use rquickjs_sys::{JSContext, JSRuntime, JSValue}; + +// ── 导入 ───────────────────────────────────────────────────────── + +#[link(wasm_import_module = "tw")] +unsafe extern "C" { + /// 插件的一行日志。宿主截断过长的行、数行数,超出上限就让这次调用失败。 + #[link_name = "log"] + fn host_log(level: u32, ptr: *const u8, len: usize); +} + +// wasi-libc 的 dlmalloc,QuickJS 的默认分配器用的也是它 +unsafe extern "C" { + fn malloc(size: usize) -> *mut c_void; + fn free(ptr: *mut c_void); +} + +#[panic_handler] +fn panic(_: &core::panic::PanicInfo) -> ! { + core::arch::wasm32::unreachable() +} + +// ── 状态 ───────────────────────────────────────────────────────── +// +// 全部在线性内存里,所以会进快照。单线程,没有并发访问。 + +static mut RT: *mut JSRuntime = null_mut(); +static mut CTX: *mut JSContext = null_mut(); +/// 桥脚本求值出来的对象。只有这里拿着它,插件够不着 +static mut BRIDGE: JSValue = q::JS_UNDEFINED; + +/// 输出缓冲:要么是 QuickJS 给的 C 字符串,要么是它 `js_malloc` 的字节码, +/// 要么是这边 `malloc` 的一块。下一次写输出前释放上一块。 +static mut OUT_PTR: *const u8 = core::ptr::null(); +static mut OUT_LEN: usize = 0; +static mut OUT_KIND: u8 = OUT_NONE; +const OUT_NONE: u8 = 0; +const OUT_CSTRING: u8 = 1; +const OUT_JS_MALLOC: u8 = 2; +const OUT_STATIC: u8 = 3; + +/// QuickJS 自己的栈上限(它量的是 wasm 线性内存里的影子栈,总共 1 MiB)。 +/// 递归太深时插件拿到一个 RangeError,而不是一个陷阱 +const JS_STACK_LIMIT: usize = 256 * 1024; + +// ── 导出 ───────────────────────────────────────────────────────── + +/// 宿主和胶水之间的约定版本写在这个导出的名字里(宿主按名字找它,不用实例化)。 +/// 改了导出的签名或含义就改名,两边一起改 +#[unsafe(no_mangle)] +pub extern "C" fn tw_abi_1() {} + +/// 要一块 `len` 字节的内存给宿主写输入。多给一个字节:要求以 NUL 结尾的 +/// 输入(`JS_Eval`)由宿主在末尾补 0 +#[unsafe(no_mangle)] +pub extern "C" fn tw_alloc(len: usize) -> *mut u8 { + unsafe { malloc(len.saturating_add(1)) as *mut u8 } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tw_free(ptr: *mut u8) { + unsafe { free(ptr as *mut c_void) } +} + +#[unsafe(no_mangle)] +pub extern "C" fn tw_out_ptr() -> *const u8 { + unsafe { OUT_PTR } +} + +#[unsafe(no_mangle)] +pub extern "C" fn tw_out_len() -> usize { + unsafe { OUT_LEN } +} + +/// 建运行时和上下文,求值桥脚本(`src` 以 NUL 结尾,`len` 不含它)。只在构建 +/// 时调一次。0 成功;1 失败,输出是错误描述。 +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tw_init(src: *mut u8, len: usize) -> u32 { + unsafe { + let rt = q::JS_NewRuntime(); + if rt.is_null() { + free(src as *mut c_void); + return fail(c"cannot create the JavaScript runtime"); + } + q::JS_SetMaxStackSize(rt, JS_STACK_LIMIT as q::size_t); + let ctx = q::JS_NewContextRaw(rt); + if ctx.is_null() { + free(src as *mut c_void); + return fail(c"cannot create the JavaScript context"); + } + RT = rt; + CTX = ctx; + // 标准 ECMAScript 的那些。**不加** performance(高精度计时)、atob/btoa、 + // DOMException —— 它们不是 ECMAScript;桥脚本另外再删一遍不在白名单里的全局 + if q::JS_AddIntrinsicBaseObjects(ctx) != 0 + || q::JS_AddIntrinsicDate(ctx) != 0 + || q::JS_AddIntrinsicEval(ctx) != 0 + || q::JS_AddIntrinsicRegExp(ctx) != 0 + || q::JS_AddIntrinsicJSON(ctx) != 0 + || q::JS_AddIntrinsicProxy(ctx) != 0 + || q::JS_AddIntrinsicMapSet(ctx) != 0 + || q::JS_AddIntrinsicTypedArrays(ctx) != 0 + || q::JS_AddIntrinsicPromise(ctx) != 0 + || q::JS_AddIntrinsicWeakRef(ctx) != 0 + { + free(src as *mut c_void); + return fail(c"cannot add the standard built-ins"); + } + + // 桥脚本先拿走这个函数,再把它从全局上删掉 + let log = q::JS_NewCFunction2( + ctx, + Some(js_log), + c"log".as_ptr(), + 2, + q::JSCFunctionEnum_JS_CFUNC_generic, + 0, + ); + let global = q::JS_GetGlobalObject(ctx); + let set = q::JS_SetPropertyStr(ctx, global, c"__tw_log".as_ptr(), log); + q::JS_FreeValue(ctx, global); + if set < 0 { + free(src as *mut c_void); + return fail_exception(); + } + + q::JS_UpdateStackTop(rt); + let bridge = q::JS_Eval( + ctx, + src as *const c_char, + len as q::size_t, + c"bridge.js".as_ptr(), + (q::JS_EVAL_TYPE_GLOBAL | q::JS_EVAL_FLAG_STRICT) as c_int, + ); + free(src as *mut c_void); + if q::JS_IsException(bridge) { + return fail_exception(); + } + if !q::JS_IsObject(bridge) { + q::JS_FreeValue(ctx, bridge); + return fail(c"bridge.js did not evaluate to the bridge object"); + } + BRIDGE = bridge; + set_out_static(c""); + 0 + } +} + +/// 把插件源码(以 NUL 结尾)编成模块字节码,不执行。0 成功,输出是字节码; +/// 1 失败(语法错误等),输出是错误描述。 +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tw_compile(src: *mut u8, len: usize) -> u32 { + unsafe { + q::JS_UpdateStackTop(RT); + let module = q::JS_Eval( + CTX, + src as *const c_char, + len as q::size_t, + c"plugin.js".as_ptr(), + (q::JS_EVAL_TYPE_MODULE | q::JS_EVAL_FLAG_STRICT | q::JS_EVAL_FLAG_COMPILE_ONLY) + as c_int, + ); + free(src as *mut c_void); + if q::JS_IsException(module) { + return fail_exception(); + } + // 模块值不能 FreeValue(QuickJS 在那里 abort):它归上下文的模块表管 + let mut size: q::size_t = 0; + let buf = q::JS_WriteObject( + CTX, + &mut size, + module, + (q::JS_WRITE_OBJ_BYTECODE | q::JS_WRITE_OBJ_STRIP_SOURCE) as c_int, + ); + if buf.is_null() { + return fail_exception(); + } + set_out(buf, size as usize, OUT_JS_MALLOC); + 0 + } +} + +/// 读入字节码、求值模块顶层(跑完它排下的 Promise 任务),再让桥把钩子和清单 +/// 取出来。0 成功,输出是桥给的 JSON;1 失败,输出是错误描述。 +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tw_load(bc: *mut u8, len: usize) -> u32 { + unsafe { + q::JS_UpdateStackTop(RT); + // 不带 ROM_DATA:QuickJS 自己拷一份,这块输入马上就能放掉 + let module = q::JS_ReadObject( + CTX, + bc as *const u8, + len as q::size_t, + q::JS_READ_OBJ_BYTECODE as c_int, + ); + free(bc as *mut c_void); + if q::JS_IsException(module) { + return fail_exception(); + } + if q::JS_VALUE_GET_TAG(module) != q::JS_TAG_MODULE { + return fail(c"the bytecode is not a module"); + } + let def = q::JS_VALUE_GET_PTR(module) as *mut q::JSModuleDef; + // JS_EvalFunction 会放掉传进去的那一份引用,模块自己要留着 + let promise = q::JS_EvalFunction(CTX, q::JS_DupValue(CTX, module)); + if q::JS_IsException(promise) { + return fail_exception(); + } + drain_jobs(); + if q::JS_IsPromise(promise) { + let state = q::JS_PromiseState(CTX, promise); + if state == q::JSPromiseStateEnum_JS_PROMISE_REJECTED { + let err = q::JS_PromiseResult(CTX, promise); + q::JS_FreeValue(CTX, promise); + return fail_value(err); + } + if state == q::JSPromiseStateEnum_JS_PROMISE_PENDING { + q::JS_FreeValue(CTX, promise); + return fail(c"the module's top-level await never finished"); + } + } + q::JS_FreeValue(CTX, promise); + + let ns = q::JS_GetModuleNamespace(CTX, def); + if q::JS_IsException(ns) { + return fail_exception(); + } + let mut args = [ns]; + let info = call_bridge(c"load", &mut args); + q::JS_FreeValue(CTX, ns); + if q::JS_IsException(info) { + return fail_exception(); + } + let ok = out_string(info); + q::JS_FreeValue(CTX, info); + if !ok { + return fail_exception(); + } + 0 + } +} + +/// 给这个实例的 `Math.random` 换种子。快照把随机数状态冻住了,每个实例都要换 +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tw_seed(a: u32, b: u32, c: u32, d: u32) -> u32 { + unsafe { + let mut args = [int(a), int(b), int(c), int(d)]; + let r = call_bridge(c"seed", &mut args); + if q::JS_IsException(r) { + return fail_exception(); + } + q::JS_FreeValue(CTX, r); + 0 + } +} + +/// 设定这个实例的 `ctx`(JSON,桥把它解析出来并整个冻住) +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tw_set_ctx(json: *mut u8, len: usize) -> u32 { + unsafe { + let s = q::JS_NewStringLen(CTX, json as *const c_char, len as q::size_t); + free(json as *mut c_void); + if q::JS_IsException(s) { + return fail_exception(); + } + let mut args = [s]; + let r = call_bridge(c"setCtx", &mut args); + q::JS_FreeValue(CTX, s); + if q::JS_IsException(r) { + return fail_exception(); + } + q::JS_FreeValue(CTX, r); + 0 + } +} + +/// 调一次钩子。`kind`:0 onRequest,1 onReplyText,2 onReplyTextEnd,3 onToolCall。 +/// `input` 可以是空指针(onReplyTextEnd 没有输入)。 +/// +/// 输出总是「一位状态码 + 内容」,返回值也是那位状态码(见 `bridge.js`)。 +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tw_call(kind: u32, input: *mut u8, len: usize) -> u32 { + unsafe { + q::JS_UpdateStackTop(RT); + let arg = if input.is_null() { + q::JS_UNDEFINED + } else { + let s = q::JS_NewStringLen(CTX, input as *const c_char, len as q::size_t); + free(input as *mut c_void); + s + }; + if q::JS_IsException(arg) { + return threw(); + } + let mut args = [int(kind), arg]; + let r = call_bridge(c"call", &mut args); + q::JS_FreeValue(CTX, arg); + if q::JS_IsException(r) { + return threw(); + } + q::JS_FreeValue(CTX, r); + // 钩子排下的 Promise 任务(async 钩子的后半段也在这里)都在这次调用里跑完 + drain_jobs(); + let mut args = [int(kind)]; + let s = call_bridge(c"settle", &mut args); + if q::JS_IsException(s) { + return threw(); + } + let ok = out_string(s); + q::JS_FreeValue(CTX, s); + if !ok { + return threw(); + } + if OUT_LEN == 0 { + return 3; + } + let status = *OUT_PTR; + if status.is_ascii_digit() { + (status - b'0') as u32 + } else { + 3 + } + } +} + +// ── 内部 ───────────────────────────────────────────────────────── + +/// `console.*` 落到这里:`__tw_log(level, text)` +unsafe extern "C" fn js_log( + ctx: *mut JSContext, + _this: JSValue, + argc: c_int, + argv: *mut JSValue, +) -> JSValue { + unsafe { + if argc < 2 || argv.is_null() { + return q::JS_UNDEFINED; + } + let mut level: i32 = 0; + if q::JS_ToInt32(ctx, &mut level, *argv) < 0 { + return q::JS_EXCEPTION; + } + let mut len: q::size_t = 0; + let s = q::JS_ToCStringLen2(ctx, &mut len, *argv.add(1), false); + if s.is_null() { + return q::JS_EXCEPTION; + } + host_log(level as u32, s as *const u8, len as usize); + q::JS_FreeCString(ctx, s); + q::JS_UNDEFINED + } +} + +fn int(v: u32) -> JSValue { + q::JS_MKVAL(q::JS_TAG_INT, v as i32) +} + +unsafe fn call_bridge(name: &CStr, args: &mut [JSValue]) -> JSValue { + unsafe { + let f = q::JS_GetPropertyStr(CTX, BRIDGE, name.as_ptr()); + if q::JS_IsException(f) { + return f; + } + let r = q::JS_Call(CTX, f, BRIDGE, args.len() as c_int, args.as_mut_ptr()); + q::JS_FreeValue(CTX, f); + r + } +} + +/// 跑完所有排着的 Promise 任务。一条永不结束的任务链由宿主的 CPU 时间上限打断 +unsafe fn drain_jobs() { + unsafe { + let mut job_ctx: *mut JSContext = null_mut(); + loop { + let r = q::JS_ExecutePendingJob(RT, &mut job_ctx); + if r == 0 { + break; + } + if r < 0 && !job_ctx.is_null() { + // 某个任务抛了异常(没人接的 Promise 拒绝)。它留在上下文上,清掉 + let e = q::JS_GetException(job_ctx); + q::JS_FreeValue(job_ctx, e); + } + } + } +} + +unsafe fn release_out() { + unsafe { + match OUT_KIND { + OUT_CSTRING => q::JS_FreeCString(CTX, OUT_PTR as *const c_char), + OUT_JS_MALLOC => q::js_free(CTX, OUT_PTR as *mut c_void), + _ => {} + } + OUT_PTR = core::ptr::null(); + OUT_LEN = 0; + OUT_KIND = OUT_NONE; + } +} + +unsafe fn set_out(ptr: *const u8, len: usize, kind: u8) { + unsafe { + release_out(); + OUT_PTR = ptr; + OUT_LEN = len; + OUT_KIND = kind; + } +} + +unsafe fn set_out_static(s: &'static CStr) { + unsafe { set_out(s.as_ptr() as *const u8, s.count_bytes(), OUT_STATIC) } +} + +/// 把一个 JS 字符串转成 UTF-8 放进输出。失败(内存不够)时异常留在上下文上 +unsafe fn out_string(v: JSValue) -> bool { + unsafe { + let mut len: q::size_t = 0; + let s = q::JS_ToCStringLen2(CTX, &mut len, v, false); + if s.is_null() { + return false; + } + set_out(s as *const u8, len as usize, OUT_CSTRING); + true + } +} + +/// 一个 JS 值(通常是异常)交给桥描述成 `{"message","stack"}` +unsafe fn describe_into_out(err: JSValue) { + unsafe { + let mut args = [err]; + let d = call_bridge(c"describe", &mut args); + if q::JS_IsException(d) { + let e = q::JS_GetException(CTX); + q::JS_FreeValue(CTX, e); + set_out_static(c"{\"message\":\"the plugin failed and the error could not be described\",\"stack\":null}"); + return; + } + if !out_string(d) { + let e = q::JS_GetException(CTX); + q::JS_FreeValue(CTX, e); + set_out_static(c"{\"message\":\"out of memory\",\"stack\":null}"); + } + q::JS_FreeValue(CTX, d); + } +} + +unsafe fn fail_exception() -> u32 { + unsafe { + let e = q::JS_GetException(CTX); + fail_value(e) + } +} + +unsafe fn fail_value(err: JSValue) -> u32 { + unsafe { + describe_into_out(err); + q::JS_FreeValue(CTX, err); + 1 + } +} + +unsafe fn fail(msg: &'static CStr) -> u32 { + unsafe { + if CTX.is_null() || !q::JS_IsObject(BRIDGE) { + set_out_static(msg); + return 1; + } + let s = q::JS_NewStringLen(CTX, msg.as_ptr(), msg.count_bytes() as q::size_t); + fail_value(s) + } +} + +/// 桥本身没走完(多半是内存耗尽):状态码 3(抛出),内容是错误描述 +unsafe fn threw() -> u32 { + unsafe { + let e = q::JS_GetException(CTX); + let mut args = [e]; + let d = call_bridge(c"describeThrown", &mut args); + q::JS_FreeValue(CTX, e); + if q::JS_IsException(d) || !out_string(d) { + let e = q::JS_GetException(CTX); + q::JS_FreeValue(CTX, e); + set_out_static(c"3{\"message\":\"out of memory\",\"stack\":null}"); + } + q::JS_FreeValue(CTX, d); + 3 + } +} diff --git a/crates/tw-plugin/src/bridge.js b/crates/tw-plugin/src/bridge.js new file mode 100644 index 0000000..39a1310 --- /dev/null +++ b/crates/tw-plugin/src/bridge.js @@ -0,0 +1,435 @@ +// 插件桥:在沙箱里、插件之前求值一次,然后连同整个 QuickJS 一起做进快照 +// (见 build.rs)。整个脚本的值是给 Rust 胶水(guest/src/lib.rs)的桥对象 —— +// 它不挂在任何全局上,插件拿不到。 +// +// 它做四件事: +// 1. 只留标准 ECMAScript 的全局,外加 console 和 reject; +// 2. Math.random 换成每个实例重新播种的版本(快照把原来的状态冻住了); +// 3. 调钩子,把返回值按钩子的类型核对、转成 JSON; +// 4. 把异常整理成 {"message","stack"}。 +// +// **宿主不信这里的任何结论。**插件和桥在同一个领域里,插件能改原型、改内建 +// 函数,所以这里的核对只是为了给出好懂的错误;输出交回宿主后,宿主照样按 +// JSON 解析、按钩子的类型重新核对一遍。这里要防的只是「桥自己被插件弄崩」: +// 用到的内建函数在插件运行之前就拿住,之后不再从全局或原型上取。 +"use strict"; +(function () { + const G = globalThis; + + const JSONparse = JSON.parse; + const JSONstringify = JSON.stringify; + const ObjectFreeze = Object.freeze; + const ObjectKeys = Object.keys; + const ObjectDefineProperty = Object.defineProperty; + const ArrayIsArray = Array.isArray; + const ReflectApply = Reflect.apply; + const ReflectOwnKeys = Reflect.ownKeys; + const ReflectDeleteProperty = Reflect.deleteProperty; + const MathImul = Math.imul; + const StringCtor = String; + const StringSlice = String.prototype.slice; + const StringSplit = String.prototype.split; + const StringIndexOf = String.prototype.indexOf; + const StringToWellFormed = String.prototype.toWellFormed; + const ArrayJoin = Array.prototype.join; + const PromiseThen = Promise.prototype.then; + const ErrorCtor = Error; + const TypeErrorCtor = TypeError; + const hostLog = G.__tw_log; + + // 状态码:输出的第一个字符(Rust 那边按它分派) + const VALUE = "0"; + const UNCHANGED = "1"; + const REJECTED = "2"; + const THREW = "3"; + const BAD = "4"; + const DROP = "5"; + + const HOOKS = ["onRequest", "onReplyText", "onReplyTextEnd", "onToolCall"]; + const MAX_MESSAGE = 4096; + const MAX_STACK = 16384; + + let hooks = { __proto__: null }; + let ctxValue; + let current = null; // 正在跑的钩子名(reject 只认 onRequest) + let rejected = null; // reject() 给的理由 + let outcome = null; // { done, ok, value } + + function clip(s, n) { + return s.length > n ? ReflectApply(StringSlice, s, [0, n]) : s; + } + + function wellFormed(s) { + return ReflectApply(StringToWellFormed, s, []); + } + + // 任何值转成一行可读的文字。绝不抛出:插件给的对象可能带会抛的 getter、 + // 会抛的 toJSON、Proxy、循环引用 + function show(v) { + try { + switch (typeof v) { + case "string": + return v; + case "undefined": + return "undefined"; + case "bigint": + return StringCtor(v) + "n"; + case "symbol": + case "number": + case "boolean": + return StringCtor(v); + case "function": + return "[Function]"; + } + if (v === null) return "null"; + if (v instanceof ErrorCtor) return errorLine(v); + const j = JSONstringify(v); + return typeof j === "string" ? j : StringCtor(v); + } catch (_) { + try { + return StringCtor(v); + } catch (_) { + return "[object]"; + } + } + } + + function errorLine(e) { + let name = "Error"; + let message = ""; + try { + name = StringCtor(e.name); + } catch (_) {} + try { + message = StringCtor(e.message); + } catch (_) {} + return message === "" ? name : name + ": " + message; + } + + // 栈里桥自己的那几帧对插件作者没有意义,去掉 + function cleanStack(s) { + const lines = ReflectApply(StringSplit, s, ["\n"]); + const kept = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line === "" || ReflectApply(StringIndexOf, line, ["bridge.js"]) !== -1) continue; + kept[kept.length] = line; + } + return ReflectApply(ArrayJoin, kept, ["\n"]); + } + + // 异常 → {"message","stack"}。JSON 用拼接写:对象字面量交给 JSON.stringify + // 的话,插件在 Object.prototype 上挂一个 toJSON 就能改掉它。字符串原值不会去 + // 查 toJSON,可以放心交给它 + function describe(e) { + let message; + let stack = null; + try { + if (e !== null && typeof e === "object" && e instanceof ErrorCtor) { + message = errorLine(e); + try { + const s = e.stack; + if (typeof s === "string") stack = cleanStack(s); + } catch (_) {} + } else { + message = "Uncaught " + show(e); + } + } catch (_) { + message = "Uncaught exception"; + } + message = clip(wellFormed(StringCtor(message)), MAX_MESSAGE); + return ( + '{"message":' + + JSONstringify(message) + + ',"stack":' + + (stack === null || stack === "" ? "null" : JSONstringify(clip(wellFormed(stack), MAX_STACK))) + + "}" + ); + } + + function typeName(v) { + if (v === null) return "null"; + if (ArrayIsArray(v)) return "an array"; + return typeof v === "object" ? "an object" : "a " + typeof v; + } + + function json(v, hook) { + let s; + try { + s = JSONstringify(v); + } catch (e) { + return BAD + hook + " returned a value that cannot be turned into JSON: " + clip(show(e), 500); + } + if (typeof s !== "string") return BAD + hook + " returned a value that cannot be turned into JSON"; + return VALUE + s; + } + + function finish(kind, v) { + const hook = HOOKS[kind]; + switch (kind) { + case 0: + if (v === undefined) return UNCHANGED; + if (v === null || typeof v !== "object" || ArrayIsArray(v)) { + return BAD + "onRequest must return the request object or undefined, not " + typeName(v); + } + return json(v, hook); + case 1: + case 2: + if (v === undefined) return UNCHANGED; + if (typeof v !== "string") { + return BAD + hook + " must return a string or undefined, not " + typeName(v); + } + // 切半个代理对(按 UTF-16 下标截文字时常见)在这里换成 U+FFFD, + // 和 TextEncoder 的做法一样,不让一个截断的表情把整个回答弄挂 + return VALUE + wellFormed(v); + case 3: + if (v === undefined) return UNCHANGED; + if (v === null) return DROP; + if (typeof v !== "object") { + return BAD + "onToolCall must return a tool call, an array of them, null or undefined, not " + typeName(v); + } + if (ArrayIsArray(v)) { + if (v.length === 0) return DROP; + for (let i = 0; i < v.length; i++) { + const c = v[i]; + if (c === null || typeof c !== "object" || ArrayIsArray(c)) { + return BAD + "onToolCall returned an array whose item " + i + " is " + typeName(c) + ", not a tool call"; + } + } + return json(v, hook); + } + return json([v], hook); + } + return BAD + "unknown hook"; + } + + // ── 插件看得见的全局 ───────────────────────────────────────── + + function emit(level, args) { + let line = ""; + for (let i = 0; i < args.length; i++) { + if (i > 0) line += " "; + line += show(args[i]); + if (line.length > 8192) break; + } + hostLog(level, wellFormed(clip(line, 8192))); + } + + const consoleObject = { + log(...args) { + emit(0, args); + }, + info(...args) { + emit(1, args); + }, + warn(...args) { + emit(2, args); + }, + error(...args) { + emit(3, args); + }, + debug(...args) { + emit(0, args); + }, + }; + + function reject(reason) { + if (current !== "onRequest") { + throw new TypeErrorCtor("reject() can only be called inside onRequest"); + } + // 理由记下就算数:插件自己 catch 住这个异常也照样拒绝 + if (rejected === null) { + rejected = clip(wellFormed(reason === undefined ? "" : show(reason)), MAX_MESSAGE); + } + throw new ErrorCtor("the request was rejected by the plugin"); + } + + // xoshiro128**:32 位运算就够,种子由宿主每个实例给一次 + let s0 = 1; + let s1 = 2; + let s2 = 3; + let s3 = 4; + function rotl(x, k) { + return (x << k) | (x >>> (32 - k)); + } + function next32() { + const result = MathImul(rotl(MathImul(s1, 5), 7), 9); + const t = s1 << 9; + s2 ^= s0; + s3 ^= s1; + s1 ^= s2; + s0 ^= s3; + s2 ^= t; + s3 = rotl(s3, 11); + return result >>> 0; + } + const random = { + random() { + return ((next32() >>> 5) * 67108864 + (next32() >>> 6)) / 9007199254740992; + }, + }.random; + + ObjectDefineProperty(Math, "random", { value: random, writable: true, configurable: true, enumerable: false }); + ObjectDefineProperty(G, "console", { value: consoleObject, writable: true, configurable: true, enumerable: false }); + ObjectDefineProperty(G, "reject", { value: reject, writable: true, configurable: true, enumerable: false }); + + // 全局只留 ECMAScript 标准里的(含附录 B 的 escape/unescape)和上面两个。 + // 引擎哪天多给了什么(queueMicrotask、performance、navigator……),在这里统一去掉 + const KEEP = [ + "globalThis", "Infinity", "NaN", "undefined", + "eval", "isFinite", "isNaN", "parseFloat", "parseInt", + "decodeURI", "decodeURIComponent", "encodeURI", "encodeURIComponent", "escape", "unescape", + "AggregateError", "Array", "ArrayBuffer", "AsyncDisposableStack", "BigInt", "BigInt64Array", + "BigUint64Array", "Boolean", "DataView", "Date", "DisposableStack", "Error", "EvalError", + "FinalizationRegistry", "Float16Array", "Float32Array", "Float64Array", "Function", + "Int8Array", "Int16Array", "Int32Array", "Iterator", "Map", "Number", "Object", "Promise", + "Proxy", "RangeError", "ReferenceError", "RegExp", "Set", "SharedArrayBuffer", "String", + "SuppressedError", "Symbol", "SyntaxError", "TypeError", "Uint8Array", "Uint8ClampedArray", + "Uint16Array", "Uint32Array", "URIError", "WeakMap", "WeakRef", "WeakSet", + "Atomics", "JSON", "Math", "Reflect", + "console", "reject", + ]; + const keep = { __proto__: null }; + for (let i = 0; i < KEEP.length; i++) keep[KEEP[i]] = true; + const names = ReflectOwnKeys(G); + for (let i = 0; i < names.length; i++) { + const k = names[i]; + if (typeof k === "string" && keep[k] !== true) ReflectDeleteProperty(G, k); + } + + // ── 给 Rust 胶水的桥 ───────────────────────────────────────── + + return { + // 模块求值完之后:记下钩子,把清单和导出交给宿主核对 + load(ns) { + const h = { __proto__: null }; + let found = "{"; + for (let i = 0; i < HOOKS.length; i++) { + const name = HOOKS[i]; + const v = ns[name]; + h[name] = typeof v === "function" ? v : undefined; + found += (i > 0 ? "," : "") + JSONstringify(name) + ":" + JSONstringify(v === undefined ? "missing" : typeof v); + } + found += "}"; + hooks = h; + + const m = ns.manifest; + const kind = m === undefined ? "missing" : m === null ? "null" : ArrayIsArray(m) ? "array" : typeof m; + let manifest = "null"; + let error = "null"; + let order = "null"; + if (kind === "object") { + try { + const s = JSONstringify(m); + if (typeof s === "string") manifest = s; + } catch (e) { + error = JSONstringify(clip(show(e), 500)); + } + // 设置项的先后就是界面上的先后,而宿主那边的 JSON 对象不保序 + try { + const st = m.settings; + if (st !== null && typeof st === "object" && !ArrayIsArray(st)) { + const ks = ObjectKeys(st); + let list = "["; + for (let i = 0; i < ks.length; i++) list += (i > 0 ? "," : "") + JSONstringify(ks[i]); + order = list + "]"; + } + } catch (_) {} + } + const def = ns.default; + return ( + '{"hooks":' + found + + ',"manifest_kind":' + JSONstringify(kind) + + ',"manifest":' + manifest + + ',"manifest_error":' + error + + ',"settings_order":' + order + + ',"has_default":' + (def !== undefined ? "true" : "false") + + "}" + ); + }, + + seed(a, b, c, d) { + s0 = a | 0; + s1 = b | 0; + s2 = c | 0; + s3 = d | 0; + if ((s0 | s1 | s2 | s3) === 0) s0 = 1; + }, + + setCtx(text) { + ctxValue = deepFreeze(JSONparse(text)); + }, + + call(kind, input) { + rejected = null; + outcome = null; + current = HOOKS[kind]; + const f = hooks[current]; + if (typeof f !== "function") { + outcome = { __proto__: null, done: true, ok: false, value: new TypeErrorCtor(current + " is not exported") }; + return; + } + let r; + try { + if (kind === 0) r = ReflectApply(f, undefined, [JSONparse(input), ctxValue]); + else if (kind === 1) r = ReflectApply(f, undefined, [input, ctxValue]); + else if (kind === 2) r = ReflectApply(f, undefined, [ctxValue]); + else r = ReflectApply(f, undefined, [JSONparse(input), ctxValue]); + } catch (e) { + outcome = { __proto__: null, done: true, ok: false, value: e }; + return; + } + // async 钩子:结果等 Rust 那边把 Promise 任务跑完再取(settle) + if (r !== null && typeof r === "object") { + const o = { __proto__: null, done: false, ok: false, value: undefined }; + try { + ReflectApply(PromiseThen, r, [ + (v) => { + o.done = true; + o.ok = true; + o.value = v; + }, + (e) => { + o.done = true; + o.value = e; + }, + ]); + outcome = o; + return; + } catch (_) { + // 不是 Promise:就是一个普通的返回值 + } + } + outcome = { __proto__: null, done: true, ok: true, value: r }; + }, + + settle(kind) { + const o = outcome; + outcome = null; + try { + if (rejected !== null) return REJECTED + rejected; + if (o === null) return THREW + describe(new ErrorCtor("the hook did not run")); + if (!o.done) return BAD + HOOKS[kind] + " returned a promise that never settled"; + if (!o.ok) return THREW + describe(o.value); + return finish(kind, o.value); + } finally { + current = null; + } + }, + + describe, + + describeThrown(e) { + return THREW + describe(e); + }, + }; + + function deepFreeze(v) { + if (v !== null && typeof v === "object") { + ObjectFreeze(v); + const ks = ObjectKeys(v); + for (let i = 0; i < ks.length; i++) deepFreeze(v[ks[i]]); + } + return v; + } +})(); diff --git a/crates/tw-plugin/src/cpu.rs b/crates/tw-plugin/src/cpu.rs new file mode 100644 index 0000000..f341301 --- /dev/null +++ b/crates/tw-plugin/src/cpu.rs @@ -0,0 +1,39 @@ +//! 这个线程用掉的 CPU 时间。 +//! +//! 插件的时间上限按 CPU 时间算,不按墙上时间:机器忙的时候线程被抢占,墙上时间 +//! 照走,而插件其实没在跑 —— 按墙上时间算,一个 5 ms 的钩子在编译大工程时也会被 +//! 判超时,请求跟着被拒。 +//! +//! unix(Linux、macOS)用 `CLOCK_THREAD_CPUTIME_ID`。Windows 的线程时间按时钟中断 +//! 记账(15.6 ms 一格),对 20 ms 的预算太粗,那里退回墙上时间:调用都跑在专用 +//! 的线程池上,平时两者差不多,只在机器很忙时偏严。 + +use std::time::Duration; + +/// 从某个任意起点算起的「这个线程的 CPU 时间」。只拿来相减 +#[cfg(unix)] +pub(crate) fn now() -> Duration { + let mut ts = libc::timespec { + tv_sec: 0, + tv_nsec: 0, + }; + // SAFETY: 只写我们给的这个 timespec + let rc = unsafe { libc::clock_gettime(libc::CLOCK_THREAD_CPUTIME_ID, &mut ts) }; + if rc == 0 { + Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32) + } else { + wall() + } +} + +#[cfg(not(unix))] +pub(crate) fn now() -> Duration { + wall() +} + +fn wall() -> Duration { + use std::sync::OnceLock; + use std::time::Instant; + static ORIGIN: OnceLock = OnceLock::new(); + ORIGIN.get_or_init(Instant::now).elapsed() +} diff --git a/crates/tw-plugin/src/engine.rs b/crates/tw-plugin/src/engine.rs new file mode 100644 index 0000000..2dda421 --- /dev/null +++ b/crates/tw-plugin/src/engine.rs @@ -0,0 +1,26 @@ +//! 引擎配置:`build.rs`(预编译)和运行时(加载预编译结果)共用这一份。 +//! +//! 预编译出来的 `.cwasm` 只肯在「编译它时的配置」下加载 —— 中断方式、内存的 +//! 预留和保护区大小、wasm 特性,哪一项对不上 Wasmtime 都拒绝加载。两边各写一份 +//! 迟早会对不上,所以都从这里取。 +//! +//! 只用运行时和编译器两种构建里都有的设置。 + +/// 两边都要的那部分配置。`build.rs` 在此之上再指定目标平台。 +pub fn config() -> wasmtime::Config { + let mut c = wasmtime::Config::new(); + // CPU 时间上限靠它:后台线程定期推进纪元,到点就进回调,回调决定继续还是 + // 打断。比「燃料」便宜(实测纪元慢 15–18%,燃料慢 25–50%),而且编译进 + // 代码里的检查点也覆盖正则回溯这种不回到 JS 解释器的循环 + c.epoch_interruption(true); + // 内存按需分配(不用池):池在启动时就按槽位预留 4 GiB 一个的地址空间, + // `ulimit -v` 或严格的 overcommit 下进程直接起不来。按需分配只在实例活着的 + // 时候占地址空间,拿不到就是这一次调用失败 + c.allocation_strategy(wasmtime::InstanceAllocationStrategy::OnDemand); + // 快照的内存镜像尽量写时复制(Linux 上用 memfd;macOS 和 Windows 上 + // Wasmtime 退回逐页拷贝) + c.memory_init_cow(true); + // 宿主线程上 wasm 能用的栈。调用方的线程要留出比这更多的栈(2 MiB 足够) + c.max_wasm_stack(1 << 20); + c +} diff --git a/crates/tw-plugin/src/lib.rs b/crates/tw-plugin/src/lib.rs new file mode 100644 index 0000000..7ca2828 --- /dev/null +++ b/crates/tw-plugin/src/lib.rs @@ -0,0 +1,880 @@ +//! 脚本插件的沙箱:在 WebAssembly 里跑一个插件的钩子。 +//! +//! 插件是一个 JavaScript 模块。它在 QuickJS 里执行,而 QuickJS 本身编成了 wasm、 +//! 跑在 Wasmtime 里(`build.rs` 把它编好、做成快照、预编译成本机机器码嵌进来)。 +//! 沙箱从宿主那里只拿得到两样东西:写一行日志、看一眼时钟。没有文件、网络、 +//! 环境变量、进程,也没有别的插件。 +//! +//! 每次请求钩子都是一个**新实例**;一个回答用**一个实例**,那个回答的每次钩子 +//! 调用共用它,回答结束就丢掉。实例从快照起步(QuickJS 已经初始化好),然后求值 +//! 插件模块的顶层 —— 所以插件的模块级变量在请求之间不会留下来。 +//! +//! 每次调用都有 CPU 时间、内存、输出大小、日志量四个上限([`Limits`]),超了就是 +//! 一个 [`RunError`]。所有调用都是阻塞的、吃 CPU 的:调用方放到专用线程上跑, +//! 别放在异步运行时的工作线程上。线程栈要有 2 MiB 以上(wasm 自己最多用 1 MiB)。 +//! +//! 这个 crate 只管「跑」:视图怎么构造、权限怎么裁、改动怎么写回,都在 tw-gateway。 + +use std::collections::BTreeSet; +use std::fmt; +use std::sync::Arc; +use std::time::Duration; + +use serde_json::Value; +use sha2::{Digest, Sha256}; +use wasmtime::{Engine, InstancePre, Module}; + +mod cpu; +mod engine; +mod manifest; +mod sandbox; +mod ticker; + +use sandbox::{Described, Hook, Sandbox}; + +/// 嵌进来的沙箱模块(QuickJS 编成的 wasm,做成快照后预编译成本机机器码) +static GUEST: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/guest.cwasm")); + +/// 沙箱的 wasm(快照之前)的 SHA-256。和 [`GUEST_CLANG`] 一起,能对上发出去的 +/// 二进制里是哪一份沙箱、用哪个编译器编的 +pub const GUEST_WASM_SHA256: &str = env!("TW_PLUGIN_GUEST_SHA256"); + +/// 编沙箱里 C 那一半(QuickJS)用的 clang(`clang --version` 的第一行) +pub const GUEST_CLANG: &str = env!("TW_PLUGIN_GUEST_CLANG"); + +// ── 上限 ───────────────────────────────────────────────────────── + +/// 每次调用的资源上限。超过任何一项都是 [`RunError`] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Limits { + /// 一次请求钩子(含建实例、求值模块顶层)的 CPU 时间 + pub request_cpu: Duration, + /// 回答钩子每次调用的 CPU 时间 + pub reply_call_cpu: Duration, + /// 一个回答所有钩子调用加起来的 CPU 时间(含建实例) + pub reply_total_cpu: Duration, + /// 请求钩子实例的内存(wasm 线性内存的上限) + pub request_memory: usize, + /// 回答实例的内存 + pub reply_memory: usize, + /// 钩子输出的大小 + pub max_output: OutputCap, + /// 一次调用最多写几行日志。再多一行这次调用就失败 + pub max_log_lines: usize, + /// 一行日志最多几个字节,超出的部分截掉 + pub max_log_line: usize, + /// 插件源码最多几个字节 + pub max_source: usize, +} + +/// 钩子输出的上限:`factor × 输入字节数 + extra`。 +/// +/// 请求钩子的输入是视图的 JSON;回答钩子的输入是这次的文字或工具调用。 +/// `onReplyTextEnd` 没有输入,于是攒着到最后才放出的文字最多 `extra` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OutputCap { + pub factor: usize, + pub extra: usize, +} + +impl OutputCap { + pub fn limit(&self, input: usize) -> usize { + input.saturating_mul(self.factor).saturating_add(self.extra) + } +} + +impl Default for Limits { + fn default() -> Self { + Limits { + request_cpu: Duration::from_millis(200), + reply_call_cpu: Duration::from_millis(20), + reply_total_cpu: Duration::from_secs(2), + request_memory: 128 << 20, + reply_memory: 64 << 20, + max_output: OutputCap { + factor: 2, + extra: 1 << 20, + }, + max_log_lines: 100, + max_log_line: 4096, + max_source: 1 << 20, + } + } +} + +// ── 结果 ───────────────────────────────────────────────────────── + +/// 一次调用的结果,连同它写的日志和用掉的 CPU 时间 +#[derive(Debug, Clone, PartialEq)] +pub struct Invocation { + pub result: Result, + pub logs: Vec, + pub cpu: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogLine { + pub level: LogLevel, + pub text: String, +} + +/// `console.log` / `info` / `warn` / `error`(`console.debug` 算 `Log`) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LogLevel { + Log, + Info, + Warn, + Error, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RequestOutcome { + /// 返回了 `undefined`,或者返回的视图和传进去的一样 + Unchanged, + /// 改过的视图(没核对过结构和权限,那是 tw-gateway 的事) + Changed(Value), + /// 调了 `reject(理由)` + Rejected(String), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ToolCallOutcome { + Unchanged, + /// 换成这些调用(一个对象也包成一个元素)。每个都是 `{ id?, name, input }`, + /// 没有 `id` 的由调用方生成 + Replace(Vec), + /// 返回了 `null` 或空数组 + Drop, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum RunError { + #[error("the plugin ran past its CPU time limit")] + CpuLimit, + #[error("the plugin ran past its memory limit")] + MemoryLimit, + #[error("the plugin's output or log was too large")] + OutputLimit, + #[error("the plugin threw: {message}")] + Threw { + message: String, + stack: Option, + }, + #[error("the plugin returned something invalid: {0}")] + BadOutput(String), + #[error("the sandbox stopped: {0}")] + Trap(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum LoadError { + #[error("the plugin file is larger than the limit")] + TooLarge, + /// 源码编不过,或者模块顶层一执行就出错(`line`/`column` 从 1 数) + #[error("{message}")] + Syntax { + message: String, + line: Option, + column: Option, + }, + #[error("{0}")] + Manifest(String), + #[error("the plugin is written for plugin API {0}; this version supports API 1")] + UnsupportedApi(u32), + #[error("the sandbox failed: {0}")] + Engine(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("the plugin sandbox cannot start: {0}")] +pub struct InitError(pub String); + +// ── 清单 ───────────────────────────────────────────────────────── + +/// 插件导出的 `manifest`,核对过的 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Manifest { + pub name: String, + pub api: u32, + pub description: Option, + pub permissions: BTreeSet, + pub scope: Scope, + pub reply_mode: ReplyMode, + /// 按作者写的先后 + pub settings: Vec, + pub hooks: Hooks, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum Permission { + System, + Messages, + Tools, + Params, + ReplyText, + ReplyToolCalls, +} + +impl Permission { + pub const ALL: [Permission; 6] = [ + Permission::System, + Permission::Messages, + Permission::Tools, + Permission::Params, + Permission::ReplyText, + Permission::ReplyToolCalls, + ]; + + /// 控制面和配置里的写法:`reply_text` + pub fn as_str(self) -> &'static str { + match self { + Permission::System => "system", + Permission::Messages => "messages", + Permission::Tools => "tools", + Permission::Params => "params", + Permission::ReplyText => "reply_text", + Permission::ReplyToolCalls => "reply_tool_calls", + } + } + + /// 插件清单里的写法:`reply.text` + pub fn manifest_name(self) -> &'static str { + match self { + Permission::ReplyText => "reply.text", + Permission::ReplyToolCalls => "reply.tool_calls", + other => other.as_str(), + } + } + + pub fn from_manifest(s: &str) -> Option { + Permission::ALL.into_iter().find(|p| p.manifest_name() == s) + } +} + +/// 清单里的 `match`。每一项是带 `*` 的通配;空的表示不限 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Scope { + pub clients: Vec, + pub models: Vec, + pub upstreams: Vec, +} + +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum ReplyMode { + #[default] + Block, + Stream, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SettingSpec { + pub key: String, + pub kind: SettingKind, + pub label: String, + /// 和 `kind` 同类型的值;清单没写就是 `""` / `0` / `false` + pub default: Value, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SettingKind { + String, + Number, + Boolean, +} + +impl SettingKind { + pub fn as_str(self) -> &'static str { + match self { + SettingKind::String => "string", + SettingKind::Number => "number", + SettingKind::Boolean => "boolean", + } + } +} + +/// 插件导出了哪些钩子 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Hooks { + pub request: bool, + pub reply_text: bool, + pub reply_text_end: bool, + pub tool_call: bool, +} + +// ── 运行时 ─────────────────────────────────────────────────────── + +/// 整个进程一份。克隆很便宜 +#[derive(Clone)] +pub struct Runtime { + inner: Arc, +} + +struct RuntimeInner { + limits: Limits, + pre: InstancePre, + ticker: ticker::Ticker, +} + +impl fmt::Debug for Runtime { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Runtime") + .field("limits", &self.inner.limits) + .finish_non_exhaustive() + } +} + +impl Runtime { + /// 加载嵌进来的沙箱(约 0.5 ms),起一个计时线程。**不建实例**:实例要预留 + /// 一大段地址空间,地址空间受限的机器上也不会在这一步失败 + pub fn new(limits: Limits) -> Result { + let engine = + Engine::new(&engine::config()).map_err(|e| InitError(format!("wasmtime: {e}")))?; + // SAFETY: 这些字节是 build.rs 用同一版本的 Wasmtime、同一份配置预编译出来、 + // 在编译期嵌进二进制的(include_bytes)—— 不是从任何可写的位置读来的 + let module = unsafe { Module::deserialize(&engine, GUEST) } + .map_err(|e| InitError(format!("the embedded sandbox does not load: {e}")))?; + sandbox::check_module(&module).map_err(InitError)?; + let linker = sandbox::linker(&engine).map_err(|e| InitError(e.to_string()))?; + let pre = linker + .instantiate_pre(&module) + .map_err(|e| InitError(format!("the sandbox cannot be linked: {e}")))?; + let ticker = ticker::Ticker::start(engine) + .map_err(|e| InitError(format!("cannot start the timer thread: {e}")))?; + Ok(Runtime { + inner: Arc::new(RuntimeInner { + limits, + pre, + ticker, + }), + }) + } + + pub fn limits(&self) -> &Limits { + &self.inner.limits + } + + /// 沙箱模块的导入(`模块.名字`)。只有日志和时钟两项 + pub fn sandbox_imports(&self) -> Vec { + self.inner + .pre + .module() + .imports() + .map(|i| format!("{}.{}", i.module(), i.name())) + .collect() + } + + /// 编译并核对一个插件。只用这里给的字节:哈希的、编译的都是它们 + pub fn load(&self, source: &[u8]) -> Result { + let limits = &self.inner.limits; + if source.len() > limits.max_source { + return Err(LoadError::TooLarge); + } + let sha256: [u8; 32] = Sha256::digest(source).into(); + let text = std::str::from_utf8(source).map_err(|e| not_utf8(source, e.valid_up_to()))?; + let text = text.strip_prefix('\u{feff}').unwrap_or(text); + + let _running = self.inner.ticker.enter(); + // 编译只在加载时做一次,给宽一点的时间;模块顶层的预算和每次调用一样 + let mut sb = Sandbox::new( + &self.inner.pre, + limits, + limits.request_memory, + limits.reply_total_cpu.max(limits.request_cpu), + ) + .map_err(sandbox_failed)?; + let bytecode = match sb.compile(text.as_bytes()).map_err(top_level_failed)? { + Ok(bc) => bc, + Err(d) => return Err(syntax(d)), + }; + sb.arm(limits.request_cpu); + sb.seed().map_err(top_level_failed)?; + let info = match sb.load(&bytecode).map_err(top_level_failed)? { + Ok(info) => info, + Err(d) => return Err(syntax(d)), + }; + drop(sb); + let manifest = manifest::parse(&info)?; + + let plugin = Plugin { + inner: Arc::new(PluginInner { + rt: Arc::clone(&self.inner), + manifest, + sha256, + bytecode, + }), + }; + // 回答实例的内存更小。模块顶层在那里放不下的话,现在就说 + if plugin.inner.manifest.hooks.reply_text || plugin.inner.manifest.hooks.tool_call { + plugin + .setup(limits.reply_memory) + .map_err(|(e, _)| top_level_failed(e))?; + } + Ok(plugin) + } +} + +// ── 插件 ───────────────────────────────────────────────────────── + +/// 一个加载好的插件。克隆很便宜,可以跨线程共享 +#[derive(Clone)] +pub struct Plugin { + inner: Arc, +} + +struct PluginInner { + rt: Arc, + manifest: Manifest, + sha256: [u8; 32], + bytecode: Vec, +} + +impl fmt::Debug for Plugin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Plugin") + .field("name", &self.inner.manifest.name) + .field("sha256", &hex(&self.inner.sha256)) + .finish_non_exhaustive() + } +} + +impl Plugin { + pub fn manifest(&self) -> &Manifest { + &self.inner.manifest + } + + /// 交给 [`Runtime::load`] 的那些字节的 SHA-256 + pub fn sha256(&self) -> [u8; 32] { + self.inner.sha256 + } + + /// 新实例 → 求值模块顶层 → 设好 `ctx`。返回实例、它写的日志 + fn setup(&self, memory: usize) -> Result<(Sandbox, Vec), (RunError, Vec)> { + let limits = &self.inner.rt.limits; + let mut sb = Sandbox::new(&self.inner.rt.pre, limits, memory, limits.request_cpu) + .map_err(|e| (e, Vec::new()))?; + let r = (|| { + sb.seed()?; + if let Err(d) = sb.load(&self.inner.bytecode)? { + return Err(d.into_threw()); + } + Ok(()) + })(); + match r { + Ok(()) => { + let logs = sb.take_logs(); + Ok((sb, logs)) + } + Err(e) => { + let logs = sb.take_logs(); + Err((e, logs)) + } + } + } + + /// 跑一次请求钩子:新实例,用完即弃。`view` 是已经按权限裁好的请求视图 + pub fn on_request(&self, view: Value, ctx: Value) -> Invocation { + if !self.inner.manifest.hooks.request { + return Invocation { + result: Ok(RequestOutcome::Unchanged), + logs: Vec::new(), + cpu: Duration::ZERO, + }; + } + let limits = &self.inner.rt.limits; + let view_json = to_json(&view); + let ctx_json = to_json(&ctx); + let _running = self.inner.rt.ticker.enter(); + let start = cpu::now(); + let (mut sb, mut logs) = match self.setup(limits.request_memory) { + Ok(x) => x, + Err((e, logs)) => { + return Invocation { + result: Err(e), + logs, + cpu: cpu::now().saturating_sub(start), + }; + } + }; + let cap = limits.max_output.limit(view_json.len()); + let raw = sb + .set_ctx(&ctx_json) + .and_then(|()| sb.call(Hook::Request, Some(&view_json), cap)); + // 量到钩子返回为止:解析输出、和原视图比较是这边的事 + let spent = sb.elapsed(); + logs.extend(sb.take_logs()); + drop(sb); + Invocation { + result: raw.and_then(|(status, payload)| decode_request(status, &payload, &view)), + logs, + cpu: spent, + } + } + + /// 为一个回答建实例。这个回答的每次钩子调用都用它,回答结束就丢掉 + pub fn reply(&self, ctx: Value) -> Result { + let limits = &self.inner.rt.limits; + let ctx_json = to_json(&ctx); + let _running = self.inner.rt.ticker.enter(); + let (mut sb, mut logs) = self.setup(limits.reply_memory).map_err(|(e, _)| e)?; + sb.set_ctx(&ctx_json)?; + logs.extend(sb.take_logs()); + let cpu = sb.elapsed(); + Ok(Reply { + plugin: Arc::clone(&self.inner), + sb, + used: cpu, + carry_logs: logs, + carry_cpu: cpu, + failed: None, + }) + } +} + +// ── 回答 ───────────────────────────────────────────────────────── + +/// 一个回答的实例。一次只能一个线程用它 +pub struct Reply { + plugin: Arc, + sb: Sandbox, + /// 这个回答到现在一共用掉的 CPU 时间 + used: Duration, + /// 建实例时的日志和 CPU 时间,记到第一次调用头上 + carry_logs: Vec, + carry_cpu: Duration, + /// 实例中途被打断过(超时、超内存、陷阱):它的状态不再可信,之后的调用都给这个错 + failed: Option, +} + +impl fmt::Debug for Reply { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Reply") + .field("plugin", &self.plugin.manifest.name) + .field("used", &self.used) + .field("failed", &self.failed) + .finish_non_exhaustive() + } +} + +impl Reply { + /// 一段助手文字。分块模式下是整块,流式时是一段增量。`None` 表示照原样 + pub fn on_text(&mut self, text: &str) -> Invocation> { + if !self.plugin.manifest.hooks.reply_text { + return self.skip(None); + } + let cap = self.plugin.rt.limits.max_output.limit(text.len()); + self.run(Hook::ReplyText, Some(text.as_bytes()), cap) + .then(|(status, payload)| match status { + sandbox::VALUE => { + let s = String::from_utf8_lossy(&payload).into_owned(); + Ok(if s == text { None } else { Some(s) }) + } + sandbox::UNCHANGED => Ok(None), + other => Err(decode_error(other, payload, "onReplyText")), + }) + } + + /// 流式模式下一个文字块结束:放出还攒着的文字 + pub fn on_text_end(&mut self) -> Invocation> { + if !self.plugin.manifest.hooks.reply_text_end { + return self.skip(None); + } + let cap = self.plugin.rt.limits.max_output.limit(0); + self.run(Hook::ReplyTextEnd, None, cap) + .then(|(status, payload)| match status { + sandbox::VALUE => Ok(Some(String::from_utf8_lossy(&payload).into_owned())), + sandbox::UNCHANGED => Ok(None), + other => Err(decode_error(other, payload, "onReplyTextEnd")), + }) + } + + /// 一个完整的工具调用 `{ id, name, input }` + pub fn on_tool_call(&mut self, call: Value) -> Invocation { + if !self.plugin.manifest.hooks.tool_call { + return self.skip(ToolCallOutcome::Unchanged); + } + let input = to_json(&call); + let cap = self.plugin.rt.limits.max_output.limit(input.len()); + self.run(Hook::ToolCall, Some(&input), cap) + .then(|(status, payload)| decode_tool_call(status, payload, &call)) + } + + fn skip(&mut self, value: T) -> Invocation { + Invocation { + result: Ok(value), + logs: std::mem::take(&mut self.carry_logs), + cpu: std::mem::take(&mut self.carry_cpu), + } + } + + fn run(&mut self, hook: Hook, input: Option<&[u8]>, cap: usize) -> Invocation<(u8, Vec)> { + let mut logs = std::mem::take(&mut self.carry_logs); + let mut cpu = std::mem::take(&mut self.carry_cpu); + if let Some(e) = &self.failed { + return Invocation { + result: Err(e.clone()), + logs, + cpu, + }; + } + let limits = &self.plugin.rt.limits; + let left = limits.reply_total_cpu.saturating_sub(self.used); + if left.is_zero() { + self.failed = Some(RunError::CpuLimit); + return Invocation { + result: Err(RunError::CpuLimit), + logs, + cpu, + }; + } + let _running = self.plugin.rt.ticker.enter(); + self.sb.arm(limits.reply_call_cpu.min(left)); + let result = self.sb.call(hook, input, cap); + let spent = self.sb.elapsed(); + self.used += spent; + cpu += spent; + logs.extend(self.sb.take_logs()); + if let Err(e) = &result + && !matches!(e, RunError::Threw { .. } | RunError::BadOutput(_)) + { + self.failed = Some(e.clone()); + } + Invocation { result, logs, cpu } + } +} + +impl Invocation { + fn then(self, f: impl FnOnce(T) -> Result) -> Invocation { + Invocation { + result: self.result.and_then(f), + logs: self.logs, + cpu: self.cpu, + } + } +} + +// ── 解码 ───────────────────────────────────────────────────────── + +fn decode_request(status: u8, payload: &[u8], view: &Value) -> Result { + match status { + sandbox::VALUE => { + let v: Value = serde_json::from_slice(payload).map_err(|e| { + RunError::BadOutput(format!( + "onRequest returned something that is not valid JSON: {e}" + )) + })?; + if !v.is_object() { + return Err(RunError::BadOutput( + "onRequest must return the request object or undefined".into(), + )); + } + Ok(if js_equal(&v, view) { + RequestOutcome::Unchanged + } else { + RequestOutcome::Changed(v) + }) + } + sandbox::UNCHANGED => Ok(RequestOutcome::Unchanged), + sandbox::REJECTED => Ok(RequestOutcome::Rejected( + String::from_utf8_lossy(payload).into_owned(), + )), + other => Err(decode_error(other, payload.to_vec(), "onRequest")), + } +} + +fn decode_tool_call( + status: u8, + payload: Vec, + call: &Value, +) -> Result { + match status { + sandbox::VALUE => { + let v: Value = serde_json::from_slice(&payload).map_err(|e| { + RunError::BadOutput(format!( + "onToolCall returned something that is not valid JSON: {e}" + )) + })?; + let Value::Array(calls) = v else { + return Err(RunError::BadOutput( + "onToolCall must return a tool call, an array of them, null or undefined" + .into(), + )); + }; + if calls.is_empty() { + return Ok(ToolCallOutcome::Drop); + } + let calls = calls + .into_iter() + .enumerate() + .map(|(i, c)| { + tool_call(c).map_err(|m| RunError::BadOutput(format!("tool call {i}: {m}"))) + }) + .collect::, _>>()?; + if calls.len() == 1 && js_equal(&calls[0], call) { + return Ok(ToolCallOutcome::Unchanged); + } + Ok(ToolCallOutcome::Replace(calls)) + } + sandbox::UNCHANGED => Ok(ToolCallOutcome::Unchanged), + sandbox::DROP => Ok(ToolCallOutcome::Drop), + other => Err(decode_error(other, payload, "onToolCall")), + } +} + +/// 一个替换用的工具调用:`{ id?, name, input }`,别的字段不收 +fn tool_call(v: Value) -> Result { + let Value::Object(mut m) = v else { + return Err("must be an object { id, name, input }".into()); + }; + for k in m.keys() { + if !matches!(k.as_str(), "id" | "name" | "input") { + return Err(format!( + "unknown field `{k}`; a tool call has id, name and input" + )); + } + } + match m.get("name") { + Some(Value::String(s)) if !s.trim().is_empty() => {} + _ => return Err("needs a non-empty string `name`".into()), + } + if !m.contains_key("input") { + return Err("needs an `input`".into()); + } + match m.get("id") { + None => {} + Some(Value::Null) => { + m.remove("id"); + } + Some(Value::String(s)) if !s.is_empty() => {} + Some(_) => return Err("`id` must be a non-empty string or left out".into()), + } + Ok(Value::Object(m)) +} + +fn decode_error(status: u8, payload: Vec, hook: &str) -> RunError { + match status { + sandbox::THREW => Described::parse(&payload).into_threw(), + sandbox::BAD => RunError::BadOutput(String::from_utf8_lossy(&payload).into_owned()), + sandbox::REJECTED => { + RunError::BadOutput(format!("{hook} cannot reject; only onRequest can")) + } + _ => RunError::BadOutput(format!("{hook} returned an unexpected result")), + } +} + +/// 两个 JSON 值在 JavaScript 看来是否相等。 +/// +/// 值进出一趟 JS 会变样:`1.0` 回来是 `1`,超过 2^53 的整数会丢精度。插件没碰的 +/// 部分不该因为这个被当成「改过」—— 数字按双精度浮点比。对象的键不分先后。 +/// 写回请求时判断某一项有没有被改,也该用这个比。 +pub fn js_equal(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) { + (Some(x), Some(y)) => x == y, + _ => x == y, + }, + (Value::Array(x), Value::Array(y)) => { + x.len() == y.len() && x.iter().zip(y).all(|(a, b)| js_equal(a, b)) + } + (Value::Object(x), Value::Object(y)) => { + x.len() == y.len() + && x.iter() + .all(|(k, v)| y.get(k).is_some_and(|w| js_equal(v, w))) + } + _ => a == b, + } +} + +// ── 杂项 ───────────────────────────────────────────────────────── + +fn to_json(v: &Value) -> Vec { + // serde_json::Value 的键都是字符串,序列化不会失败 + serde_json::to_vec(v).unwrap_or_else(|_| b"null".to_vec()) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// 编译或求值模块时抛出的错误:从栈里找出 `plugin.js:行:列` +fn syntax(d: Described) -> LoadError { + let (line, column) = d + .stack + .as_deref() + .and_then(location) + .or_else(|| location(&d.message)) + .map_or((None, None), |(l, c)| (Some(l), Some(c))); + LoadError::Syntax { + message: d.message, + line, + column, + } +} + +fn location(s: &str) -> Option<(u32, u32)> { + let mut rest = s; + while let Some(at) = rest.find("plugin.js:") { + rest = &rest[at + "plugin.js:".len()..]; + let line_end = rest + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(rest.len()); + let line = rest[..line_end].parse::().ok(); + let after = &rest[line_end..]; + if let (Some(line), Some(col)) = (line, after.strip_prefix(':')) { + let col_end = col.find(|c: char| !c.is_ascii_digit()).unwrap_or(col.len()); + if let Ok(column) = col[..col_end].parse::() { + return Some((line, column)); + } + } + } + None +} + +fn not_utf8(source: &[u8], valid_up_to: usize) -> LoadError { + let before = String::from_utf8_lossy(&source[..valid_up_to]); + let line = before.matches('\n').count() + 1; + let column = before.rsplit('\n').next().map_or(0, |l| l.chars().count()) + 1; + LoadError::Syntax { + message: "the plugin file is not valid UTF-8".into(), + line: u32::try_from(line).ok(), + column: u32::try_from(column).ok(), + } +} + +/// 建实例这一步就失败了:不是插件的错 +fn sandbox_failed(e: RunError) -> LoadError { + match e { + RunError::MemoryLimit => { + LoadError::Engine("the sandbox could not get its initial memory".into()) + } + other => LoadError::Engine(other.to_string()), + } +} + +/// 编译或模块顶层跑到一半撞了上限 +fn top_level_failed(e: RunError) -> LoadError { + let message = match e { + RunError::CpuLimit => "the plugin's top-level code ran past the CPU time limit".to_string(), + RunError::MemoryLimit => { + "the plugin's top-level code ran past the memory limit".to_string() + } + RunError::OutputLimit => "the plugin's top-level code wrote too many log lines".to_string(), + RunError::Threw { message, stack } => { + return syntax(Described { message, stack }); + } + RunError::BadOutput(m) => return LoadError::Engine(m), + RunError::Trap(m) => format!("the plugin's top-level code stopped the sandbox: {m}"), + }; + LoadError::Syntax { + message, + line: None, + column: None, + } +} diff --git a/crates/tw-plugin/src/manifest.rs b/crates/tw-plugin/src/manifest.rs new file mode 100644 index 0000000..73c812a --- /dev/null +++ b/crates/tw-plugin/src/manifest.rs @@ -0,0 +1,442 @@ +//! 插件清单:从沙箱里读出来,在这边按约定逐项核对。 +//! +//! 桥(`bridge.js`)把模块的导出整理成一份 JSON 交过来;这里**不信**它,所有 +//! 规则都在这边重新判一遍。错误消息给插件作者看,说清楚哪一项、为什么。 + +use std::collections::BTreeSet; + +use serde_json::{Map, Value}; + +use crate::{Hooks, LoadError, Manifest, Permission, ReplyMode, Scope, SettingKind, SettingSpec}; + +const MAX_NAME: usize = 64; +const MAX_DESCRIPTION: usize = 500; +const MAX_SETTINGS: usize = 20; +const MAX_SETTING_KEY: usize = 64; +const MAX_LABEL: usize = 100; +const MAX_STRING_DEFAULT: usize = 10_000; +const MAX_GLOBS: usize = 100; +const MAX_GLOB: usize = 200; + +/// 沙箱里的桥交回来的那份 +#[derive(serde::Deserialize)] +struct LoadInfo { + hooks: Map, + manifest_kind: String, + manifest: Value, + manifest_error: Option, + settings_order: Option>, + has_default: bool, +} + +pub(crate) fn parse(info: &[u8]) -> Result { + let info: LoadInfo = serde_json::from_slice(info).map_err(|e| { + LoadError::Engine(format!( + "the sandbox returned an unreadable module description: {e}" + )) + })?; + + let hook = |name: &str| -> Result { + match info.hooks.get(name).and_then(Value::as_str) { + Some("function") => Ok(true), + Some("missing") | None => Ok(false), + Some(other) => Err(err(format!( + "{name} is exported but is a {other}, not a function" + ))), + } + }; + let hooks = Hooks { + request: hook("onRequest")?, + reply_text: hook("onReplyText")?, + reply_text_end: hook("onReplyTextEnd")?, + tool_call: hook("onToolCall")?, + }; + + match info.manifest_kind.as_str() { + "object" => {} + "missing" if info.has_default => { + return Err(err( + "export the manifest and the hooks by name (`export const manifest = {…}`, \ + `export function onRequest(…)`), not as a default export", + )); + } + "missing" => { + return Err(err( + "the plugin does not export a manifest (`export const manifest = {…}`)", + )); + } + other => { + return Err(err(format!( + "the manifest must be an object, not {}", + article(other) + ))); + } + } + if let Some(e) = info.manifest_error { + return Err(err(format!("the manifest cannot be read as JSON: {e}"))); + } + let Value::Object(m) = info.manifest else { + return Err(err("the manifest must be an object")); + }; + + // api 先看:将来的 api 2 可能有这里不认识的字段,那时该说的是「版本不支持」 + let api = match m.get("api") { + None | Some(Value::Null) => return Err(err("the manifest needs `api: 1`")), + Some(Value::Number(n)) => match n.as_u64() { + Some(1) => 1, + Some(v) => { + return Err(LoadError::UnsupportedApi( + u32::try_from(v).unwrap_or(u32::MAX), + )); + } + None => return Err(err("`api` must be 1")), + }, + Some(_) => return Err(err("`api` must be the number 1")), + }; + + for key in m.keys() { + if !matches!( + key.as_str(), + "name" | "api" | "description" | "permissions" | "match" | "reply" | "settings" + ) { + return Err(err(format!("the manifest has an unknown field `{key}`"))); + } + } + + let name = match m.get("name") { + Some(Value::String(s)) => s.trim().to_string(), + Some(Value::Null) | None => return Err(err("the manifest needs a `name`")), + Some(_) => return Err(err("`name` must be a string")), + }; + let n = name.chars().count(); + if n == 0 { + return Err(err("`name` must not be empty")); + } + if n > MAX_NAME { + return Err(err(format!( + "`name` is {n} characters long; at most {MAX_NAME} are allowed" + ))); + } + if name.chars().any(char::is_control) { + return Err(err( + "`name` must not contain line breaks or other control characters", + )); + } + + let description = match m.get("description") { + None | Some(Value::Null) => None, + Some(Value::String(s)) => { + let n = s.chars().count(); + if n > MAX_DESCRIPTION { + return Err(err(format!( + "`description` is {n} characters long; at most {MAX_DESCRIPTION} are allowed" + ))); + } + Some(s.clone()) + } + Some(_) => return Err(err("`description` must be a string")), + }; + + let permissions = permissions(m.get("permissions"))?; + let scope = scope(m.get("match"))?; + let reply_mode = match m.get("reply") { + None | Some(Value::Null) => ReplyMode::Block, + Some(Value::String(s)) if s == "block" => ReplyMode::Block, + Some(Value::String(s)) if s == "stream" => ReplyMode::Stream, + Some(_) => return Err(err("`reply` must be \"block\" or \"stream\"")), + }; + let settings = settings(m.get("settings"), info.settings_order.as_deref())?; + + check_hooks(&hooks, &permissions, reply_mode)?; + + Ok(Manifest { + name, + api, + description, + permissions, + scope, + reply_mode, + settings, + hooks, + }) +} + +fn permissions(v: Option<&Value>) -> Result, LoadError> { + let list = match v { + Some(Value::Array(a)) => a, + None | Some(Value::Null) => return Err(err("the manifest needs `permissions`")), + Some(_) => return Err(err("`permissions` must be a list")), + }; + if list.is_empty() { + return Err(err("`permissions` must list at least one permission")); + } + let mut out = BTreeSet::new(); + for p in list { + let Some(s) = p.as_str() else { + return Err(err("every entry of `permissions` must be a string")); + }; + let Some(perm) = Permission::from_manifest(s) else { + return Err(err(format!( + "unknown permission \"{s}\"; the permissions are {}", + Permission::ALL + .iter() + .map(|p| format!("\"{}\"", p.manifest_name())) + .collect::>() + .join(", ") + ))); + }; + if !out.insert(perm) { + return Err(err(format!("permission \"{s}\" is listed twice"))); + } + } + Ok(out) +} + +fn scope(v: Option<&Value>) -> Result { + let m = match v { + None | Some(Value::Null) => return Ok(Scope::default()), + Some(Value::Object(m)) => m, + Some(_) => return Err(err("`match` must be an object")), + }; + for key in m.keys() { + if !matches!(key.as_str(), "clients" | "models" | "upstreams") { + return Err(err(format!( + "`match` has an unknown field `{key}`; it takes clients, models and upstreams" + ))); + } + } + let globs = |key: &str| -> Result, LoadError> { + let list = match m.get(key) { + None | Some(Value::Null) => return Ok(Vec::new()), + Some(Value::Array(a)) => a, + Some(_) => return Err(err(format!("`match.{key}` must be a list of strings"))), + }; + if list.len() > MAX_GLOBS { + return Err(err(format!( + "`match.{key}` has more than {MAX_GLOBS} entries" + ))); + } + let mut out = Vec::with_capacity(list.len()); + for g in list { + let Some(s) = g.as_str() else { + return Err(err(format!("`match.{key}` must be a list of strings"))); + }; + if s.trim().is_empty() { + return Err(err(format!("`match.{key}` has an empty entry"))); + } + if s.chars().count() > MAX_GLOB || s.chars().any(char::is_control) { + return Err(err(format!( + "`match.{key}` has an entry that is too long or not plain text" + ))); + } + out.push(s.to_string()); + } + Ok(out) + }; + Ok(Scope { + clients: globs("clients")?, + models: globs("models")?, + upstreams: globs("upstreams")?, + }) +} + +fn settings(v: Option<&Value>, order: Option<&[String]>) -> Result, LoadError> { + let m = match v { + None | Some(Value::Null) => return Ok(Vec::new()), + Some(Value::Object(m)) => m, + Some(_) => return Err(err("`settings` must be an object of setting definitions")), + }; + if m.len() > MAX_SETTINGS { + return Err(err(format!( + "`settings` has {} entries; at most {MAX_SETTINGS} are allowed", + m.len() + ))); + } + // 作者写的先后(JSON 对象在这边按键排序,先后只能从沙箱里带过来) + let mut keys: Vec<&String> = Vec::with_capacity(m.len()); + if let Some(order) = order { + for k in order { + if let Some((key, _)) = m.get_key_value(k) + && !keys.contains(&key) + { + keys.push(key); + } + } + } + for k in m.keys() { + if !keys.contains(&k) { + keys.push(k); + } + } + + let mut out = Vec::with_capacity(keys.len()); + for key in keys { + if !valid_key(key) { + return Err(err(format!( + "setting `{key}` has an invalid name: use letters, digits and _ (up to {MAX_SETTING_KEY}), not starting with a digit" + ))); + } + let Some(Value::Object(spec)) = m.get(key) else { + return Err(err(format!( + "setting `{key}` must be an object like {{ type: \"string\", label: \"…\" }}" + ))); + }; + for f in spec.keys() { + if !matches!(f.as_str(), "type" | "label" | "default") { + return Err(err(format!( + "setting `{key}` has an unknown field `{f}`; it takes type, label and default" + ))); + } + } + let kind = match spec.get("type").and_then(Value::as_str) { + Some("string") => SettingKind::String, + Some("number") => SettingKind::Number, + Some("boolean") => SettingKind::Boolean, + _ => { + return Err(err(format!( + "setting `{key}` needs a type: \"string\", \"number\" or \"boolean\"" + ))); + } + }; + let label = match spec.get("label") { + None | Some(Value::Null) => key.clone(), + Some(Value::String(s)) => { + let s = s.trim(); + if s.is_empty() { + key.clone() + } else if s.chars().count() > MAX_LABEL || s.chars().any(char::is_control) { + return Err(err(format!( + "the label of setting `{key}` must be plain text of at most {MAX_LABEL} characters" + ))); + } else { + s.to_string() + } + } + Some(_) => { + return Err(err(format!( + "the label of setting `{key}` must be a string" + ))); + } + }; + let default = match (kind, spec.get("default")) { + (SettingKind::String, None | Some(Value::Null)) => Value::String(String::new()), + (SettingKind::Number, None | Some(Value::Null)) => Value::from(0), + (SettingKind::Boolean, None | Some(Value::Null)) => Value::Bool(false), + (SettingKind::String, Some(Value::String(s))) => { + if s.chars().count() > MAX_STRING_DEFAULT { + return Err(err(format!("the default of setting `{key}` is too long"))); + } + Value::String(s.clone()) + } + (SettingKind::Number, Some(Value::Number(n))) => Value::Number(n.clone()), + (SettingKind::Boolean, Some(Value::Bool(b))) => Value::Bool(*b), + (kind, Some(_)) => { + return Err(err(format!( + "the default of setting `{key}` must be a {}", + kind.as_str() + ))); + } + }; + out.push(SettingSpec { + key: key.clone(), + kind, + label, + default, + }); + } + Ok(out) +} + +fn valid_key(k: &str) -> bool { + let mut chars = k.chars(); + let Some(first) = chars.next() else { + return false; + }; + k.len() <= MAX_SETTING_KEY + && (first.is_ascii_alphabetic() || first == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// 钩子和权限一一对应:导出了钩子就得申请它的权限,申请了权限就得有钩子用它 +fn check_hooks( + hooks: &Hooks, + perms: &BTreeSet, + mode: ReplyMode, +) -> Result<(), LoadError> { + let request_perms = [ + Permission::System, + Permission::Messages, + Permission::Tools, + Permission::Params, + ]; + if hooks.request { + if !request_perms.iter().any(|p| perms.contains(p)) { + return Err(err( + "onRequest is exported but the manifest requests none of \"system\", \"messages\", \"tools\", \"params\"", + )); + } + } else if let Some(p) = request_perms.iter().find(|p| perms.contains(p)) { + return Err(err(format!( + "permission \"{}\" is requested but onRequest is not exported", + p.manifest_name() + ))); + } + + match (hooks.reply_text, perms.contains(&Permission::ReplyText)) { + (true, false) => { + return Err(err( + "onReplyText is exported but the manifest does not request \"reply.text\"", + )); + } + (false, true) => { + return Err(err( + "permission \"reply.text\" is requested but onReplyText is not exported", + )); + } + _ => {} + } + if hooks.reply_text_end { + if !hooks.reply_text { + return Err(err("onReplyTextEnd is exported without onReplyText")); + } + if mode != ReplyMode::Stream { + return Err(err( + "onReplyTextEnd is only called in stream mode; set `reply: \"stream\"` in the manifest", + )); + } + } + + match (hooks.tool_call, perms.contains(&Permission::ReplyToolCalls)) { + (true, false) => { + return Err(err( + "onToolCall is exported but the manifest does not request \"reply.tool_calls\"", + )); + } + (false, true) => { + return Err(err( + "permission \"reply.tool_calls\" is requested but onToolCall is not exported", + )); + } + _ => {} + } + + if !hooks.request && !hooks.reply_text && !hooks.tool_call { + return Err(err( + "the plugin exports no hook; export at least one of onRequest, onReplyText, onToolCall", + )); + } + Ok(()) +} + +fn article(kind: &str) -> String { + match kind { + "null" => "null".into(), + "array" => "an array".into(), + "undefined" => "undefined".into(), + other => format!("a {other}"), + } +} + +fn err(msg: impl Into) -> LoadError { + LoadError::Manifest(msg.into()) +} diff --git a/crates/tw-plugin/src/sandbox.rs b/crates/tw-plugin/src/sandbox.rs new file mode 100644 index 0000000..8f5f112 --- /dev/null +++ b/crates/tw-plugin/src/sandbox.rs @@ -0,0 +1,532 @@ +//! 一个沙箱实例:一个 Store、一个 wasm 实例,加上它的 CPU、内存、日志三本账。 +//! +//! 和 guest(`guest/src/lib.rs`)之间的约定都在这里:导出的名字和签名、输入 +//! 怎么放进去(`tw_alloc` 一块、写进去、交出去)、输出怎么读(`tw_out_ptr` / +//! `tw_out_len`)、状态码。**guest 给的任何东西都按不可信处理**:指针和长度先 +//! 过边界检查,输出先比上限再拷贝。 + +use std::time::Duration; + +use wasmtime::{ + Caller, Instance, InstancePre, Linker, Memory, Module, ResourceLimiter, Store, Trap, TypedFunc, + UpdateDeadline, +}; + +use crate::{Limits, LogLevel, LogLine, RunError, cpu}; + +/// guest 和这边的约定版本:guest 导出一个带版本号的函数名,见它的 `tw_abi_1`。 +/// 改了导出的签名或含义,两边一起改名 +const ABI_EXPORT: &str = "tw_abi_1"; + +/// 沙箱模块唯一允许的导入(build.rs 编的时候查过一次,加载时再查一次) +pub(crate) const ALLOWED_IMPORTS: &[(&str, &str)] = + &[("tw", "log"), ("env", "__rquickjs_host_now_us")]; + +/// guest 函数表的上限(实际几百项) +const MAX_TABLE: usize = 4096; + +/// 桥给回来的状态码(输出的第一个字节) +pub(crate) const VALUE: u8 = b'0'; +pub(crate) const UNCHANGED: u8 = b'1'; +pub(crate) const REJECTED: u8 = b'2'; +pub(crate) const THREW: u8 = b'3'; +pub(crate) const BAD: u8 = b'4'; +pub(crate) const DROP: u8 = b'5'; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Hook { + Request = 0, + ReplyText = 1, + ReplyTextEnd = 2, + ToolCall = 3, +} + +/// 一个实例的宿主侧状态:资源账本 +pub(crate) struct HostState { + memory_cap: usize, + memory_denied: bool, + logs: Vec, + max_log_lines: usize, + max_log_line: usize, + log_overflow: bool, + /// 这一段执行的起点(线程 CPU 时间)和预算 + start: Duration, + budget: Duration, + cpu_hit: bool, +} + +impl ResourceLimiter for HostState { + fn memory_growing( + &mut self, + _current: usize, + desired: usize, + _maximum: Option, + ) -> wasmtime::Result { + if desired > self.memory_cap { + // 拒绝而不是陷阱:guest 的 malloc 拿到空指针,QuickJS 抛一个 + // out of memory。记下来,这次调用按超出内存上限算 + self.memory_denied = true; + return Ok(false); + } + Ok(true) + } + + fn table_growing( + &mut self, + _current: usize, + desired: usize, + _maximum: Option, + ) -> wasmtime::Result { + // guest 只有一张函数表(几百项),建实例之后从不扩它 + Ok(desired <= MAX_TABLE) + } + + fn instances(&self) -> usize { + 1 + } + + fn tables(&self) -> usize { + 1 + } + + fn memories(&self) -> usize { + 1 + } +} + +impl HostState { + fn exceeded(&self) -> bool { + cpu::now().saturating_sub(self.start) >= self.budget + } +} + +/// 把 guest 的两个导入接到宿主上。整个进程只建一次(`Runtime::new`) +pub(crate) fn linker(engine: &wasmtime::Engine) -> wasmtime::Result> { + let mut linker = Linker::new(engine); + linker.func_wrap( + "tw", + "log", + |mut caller: Caller<'_, HostState>, + level: u32, + ptr: u32, + len: u32| + -> wasmtime::Result<()> { log(&mut caller, level, ptr, len) }, + )?; + // `Date` 用的时钟:墙上时间,粗到毫秒(`Date` 本来就是毫秒),不给插件一个 + // 高精度计时器。rquickjs-sys 的垫片按微秒要 + linker.func_wrap("env", "__rquickjs_host_now_us", || -> f64 { + let ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + (ms as f64) * 1000.0 + })?; + Ok(linker) +} + +fn log(caller: &mut Caller<'_, HostState>, level: u32, ptr: u32, len: u32) -> wasmtime::Result<()> { + let max_line = caller.data().max_log_line; + let text = { + let Some(memory) = caller.get_export("memory").and_then(|e| e.into_memory()) else { + return Ok(()); + }; + let data = memory.data(&caller); + let start = ptr as usize; + // 多读几个字节,截断时才知道是不是正好断在一个字符中间 + let take = (len as usize).min(max_line.saturating_add(4)); + let bytes = start + .checked_add(take) + .and_then(|end| data.get(start..end)) + .unwrap_or(&[]); + truncate_line( + &String::from_utf8_lossy(bytes), + len as usize > take, + max_line, + ) + }; + let state = caller.data_mut(); + if state.logs.len() >= state.max_log_lines { + // 第 max+1 行:这次调用按超出输出上限算,立刻停下 + state.log_overflow = true; + return Err(wasmtime::Error::msg("the plugin wrote too many log lines")); + } + let level = match level { + 1 => LogLevel::Info, + 2 => LogLevel::Warn, + 3 => LogLevel::Error, + _ => LogLevel::Log, + }; + state.logs.push(LogLine { level, text }); + Ok(()) +} + +/// 一行日志不超过 `max` 字节:超了就在字符边界上截断,末尾标上省略号 +fn truncate_line(s: &str, longer: bool, max: usize) -> String { + if s.len() <= max && !longer { + return s.to_string(); + } + let mark = "…"; + let mut end = max.saturating_sub(mark.len()).min(s.len()); + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + let mut out = String::with_capacity(end + mark.len()); + out.push_str(&s[..end]); + out.push_str(mark); + out +} + +struct Exports { + alloc: TypedFunc, + out_ptr: TypedFunc<(), u32>, + out_len: TypedFunc<(), u32>, + compile: TypedFunc<(u32, u32), u32>, + load: TypedFunc<(u32, u32), u32>, + seed: TypedFunc<(u32, u32, u32, u32), u32>, + set_ctx: TypedFunc<(u32, u32), u32>, + call: TypedFunc<(u32, u32, u32), u32>, +} + +/// 一段 JS 抛出的错误,桥整理成的样子 +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Described { + pub message: String, + pub stack: Option, +} + +impl Described { + pub(crate) fn parse(bytes: &[u8]) -> Described { + #[derive(serde::Deserialize)] + struct D { + message: String, + stack: Option, + } + match serde_json::from_slice::(bytes) { + Ok(d) => Described { + message: d.message, + stack: d.stack, + }, + Err(_) => Described { + message: String::from_utf8_lossy(bytes).into_owned(), + stack: None, + }, + } + } + + pub(crate) fn into_threw(self) -> RunError { + RunError::Threw { + message: self.message, + stack: self.stack, + } + } +} + +pub(crate) struct Sandbox { + store: Store, + memory: Memory, + f: Exports, +} + +impl Sandbox { + /// 新建一个实例。CPU 预算从这里就开始算:实例化本身也是这次调用的开销 + pub(crate) fn new( + pre: &InstancePre, + limits: &Limits, + memory_cap: usize, + budget: Duration, + ) -> Result { + let start = cpu::now(); + let mut store = Store::new( + pre.module().engine(), + HostState { + memory_cap, + memory_denied: false, + logs: Vec::new(), + max_log_lines: limits.max_log_lines, + max_log_line: limits.max_log_line, + log_overflow: false, + start, + budget, + cpu_hit: false, + }, + ); + store.limiter(|s| s as &mut dyn ResourceLimiter); + store.epoch_deadline_callback(|mut ctx| { + let s = ctx.data_mut(); + if s.exceeded() { + s.cpu_hit = true; + Ok(UpdateDeadline::Interrupt) + } else { + Ok(UpdateDeadline::Continue(1)) + } + }); + store.set_epoch_deadline(1); + let instance = match pre.instantiate(&mut store) { + Ok(i) => i, + Err(e) => { + let s = store.data(); + if s.memory_denied { + return Err(RunError::MemoryLimit); + } + return Err(RunError::Trap(format!( + "the sandbox could not be created: {e}" + ))); + } + }; + let f = exports(&mut store, &instance)?; + let memory = instance + .get_memory(&mut store, "memory") + .ok_or_else(|| RunError::Trap("the sandbox has no memory".into()))?; + Ok(Sandbox { store, memory, f }) + } + + /// 下一段执行的 CPU 预算从现在算起 + pub(crate) fn arm(&mut self, budget: Duration) { + let s = self.store.data_mut(); + s.start = cpu::now(); + s.budget = budget; + s.cpu_hit = false; + self.store.set_epoch_deadline(1); + } + + /// 这一段执行(上次 `arm` 或建实例以来)用掉的 CPU 时间 + pub(crate) fn elapsed(&self) -> Duration { + cpu::now().saturating_sub(self.store.data().start) + } + + pub(crate) fn take_logs(&mut self) -> Vec { + std::mem::take(&mut self.store.data_mut().logs) + } + + /// 这次调用里内存上限有没有被碰到(有的话不管结果如何都算失败) + fn memory_denied(&self) -> bool { + self.store.data().memory_denied + } + + /// 一次失败的 wasm 调用是哪一种失败 + fn classify(&self, e: wasmtime::Error) -> RunError { + let s = self.store.data(); + if s.cpu_hit { + return RunError::CpuLimit; + } + if s.log_overflow { + return RunError::OutputLimit; + } + if s.memory_denied { + return RunError::MemoryLimit; + } + match e.downcast_ref::() { + Some(Trap::Interrupt) => RunError::CpuLimit, + Some(Trap::StackOverflow) => RunError::Trap("stack overflow".into()), + Some(Trap::UnreachableCodeReached) => { + RunError::Trap("the JavaScript engine aborted".into()) + } + Some(t) => RunError::Trap(t.to_string()), + None => RunError::Trap(e.to_string()), + } + } + + /// 把输入放进 guest 的内存。`nul` 时末尾补一个 0(`JS_Eval` 要) + fn put(&mut self, bytes: &[u8], nul: bool) -> Result { + let len = u32::try_from(bytes.len()).map_err(|_| RunError::MemoryLimit)?; + let ptr = self + .f + .alloc + .call(&mut self.store, len) + .map_err(|e| self.classify(e))?; + if ptr == 0 { + // guest 的 malloc 失败:内存到顶了 + return Err(RunError::MemoryLimit); + } + let at = ptr as usize; + self.memory + .write(&mut self.store, at, bytes) + .map_err(|e| RunError::Trap(e.to_string()))?; + if nul { + self.memory + .write(&mut self.store, at + bytes.len(), &[0]) + .map_err(|e| RunError::Trap(e.to_string()))?; + } + Ok(ptr) + } + + /// 读输出。超过 `cap` 字节就不拷,直接按超出输出上限算 + fn out(&mut self, cap: usize) -> Result, RunError> { + let ptr = self + .f + .out_ptr + .call(&mut self.store, ()) + .map_err(|e| self.classify(e))? as usize; + let len = self + .f + .out_len + .call(&mut self.store, ()) + .map_err(|e| self.classify(e))? as usize; + if len > cap { + return Err(RunError::OutputLimit); + } + let data = self.memory.data(&self.store); + ptr.checked_add(len) + .and_then(|end| data.get(ptr..end)) + .map(<[u8]>::to_vec) + .ok_or_else(|| { + RunError::Trap("the sandbox reported an output outside its memory".into()) + }) + } + + /// 编译插件源码。外层 `Err` 是沙箱失败(超时、内存……),内层 `Err` 是源码的错 + pub(crate) fn compile(&mut self, src: &[u8]) -> Result, Described>, RunError> { + let ptr = self.put(src, true)?; + let len = src.len() as u32; + let rc = self + .f + .compile + .call(&mut self.store, (ptr, len)) + .map_err(|e| self.classify(e))?; + if self.memory_denied() { + return Err(RunError::MemoryLimit); + } + // 字节码大约是源码的两倍;错误描述很小 + let out = self.out(src.len().saturating_mul(8).max(1 << 20))?; + Ok(if rc == 0 { + Ok(out) + } else { + Err(Described::parse(&out)) + }) + } + + /// 求值模块顶层。成功时给回桥的那份 JSON(钩子、清单) + pub(crate) fn load(&mut self, bytecode: &[u8]) -> Result, Described>, RunError> { + let ptr = self.put(bytecode, false)?; + let rc = self + .f + .load + .call(&mut self.store, (ptr, bytecode.len() as u32)) + .map_err(|e| self.classify(e))?; + if self.memory_denied() { + return Err(RunError::MemoryLimit); + } + let out = self.out(1 << 20)?; + Ok(if rc == 0 { + Ok(out) + } else { + Err(Described::parse(&out)) + }) + } + + /// 给 `Math.random` 换一个新种子(快照把原来的状态冻住了) + pub(crate) fn seed(&mut self) -> Result<(), RunError> { + let s: [u32; 4] = rand::random(); + let rc = self + .f + .seed + .call(&mut self.store, (s[0], s[1], s[2], s[3])) + .map_err(|e| self.classify(e))?; + if rc != 0 { + return Err(RunError::Trap( + "the sandbox could not seed Math.random".into(), + )); + } + Ok(()) + } + + pub(crate) fn set_ctx(&mut self, json: &[u8]) -> Result<(), RunError> { + let ptr = self.put(json, false)?; + let rc = self + .f + .set_ctx + .call(&mut self.store, (ptr, json.len() as u32)) + .map_err(|e| self.classify(e))?; + if self.memory_denied() { + return Err(RunError::MemoryLimit); + } + if rc != 0 { + let out = self.out(1 << 20)?; + return Err(Described::parse(&out).into_threw()); + } + Ok(()) + } + + /// 调一次钩子。返回状态码和内容;内容超过 `cap` 字节按超出输出上限算 + pub(crate) fn call( + &mut self, + hook: Hook, + input: Option<&[u8]>, + cap: usize, + ) -> Result<(u8, Vec), RunError> { + let (ptr, len) = match input { + Some(bytes) => (self.put(bytes, false)?, bytes.len() as u32), + None => (0, 0), + }; + self.f + .call + .call(&mut self.store, (hook as u32, ptr, len)) + .map_err(|e| self.classify(e))?; + if self.memory_denied() { + return Err(RunError::MemoryLimit); + } + // 第一个字节是状态码 + let mut out = self.out(cap.saturating_add(1))?; + if out.is_empty() { + return Err(RunError::BadOutput("the sandbox returned nothing".into())); + } + let status = out.remove(0); + Ok((status, out)) + } +} + +fn exports(store: &mut Store, i: &Instance) -> Result { + fn get( + store: &mut Store, + i: &Instance, + name: &str, + ) -> Result, RunError> { + i.get_typed_func::(&mut *store, name) + .map_err(|e| RunError::Trap(format!("the sandbox lacks {name}: {e}"))) + } + Ok(Exports { + alloc: get(store, i, "tw_alloc")?, + out_ptr: get(store, i, "tw_out_ptr")?, + out_len: get(store, i, "tw_out_len")?, + compile: get(store, i, "tw_compile")?, + load: get(store, i, "tw_load")?, + seed: get(store, i, "tw_seed")?, + set_ctx: get(store, i, "tw_set_ctx")?, + call: get(store, i, "tw_call")?, + }) +} + +/// 核对预编译好的沙箱:导入表只有允许的那几个,该有的导出都在。不实例化 —— +/// 实例要预留一大段地址空间,启动时不该为这个检查去要 +pub(crate) fn check_module(module: &Module) -> Result<(), String> { + for import in module.imports() { + let ok = matches!(import.ty(), wasmtime::ExternType::Func(_)) + && ALLOWED_IMPORTS + .iter() + .any(|(m, n)| *m == import.module() && *n == import.name()); + if !ok { + return Err(format!( + "the sandbox module imports {}.{}, which is not allowed", + import.module(), + import.name() + )); + } + } + for name in [ + "memory", + "tw_alloc", + "tw_out_ptr", + "tw_out_len", + "tw_compile", + "tw_load", + "tw_seed", + "tw_set_ctx", + "tw_call", + ABI_EXPORT, + ] { + if module.get_export(name).is_none() { + return Err(format!("the sandbox module does not export {name}")); + } + } + Ok(()) +} diff --git a/crates/tw-plugin/src/ticker.rs b/crates/tw-plugin/src/ticker.rs new file mode 100644 index 0000000..1235cd3 --- /dev/null +++ b/crates/tw-plugin/src/ticker.rs @@ -0,0 +1,82 @@ +//! 推进纪元的后台线程。 +//! +//! 每个沙箱的截止纪元都设成「当前 + 1」:线程每推一格,正在跑的沙箱就进一次 +//! 回调,回调量这个线程真用了多少 CPU,超了才打断(见 `sandbox.rs`)。所以这里 +//! 的节拍只决定**多久查一次**,不决定预算本身:macOS 上 `sleep(1ms)` 常睡到 +//! 1.5 ms,结果只是超出预算后最多再多跑一格,预算照样按实际的 CPU 时间算。 +//! +//! 没有沙箱在跑的时候它停着(park),不在桌面上每秒白醒一千次。 + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::thread::{self, Thread}; +use std::time::Duration; + +use wasmtime::Engine; + +const TICK: Duration = Duration::from_millis(1); + +pub(crate) struct Ticker { + shared: Arc, + thread: Thread, +} + +struct Shared { + active: AtomicUsize, + stop: AtomicBool, +} + +/// 只要有一个守卫活着,线程就在走 +pub(crate) struct Running<'a>(&'a Ticker); + +impl Ticker { + pub(crate) fn start(engine: Engine) -> std::io::Result { + let shared = Arc::new(Shared { + active: AtomicUsize::new(0), + stop: AtomicBool::new(false), + }); + let s = Arc::clone(&shared); + let handle = thread::Builder::new() + .name("tw-plugin-epoch".into()) + .spawn(move || run(engine, s))?; + Ok(Ticker { + shared, + thread: handle.thread().clone(), + }) + } + + pub(crate) fn enter(&self) -> Running<'_> { + if self.shared.active.fetch_add(1, Ordering::SeqCst) == 0 { + self.thread.unpark(); + } + Running(self) + } +} + +impl Drop for Running<'_> { + fn drop(&mut self) { + self.0.shared.active.fetch_sub(1, Ordering::SeqCst); + } +} + +impl Drop for Ticker { + fn drop(&mut self) { + self.shared.stop.store(true, Ordering::SeqCst); + self.thread.unpark(); + } +} + +fn run(engine: Engine, s: Arc) { + loop { + if s.stop.load(Ordering::SeqCst) { + return; + } + if s.active.load(Ordering::SeqCst) == 0 { + // unpark 先到也没关系:park 会立刻返回,下一圈重新看 + thread::park(); + continue; + } + thread::sleep(TICK); + engine.increment_epoch(); + } +} diff --git a/crates/tw-plugin/tests/address_space.rs b/crates/tw-plugin/tests/address_space.rs new file mode 100644 index 0000000..b9d1622 --- /dev/null +++ b/crates/tw-plugin/tests/address_space.rs @@ -0,0 +1,67 @@ +//! 地址空间受限(`ulimit -v`)的服务器上,起 core 不能因为插件沙箱失败。 +//! +//! 每个沙箱实例要预留 4 GiB 出头的地址空间(换来不做边界检查的内存访问)。 +//! `Runtime::new` 不建实例,所以它在这种机器上照样成功;真要跑插件时预留不到, +//! 是那一次调用的错误,不是崩溃。 +//! +//! 只在 Linux 上有意义:macOS 不执行 RLIMIT_AS。限额要设在一个子进程里, +//! 不能设在跑其他测试的这个进程上。 +#![cfg(target_os = "linux")] + +use std::process::Command; + +const CHILD: &str = "TW_PLUGIN_ADDRESS_SPACE_CHILD"; + +#[test] +fn startup_survives_a_small_address_space() { + let out = Command::new(std::env::current_exe().expect("test binary")) + .args([ + "--exact", + "child_with_a_small_address_space", + "--include-ignored", + "--nocapture", + ]) + .env(CHILD, "1") + .output() + .expect("run the child"); + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + assert!(out.status.success(), "{text}"); + assert!(text.contains("1 passed"), "{text}"); +} + +#[test] +#[ignore = "only runs as the child of startup_survives_a_small_address_space"] +fn child_with_a_small_address_space() { + if std::env::var_os(CHILD).is_none() { + return; + } + // 现在已经用了多少,再加 1 GiB:远不够一个沙箱实例要的 4 GiB + let status = std::fs::read_to_string("/proc/self/status").expect("/proc/self/status"); + let vm_kib: u64 = status + .lines() + .find_map(|l| l.strip_prefix("VmSize:")) + .and_then(|v| v.trim().trim_end_matches("kB").trim().parse().ok()) + .expect("VmSize"); + let limit = (vm_kib << 10) + (1 << 30); + let rl = libc::rlimit { + rlim_cur: limit, + rlim_max: limit, + }; + // SAFETY: 只设这个进程自己的限额 + assert_eq!(unsafe { libc::setrlimit(libc::RLIMIT_AS, &rl) }, 0); + + let rt = + tw_plugin::Runtime::new(tw_plugin::Limits::default()).expect("startup under RLIMIT_AS"); + let src = r#"export const manifest = { name: "t", api: 1, permissions: ["system"] }; + export function onRequest() {}"#; + match rt.load(src.as_bytes()) { + // 预留不到:一个干净的错误 + Err(tw_plugin::LoadError::Engine(m)) => println!("load failed cleanly: {m}"), + Ok(_) => println!("load succeeded"), + Err(other) => panic!("unexpected error: {other:?}"), + } +} diff --git a/crates/tw-plugin/tests/boundary.rs b/crates/tw-plugin/tests/boundary.rs new file mode 100644 index 0000000..58a30a7 --- /dev/null +++ b/crates/tw-plugin/tests/boundary.rs @@ -0,0 +1,133 @@ +//! 谁能依赖 tw-plugin。 +//! +//! 编 tw-plugin 要一个能出 wasm 的 clang(见 build.rs)。桌面端从 git 编 core 的 +//! 几个 crate(tw-api、tw-types、tw-yaml、tw-guard、tw-watch、tw-link),企业版编 +//! 第一层(tw-dialect、tw-guard、tw-breaker、tw-bedrock)—— 它们哪个沾上 tw-plugin, +//! 不管直接还是间接,桌面端和企业版的构建就突然都要装 clang 了。 +//! +//! 所以:直接依赖它的只能是 tw-gateway 和 twcore;上面那些 crate 顺着依赖 +//! 往下走也碰不到它。 + +use std::collections::{BTreeMap, BTreeSet}; +use std::process::Command; + +use serde_json::Value; + +const PLUGIN: &str = "tw-plugin"; +const MAY_DEPEND: &[&str] = &["tw-gateway", "twcore"]; +/// 桌面端(Lite)从 git 编的 +const LITE: &[&str] = &[ + "tw-api", "tw-types", "tw-yaml", "tw-guard", "tw-watch", "tw-link", +]; +/// 企业版依赖的第一层 +const ENTERPRISE: &[&str] = &["tw-dialect", "tw-guard", "tw-breaker", "tw-bedrock"]; + +/// 工作区里每个 crate 依赖的工作区 crate(各种依赖都算:构建依赖、开发依赖也会 +/// 让 `cargo test -p` 那个 crate 时要 clang) +fn workspace_graph() -> BTreeMap> { + let out = Command::new(env!("CARGO")) + .args([ + "metadata", + "--format-version", + "1", + "--no-deps", + "--offline", + ]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .expect("run cargo metadata"); + assert!( + out.status.success(), + "cargo metadata failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let meta: Value = serde_json::from_slice(&out.stdout).expect("cargo metadata is JSON"); + let packages = meta["packages"].as_array().expect("packages"); + let ours: BTreeSet = packages + .iter() + .filter_map(|p| p["name"].as_str().map(str::to_string)) + .collect(); + packages + .iter() + .map(|p| { + let name = p["name"].as_str().unwrap_or_default().to_string(); + let deps = p["dependencies"] + .as_array() + .into_iter() + .flatten() + .filter_map(|d| d["name"].as_str()) + .filter(|d| ours.contains(*d)) + .map(str::to_string) + .collect(); + (name, deps) + }) + .collect() +} + +fn reaches( + graph: &BTreeMap>, + from: &str, + to: &str, +) -> Option> { + // 深度优先,带上路径,报错时说清楚是哪条路 + fn walk( + graph: &BTreeMap>, + at: &str, + to: &str, + path: &mut Vec, + seen: &mut BTreeSet, + ) -> bool { + if !seen.insert(at.to_string()) { + return false; + } + path.push(at.to_string()); + if at == to { + return true; + } + for next in graph.get(at).into_iter().flatten() { + if walk(graph, next, to, path, seen) { + return true; + } + } + path.pop(); + false + } + let mut path = Vec::new(); + walk(graph, from, to, &mut path, &mut BTreeSet::new()).then_some(path) +} + +#[test] +fn only_the_gateway_and_the_binary_depend_on_tw_plugin() { + let graph = workspace_graph(); + assert!( + graph.contains_key(PLUGIN), + "{PLUGIN} is no longer a workspace member" + ); + let direct: Vec<&String> = graph + .iter() + .filter(|(_, deps)| deps.contains(PLUGIN)) + .map(|(name, _)| name) + .filter(|name| !MAY_DEPEND.contains(&name.as_str())) + .collect(); + assert!( + direct.is_empty(), + "only {MAY_DEPEND:?} may depend on {PLUGIN}, but these do: {direct:?}" + ); +} + +#[test] +fn what_lite_and_enterprise_build_never_reaches_tw_plugin() { + let graph = workspace_graph(); + for name in LITE.iter().chain(ENTERPRISE) { + assert!( + graph.contains_key(*name), + "{name} is no longer a workspace member" + ); + if let Some(path) = reaches(&graph, name, PLUGIN) { + panic!( + "{name} reaches {PLUGIN} ({}), so building it would need a wasm clang", + path.join(" → ") + ); + } + } +} diff --git a/crates/tw-plugin/tests/runtime.rs b/crates/tw-plugin/tests/runtime.rs new file mode 100644 index 0000000..62cd32c --- /dev/null +++ b/crates/tw-plugin/tests/runtime.rs @@ -0,0 +1,953 @@ +//! 沙箱的行为:钩子的输入输出、四个上限、状态隔离、清单核对。 +//! +//! 插件源码都写在测试里,一眼能看出每条测的是什么。 + +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use tw_plugin::*; + +fn rt() -> &'static Runtime { + static RT: OnceLock = OnceLock::new(); + RT.get_or_init(|| Runtime::new(Limits::default()).expect("runtime")) +} + +/// 测别的上限(内存、输出、栈)时用:CPU 时间放得很宽。CI 的机器比开发机慢好几倍, +/// 测试又是 debug 构建,默认的 200 ms 可能先到,测到的就成了 CPU 上限 +/// 超出预算之后最多还能跑多久才停下。unix 上量的是线程的 CPU 时间,只差一格 +/// 节拍;Windows 上量的是墙上时间,并发跑测试时线程会被抢占,留宽一些 +fn slack() -> Duration { + if cfg!(windows) { + Duration::from_secs(1) + } else { + Duration::from_millis(100) + } +} + +fn roomy() -> &'static Runtime { + static RT: OnceLock = OnceLock::new(); + RT.get_or_init(|| { + Runtime::new(Limits { + request_cpu: Duration::from_secs(30), + reply_call_cpu: Duration::from_secs(30), + reply_total_cpu: Duration::from_secs(60), + ..Limits::default() + }) + .expect("runtime") + }) +} + +fn load(src: &str) -> Plugin { + load_in(rt(), src) +} + +fn load_in(rt: &Runtime, src: &str) -> Plugin { + match rt.load(src.as_bytes()) { + Ok(p) => p, + Err(e) => panic!("load failed: {e:?}\n{src}"), + } +} + +fn load_err(src: &str) -> LoadError { + match rt().load(src.as_bytes()) { + Ok(p) => panic!("expected a load error, got {:?}", p.manifest()), + Err(e) => e, + } +} + +fn manifest_err(src: &str) -> String { + match load_err(src) { + LoadError::Manifest(m) => m, + other => panic!("expected a manifest error, got {other:?}"), + } +} + +fn ctx() -> Value { + json!({ + "client": "claude-code", + "model": "claude-x", + "format": "anthropic", + "upstream": null, + "settings": { "note": "hello" } + }) +} + +fn view() -> Value { + json!({ + "format": "anthropic", + "model": "claude-x", + "system": "be brief", + "messages": [ + { "key": "m0", "role": "user", "parts": [ { "key": "p0", "type": "text", "text": "hi" } ] } + ], + "params": { "model": "claude-x", "max_tokens": 1024, "temperature": 1.0 } + }) +} + +const SYSTEM: &str = r#"export const manifest = { name: "t", api: 1, permissions: ["system"] }; +"#; + +fn request(body: &str) -> Plugin { + load(&format!( + "{SYSTEM}export function onRequest(req, ctx) {{ {body} }}" + )) +} + +fn run(body: &str) -> Invocation { + request(body).on_request(view(), ctx()) +} + +fn run_roomy(body: &str) -> Invocation { + load_in( + roomy(), + &format!("{SYSTEM}export function onRequest(req, ctx) {{ {body} }}"), + ) + .on_request(view(), ctx()) +} + +fn changed(inv: &Invocation) -> &Value { + match &inv.result { + Ok(RequestOutcome::Changed(v)) => v, + other => panic!("expected Changed, got {other:?} (logs {:?})", inv.logs), + } +} + +fn threw(inv: &Invocation) -> String { + match &inv.result { + Err(RunError::Threw { message, .. }) => message.clone(), + other => panic!("expected Threw, got {other:?}"), + } +} + +// ── 基本 ───────────────────────────────────────────────────────── + +#[test] +fn a_request_hook_edits_the_view() { + let inv = run(r#"req.system = req.system + " / " + ctx.settings.note; return req;"#); + assert_eq!(changed(&inv)["system"], "be brief / hello"); + assert!(inv.cpu > Duration::ZERO); +} + +#[test] +fn undefined_and_an_identical_view_are_unchanged() { + assert_eq!(run("").result, Ok(RequestOutcome::Unchanged)); + assert_eq!( + run("return undefined;").result, + Ok(RequestOutcome::Unchanged) + ); + // 原样返回:1.0 进出 JS 变成 1,也不算改过 + assert_eq!(run("return req;").result, Ok(RequestOutcome::Unchanged)); +} + +#[test] +fn big_integers_that_lose_precision_in_js_do_not_count_as_changes() { + let p = request("return req;"); + let mut v = view(); + v["params"]["seed"] = json!(12345678901234567891u64); + assert_eq!(p.on_request(v, ctx()).result, Ok(RequestOutcome::Unchanged)); +} + +#[test] +fn reject_refuses_the_request_even_when_caught() { + assert_eq!( + run(r#"reject("no secrets here");"#).result, + Ok(RequestOutcome::Rejected("no secrets here".into())) + ); + assert_eq!( + run(r#"try { reject("caught"); } catch (e) {} return req;"#).result, + Ok(RequestOutcome::Rejected("caught".into())) + ); + // 第一次给的理由算数 + assert_eq!( + run(r#"try { reject("first"); } catch (e) {} reject("second");"#).result, + Ok(RequestOutcome::Rejected("first".into())) + ); +} + +#[test] +fn async_hooks_work_and_can_reject_after_await() { + let p = load(&format!( + "{SYSTEM}export async function onRequest(req) {{ await null; req.system = 'async'; return req; }}" + )); + assert_eq!(changed(&p.on_request(view(), ctx()))["system"], "async"); + let p = load(&format!( + "{SYSTEM}export async function onRequest(req) {{ await Promise.resolve(); reject('later'); }}" + )); + assert_eq!( + p.on_request(view(), ctx()).result, + Ok(RequestOutcome::Rejected("later".into())) + ); + let p = load(&format!( + "{SYSTEM}export function onRequest(req) {{ return new Promise(() => {{}}); }}" + )); + assert!( + matches!(p.on_request(view(), ctx()).result, Err(RunError::BadOutput(m)) if m.contains("never settled")) + ); +} + +#[test] +fn return_types_are_checked() { + for (body, want) in [ + ("return 42;", "not a number"), + ("return 'text';", "not a string"), + ("return null;", "not null"), + ("return [];", "not an array"), + ( + "const o = {}; o.self = o; return o;", + "cannot be turned into JSON", + ), + ("return { n: 1n };", "cannot be turned into JSON"), + ] { + match run(body).result { + Err(RunError::BadOutput(m)) => assert!(m.contains(want), "{body}: {m}"), + other => panic!("{body}: expected BadOutput, got {other:?}"), + } + } +} + +#[test] +fn thrown_errors_and_non_errors_are_reported() { + let inv = run("throw new TypeError('bad thing');"); + match &inv.result { + Err(RunError::Threw { message, stack }) => { + assert_eq!(message, "TypeError: bad thing"); + let stack = stack.as_deref().unwrap_or_default(); + assert!(stack.contains("onRequest (plugin.js:2:"), "{stack}"); + assert!(!stack.contains("bridge.js"), "{stack}"); + } + other => panic!("{other:?}"), + } + assert_eq!( + threw(&run("throw 'just a string';")), + "Uncaught just a string" + ); + assert_eq!(threw(&run("throw { code: 7 };")), r#"Uncaught {"code":7}"#); + // 一个会抛的 getter 也不让描述错误的那段代码跟着出事 + assert!(threw(&run( + "throw new Proxy({}, { get() { throw new Error('trap'); }, getPrototypeOf() { throw new Error('trap'); } });" + )) + .starts_with("Uncaught")); +} + +#[test] +fn ctx_is_frozen_and_read_only() { + let m = threw(&run("ctx.model = 'other';")); + assert!(m.starts_with("TypeError"), "{m}"); + let m = threw(&run("ctx.settings.note = 'x';")); + assert!(m.starts_with("TypeError"), "{m}"); + assert_eq!( + run("if (!Object.isFrozen(ctx.settings)) throw 1;").result, + Ok(RequestOutcome::Unchanged) + ); +} + +#[test] +fn console_lines_are_captured_with_levels() { + let inv = run( + r#"console.log("a", 1, {b: 2}, [3]); console.info("i"); console.warn("w"); console.error(new Error("e")); console.debug("d");"#, + ); + let lines: Vec<(LogLevel, &str)> = inv + .logs + .iter() + .map(|l| (l.level, l.text.as_str())) + .collect(); + assert_eq!( + lines, + vec![ + (LogLevel::Log, r#"a 1 {"b":2} [3]"#), + (LogLevel::Info, "i"), + (LogLevel::Warn, "w"), + (LogLevel::Error, "Error: e"), + (LogLevel::Log, "d"), + ] + ); +} + +// ── 没有环境里的 I/O ─────────────────────────────────────────── + +#[test] +fn the_sandbox_imports_only_the_log_and_the_clock() { + let mut imports = rt().sandbox_imports(); + imports.sort(); + assert_eq!(imports, vec!["env.__rquickjs_host_now_us", "tw.log"]); +} + +#[test] +fn only_standard_globals_exist() { + let inv = run( + r#"for (const n of ["fetch", "require", "std", "os", "process", "queueMicrotask", "setTimeout", + "setInterval", "performance", "navigator", "XMLHttpRequest", "WebAssembly", "Deno", "Bun", + "print", "gc", "scriptArgs", "atob", "btoa", "__tw_log", "module", "exports"]) { + if (typeof globalThis[n] !== "undefined") throw new Error(n + " is defined"); + } + if (typeof console.log !== "function" || typeof reject !== "function") throw new Error("missing"); + return undefined;"#, + ); + assert_eq!( + inv.result, + Ok(RequestOutcome::Unchanged), + "{:?}", + inv.result + ); +} + +#[test] +fn static_and_dynamic_imports_do_not_load_anything() { + let e = load_err(&format!( + "import fs from 'fs';\n{SYSTEM}export function onRequest() {{}}" + )); + assert!( + matches!(&e, LoadError::Syntax { message, .. } if message.contains("fs")), + "{e:?}" + ); + let inv = run("return import('os').then(() => ({ system: 'loaded' }));"); + assert!( + matches!(inv.result, Err(RunError::Threw { .. })), + "{:?}", + inv.result + ); +} + +#[test] +fn date_is_the_real_time_and_random_is_reseeded() { + let p = request("return { ...req, system: String(Date.now()) + ' ' + Math.random() };"); + let a = changed(&p.on_request(view(), ctx()))["system"] + .as_str() + .unwrap() + .to_string(); + let b = changed(&p.on_request(view(), ctx()))["system"] + .as_str() + .unwrap() + .to_string(); + let (ms, ra) = a.split_once(' ').unwrap(); + let (_, rb) = b.split_once(' ').unwrap(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as f64; + let ms: f64 = ms.parse().unwrap(); + assert!( + (now - ms).abs() < 60_000.0, + "Date.now() = {ms}, now = {now}" + ); + assert_ne!(ra, rb, "Math.random repeats between instances"); +} + +// ── 状态 ───────────────────────────────────────────────────────── + +#[test] +fn state_does_not_survive_between_requests() { + let p = load(&format!( + "{SYSTEM}let n = 0; globalThis.g = (globalThis.g || 0); + export function onRequest(req) {{ n++; globalThis.g++; Object.prototype.polluted = true; req.system = n + ',' + globalThis.g; return req; }}" + )); + for _ in 0..3 { + assert_eq!(changed(&p.on_request(view(), ctx()))["system"], "1,1"); + } + // 另一个插件看不到上一个留下的原型污染 + assert_eq!( + run("if (({}).polluted !== undefined) throw new Error('leak');").result, + Ok(RequestOutcome::Unchanged) + ); +} + +#[test] +fn state_persists_within_one_reply() { + let p = load( + r##"export const manifest = { name: "count", api: 1, permissions: ["reply.text"] }; + let n = 0; + export function onReplyText(text) { n++; return text + "#" + n; }"##, + ); + let mut r = p.reply(ctx()).unwrap(); + assert_eq!(r.on_text("a").result, Ok(Some("a#1".into()))); + assert_eq!(r.on_text("b").result, Ok(Some("b#2".into()))); + let mut r2 = p.reply(ctx()).unwrap(); + assert_eq!(r2.on_text("c").result, Ok(Some("c#1".into()))); + assert_eq!(r.on_text("d").result, Ok(Some("d#3".into()))); +} + +// ── 回答钩子 ───────────────────────────────────────────────────── + +#[test] +fn stream_mode_holds_text_and_flushes_it_at_the_end() { + let p = load( + r#"export const manifest = { name: "hold", api: 1, permissions: ["reply.text"], reply: "stream" }; + let held = ""; + export function onReplyText(t) { held += t; if (held.length < 6) return ""; const out = held; held = ""; return out.toUpperCase(); } + export function onReplyTextEnd() { const out = held; held = ""; return out; }"#, + ); + assert_eq!(p.manifest().reply_mode, ReplyMode::Stream); + assert!(p.manifest().hooks.reply_text_end); + let mut r = p.reply(ctx()).unwrap(); + assert_eq!(r.on_text("abc").result, Ok(Some(String::new()))); + assert_eq!(r.on_text("def").result, Ok(Some("ABCDEF".into()))); + assert_eq!(r.on_text("gh").result, Ok(Some(String::new()))); + assert_eq!(r.on_text_end().result, Ok(Some("gh".into()))); +} + +#[test] +fn reply_text_output_is_validated_and_made_well_formed() { + let p = load( + r#"export const manifest = { name: "x", api: 1, permissions: ["reply.text"] }; + export function onReplyText(t) { + if (t === "num") return 5; + if (t === "split") return "a😀".slice(0, 2); + if (t === "same") return t; + if (t === "reject") reject("no"); + return undefined; + }"#, + ); + let mut r = p.reply(ctx()).unwrap(); + assert!( + matches!(r.on_text("num").result, Err(RunError::BadOutput(m)) if m.contains("must return a string")) + ); + // 切半的代理对换成 U+FFFD,不让整段回答出错 + assert_eq!(r.on_text("split").result, Ok(Some("a\u{fffd}".into()))); + assert_eq!(r.on_text("same").result, Ok(None)); + assert_eq!(r.on_text("other").result, Ok(None)); + let m = match r.on_text("reject").result { + Err(RunError::Threw { message, .. }) => message, + other => panic!("{other:?}"), + }; + assert!(m.contains("only be called inside onRequest"), "{m}"); +} + +#[test] +fn tool_call_hooks_replace_drop_and_keep_calls() { + let p = load( + r#"export const manifest = { name: "tools", api: 1, permissions: ["reply.tool_calls"] }; + export function onToolCall(call) { + switch (call.name) { + case "keep": return undefined; + case "same": return call; + case "drop": return null; + case "none": return []; + case "path": call.input.path = call.input.path.replace("/mnt/c/", "C:\\"); return call; + case "split": return [{ name: "a", input: {} }, { id: null, name: "b", input: { x: 1 } }]; + case "noname": return { input: {} }; + case "extra": return { name: "x", input: {}, cmd: "rm -rf /" }; + case "scalar": return 1; + } + }"#, + ); + let mut r = p.reply(ctx()).unwrap(); + let call = + |name: &str| json!({ "id": "call_1", "name": name, "input": { "path": "/mnt/c/x" } }); + assert_eq!( + r.on_tool_call(call("keep")).result, + Ok(ToolCallOutcome::Unchanged) + ); + assert_eq!( + r.on_tool_call(call("same")).result, + Ok(ToolCallOutcome::Unchanged) + ); + assert_eq!( + r.on_tool_call(call("drop")).result, + Ok(ToolCallOutcome::Drop) + ); + assert_eq!( + r.on_tool_call(call("none")).result, + Ok(ToolCallOutcome::Drop) + ); + assert_eq!( + r.on_tool_call(call("path")).result, + Ok(ToolCallOutcome::Replace(vec![ + json!({ "id": "call_1", "name": "path", "input": { "path": "C:\\x" } }) + ])) + ); + assert_eq!( + r.on_tool_call(call("split")).result, + Ok(ToolCallOutcome::Replace(vec![ + json!({ "name": "a", "input": {} }), + json!({ "name": "b", "input": { "x": 1 } }), + ])) + ); + for bad in ["noname", "extra", "scalar"] { + assert!( + matches!( + r.on_tool_call(call(bad)).result, + Err(RunError::BadOutput(_)) + ), + "{bad}" + ); + } +} + +#[test] +fn hooks_a_plugin_does_not_export_are_no_ops() { + let p = request("return req;"); + let mut r = p.reply(ctx()).unwrap(); + assert_eq!(r.on_text("x").result, Ok(None)); + assert_eq!(r.on_text_end().result, Ok(None)); + assert_eq!( + r.on_tool_call(json!({"name": "x", "input": {}})).result, + Ok(ToolCallOutcome::Unchanged) + ); +} + +// ── 上限 ───────────────────────────────────────────────────────── + +#[test] +fn an_infinite_loop_is_stopped_by_the_cpu_limit() { + let t = Instant::now(); + let inv = run("for (;;) {}"); + let wall = t.elapsed(); + assert_eq!(inv.result, Err(RunError::CpuLimit)); + let limit = rt().limits().request_cpu; + assert!(inv.cpu >= limit, "stopped after {:?}", inv.cpu); + assert!(inv.cpu < limit + slack(), "overran: {:?}", inv.cpu); + assert!(wall < Duration::from_secs(5), "wall {wall:?}"); +} + +#[test] +fn loop_free_cpu_burners_are_stopped_too() { + // 不经过 JS 循环的:指数级递归、正则灾难回溯、对大字符串反复调内建函数 + for body in [ + "function f(n) { return n < 2 ? n : f(n - 1) + f(n - 2); } f(60);", + "/(a+)+$/.test('a'.repeat(40) + 'b');", + "const s = 'x'.repeat(1 << 20); for (let i = 0; i < 1e9; i++) s.toUpperCase();", + ] { + assert_eq!(run(body).result, Err(RunError::CpuLimit), "{body}"); + } +} + +#[test] +fn a_memory_bomb_is_stopped_by_the_memory_limit() { + let inv = run_roomy("const a = []; for (;;) a.push(new Uint8Array(16 << 20));"); + assert_eq!(inv.result, Err(RunError::MemoryLimit)); + // 接住内存耗尽的异常也没用:碰过上限就算超了 + let inv = run_roomy( + "try { const a = []; for (;;) a.push(new Array(1 << 20).fill(1)); } catch (e) {} return req;", + ); + assert_eq!(inv.result, Err(RunError::MemoryLimit)); + // 一次要一大块 + let inv = run_roomy("new ArrayBuffer(512 * 1024 * 1024);"); + assert_eq!(inv.result, Err(RunError::MemoryLimit)); +} + +#[test] +fn deep_recursion_is_an_error_not_a_crash() { + let m = threw(&run_roomy("function f(n) { return f(n + 1) + 1; } f(0);")); + assert!(m.contains("stack"), "{m}"); + // 嵌得很深的数据交给 C 写的内建函数:要么是 RangeError,要么是 wasm 栈耗尽的陷阱 + for body in [ + "let o = {}; for (let i = 0; i < 2e5; i++) o = { o }; JSON.stringify(o);", + "JSON.parse('['.repeat(1e6));", + ] { + match run_roomy(body).result { + Err(RunError::Threw { .. }) | Err(RunError::Trap(_)) => {} + other => panic!("{body}: {other:?}"), + } + } + // 之后照常能用 + assert_eq!( + run("return undefined;").result, + Ok(RequestOutcome::Unchanged) + ); +} + +#[test] +fn a_giant_output_is_stopped_by_the_output_cap() { + // 视图几百字节,上限约 1 MiB;返回 2 MiB + let inv = run_roomy("req.system = 'x'.repeat(2 << 20); return req;"); + assert_eq!(inv.result, Err(RunError::OutputLimit)); + // 攒着到最后才放出来的文字也一样 + let p = load_in( + roomy(), + r#"export const manifest = { name: "big", api: 1, permissions: ["reply.text"], reply: "stream" }; + export function onReplyText() { return ""; } + export function onReplyTextEnd() { return "y".repeat(2 << 20); }"#, + ); + let mut r = p.reply(ctx()).unwrap(); + assert_eq!(r.on_text("a").result, Ok(Some(String::new()))); + assert_eq!(r.on_text_end().result, Err(RunError::OutputLimit)); +} + +#[test] +fn log_volume_is_capped() { + let limits = rt().limits(); + let inv = run(&format!( + "for (let i = 0; i < {}; i++) console.log(i);", + limits.max_log_lines + )); + assert_eq!(inv.result, Ok(RequestOutcome::Unchanged)); + assert_eq!(inv.logs.len(), limits.max_log_lines); + // 再多一行这次调用就失败;已经写下的留着 + let inv = run(&format!( + "for (let i = 0; i <= {}; i++) console.log(i);", + limits.max_log_lines + )); + assert_eq!(inv.result, Err(RunError::OutputLimit)); + assert_eq!(inv.logs.len(), limits.max_log_lines); + // 一行太长就截断,不失败 + let inv = run("console.log('é'.repeat(10000));"); + assert_eq!(inv.result, Ok(RequestOutcome::Unchanged)); + let line = &inv.logs[0].text; + assert!(line.len() <= limits.max_log_line, "{}", line.len()); + assert!(line.ends_with('…')); +} + +#[test] +fn reply_calls_have_their_own_and_a_total_cpu_budget() { + let limits = rt().limits().clone(); + let p = load( + r#"export const manifest = { name: "slow", api: 1, permissions: ["reply.text"] }; + export function onReplyText(t) { + if (t === "spin") for (;;) {} + const end = Date.now() + Number(t); while (Date.now() < end) {} + return undefined; + }"#, + ); + let mut r = p.reply(ctx()).unwrap(); + let inv = r.on_text("spin"); + assert_eq!(inv.result, Err(RunError::CpuLimit)); + assert!( + inv.cpu >= limits.reply_call_cpu && inv.cpu < limits.reply_call_cpu + slack(), + "{:?}", + inv.cpu + ); + // 被打断过的实例不再用 + assert_eq!(r.on_text("0").result, Err(RunError::CpuLimit)); + + // 每次都没超单次的预算,但加起来超过一个回答的总预算。单次预算放宽到 + // 远大于每次的用量:Windows 上量的是墙上时间,被抢占一下就可能先撞单次上限 + static RT: OnceLock = OnceLock::new(); + let rt = RT.get_or_init(|| { + Runtime::new(Limits { + reply_call_cpu: Duration::from_millis(500), + reply_total_cpu: Duration::from_secs(1), + ..Limits::default() + }) + .expect("runtime") + }); + let total = rt.limits().reply_total_cpu; + let p = rt + .load( + br#"export const manifest = { name: "busy", api: 1, permissions: ["reply.text"] }; + export function onReplyText(t) { const end = Date.now() + Number(t); while (Date.now() < end) {} }"#, + ) + .unwrap(); + let mut r = p.reply(ctx()).unwrap(); + let mut used = Duration::ZERO; + let mut stopped = false; + for _ in 0..1000 { + let inv = r.on_text("10"); + used += inv.cpu; + if inv.result == Err(RunError::CpuLimit) { + stopped = true; + break; + } + assert_eq!(inv.result, Ok(None)); + } + assert!(stopped, "never stopped after {used:?}"); + assert!(used >= total - Duration::from_millis(5), "{used:?}"); + assert!(used < total + slack(), "{used:?}"); +} + +#[test] +fn traps_never_take_the_process_down() { + // 多个线程同时撞各种上限、各种陷阱,然后一切照常 + let p = request("return req;"); + let bombs = [ + "for (;;) {}", + "const a = []; for (;;) a.push(new Uint8Array(1 << 20));", + "JSON.parse('['.repeat(1e6));", + "function f() { f(); } f();", + ]; + let handles: Vec<_> = (0..8) + .map(|i| { + let body = bombs[i % bombs.len()]; + std::thread::spawn(move || run(body).result) + }) + .collect(); + for h in handles { + assert!(h.join().expect("the thread survived").is_err()); + } + assert_eq!( + p.on_request(view(), ctx()).result, + Ok(RequestOutcome::Unchanged) + ); +} + +#[test] +fn a_plugin_is_shared_across_threads() { + let p = request("req.system = String(ctx.model); return req;"); + let handles: Vec<_> = (0..8) + .map(|_| { + let p = p.clone(); + std::thread::spawn(move || { + for _ in 0..20 { + let inv = p.on_request(view(), ctx()); + assert_eq!(changed(&inv)["system"], "claude-x"); + } + }) + }) + .collect(); + for h in handles { + h.join().unwrap(); + } +} + +// ── 大请求 ─────────────────────────────────────────────────────── + +/// 一个像真的 Anthropic 请求视图:很多轮对话,文字里夹着要替换的词 +fn big_view(target: usize) -> (Value, usize) { + let words = [ + "the", + "function", + "returns", + "a", + "value", + "when", + "config", + "widget", + "请求", + "上游", + "🙂", + "\"quoted\"", + "line\nbreak", + ]; + let mut messages = Vec::new(); + let mut size = 0; + let mut hits = 0; + let mut i = 0usize; + while size < target { + let mut text = String::new(); + for j in 0..200 { + let w = words[(i * 7 + j * 13) % words.len()]; + if w == "widget" { + hits += 1; + } + text.push_str(w); + text.push(' '); + } + size += text.len() + 100; + messages.push(json!({ "key": format!("m{i}"), "role": if i % 2 == 0 { "user" } else { "assistant" }, + "parts": [ { "key": format!("p{i}"), "type": "text", "text": text } ] })); + i += 1; + } + ( + json!({ "format": "anthropic", "model": "claude-x", "system": "s", "messages": messages }), + hits, + ) +} + +fn edit_big_view(size: usize) -> Duration { + let p = load( + r#"export const manifest = { name: "words", api: 1, permissions: ["messages"] }; + export function onRequest(req) { + for (const m of req.messages) for (const p of m.parts) if (p.type === "text") p.text = p.text.replaceAll("widget", "gadget"); + return req; + }"#, + ); + let (v, hits) = big_view(size); + let bytes = serde_json::to_vec(&v).unwrap().len(); + assert!(bytes >= size); + let t = Instant::now(); + let inv = p.on_request(v, ctx()); + let wall = t.elapsed(); + let out = changed(&inv); + let text = serde_json::to_string(out).unwrap(); + assert_eq!(text.matches("gadget").count(), hits); + assert!(!text.contains("widget")); + println!( + "edit a {bytes} byte view: wall {wall:?}, sandbox cpu {:?}", + inv.cpu + ); + inv.cpu +} + +#[test] +fn a_plugin_edits_a_100_kb_view() { + edit_big_view(100 << 10); +} + +#[test] +fn a_plugin_edits_a_1_mb_view() { + let cpu = edit_big_view(1 << 20); + // 默认的预算放得下:量的是 CPU 时间(Windows 上是墙上时间,并发跑测试时不准) + if !cfg!(windows) { + assert!(cpu < rt().limits().request_cpu, "{cpu:?}"); + } +} + +// ── 加载 ───────────────────────────────────────────────────────── + +#[test] +fn syntax_errors_carry_line_and_column() { + match load_err( + "export const manifest = { name: 'x', api: 1, permissions: ['system'] };\n\nconst x = ;\nexport function onRequest() {}\n", + ) { + LoadError::Syntax { + message, + line, + column, + } => { + assert!(message.starts_with("SyntaxError"), "{message}"); + assert_eq!(line, Some(3)); + assert!(column.is_some()); + } + other => panic!("{other:?}"), + } + // 顶层一执行就抛:也带位置 + match load_err(&format!( + "{SYSTEM}\nnull.x;\nexport function onRequest() {{}}" + )) { + LoadError::Syntax { message, line, .. } => { + assert!(message.starts_with("TypeError"), "{message}"); + assert_eq!(line, Some(3)); + } + other => panic!("{other:?}"), + } +} + +#[test] +fn source_must_be_small_utf8() { + let big = format!( + "{SYSTEM}export function onRequest() {{}}\n//{}", + "x".repeat(1 << 20) + ); + assert_eq!(load_err(&big), LoadError::TooLarge); + let mut bytes = b"export const manifest = {};\n// ok\n// \xff\n".to_vec(); + bytes.extend_from_slice(b"\n"); + match rt().load(&bytes) { + Err(LoadError::Syntax { line, column, .. }) => { + assert_eq!(line, Some(3)); + assert_eq!(column, Some(4)); + } + other => panic!("{other:?}"), + } + // 开头的 BOM 照常 + let p = rt() + .load(format!("\u{feff}{SYSTEM}export function onRequest() {{}}").as_bytes()) + .unwrap(); + assert_eq!(p.manifest().name, "t"); +} + +#[test] +fn top_level_limits_fail_the_load() { + for (rt, body, want) in [ + (rt(), "for (;;) {}", "CPU"), + ( + roomy(), + "const a = []; for (;;) a.push(new Uint8Array(16 << 20));", + "memory", + ), + ] { + let src = format!("{SYSTEM}{body}\nexport function onRequest() {{}}"); + match rt.load(src.as_bytes()) { + Err(LoadError::Syntax { message, .. }) => assert!(message.contains(want), "{message}"), + other => panic!("{other:?}"), + } + } +} + +#[test] +fn the_sha256_is_of_the_exact_bytes() { + let src = format!("{SYSTEM}export function onRequest() {{}}\n"); + let p = load(&src); + use sha2::Digest; + let want: [u8; 32] = sha2::Sha256::digest(src.as_bytes()).into(); + assert_eq!(p.sha256(), want); +} + +#[test] +fn a_full_manifest_is_read() { + let p = load( + r#"export const manifest = { + name: " 附加当前日期 ", + api: 1, + description: "adds the date", + permissions: ["system", "params", "reply.text"], + match: { clients: ["claude-code"], models: ["claude-*"], upstreams: ["anthropic"] }, + reply: "block", + settings: { + zeta: { type: "string", label: "附加内容", default: "x" }, + alpha: { type: "number" }, + mid: { type: "boolean", label: "On", default: true }, + }, + }; + export function onRequest() {} + export function onReplyText() {}"#, + ); + let m = p.manifest(); + assert_eq!(m.name, "附加当前日期"); + assert_eq!(m.description.as_deref(), Some("adds the date")); + assert_eq!( + m.permissions.iter().copied().collect::>(), + vec![ + Permission::System, + Permission::Params, + Permission::ReplyText + ] + ); + assert_eq!(m.scope.models, vec!["claude-*"]); + // 设置项保持作者写的先后 + let keys: Vec<_> = m.settings.iter().map(|s| s.key.as_str()).collect(); + assert_eq!(keys, vec!["zeta", "alpha", "mid"]); + assert_eq!(m.settings[0].label, "附加内容"); + assert_eq!(m.settings[1].label, "alpha"); + assert_eq!(m.settings[1].default, json!(0)); + assert_eq!(m.settings[2].default, json!(true)); + assert_eq!( + m.hooks, + Hooks { + request: true, + reply_text: true, + reply_text_end: false, + tool_call: false + } + ); +} + +#[test] +fn manifest_errors_say_what_is_wrong() { + let hook = "export function onRequest() {}"; + let cases: Vec<(String, &str)> = vec![ + (hook.to_string(), "does not export a manifest"), + (format!("export default {{ manifest: {{}} }};\n{hook}"), "not as a default export"), + (format!("export const manifest = 3;\n{hook}"), "must be an object"), + (format!("export const manifest = {{ api: 1, permissions: ['system'] }};\n{hook}"), "needs a `name`"), + (format!("export const manifest = {{ name: '', api: 1, permissions: ['system'] }};\n{hook}"), "must not be empty"), + (format!("export const manifest = {{ name: 'x'.repeat(65), api: 1, permissions: ['system'] }};\n{hook}"), "at most 64"), + (format!("export const manifest = {{ name: 'x', permissions: ['system'] }};\n{hook}"), "api: 1"), + (format!("export const manifest = {{ name: 'x', api: 1, permissions: [] }};\n{hook}"), "at least one"), + (format!("export const manifest = {{ name: 'x', api: 1, permissions: ['network'] }};\n{hook}"), "unknown permission \"network\""), + (format!("export const manifest = {{ name: 'x', api: 1, permissions: ['system', 'system'] }};\n{hook}"), "listed twice"), + (format!("export const manifest = {{ name: 'x', api: 1, permissions: ['system'], extra: 1 }};\n{hook}"), "unknown field `extra`"), + (format!("export const manifest = {{ name: 'x', api: 1, permissions: ['system'], reply: 'fast' }};\n{hook}"), "\"block\" or \"stream\""), + (format!("export const manifest = {{ name: 'x', api: 1, permissions: ['system'], match: {{ hosts: [] }} }};\n{hook}"), "unknown field `hosts`"), + (format!("export const manifest = {{ name: 'x', api: 1, permissions: ['system'], settings: {{ a: {{ type: 'date' }} }} }};\n{hook}"), "needs a type"), + (format!("export const manifest = {{ name: 'x', api: 1, permissions: ['system'], settings: {{ a: {{ type: 'number', default: 'x' }} }} }};\n{hook}"), "must be a number"), + (format!("export const manifest = {{ name: 'x', api: 1, permissions: ['system'], settings: Object.fromEntries(Array.from({{length: 21}}, (_, i) => ['k' + i, {{ type: 'string' }}])) }};\n{hook}"), "at most 20"), + (format!("export const manifest = {{ name: 'x', api: 1, permissions: ['system'], settings: {{ '1x': {{ type: 'string' }} }} }};\n{hook}"), "invalid name"), + // 钩子和权限一一对应 + ("export const manifest = { name: 'x', api: 1, permissions: ['reply.text'] };\nexport function onRequest() {}".into(), "requests none of"), + ("export const manifest = { name: 'x', api: 1, permissions: ['system', 'reply.text'] };\nexport function onRequest() {}".into(), "onReplyText is not exported"), + ("export const manifest = { name: 'x', api: 1, permissions: ['tools'] };\nexport function onReplyText() {}".into(), "onRequest is not exported"), + ("export const manifest = { name: 'x', api: 1, permissions: ['system'] };\nexport function onRequest() {}\nexport function onToolCall() {}".into(), "\"reply.tool_calls\""), + ("export const manifest = { name: 'x', api: 1, permissions: ['reply.text'] };\nexport function onReplyText() {}\nexport function onReplyTextEnd() {}".into(), "only called in stream mode"), + ("export const manifest = { name: 'x', api: 1, permissions: ['system'] };\nexport const onRequest = 5;".into(), "not a function"), + ("export const manifest = { name: 'x', api: 1, permissions: ['system'] };\nexport function onReqest() {}".into(), "onRequest is not exported"), + ]; + for (src, want) in cases { + let m = manifest_err(&src); + assert!(m.contains(want), "{src}\n=> {m}\n(want {want})"); + } + assert_eq!( + load_err(&format!( + "export const manifest = {{ name: 'x', api: 2, permissions: ['system'], newField: 1 }};\n{hook}" + )), + LoadError::UnsupportedApi(2) + ); +} + +#[test] +fn the_types_cross_threads_as_promised() { + fn send_sync() {} + fn send() {} + send_sync::(); + send_sync::(); + send::(); +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 292fe49..5d9d437 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,5 @@ [toolchain] channel = "stable" +# 插件沙箱(crates/tw-plugin)里的 QuickJS 编成 wasm32-unknown-unknown。列在这里, +# rustup 会自己把这个目标装上 +targets = ["wasm32-unknown-unknown"] diff --git a/scripts/wasm-toolchain.sh b/scripts/wasm-toolchain.sh new file mode 100755 index 0000000..d39af0e --- /dev/null +++ b/scripts/wasm-toolchain.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# 给 CI 和发版的 runner 备好编插件沙箱要的 clang 和 llvm-ar。 +# +# 插件沙箱里的 QuickJS 在构建时编成 WebAssembly(crates/tw-plugin/build.rs), +# 要一个能出 wasm32 的 clang 和配套的 llvm-ar;链接用 Rust 自带的 rust-lld。 +# +# - macOS:Homebrew 的 llvm(Apple 的 clang 编不了 wasm)。不写环境变量 —— +# build.rs 自己找 Homebrew 的位置,顺便测了这条路。 +# - Linux:Ubuntu 22.04 镜像(x64 和 arm64 两种)都预装的 clang-15,显式指定, +# 两个架构用同一个版本。没有就从 apt 装。 +# - Windows:镜像预装的 LLVM(C:\Program Files\LLVM),没有就装官方发行版。 +# 同样交给 build.rs 自己找。 +# +# 用的是哪个 clang、编出的 wasm 是什么哈希,构建之后由 `wasm-toolchain.sh record` +# 打印出来(写进 crates/tw-plugin 的 OUT_DIR/guest-build.txt,也编进二进制)。 +# +# 用法:scripts/wasm-toolchain.sh 准备工具链 +# scripts/wasm-toolchain.sh record DIR [FILE] 打印 DIR 下找到的 guest-build.txt, +# 给了 FILE 就再拷一份过去 +set -euo pipefail + +if [ "${1:-}" = record ]; then + dir="${2:-target}" + copy="${3:-}" + found=0 + while IFS= read -r f; do + found=1 + if [ -n "$copy" ]; then + mkdir -p "$(dirname "$copy")" + cp "$f" "$copy" + fi + echo "== $f" + cat "$f" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo '```' + cat "$f" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + fi + done < <(find "$dir" -path '*/tw-plugin-*/out/guest-build.txt' 2>/dev/null | sort) + if [ "$found" = 0 ]; then + echo "no guest-build.txt under $dir: tw-plugin was not built" >&2 + exit 1 + fi + exit 0 +fi + +case "$(uname -s)" in + Darwin) + # brew update 要几分钟,而且换不来什么:镜像里的 Homebrew 本来就够新 + HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=1 \ + brew install llvm + "$(brew --prefix llvm)/bin/clang" --version + ;; + Linux) + v=15 + if ! command -v "clang-$v" > /dev/null || ! command -v "llvm-ar-$v" > /dev/null; then + sudo apt-get update -q + sudo apt-get install -y -q "clang-$v" "llvm-$v" + fi + clang=$(command -v "clang-$v") + ar=$(command -v "llvm-ar-$v") + "$clang" --version + if [ -n "${GITHUB_ENV:-}" ]; then + echo "TW_WASM_CLANG=$clang" >> "$GITHUB_ENV" + echo "TW_WASM_AR=$ar" >> "$GITHUB_ENV" + fi + ;; + MINGW* | MSYS* | CYGWIN*) + dir="/c/Program Files/LLVM/bin" + if [ ! -x "$dir/clang.exe" ] || [ ! -x "$dir/llvm-ar.exe" ]; then + choco install llvm -y --no-progress + fi + "$dir/clang.exe" --version + ;; + *) + echo "unsupported runner: $(uname -s)" >&2 + exit 1 + ;; +esac