From a490a00455b0bdca642c46aa033d4ef41af5ddb6 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:09:54 +0800 Subject: [PATCH 1/4] Add tw-plugin: run script plugins in a QuickJS-in-Wasmtime sandbox The runtime half of script plugins (contract section 5). A plugin is a JavaScript module; it runs in QuickJS-ng, which is compiled to WebAssembly and run by Wasmtime 49.0.1 with only the runtime linked in. The sandbox imports two functions, a log line and the clock. It has no WASI, files, sockets, environment or other plugins. How the sandbox is built (build.rs, on every build; nothing is committed and nothing is downloaded): - The guest (crates/tw-plugin/guest, outside the workspace, no_std over the pinned rquickjs-sys 0.14.0) is compiled for wasm32-unknown-unknown by a clang that can target wasm. It looks at TW_WASM_CLANG, then Homebrew's llvm, then clang / clang-N on PATH, probing each one. Linking uses rustc's rust-lld, so clang and llvm-ar are the only external tools. The build fails if the module imports anything besides the two functions. - The initialized QuickJS runtime and the bridge script (src/bridge.js: standard globals only, console, reject, a reseedable Math.random) are snapshotted into the module's data segments. - Cranelift (a build-dependency only) precompiles the snapshot for the build target, cross targets included. twcore embeds it. Each request hook gets a fresh instance, and each reply gets one. The plugin module is evaluated from bytecode in every instance (about 50 us), so module state never survives between requests. Every call has four limits: - CPU: thread CPU time, checked at every epoch tick. Wall clock on Windows. - Memory: a ResourceLimiter refuses growth past the cap. - Output: at most factor x input + extra bytes. - Logs: 100 lines of up to 4 KiB. Exceeding any limit is a RunError. A reply instance that was interrupted is not used again. Only tw-gateway and twcore may depend on tw-plugin. Lite builds tw-api, tw-types, tw-yaml, tw-guard, tw-watch and tw-link from git, and Enterprise builds the first layer. A dependency on tw-plugin would make those builds need clang, and tests/boundary.rs fails when one appears. CI and release now install a wasm-capable clang on every runner: brew's llvm on macOS, clang-15 on both Ubuntu 22.04 images, and the LLVM that comes with the Windows image. Each run records the clang version and the guest's SHA-256. Release builds name -p tw-plugin, so every target, including the cross-compiled aarch64 Windows one, proves it can build the sandbox before the gateway links it. The README files (en, zh-CN) and CONTRIBUTING.md list the new prerequisite. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 42 +- .github/workflows/release.yml | 85 ++- CONTRIBUTING.md | 40 ++ Cargo.lock | 666 ++++++++++++++++- Cargo.toml | 5 + README.md | 26 +- README.zh-CN.md | 14 +- crates/tw-plugin/Cargo.toml | 39 + crates/tw-plugin/build.rs | 686 ++++++++++++++++++ crates/tw-plugin/examples/latency.rs | 124 ++++ crates/tw-plugin/guest/.gitignore | 2 + crates/tw-plugin/guest/Cargo.lock | 41 ++ crates/tw-plugin/guest/Cargo.toml | 32 + crates/tw-plugin/guest/src/lib.rs | 519 ++++++++++++++ crates/tw-plugin/src/bridge.js | 435 ++++++++++++ crates/tw-plugin/src/cpu.rs | 39 + crates/tw-plugin/src/engine.rs | 26 + crates/tw-plugin/src/lib.rs | 880 +++++++++++++++++++++++ crates/tw-plugin/src/manifest.rs | 442 ++++++++++++ crates/tw-plugin/src/sandbox.rs | 532 ++++++++++++++ crates/tw-plugin/src/ticker.rs | 82 +++ crates/tw-plugin/tests/address_space.rs | 67 ++ crates/tw-plugin/tests/boundary.rs | 133 ++++ crates/tw-plugin/tests/runtime.rs | 904 ++++++++++++++++++++++++ rust-toolchain.toml | 3 + scripts/wasm-toolchain.sh | 81 +++ 26 files changed, 5928 insertions(+), 17 deletions(-) create mode 100644 crates/tw-plugin/Cargo.toml create mode 100644 crates/tw-plugin/build.rs create mode 100644 crates/tw-plugin/examples/latency.rs create mode 100644 crates/tw-plugin/guest/.gitignore create mode 100644 crates/tw-plugin/guest/Cargo.lock create mode 100644 crates/tw-plugin/guest/Cargo.toml create mode 100644 crates/tw-plugin/guest/src/lib.rs create mode 100644 crates/tw-plugin/src/bridge.js create mode 100644 crates/tw-plugin/src/cpu.rs create mode 100644 crates/tw-plugin/src/engine.rs create mode 100644 crates/tw-plugin/src/lib.rs create mode 100644 crates/tw-plugin/src/manifest.rs create mode 100644 crates/tw-plugin/src/sandbox.rs create mode 100644 crates/tw-plugin/src/ticker.rs create mode 100644 crates/tw-plugin/tests/address_space.rs create mode 100644 crates/tw-plugin/tests/boundary.rs create mode 100644 crates/tw-plugin/tests/runtime.rs create mode 100755 scripts/wasm-toolchain.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bf43aa9..fe1888e1 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 e25ec673..3f51e235 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 3e93c0b8..60bfca86 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 e4a784f0..fd9bf94d 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 28b92669..d46da332 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 62cbdad1..f34bf031 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 c00a5546..2aa7044b 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 00000000..10222c7d --- /dev/null +++ b/crates/tw-plugin/Cargo.toml @@ -0,0 +1,39 @@ +[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 } diff --git a/crates/tw-plugin/build.rs b/crates/tw-plugin/build.rs new file mode 100644 index 00000000..bf1981b7 --- /dev/null +++ b/crates/tw-plugin/build.rs @@ -0,0 +1,686 @@ +//! 编出插件沙箱: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 = env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo")); + 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 = Command::new(cargo); + cmd.args([ + "build", + "--release", + "--locked", + "--target", + WASM_TARGET, + "--manifest-path", + ]) + .arg(guest.join("Cargo.toml")) + .arg("--target-dir") + .arg(&target_dir); + + // 外层 cargo 给构建脚本的环境里有它自己的编译选项(CI 的 `-D warnings`、 + // clippy 的包装器、用户的 profile 覆盖),都不该落到这个独立的小工程上 + 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" + ) || key.starts_with("CARGO_PROFILE_") + || key.starts_with("CARGO_TARGET_"); + if drop { + cmd.env_remove(key); + } + } + + // 编出来的东西不带构建机的路径:同一套工具链在哪台机器上编都一样 + 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 triple = WASM_TARGET.replace('-', "_"); + cmd.env(format!("CC_{triple}"), &tools.clang) + .env(format!("AR_{triple}"), &tools.ar) + .env( + format!("CFLAGS_{triple}"), + "-Wno-builtin-macro-redefined -D__FILE__=\"quickjs\"", + ); + + 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()))) +} + +/// 导入表必须正好是允许的那几个,内存必须是模块自己的、导出出来的 +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 00000000..41972000 --- /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 00000000..fddc1dbf --- /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 00000000..2986d2e0 --- /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 00000000..54b3e8a3 --- /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 00000000..bd6930ad --- /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 00000000..39a13109 --- /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 00000000..f3413013 --- /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 00000000..2dda4218 --- /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 00000000..7ca2828a --- /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 00000000..73c812af --- /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 00000000..8f5f112f --- /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 00000000..1235cd38 --- /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 00000000..b9d16221 --- /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 00000000..58a30a74 --- /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 00000000..9e4d55cf --- /dev/null +++ b/crates/tw-plugin/tests/runtime.rs @@ -0,0 +1,904 @@ +//! 沙箱的行为:钩子的输入输出、四个上限、状态隔离、清单核对。 +//! +//! 插件源码都写在测试里,一眼能看出每条测的是什么。 + +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")) +} + +fn load(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 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 + Duration::from_millis(100), + "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("const a = []; for (;;) a.push(new Uint8Array(1 << 20));"); + assert_eq!(inv.result, Err(RunError::MemoryLimit)); + // 接住内存耗尽的异常也没用:碰过上限就算超了 + let inv = run( + "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("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("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 < 1e6; i++) o = { o }; JSON.stringify(o);", + "JSON.parse('['.repeat(1e6));", + ] { + match run(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("req.system = 'x'.repeat(2 << 20); return req;"); + assert_eq!(inv.result, Err(RunError::OutputLimit)); + // 攒着到最后才放出来的文字也一样 + let p = load( + 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 + Duration::from_millis(50), + "{:?}", + inv.cpu + ); + // 被打断过的实例不再用 + assert_eq!(r.on_text("0").result, Err(RunError::CpuLimit)); + + // 每次 15 ms,没超单次的预算,但加起来超过一个回答的总预算 + let mut r = p.reply(ctx()).unwrap(); + let mut used = Duration::ZERO; + let mut stopped = false; + for _ in 0..400 { + let inv = r.on_text("15"); + 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 >= limits.reply_total_cpu - Duration::from_millis(20), + "{used:?}" + ); + assert!( + used < limits.reply_total_cpu + Duration::from_millis(100), + "{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); + 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 (body, want) in [ + ("for (;;) {}", "CPU"), + ( + "const a = []; for (;;) a.push(new Uint8Array(1 << 20));", + "memory", + ), + ] { + match load_err(&format!("{SYSTEM}{body}\nexport function onRequest() {{}}")) { + 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 292fe499..5d9d4370 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 00000000..d39af0e8 --- /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 From 751c964170ca83e8ab02790a46fc433a83eee13b Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:42:01 +0800 Subject: [PATCH 2/4] Fix the Windows guest build and the limit tests on slower runners On Windows rquickjs-sys gives clang its bundled libc headers as a canonicalized `\\?\C:\...` path, and in such a path `/` is not a separator, so `#include ` fails. build.rs now asks cargo metadata where the crate is and passes the same directory again in plain form. The C flags are now shell-quoted (CC_SHELL_ESCAPED_FLAGS), so paths with spaces survive. The memory, output and stack tests ran under the default 200 ms CPU budget, and on CI's slower machines with a debug-built host the CPU limit hit first. They now use a runtime with a wide CPU budget, so they test the limit they are named for. On Windows the CPU limit is measured in wall-clock time, so the slack in the CPU-limit tests is wider there. Co-Authored-By: Claude Opus 5.5 --- crates/tw-plugin/Cargo.toml | 2 + crates/tw-plugin/build.rs | 128 +++++++++++++++++++++--------- crates/tw-plugin/tests/runtime.rs | 87 ++++++++++++++------ 3 files changed, 155 insertions(+), 62 deletions(-) diff --git a/crates/tw-plugin/Cargo.toml b/crates/tw-plugin/Cargo.toml index 10222c7d..d28c455e 100644 --- a/crates/tw-plugin/Cargo.toml +++ b/crates/tw-plugin/Cargo.toml @@ -37,3 +37,5 @@ wasmtime = { version = "=49.0.1", default-features = false, features = ["runtime 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 index bf1981b7..6136ef08 100644 --- a/crates/tw-plugin/build.rs +++ b/crates/tw-plugin/build.rs @@ -362,13 +362,12 @@ fn check_rust_wasm_target() { 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 = env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo")); 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 = Command::new(cargo); + let mut cmd = nested_cargo(); cmd.args([ "build", "--release", @@ -381,36 +380,6 @@ fn build_guest(manifest_dir: &Path, out_dir: &Path, tools: &Tools) -> Vec { .arg("--target-dir") .arg(&target_dir); - // 外层 cargo 给构建脚本的环境里有它自己的编译选项(CI 的 `-D warnings`、 - // clippy 的包装器、用户的 profile 覆盖),都不该落到这个独立的小工程上 - 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" - ) || key.starts_with("CARGO_PROFILE_") - || key.starts_with("CARGO_TARGET_"); - if drop { - cmd.env_remove(key); - } - } - // 编出来的东西不带构建机的路径:同一套工具链在哪台机器上编都一样 let mut remap = vec![ format!("--remap-path-prefix={}=/guest", guest.display()), @@ -423,13 +392,27 @@ fn build_guest(manifest_dir: &Path, out_dir: &Path, tools: &Tools) -> Vec { // 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 ` 找不到。同一个目录再用普通写法给一遍 + 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}"))); + cflags.push("-isystem".into()); + cflags.push(include.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( - format!("CFLAGS_{triple}"), - "-Wno-builtin-macro-redefined -D__FILE__=\"quickjs\"", - ); + .env("CC_SHELL_ESCAPED_FLAGS", "1") + .env(format!("CFLAGS_{triple}"), quoted.join(" ")); let status = cmd .status() @@ -448,6 +431,79 @@ fn build_guest(manifest_dir: &Path, out_dir: &Path, tools: &Tools) -> Vec { .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()) +} + +/// 给 cc 的 `CC_SHELL_ESCAPED_FLAGS` 用的单引号括起来的写法 +fn sh_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + /// 导入表必须正好是允许的那几个,内存必须是模块自己的、导出出来的 fn check_imports(wasm: &[u8]) { use wasmparser::{Parser, Payload, TypeRef}; diff --git a/crates/tw-plugin/tests/runtime.rs b/crates/tw-plugin/tests/runtime.rs index 9e4d55cf..56eaaff6 100644 --- a/crates/tw-plugin/tests/runtime.rs +++ b/crates/tw-plugin/tests/runtime.rs @@ -13,8 +13,37 @@ fn rt() -> &'static Runtime { 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 { - match rt().load(src.as_bytes()) { + 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}"), } @@ -69,6 +98,14 @@ 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, @@ -459,11 +496,7 @@ fn an_infinite_loop_is_stopped_by_the_cpu_limit() { 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 + Duration::from_millis(100), - "overran: {:?}", - inv.cpu - ); + assert!(inv.cpu < limit + slack(), "overran: {:?}", inv.cpu); assert!(wall < Duration::from_secs(5), "wall {wall:?}"); } @@ -481,28 +514,28 @@ fn loop_free_cpu_burners_are_stopped_too() { #[test] fn a_memory_bomb_is_stopped_by_the_memory_limit() { - let inv = run("const a = []; for (;;) a.push(new Uint8Array(1 << 20));"); + let inv = run_roomy("const a = []; for (;;) a.push(new Uint8Array(16 << 20));"); assert_eq!(inv.result, Err(RunError::MemoryLimit)); // 接住内存耗尽的异常也没用:碰过上限就算超了 - let inv = run( + 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("new ArrayBuffer(512 * 1024 * 1024);"); + 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("function f(n) { return f(n + 1) + 1; } f(0);")); + 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 < 1e6; i++) o = { o }; JSON.stringify(o);", + "let o = {}; for (let i = 0; i < 2e5; i++) o = { o }; JSON.stringify(o);", "JSON.parse('['.repeat(1e6));", ] { - match run(body).result { + match run_roomy(body).result { Err(RunError::Threw { .. }) | Err(RunError::Trap(_)) => {} other => panic!("{body}: {other:?}"), } @@ -517,10 +550,11 @@ fn deep_recursion_is_an_error_not_a_crash() { #[test] fn a_giant_output_is_stopped_by_the_output_cap() { // 视图几百字节,上限约 1 MiB;返回 2 MiB - let inv = run("req.system = 'x'.repeat(2 << 20); return req;"); + let inv = run_roomy("req.system = 'x'.repeat(2 << 20); return req;"); assert_eq!(inv.result, Err(RunError::OutputLimit)); // 攒着到最后才放出来的文字也一样 - let p = load( + 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); }"#, @@ -569,8 +603,7 @@ fn reply_calls_have_their_own_and_a_total_cpu_budget() { 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 + Duration::from_millis(50), + inv.cpu >= limits.reply_call_cpu && inv.cpu < limits.reply_call_cpu + slack(), "{:?}", inv.cpu ); @@ -595,10 +628,7 @@ fn reply_calls_have_their_own_and_a_total_cpu_budget() { used >= limits.reply_total_cpu - Duration::from_millis(20), "{used:?}" ); - assert!( - used < limits.reply_total_cpu + Duration::from_millis(100), - "{used:?}" - ); + assert!(used < limits.reply_total_cpu + slack(), "{used:?}"); } #[test] @@ -722,7 +752,10 @@ fn a_plugin_edits_a_100_kb_view() { #[test] fn a_plugin_edits_a_1_mb_view() { let cpu = edit_big_view(1 << 20); - assert!(cpu < rt().limits().request_cpu, "{cpu:?}"); + // 默认的预算放得下:量的是 CPU 时间(Windows 上是墙上时间,并发跑测试时不准) + if !cfg!(windows) { + assert!(cpu < rt().limits().request_cpu, "{cpu:?}"); + } } // ── 加载 ───────────────────────────────────────────────────────── @@ -780,15 +813,17 @@ fn source_must_be_small_utf8() { #[test] fn top_level_limits_fail_the_load() { - for (body, want) in [ - ("for (;;) {}", "CPU"), + for (rt, body, want) in [ + (rt(), "for (;;) {}", "CPU"), ( - "const a = []; for (;;) a.push(new Uint8Array(1 << 20));", + roomy(), + "const a = []; for (;;) a.push(new Uint8Array(16 << 20));", "memory", ), ] { - match load_err(&format!("{SYSTEM}{body}\nexport function onRequest() {{}}")) { - LoadError::Syntax { message, .. } => assert!(message.contains(want), "{message}"), + 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:?}"), } } From fcbbd1e013b92f2cedfcee650d719aba0cf74e32 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:51:54 +0800 Subject: [PATCH 3/4] Give clang on Windows its own copy of the libc headers Passing the vendored include directory a second time in plain form did not help: clang sees that both entries are the same directory and drops the later one. A copy in OUT_DIR counts as a different directory, so when the `\\?\` path fails to resolve `bits/alltypes.h`, clang finds it in the copy. Co-Authored-By: Claude Opus 5.5 --- crates/tw-plugin/build.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/tw-plugin/build.rs b/crates/tw-plugin/build.rs index 6136ef08..476650c6 100644 --- a/crates/tw-plugin/build.rs +++ b/crates/tw-plugin/build.rs @@ -399,12 +399,17 @@ fn build_guest(manifest_dir: &Path, out_dir: &Path, tools: &Tools) -> Vec { if cfg!(windows) { // rquickjs-sys 把它带的 libc 头文件目录 canonicalize 成 `\\?\C:\…` 交给 // clang。这种写法里 `/` 不算分隔符,于是头文件里的 - // `#include ` 找不到。同一个目录再用普通写法给一遍 + // `#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(include.display().to_string()); + cflags.push(copy.display().to_string()); } let triple = WASM_TARGET.replace('-', "_"); // 按 shell 的规则拆:路径里可以有空格 @@ -499,6 +504,20 @@ fn rquickjs_sys_dir(guest: &Path) -> Result { .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('\'', "'\\''")) From 76417096b6660dd44ea08d1b699d000ceb4c53d8 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:06:53 +0800 Subject: [PATCH 4/4] Test the per-reply CPU budget without tripping the per-call one On Windows the CPU limit is measured in wall-clock time. With 15 ms of work per call against a 20 ms per-call budget, one preemption was enough to stop the reply early, at 370 ms instead of the 2 s total. The total budget is now tested with its own limits: 500 ms per call and 1 s per reply, at 10 ms per call, so only the total budget can stop it. Co-Authored-By: Claude Opus 5.5 --- crates/tw-plugin/tests/runtime.rs | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/crates/tw-plugin/tests/runtime.rs b/crates/tw-plugin/tests/runtime.rs index 56eaaff6..62cd32c1 100644 --- a/crates/tw-plugin/tests/runtime.rs +++ b/crates/tw-plugin/tests/runtime.rs @@ -610,12 +610,29 @@ fn reply_calls_have_their_own_and_a_total_cpu_budget() { // 被打断过的实例不再用 assert_eq!(r.on_text("0").result, Err(RunError::CpuLimit)); - // 每次 15 ms,没超单次的预算,但加起来超过一个回答的总预算 + // 每次都没超单次的预算,但加起来超过一个回答的总预算。单次预算放宽到 + // 远大于每次的用量: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..400 { - let inv = r.on_text("15"); + for _ in 0..1000 { + let inv = r.on_text("10"); used += inv.cpu; if inv.result == Err(RunError::CpuLimit) { stopped = true; @@ -624,11 +641,8 @@ fn reply_calls_have_their_own_and_a_total_cpu_budget() { assert_eq!(inv.result, Ok(None)); } assert!(stopped, "never stopped after {used:?}"); - assert!( - used >= limits.reply_total_cpu - Duration::from_millis(20), - "{used:?}" - ); - assert!(used < limits.reply_total_cpu + slack(), "{used:?}"); + assert!(used >= total - Duration::from_millis(5), "{used:?}"); + assert!(used < total + slack(), "{used:?}"); } #[test]