diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 4df6a024..aaace39b 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -85,6 +85,7 @@ prettier_targets=() rust_tauri_targets=() has_src_changes=false has_tauri_changes=false +has_support_matrix_changes=false while IFS= read -r file; do case "$file" in @@ -105,10 +106,18 @@ while IFS= read -r file; do esac ;; esac + + # These files are the inputs of CI's support-contract job + # (scripts/support-matrix.mjs check + homebrew-cask selftest). + case "$file" in + scripts/support-matrix*|README.md|README_EN.md|.github/workflows/ci.yml|.github/workflows/release.yml|package.json) + has_support_matrix_changes=true + ;; + esac done < <(git diff --cached --name-only --diff-filter=ACMR) -if [ "$has_src_changes" = false ] && [ "$has_tauri_changes" = false ]; then - echo "[pre-commit] No src/ or src-tauri/ changes, skipping checks." +if [ "$has_src_changes" = false ] && [ "$has_tauri_changes" = false ] && [ "$has_support_matrix_changes" = false ]; then + echo "[pre-commit] No src/, src-tauri/ or support-matrix changes, skipping checks." exit 0 fi @@ -158,7 +167,13 @@ fi if [ "$has_src_changes" = true ]; then echo "[pre-commit] Running frontend checks..." - pnpm run check:precommit:src + node scripts/run-checks.mjs precommit-src +fi + +if [ "$has_support_matrix_changes" = true ]; then + echo "[pre-commit] Running support matrix checks..." + pnpm run check:support-matrix + pnpm run check:homebrew-cask fi if [ "$has_tauri_changes" = true ]; then @@ -167,7 +182,7 @@ if [ "$has_tauri_changes" = true ]; then # cargo --locked 会直接失败,问题常常被拖到 pre-push 才暴露。 # 这里在检测到典型报错后自动更新 Cargo.lock 并重新校验,减少反复踩坑。 tauri_log="$(mktemp -t aio-precommit-tauri.XXXXXX)" - if pnpm run check:precommit:tauri 2>&1 | tee "$tauri_log"; then + if node scripts/run-checks.mjs precommit-tauri 2>&1 | tee "$tauri_log"; then rm -f "$tauri_log" else if grep -q "needs to be updated but --locked was passed" "$tauri_log"; then @@ -188,7 +203,7 @@ if [ "$has_tauri_changes" = true ]; then git add -- "src-tauri/Cargo.lock" echo "[pre-commit] Re-running Rust fast checks with --locked..." - pnpm run check:precommit:tauri + node scripts/run-checks.mjs precommit-tauri rm -f "$tauri_log" else rm -f "$tauri_log" diff --git a/.githooks/pre-push b/.githooks/pre-push index 58c82961..e6bad687 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -9,5 +9,5 @@ cd "$repo_root" # tests accidentally execute ref names as commands. exec &2 + echo "macos_arm_sha256=${macos_arm_sha256:-}" >&2 + echo "macos_intel_sha256=${macos_intel_sha256:-}" >&2 + exit 1 + fi + + node scripts/support-matrix.mjs homebrew-cask \ + --tag "${{ needs.release-please.outputs.tag_name }}" \ + --repo "${{ github.repository }}" \ + --macos-arm-sha256 "$macos_arm_sha256" \ + --macos-intel-sha256 "$macos_intel_sha256" \ + --output generated-homebrew/Casks/aio-coding-hub.rb + + - name: Skip Homebrew tap sync when token is missing + if: env.HOMEBREW_TAP_TOKEN == '' + run: | + echo "HOMEBREW_TAP_TOKEN is not configured; generated Cask will not be pushed." + + - name: Sync Homebrew tap + if: env.HOMEBREW_TAP_TOKEN != '' + shell: bash + run: | + set -euo pipefail + + # Fail fast with an actionable message when the token cannot access + # the tap repo — git's own failure mode is a cryptic + # "could not read Username" prompt. + status=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer ${HOMEBREW_TAP_TOKEN}" \ + "https://api.github.com/repos/${HOMEBREW_TAP_REPOSITORY}") + if [[ "$status" != "200" ]]; then + echo "::error::HOMEBREW_TAP_TOKEN cannot access ${HOMEBREW_TAP_REPOSITORY} (HTTP ${status}). The PAT must be valid and have contents read/write on the tap repository." + exit 1 + fi + + git check-ref-format --normalize "refs/heads/main" >/dev/null + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # git-over-HTTPS expects Basic auth (this is what actions/checkout + # sends); a raw "bearer" header is rejected for some token types. + auth_header="AUTHORIZATION: basic $(printf 'x-access-token:%s' "${HOMEBREW_TAP_TOKEN}" | base64 -w0)" + + git -c http.extraheader="$auth_header" \ + clone "https://github.com/${HOMEBREW_TAP_REPOSITORY}.git" homebrew-tap + + mkdir -p homebrew-tap/Casks + cp generated-homebrew/Casks/aio-coding-hub.rb homebrew-tap/Casks/aio-coding-hub.rb + + cd homebrew-tap + if git diff --quiet -- Casks/aio-coding-hub.rb; then + echo "Homebrew Cask is already up to date." + exit 0 + fi + + git add Casks/aio-coding-hub.rb + git commit -m "chore: update aio-coding-hub ${{ needs.release-please.outputs.tag_name }}" + git -c http.extraheader="$auth_header" push origin HEAD diff --git a/.gitignore b/.gitignore index 46e6301c..5071a12c 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ docs/* !docs/plugin-system-development-plan.md !docs/plugin-system-rfc.md !docs/plugin-manifest-v1.md +!docs/release-homebrew.md !docs/plugins/ !docs/plugins/** docs/superpowers/ @@ -41,10 +42,12 @@ docs/superpowers/ check-doc/ AGENTS.md coverage*/ +.vitest-reports/ .cursor/ .trellis/ .agents/ .serena/ +.superpowers/ src/templates/ .codex/ .codex-temp/ diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b9196eaa..a1d502e6 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.60.2" + ".": "0.60.11" } diff --git a/CHANGELOG.md b/CHANGELOG.md index bc6120d6..6bdbb7e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,243 @@ # Changelog +## [0.60.11](https://github.com/dyndynjyxa/aio-coding-hub/compare/aio-coding-hub-v0.60.10...aio-coding-hub-v0.60.11) (2026-07-09) + + +### Bug Fixes + +* **plugins:** stabilize extension host process CI ([ea4001a](https://github.com/dyndynjyxa/aio-coding-hub/commit/ea4001a5e7ff92f6e204322b3340d0427d014632)) + +## [0.60.10](https://github.com/dyndynjyxa/aio-coding-hub/compare/aio-coding-hub-v0.60.9...aio-coding-hub-v0.60.10) (2026-07-09) + + +### Bug Fixes + +* **gateway:** arm request abort guard before active-request registration ([49d590c](https://github.com/dyndynjyxa/aio-coding-hub/commit/49d590c4df63a4cd9a1428cb5cbacb58b1d0f080)) +* **home:** keep freshness watchdog alive while a recent log row lacks terminal state ([46ce59e](https://github.com/dyndynjyxa/aio-coding-hub/commit/46ce59eebce5ad962592db6121c9e77ae8cc0044)) +* **home:** keep signal-driven request log refresh alive while backgrounded ([4ed3b4b](https://github.com/dyndynjyxa/aio-coding-hub/commit/4ed3b4b4d89ef840813e3f32e2ccc0a6ccd1e432)) + +## [0.60.9](https://github.com/dyndynjyxa/aio-coding-hub/compare/aio-coding-hub-v0.60.8...aio-coding-hub-v0.60.9) (2026-07-07) + + +### Bug Fixes + +* **home:** refresh stale request log activity ([dc30559](https://github.com/dyndynjyxa/aio-coding-hub/commit/dc30559e63ed37d3946ca2f6b8061bc5d3cc6c33)) + +## [0.60.8](https://github.com/dyndynjyxa/aio-coding-hub/compare/aio-coding-hub-v0.60.7...aio-coding-hub-v0.60.8) (2026-07-05) + + +### Features + +* **gateway:** 熔断跳过归因落库并在日志详情展示原因与冷却时间 ([530e6d3](https://github.com/dyndynjyxa/aio-coding-hub/commit/530e6d3b16815b67bf5fe036fc356cfab267b2c8)) +* **settings:** 请求日志留存策略可见并提供数据库压缩入口 ([c23e4bd](https://github.com/dyndynjyxa/aio-coding-hub/commit/c23e4bd7f19355dba75afa63bec19666ae613c7f)) + + +### Bug Fixes + +* **gateway:** 流式空闲超时在流式转发路径真正生效 ([6197e81](https://github.com/dyndynjyxa/aio-coding-hub/commit/6197e81046867e229f0e1dfe0a98f1bf311a484a)) +* **ui:** failover 徽章推导与落库语义收敛并清理状态死代码 ([5b5eae8](https://github.com/dyndynjyxa/aio-coding-hub/commit/5b5eae8ee0088b0be407e64acd2a36426768976b)) + + +### Code Refactoring + +* **gateway:** 熔断通知文案迁移前端渲染并删除后端文本构造 ([5cd4915](https://github.com/dyndynjyxa/aio-coding-hub/commit/5cd49151d1ea93034d0e371a3d68aa09800f10f7)) +* **ui:** 拆分 HomeLogShared 杂物间为三个单一职责模块 ([8590f3f](https://github.com/dyndynjyxa/aio-coding-hub/commit/8590f3f04fdbfc2529d754ac90e93c189af81540)) +* **ui:** 清理 review 发现的 P2 项并移除 traceRoute skipped 死分支 ([ddf131f](https://github.com/dyndynjyxa/aio-coding-hub/commit/ddf131fe735a4459d4ed0e177dd1b4c82063bb93)) +* **ui:** 网关事件 payload 类型改由 Specta 生成派生 ([85b4007](https://github.com/dyndynjyxa/aio-coding-hub/commit/85b40072327181f00e2efe7020353dbcf4fbe9ab)) + +## [0.60.7](https://github.com/dyndynjyxa/aio-coding-hub/compare/aio-coding-hub-v0.60.6...aio-coding-hub-v0.60.7) (2026-07-05) + + +### Features + +* **ui:** 请求日志错误卡片展示失败尝试归因摘要 ([bdca955](https://github.com/dyndynjyxa/aio-coding-hub/commit/bdca955f9ce48c2abfd1a1ae33a29aeace322f2b)) + + +### Bug Fixes + +* **gateway:** 识别 compact 请求并放宽首字节超时至 300 秒 ([bbbdeaf](https://github.com/dyndynjyxa/aio-coding-hub/commit/bbbdeaf019f93b3069d8a554647c24ef5813fd8c)) + +## [0.60.6](https://github.com/dyndynjyxa/aio-coding-hub/compare/aio-coding-hub-v0.60.5...aio-coding-hub-v0.60.6) (2026-07-03) + + +### Bug Fixes + +* **release:** 以 0.60.6 重新发布 0.60.5 的全部内容 ([b4378f1](https://github.com/dyndynjyxa/aio-coding-hub/commit/b4378f135ae72afc87bd56aa72723df0c9e1c327)) + +## [0.60.5](https://github.com/dyndynjyxa/aio-coding-hub/compare/aio-coding-hub-v0.60.4...aio-coding-hub-v0.60.5) (2026-07-03) + + +### Bug Fixes + +* **home:** unify in-progress request rendering as realtime cards ([faaa440](https://github.com/dyndynjyxa/aio-coding-hub/commit/faaa4406d6892c6f16c2bf7239b2be7ef3c2bede)) +* **watchdog:** 后台修复死亡 WebView,重构白屏恢复状态机 ([edeb28e](https://github.com/dyndynjyxa/aio-coding-hub/commit/edeb28e994b1ea6d8b7cccdd21027fd4176ac1e5)) + + +### Code Refactoring + +* single-source check stages, add Homebrew cask release pipeline ([e873103](https://github.com/dyndynjyxa/aio-coding-hub/commit/e873103a331c2a153acc59223038bfd52ca192d7)) + +## [0.60.4](https://github.com/dyndynjyxa/aio-coding-hub/compare/aio-coding-hub-v0.60.3...aio-coding-hub-v0.60.4) (2026-07-03) + + +### Bug Fixes + +* **gateway:** derive in-progress request logs from active request registry ([be0d772](https://github.com/dyndynjyxa/aio-coding-hub/commit/be0d7725e91134a90574e61c943e6de98547b63e)), closes [#323](https://github.com/dyndynjyxa/aio-coding-hub/issues/323) + + +### Code Refactoring + +* single-source cross-layer contracts, hot-path DB offload, request-log retention ([0f1d8d8](https://github.com/dyndynjyxa/aio-coding-hub/commit/0f1d8d8984651e14d76837892b431c4a3bf546bc)) + +## [0.60.3](https://github.com/dyndynjyxa/aio-coding-hub/compare/aio-coding-hub-v0.60.2...aio-coding-hub-v0.60.3) (2026-07-01) + + +### Features + +* add plugin file preview ipc ([5f82075](https://github.com/dyndynjyxa/aio-coding-hub/commit/5f820756a7e8d3da3d0ecb557a83331ca4e61515)) +* add plugin lifecycle previews ([5b5200b](https://github.com/dyndynjyxa/aio-coding-hub/commit/5b5200b5e994c6583f7e6161134c3d8f82c052bd)) +* **create-aio-plugin:** add replay and publish parity ([0928613](https://github.com/dyndynjyxa/aio-coding-hub/commit/09286132ed0dc515400da49055da98e727d21a73)) +* monitor streaming request lifecycle ([35beb37](https://github.com/dyndynjyxa/aio-coding-hub/commit/35beb37e55e44ac5e2ff9a4bd836b5d9a6e7ccb5)) +* **plugin-devtools:** add plugin doctor diagnostics ([9354298](https://github.com/dyndynjyxa/aio-coding-hub/commit/9354298d9b578a90b9b491a5a1cec6bcad6d89be)) +* **plugin-devtools:** add strict plugin validation ([963e191](https://github.com/dyndynjyxa/aio-coding-hub/commit/963e1914767ebd7288f2ddc0be21a7d391e341d8)) +* **plugin-devtools:** explain declarative rule replay ([e768f79](https://github.com/dyndynjyxa/aio-coding-hub/commit/e768f790e7c9259aaf46407d9db99c13c87233c2)) +* **plugins:** add example developer loop templates ([14118c0](https://github.com/dyndynjyxa/aio-coding-hub/commit/14118c03a6e08cbf1def1b2f0b6dc2a1154c62fc)) +* **plugins:** add extension host instance registry ([4415158](https://github.com/dyndynjyxa/aio-coding-hub/commit/44151588b61f40aab4e37e7c8abe5ce2dff68aae)) +* **plugins:** add hook resource budgets ([096ef7c](https://github.com/dyndynjyxa/aio-coding-hub/commit/096ef7c4a5efac16a1b320d312e5903c1143a7db)) +* **plugins:** add lifecycle preview and update diff ([d2117a5](https://github.com/dyndynjyxa/aio-coding-hub/commit/d2117a55edeadecbcb33c7d3e64a55b4b52162de)) +* **plugins:** add managed extension host worker ([54bc811](https://github.com/dyndynjyxa/aio-coding-hub/commit/54bc811f13dfc5b0a0e6ee1246e4587ad7c3f943)) +* **plugins:** add market card view model ([3e5755f](https://github.com/dyndynjyxa/aio-coding-hub/commit/3e5755ffb58f2153b5a169b3d870035d81fde435)) +* **plugins:** add runtime lifecycle registry ([818a43d](https://github.com/dyndynjyxa/aio-coding-hub/commit/818a43d04febb2cc3579a959d4042f530d57c093)) +* **plugins:** add structured runtime reports ([3a718fb](https://github.com/dyndynjyxa/aio-coding-hub/commit/3a718fbe0676b19674986307003c55a499012f77)) +* **plugins:** define extension host manifest contract ([f63219e](https://github.com/dyndynjyxa/aio-coding-hub/commit/f63219e4f3d711de8274a53a22db87263fac1628)) +* **plugins:** define extension protocol bridge skeleton ([ef294b7](https://github.com/dyndynjyxa/aio-coding-hub/commit/ef294b72cb225404e2ec9d533b7d2d6143a8c265)) +* **plugins:** enforce extension host capabilities ([08b5a9a](https://github.com/dyndynjyxa/aio-coding-hub/commit/08b5a9a9087da16c822482509cf4aa2cc6001cf0)) +* **plugins:** enforce extension host manifest contract ([8ae8dbc](https://github.com/dyndynjyxa/aio-coding-hub/commit/8ae8dbc312dd298e77380b16e93ac7dd8728b65b)) +* **plugins:** execute commands through extension host registry ([689f5bc](https://github.com/dyndynjyxa/aio-coding-hub/commit/689f5bceea7e1f21ce027a4bdd4532d0a840ccd4)) +* **plugins:** execute extension commands with reports ([3a7674d](https://github.com/dyndynjyxa/aio-coding-hub/commit/3a7674dff2e31c88b6948d5dd46744848ce2a6be)) +* **plugins:** export trace replay fixtures ([4038339](https://github.com/dyndynjyxa/aio-coding-hub/commit/403833904a4b4b4022d763e37a44b3b16bcc2f43)) +* **plugins:** expose active contribution registry ([52c38fb](https://github.com/dyndynjyxa/aio-coding-hub/commit/52c38fb742e094065cad449a873bfe74d48bea9a)) +* **plugins:** make sdk contract extension host only ([5936902](https://github.com/dyndynjyxa/aio-coding-hub/commit/59369023dcdba32769613283eb61667fcf99ed39)) +* **plugins:** productize marketplace panel ([f27a9a9](https://github.com/dyndynjyxa/aio-coding-hub/commit/f27a9a9df7c9c06c1a84b17acdb7488201db54da)) +* **plugins:** productize observability and market UI ([0552e4e](https://github.com/dyndynjyxa/aio-coding-hub/commit/0552e4e006ba29660f83be0426b0ea9f5705da5c)) +* **plugins:** reject unsupported legacy plugin packages ([57c6363](https://github.com/dyndynjyxa/aio-coding-hub/commit/57c63639159b9ed2782f7feb90dada0006e2b2d5)) +* **plugins:** render host-owned UI contributions ([c80ee91](https://github.com/dyndynjyxa/aio-coding-hub/commit/c80ee914cc214c10667863458392a36ee349063f)) +* **plugins:** scaffold extension host plugins ([be77b5f](https://github.com/dyndynjyxa/aio-coding-hub/commit/be77b5f3b338f8158ea75967bd25d27f3b7f1008)) +* **plugins:** show extension contribution impact ([a762e28](https://github.com/dyndynjyxa/aio-coding-hub/commit/a762e28cb77ef91a0087148d706e8923008cc9e1)) +* **plugins:** show runtime observability in plugin details ([7fbb4e7](https://github.com/dyndynjyxa/aio-coding-hub/commit/7fbb4e701f01f4ba0b8bbb11e081368b49c10688)) +* **plugins:** wire extension host gateway hooks ([34c0314](https://github.com/dyndynjyxa/aio-coding-hub/commit/34c03142e56da6e2f3c8fbff62ac1ee5f79acc8d)) +* **providers:** add plugin extension value storage ([cd35a84](https://github.com/dyndynjyxa/aio-coding-hub/commit/cd35a84fa3f7ab2a4d4f6acf8f024ab055549e2e)) +* **providers:** 整合供应商路由排序 ([#307](https://github.com/dyndynjyxa/aio-coding-hub/issues/307)) ([d2e4465](https://github.com/dyndynjyxa/aio-coding-hub/commit/d2e4465c6de9850771253a12519335462fa10892)) + + +### Bug Fixes + +* address plugin platform review findings ([cdb9d87](https://github.com/dyndynjyxa/aio-coding-hub/commit/cdb9d87e77fd4e4a1d80e79003eeef7cbb22dfa8)) +* **cli-manager:** include exe parent dir in PATH for version probe ([#306](https://github.com/dyndynjyxa/aio-coding-hub/issues/306)) ([78d27e2](https://github.com/dyndynjyxa/aio-coding-hub/commit/78d27e2c85c4bf07ae7c6a3947d246ba5bba2051)) +* isolate provider route action at card edge ([83e8884](https://github.com/dyndynjyxa/aio-coding-hub/commit/83e888463fe2c0db01c838d116b0757f711c0c4e)) +* keep provider route action width stable ([4c74357](https://github.com/dyndynjyxa/aio-coding-hub/commit/4c743574ba24bbb9e29a13b3d4a982f5b3392719)) +* move provider route action to card edge ([9bc4145](https://github.com/dyndynjyxa/aio-coding-hub/commit/9bc4145229628e4d4d59d8e828669fa83cefb15e)) +* **plugin-devtools:** align strict rule permissions with hooks ([d6e8f5d](https://github.com/dyndynjyxa/aio-coding-hub/commit/d6e8f5ded0a067b511758e12a25b032ca1b84f96)) +* **plugin-devtools:** align strict rules with host runtime ([8381e66](https://github.com/dyndynjyxa/aio-coding-hub/commit/8381e66cbe21b1397c5086b443861254a38eb6a4)) +* **plugin-devtools:** avoid js regex strict false positives ([6f5dc4e](https://github.com/dyndynjyxa/aio-coding-hub/commit/6f5dc4ec059ceb575087e6be31a983b57d248dd5)) +* **plugin-devtools:** catch malformed strict regex syntax ([7cf4e5b](https://github.com/dyndynjyxa/aio-coding-hub/commit/7cf4e5b0af9c42660df3fc40c1f20bd6d30a4561)) +* **plugin-devtools:** check runtime file presence by path ([6478a86](https://github.com/dyndynjyxa/aio-coding-hub/commit/6478a8643647b421190f7b16a5d17c8d25df71ed)) +* **plugin-devtools:** classify empty strict rule documents ([e7df3f4](https://github.com/dyndynjyxa/aio-coding-hub/commit/e7df3f45aa0bee216e0f8f268f1cc843566d3fd1)) +* **plugin-devtools:** classify malformed doctor manifests ([ffab898](https://github.com/dyndynjyxa/aio-coding-hub/commit/ffab898636468b35a12f7801ff6fc785dfb00d9b)) +* **plugin-devtools:** diagnose malformed runtime shapes ([c082124](https://github.com/dyndynjyxa/aio-coding-hub/commit/c0821242a4c227b336190ec19d3d7d32daf53981)) +* **plugin-devtools:** explain extended regex flags ([5ee20eb](https://github.com/dyndynjyxa/aio-coding-hub/commit/5ee20eb3a19dbca26157ae95358abf1c917f335f)) +* **plugin-devtools:** explain rust inline regex flags ([213296e](https://github.com/dyndynjyxa/aio-coding-hub/commit/213296e6c86e1d63fb5cdc269b51d13533d534a7)) +* **plugin-devtools:** guard doctor manifest field shapes ([41dd845](https://github.com/dyndynjyxa/aio-coding-hub/commit/41dd845801365c30e9f0b1d30ab05e683f931f3d)) +* **plugin-devtools:** harden doctor file and metadata checks ([94cf4c0](https://github.com/dyndynjyxa/aio-coding-hub/commit/94cf4c02721f5bc2f6bb05d6cf6d666d96d394e4)) +* **plugin-devtools:** normalize malformed wasm entry checks ([1c47926](https://github.com/dyndynjyxa/aio-coding-hub/commit/1c47926dfad63f87cf2ede7b3333d09451535d07)) +* **plugin-devtools:** preserve legacy replay regex behavior ([1e1c7d8](https://github.com/dyndynjyxa/aio-coding-hub/commit/1e1c7d8f032372577051e9867228a5d118195e11)) +* **plugin-devtools:** refine strict action diagnostics ([6ea9ed7](https://github.com/dyndynjyxa/aio-coding-hub/commit/6ea9ed77ac9a6659d62eaa55b164d5c6b717b7f5)) +* **plugin-devtools:** reject alternation-leading repeaters ([0ddc5f5](https://github.com/dyndynjyxa/aio-coding-hub/commit/0ddc5f567b288b8305b284d8acfcfd6cd67138cc)) +* **plugin-devtools:** reject group-leading repeaters ([43c0eb7](https://github.com/dyndynjyxa/aio-coding-hub/commit/43c0eb776161ae786947af3af116d9f686d207de)) +* **plugin-devtools:** replay unicode regex classes ([43b8231](https://github.com/dyndynjyxa/aio-coding-hub/commit/43b82312e2d81de08db077c3004175f996e0ed2d)) +* **plugin-devtools:** return doctor diagnostics for invalid manifests ([52bbb50](https://github.com/dyndynjyxa/aio-coding-hub/commit/52bbb50a4848b5e11325b6e1f02582b4d07cbcff)) +* **plugin-devtools:** validate declarative rule path entries ([d55164f](https://github.com/dyndynjyxa/aio-coding-hub/commit/d55164f114440ead46b2136d9301457417206f3e)) +* **plugin-devtools:** validate merged strict rule shapes ([fb7494c](https://github.com/dyndynjyxa/aio-coding-hub/commit/fb7494ccffa0f47c44909e112af90de67de1604f)) +* **plugin-devtools:** validate strict rule runtime limits ([39f58c9](https://github.com/dyndynjyxa/aio-coding-hub/commit/39f58c99abac2d3e43aaab51c25e140d92832438)) +* **plugin-devtools:** validate strict rule structure ([8c59920](https://github.com/dyndynjyxa/aio-coding-hub/commit/8c59920976071133ec01a162d0b5f95acbcbb8cc)) +* **plugin-devtools:** warn on unsupported replay regex flags ([7283908](https://github.com/dyndynjyxa/aio-coding-hub/commit/7283908e5882f6f3f0c2a45b90239a106c6d128b)) +* **plugin-sdk:** align permission dependencies with hook contract ([5a105c0](https://github.com/dyndynjyxa/aio-coding-hub/commit/5a105c0a090410e3bd8b7b62acc5f804d5ebef5b)) +* **plugins:** align extension contract consumers ([517eb96](https://github.com/dyndynjyxa/aio-coding-hub/commit/517eb96df8a9fe8b4f9a8a21a66e9559ca3db82a)) +* **plugins:** align extension host docs with runtime contract ([812910d](https://github.com/dyndynjyxa/aio-coding-hub/commit/812910d8d5a1c680e672931fb924ec8b60c045ab)) +* **plugins:** align extension host scaffold checks ([89c2fbf](https://github.com/dyndynjyxa/aio-coding-hub/commit/89c2fbf2b6360b9241870125e418e98b5bbb5fb4)) +* **plugins:** align lifecycle rollback and trust state ([5891e62](https://github.com/dyndynjyxa/aio-coding-hub/commit/5891e6200fcc657074e946d7e51f26cf7cf781f3)) +* **plugins:** align manifest api version contract ([0e99322](https://github.com/dyndynjyxa/aio-coding-hub/commit/0e993229d049a06498e50304e405cfa5e2e232ae)) +* **plugins:** align service permissions with extension host runtime ([eb4fceb](https://github.com/dyndynjyxa/aio-coding-hub/commit/eb4fceb87c40c7ab8ee44d295b75df1193408f09)) +* **plugins:** allow extension manifests in devtools validation ([f961f66](https://github.com/dyndynjyxa/aio-coding-hub/commit/f961f66effd97638b66de9933982b21dcbffb319)) +* **plugins:** avoid global registry lock during host execution ([6265064](https://github.com/dyndynjyxa/aio-coding-hub/commit/6265064b877b78ce55ecc2bede6d65c09921b167)) +* **plugins:** block reserved official market listings ([bdbd629](https://github.com/dyndynjyxa/aio-coding-hub/commit/bdbd629ed0372e7dde9966351dffcbc149104d58)) +* **plugins:** block unroutable market cards ([35a89bd](https://github.com/dyndynjyxa/aio-coding-hub/commit/35a89bdd092b1283ec9e74c51b9cd24b9b0a0553)) +* **plugins:** bound runtime artifact reads ([13141ca](https://github.com/dyndynjyxa/aio-coding-hub/commit/13141ca8b479175af7c3854ed2829181642a6f59)) +* **plugins:** clarify lifecycle preview UI ([96e9241](https://github.com/dyndynjyxa/aio-coding-hub/commit/96e9241449443729a9a177f7d079c936d0ddb790)) +* **plugins:** clarify protocol bridge boundary ([574b093](https://github.com/dyndynjyxa/aio-coding-hub/commit/574b09395d79fbb4d348ea01ba3d38b097a8afdd)) +* **plugins:** clean runtime report pruning ([4dfd5ab](https://github.com/dyndynjyxa/aio-coding-hub/commit/4dfd5abfb386a97aad8cc425d40b674d58a69d02)) +* **plugins:** clean up extension host gateway lifecycles ([2eab4ca](https://github.com/dyndynjyxa/aio-coding-hub/commit/2eab4ca9b80566a3785ca66bd91008d42698e2af)) +* **plugins:** derive installed market state from summaries ([d68c07e](https://github.com/dyndynjyxa/aio-coding-hub/commit/d68c07e41d482fd916f0fc6fb78ec434770c5010)) +* **plugins:** detect null gateway rules marker ([50626d9](https://github.com/dyndynjyxa/aio-coding-hub/commit/50626d96e4a48ebe17baabcfd72979bfecd123fb)) +* **plugins:** dispose extension hosts on plugin lifecycle changes ([a716901](https://github.com/dyndynjyxa/aio-coding-hub/commit/a716901580d60eb86a271f8016c53e6a101b52ef)) +* **plugins:** drop failed warm extension hosts ([d9bcf1d](https://github.com/dyndynjyxa/aio-coding-hub/commit/d9bcf1d1c90053a6715c2ffb21fcddf2669e85c8)) +* **plugins:** enforce command capability at worker dispatch ([0036ba6](https://github.com/dyndynjyxa/aio-coding-hub/commit/0036ba6685c4f1b985b78936145a36e60bda9c38)) +* **plugins:** enforce full devtools contract drift checks ([ee4cc1d](https://github.com/dyndynjyxa/aio-coding-hub/commit/ee4cc1dcdc6e14d58737936d028dbb5a9600bb69)) +* **plugins:** guard pending permission selection ([10eadd9](https://github.com/dyndynjyxa/aio-coding-hub/commit/10eadd9ce8ef7e69954a0530546319d9f5fdf297)) +* **plugins:** guard registry dispose all against in flight starts ([8b2a37d](https://github.com/dyndynjyxa/aio-coding-hub/commit/8b2a37dedb90fba68126730c4a50d54e6d2f0ca7)) +* **plugins:** guard stale declarative rules plans ([24799a8](https://github.com/dyndynjyxa/aio-coding-hub/commit/24799a8d9bd266f40d318f873215f220c868e658)) +* **plugins:** harden runtime mutation boundaries ([1c060ce](https://github.com/dyndynjyxa/aio-coding-hub/commit/1c060ce059b7c6c57c5a5948f17850e7b7cad6ba)) +* **plugins:** honor gateway hook timeout and fallback executor ([1df1091](https://github.com/dyndynjyxa/aio-coding-hub/commit/1df1091b1f13ef37fc1fa8df1037de5842631a3f)) +* **plugins:** isolate provider UI contribution state ([6e753e6](https://github.com/dyndynjyxa/aio-coding-hub/commit/6e753e62fd4fd5e83c8cf7d6e1ea6be4674577c5)) +* **plugins:** make create plugin docs command executable ([b5c2fc4](https://github.com/dyndynjyxa/aio-coding-hub/commit/b5c2fc4c1935e51d97f41ab2c0b1c66a5ecc2e0e)) +* **plugins:** make lifecycle state writes transactional ([65060d7](https://github.com/dyndynjyxa/aio-coding-hub/commit/65060d71400ac7ebe0ae6b820008dff77eea07e7)) +* **plugins:** make native runtime preview source aware ([c0e42f6](https://github.com/dyndynjyxa/aio-coding-hub/commit/c0e42f633a3a57bb4147c542d2d16e0f9e6b39a9)) +* **plugins:** mark stale replay plans superseded ([51263e9](https://github.com/dyndynjyxa/aio-coding-hub/commit/51263e94c27d14c61ca072372979cb707890b19b)) +* **plugins:** normalize third party native privacy filter rows ([81a0bec](https://github.com/dyndynjyxa/aio-coding-hub/commit/81a0bec9a198c4cb298628bcf0dcb94d44cc2880)) +* **plugins:** prefer exact market source trust ([4fbbba3](https://github.com/dyndynjyxa/aio-coding-hub/commit/4fbbba31334b814b9752e6e24a2cec235618d21a)) +* **plugins:** preserve market source trust context ([eb1260e](https://github.com/dyndynjyxa/aio-coding-hub/commit/eb1260e51b421823b5248483fcc1272483a3ea0f)) +* **plugins:** prevent stale replay developer paths ([cc149d9](https://github.com/dyndynjyxa/aio-coding-hub/commit/cc149d92e8d5180d11a89d6db9255e6c21148af1)) +* **plugins:** prune runtime reports ([b88feb9](https://github.com/dyndynjyxa/aio-coding-hub/commit/b88feb927c056bd6066d4e167b873cb4bac14ea1)) +* **plugins:** reconcile rollback state ([69a39ce](https://github.com/dyndynjyxa/aio-coding-hub/commit/69a39ce03c4bec7e5e19f4ce735859644cb0165a)) +* **plugins:** reconcile selection after uninstall ([306b5b1](https://github.com/dyndynjyxa/aio-coding-hub/commit/306b5b124d2099c0b6621c8d07b702c8758d2ad4)) +* **plugins:** refine contribution impact diff ([330418c](https://github.com/dyndynjyxa/aio-coding-hub/commit/330418c7c4f4e27c2a3bf41e4e223444549106ca)) +* **plugins:** reject command execution before activation without capability ([2f2ba02](https://github.com/dyndynjyxa/aio-coding-hub/commit/2f2ba02b9075de7fdd8db98f0f776b51504e46ea)) +* **plugins:** reject gateway rules by presence ([b940985](https://github.com/dyndynjyxa/aio-coding-hub/commit/b940985020e3abbced44bf81c5519316d35cb8f8)) +* **plugins:** route advanced marketplace installs remotely ([56d39d7](https://github.com/dyndynjyxa/aio-coding-hub/commit/56d39d7b34c27a43c1f8870947710e69e0c88d4d)) +* **plugins:** satisfy privacy filter clippy check ([d6d93ba](https://github.com/dyndynjyxa/aio-coding-hub/commit/d6d93bac2c86f989077ae978bbd899de7c423525)) +* **plugins:** serialize registry starts per plugin ([c5184ff](https://github.com/dyndynjyxa/aio-coding-hub/commit/c5184fff836b96499571f79a09a53ed01dee88a0)) +* **plugins:** simplify plugins page user layout ([3b70933](https://github.com/dyndynjyxa/aio-coding-hub/commit/3b7093309a0c20a42d9681d65189ae5639647346)) +* **plugins:** stabilize unsupported gateway rules validation ([70b089b](https://github.com/dyndynjyxa/aio-coding-hub/commit/70b089b49d66decd7ba7203c13cdf59e22e19322)) +* **plugins:** sync extension manifest contract ([8dec73f](https://github.com/dyndynjyxa/aio-coding-hub/commit/8dec73f9639e6f3fee6c46de03df69d998e16a17)) +* **plugins:** trim advanced market source inputs ([5da7508](https://github.com/dyndynjyxa/aio-coding-hub/commit/5da750892e12ac8f0f7d22b60a917f5cceeaca5f)) +* **plugins:** validate extension contributions ([718f93c](https://github.com/dyndynjyxa/aio-coding-hub/commit/718f93c9fffdea23ab1763452380118f546e7d80)) +* **plugins:** validate extension host package shape ([8463f4f](https://github.com/dyndynjyxa/aio-coding-hub/commit/8463f4f35bcebdcdd574426fc059e0bfacddb882)) +* **plugins:** validate lifecycle rollback target ([41a1e45](https://github.com/dyndynjyxa/aio-coding-hub/commit/41a1e4507b7d3c3c4005c6f0e62f684570398a4f)) +* **plugins:** validate protocol bridge ids in host ([239d98e](https://github.com/dyndynjyxa/aio-coding-hub/commit/239d98eceb17d7ed7260f4af1bbe3e7b09d9549d)) +* **providers:** tighten extension value storage semantics ([aa08ed9](https://github.com/dyndynjyxa/aio-coding-hub/commit/aa08ed9f9435fd394f592b3f4ad0a51f67332459)) +* recognize claude message_stop stream marker ([b8d5df0](https://github.com/dyndynjyxa/aio-coding-hub/commit/b8d5df0a41b2be7dae54158952a8282f4f4742ee)) +* restore recharts component identity ([b325aa4](https://github.com/dyndynjyxa/aio-coding-hub/commit/b325aa432077543bd93f0be6004eba9cd063ca0a)) +* **updater:** open changelog links externally ([7f4e332](https://github.com/dyndynjyxa/aio-coding-hub/commit/7f4e332bee95fc486c391633241c1d5264a48f23)) + + +### Code Refactoring + +* **gateway:** add provider adapter capability facade ([773992b](https://github.com/dyndynjyxa/aio-coding-hub/commit/773992b2b0d5fa70cdd2e46fc36ecbafd4909519)) +* **gateway:** route cx2cc count tokens through provider adapter ([d7d54ed](https://github.com/dyndynjyxa/aio-coding-hub/commit/d7d54ed1128000f1fc628c0b1a81fa614c113759)) +* **plugins:** add internal hook registry ([108e637](https://github.com/dyndynjyxa/aio-coding-hub/commit/108e637d13ecb7a8b177f561ce6888c3da0bdc3e)) +* **plugins:** centralize rust plugin contract metadata ([395f69b](https://github.com/dyndynjyxa/aio-coding-hub/commit/395f69b3991f889896bf1a6fc6f644a33fd98e3c)) +* **plugins:** enforce mutations through hook descriptors ([ff52064](https://github.com/dyndynjyxa/aio-coding-hub/commit/ff5206440360dad9d6c2525c9c723a75bab04919)) +* **plugins:** extract runtime reports panel ([4c43df7](https://github.com/dyndynjyxa/aio-coding-hub/commit/4c43df7f8f0b91b4797245f048ff2bcc50dea824)) +* **plugins:** introduce runtime manager policy facade ([61e0886](https://github.com/dyndynjyxa/aio-coding-hub/commit/61e0886ccc44b0f792f1e60fbe356742b2cb3d64)) +* **plugins:** migrate privacy filter to extension host ([6cbd01b](https://github.com/dyndynjyxa/aio-coding-hub/commit/6cbd01b7651caf435c858ed7966f9589939e383e)) +* **plugins:** remove declarative rule runtime dispatch ([e5a1c5d](https://github.com/dyndynjyxa/aio-coding-hub/commit/e5a1c5d124b56350da1f7a2231d7fc691b708f1f)) +* **plugins:** remove declarative rules runtime ([8062b5e](https://github.com/dyndynjyxa/aio-coding-hub/commit/8062b5ea9c673b7e1412da96143ab1dfe77319ec)) +* **plugins:** share runtime cache key helpers ([05e685a](https://github.com/dyndynjyxa/aio-coding-hub/commit/05e685a94bc3c7b7e77777161386945c07c4543d)) +* **plugins:** split official privacy filter runtime ([3b8ed70](https://github.com/dyndynjyxa/aio-coding-hub/commit/3b8ed702a93d6bd7895dd60fa40a60fffeb33380)) +* **plugins:** 提取并复用更新插件详情和概要的逻辑函数 ([31bafc1](https://github.com/dyndynjyxa/aio-coding-hub/commit/31bafc12e5a3e81b15ebcd1eb940681269a8f2d3)) +* **providers:** 移除排序模板相关代码并简化启用开关逻辑 ([27258af](https://github.com/dyndynjyxa/aio-coding-hub/commit/27258af0c725f731a568e6b179c6b0faf9a5345b)) +* resolve react doctor diagnostics ([a28dc41](https://github.com/dyndynjyxa/aio-coding-hub/commit/a28dc41582f6654852df71b6623c7715f49a93c7)) + ## [0.60.2](https://github.com/dyndynjyxa/aio-coding-hub/compare/aio-coding-hub-v0.60.1...aio-coding-hub-v0.60.2) (2026-06-21) diff --git a/README.md b/README.md index cc5eece4..4a54081b 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,16 @@ **本地 AI CLI 统一网关** — 让 Claude Code / Codex / Gemini CLI 请求走同一个入口 [![Release](https://img.shields.io/github/v/release/dyndynjyxa/aio-coding-hub?style=flat-square)](https://github.com/dyndynjyxa/aio-coding-hub/releases) +[![Downloads](https://img.shields.io/github/downloads/dyndynjyxa/aio-coding-hub/total?style=flat-square)](https://github.com/dyndynjyxa/aio-coding-hub/releases) [![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE) [![Platform](https://img.shields.io/badge/platform-Windows%20|%20macOS%20|%20Linux-lightgrey?style=flat-square)](#安装) +[![Tauri](https://img.shields.io/badge/built%20with-Tauri%202-24C8DB?style=flat-square&logo=tauri&logoColor=white)](https://tauri.app/) 简体中文 | [English](./README_EN.md) - +[安装](#安装) · [快速开始](#快速开始) · [核心功能](#核心功能) · [工作原理](#工作原理) · [FAQ](#faq) · [参与贡献](#参与贡献) -> **致谢** — 本项目借鉴了 [cc-switch](https://github.com/farion1231/cc-switch)、[claude-code-hub](https://github.com/ding113/claude-code-hub)、[code-switch-R](https://github.com/Rogers-F/code-switch-R) 等优秀开源项目。 + --- @@ -27,6 +29,8 @@ | 不知道用了多少 Token 和花了多少钱 | **全链路可观测** — Trace 追踪、用量统计、花费估算 | | 不同项目需要不同的 Prompts / MCP 配置 | **工作区隔离** — 按项目管理 CLI 配置,一键切换 | +> 本项目定位为 **单机桌面工具 + 本地网关**:网关只监听 `127.0.0.1`,所有数据保存在本机,不做公网部署、远程访问和多租户。 + --- ## 产品截图 @@ -47,14 +51,14 @@ ## 核心功能 -### 网关代理 +### 🔀 网关代理 - 单一入口代理 Claude Code / Codex / Gemini CLI 请求 - 首页每个 CLI 独立代理开关,一键启停 - 自定义模型名称映射 - SSE / JSON 响应自动修复 -### 智能路由与容错 +### 🛡️ 智能路由与容错 - 多供应商优先级排序 + 自动故障转移 - 熔断器模式(可配置阈值与恢复时间) @@ -62,7 +66,7 @@ - 排序模板:多套供应商组合,三个 CLI 各自激活 - 模板内拖拽排序、独立 enabled 开关、切换即时生效 -### 用量与可观测 +### 📊 用量与可观测 - Token 用量统计(按 CLI / 供应商 / 模型维度) - 花费估算 + 模型价格自动同步 @@ -71,41 +75,41 @@ - 缓存走势图:分供应商命中率折线,60% 预警线 - 可用率:供应商时间线点阵,15s 自动刷新 -### 工作区管理 +### 🗂️ 工作区管理 - 按项目隔离 Prompts、MCP、Skill 配置 - 工作区对比、克隆、切换与回滚 - 配置自动同步到各 CLI -### Skill 市场 +### 🧩 Skill 市场 - 从 Git 仓库发现并安装 Skill - 仓库管理、过滤、排序 - 关联工作区批量管理 -### 插件系统 +### 🔌 插件系统 - 官方内置插件:Privacy Filter -- 声明式规则插件:请求、响应、流式 chunk、日志 hook +- Extension Host 插件:命令、Provider 扩展值、网关 hook、协议桥骨架、宿主渲染 UI - 插件权限、配置 schema、审计日志、启用 / 禁用 / 卸载 - SDK 与脚手架:`@aio-coding-hub/plugin-sdk`、`create-aio-plugin` -插件作者应从 [插件开发手册](docs/plugins/README.md) 开始。稳定社区运行时是 `declarativeRules`;WASM 可以用于 ABI 实验和打包验证,但 gateway execution 仍受宿主策略控制。 +插件作者应从 [插件开发手册](docs/plugins/README.md) 开始。社区插件统一使用 Extension Host;旧的预发布规则 / WASM / 进程运行时只作为不支持的迁移历史处理。 -### CLI 管理 +### 🖥️ CLI 管理 - Claude Code 设置直接编辑 - Codex config.toml 代码编辑器 - 环境变量冲突检测 - 本地 Session 历史浏览(项目 → 会话 → 消息) -### 模型验证 +### ✅ 模型验证 - 多维度验证模板(Token 截断、Extended Thinking 等) - 跨供应商签名验证 - 批量验证 + 历史记录 -### 其他 +### ⚙️ 其他 - 自动更新、开机自启、单实例 - 数据导入 / 导出 / 清空 @@ -115,8 +119,6 @@ ## 安装 -### 从 Release 下载(推荐) - 前往 [Releases](https://github.com/dyndynjyxa/aio-coding-hub/releases) 下载对应平台安装包: @@ -130,10 +132,63 @@ 官方支持矩阵只覆盖上表 4 个目标。`mac:universal` 和 `win:arm64` 只保留本地构建命令,不进入 Release 产物和 `latest.json`。 -
-Linux Arch / Wayland 用户 +### macOS -**推荐:AUR 软件包**(使用系统库,兼容性最好) +**方式一:Homebrew(推荐)** + +```bash +brew tap dyndynjyxa/aio-coding-hub +brew install --cask aio-coding-hub +``` + +后续升级: + +```bash +brew update +brew upgrade --cask aio-coding-hub +``` + +**方式二:手动下载** + +从 [Releases](https://github.com/dyndynjyxa/aio-coding-hub/releases) 下载对应芯片的 `.zip`(Apple Silicon 选 `arm`,Intel 选 `intel`),解压后把 `AIO Coding Hub.app` 拖入「应用程序」文件夹。 + +> [!IMPORTANT] +> **首次打开提示"已损坏"或"无法验证开发者"?** +> +> 当前 macOS 安装包**未经 Apple 开发者证书签名与公证**,Gatekeeper 会拦截首次启动。任选一种方式处理: +> +> **① 移除隔离属性(推荐,一条命令)** +> +> ```bash +> sudo xattr -cr "/Applications/AIO Coding Hub.app" +> ``` +> +> **② 系统设置放行** +> +> 首次双击被拦截后,打开「系统设置 → 隐私与安全性」,在页面底部点击「仍要打开」。 +> +> **③ 本地自签名(可选,一劳永逸)** +> +> 用 ad-hoc 签名替换掉无效签名,之后系统升级也不会再提示: +> +> ```bash +> sudo codesign --force --deep --sign - "/Applications/AIO Coding Hub.app" +> ``` +> +> 以上处理只需在首次安装或手动覆盖安装后执行一次。 + +### Windows + +从 [Releases](https://github.com/dyndynjyxa/aio-coding-hub/releases) 下载: + +- `.msi` — 标准安装包,支持自动更新 +- `-portable.zip` — 免安装便携版,解压即用 + +### Linux + +从 [Releases](https://github.com/dyndynjyxa/aio-coding-hub/releases) 下载 `.deb`(Debian / Ubuntu)或 `.AppImage`(通用)。 + +**Arch Linux(AUR,推荐)** — 使用系统库,兼容性最好: ```bash paru -S aio-coding-hub-bin @@ -141,7 +196,8 @@ paru -S aio-coding-hub-bin yay -S aio-coding-hub-bin ``` -**AppImage 用户** +
+Wayland 白屏 / 启动崩溃排查 应用在 Wayland 下启动时会自动检测并注入 `WEBKIT_DISABLE_COMPOSITING_MODE=1` 以避免 EGL 冲突崩溃(见 [issue #93](https://github.com/dyndynjyxa/aio-coding-hub/issues/93))。 若仍遇到白屏,可改用 Release 中附带的 `*-wayland.AppImage`(已剥离内置 EGL/Mesa 库,使用系统版本): @@ -153,17 +209,6 @@ yay -S aio-coding-hub-bin
-
-macOS 安全提示 - -若遇到"无法打开 / 来源未验证"提示: - -```bash -sudo xattr -cr /Applications/"AIO Coding Hub.app" -``` - -
- ### 从源码构建
@@ -214,11 +259,10 @@ pnpm tauri:build ## 快速开始 -``` -1. 供应商页 → 添加上游(官方 API / 自建代理 / 公司网关) -2. 首页 → 打开目标 CLI 的"代理"开关 -3. 终端发起请求 → 在控制台 / 用量页查看 Trace 与统计 -``` +1. **添加供应商** — 打开「供应商」页,添加上游(官方 API / 自建代理 / 公司网关) +2. **打开代理** — 首页打开目标 CLI 的「代理」开关,请求即经由本机网关转发 +3. **照常使用 CLI** — 在终端正常使用 Claude Code / Codex / Gemini CLI +4. **查看统计** — 在控制台 / 用量页查看 Trace、Token 用量与花费 验证网关运行: @@ -227,14 +271,52 @@ curl http://127.0.0.1:37123/health # {"status":"ok"} ``` -### 插件开发文档 +--- -插件系统面向社区扩展,短期优先支持安全的声明式规则插件。开发入口: +## 工作原理 + +``` + Claude Code ──┐ + Codex ─┼──▶ AIO Coding Hub 网关 (127.0.0.1:37123) ──▶ 供应商 A(优先级 1) + Gemini CLI ──┘ 排序模板 · 熔断器 · Failover · 用量计量 ├▶ 供应商 B(优先级 2) + └▶ 供应商 C(优先级 3) +``` + +三个 CLI 的请求统一进入本机网关;网关按当前激活的排序模板选择供应商,失败时自动熔断并切换到下一个,同时记录 Trace、Token 用量与花费。 + +--- + +## FAQ + +**macOS 提示"已损坏,无法打开"或"无法验证开发者"?** + +安装包未经 Apple 签名公证,属预期行为。参见 [macOS 安装说明](#macos),执行 `sudo xattr -cr "/Applications/AIO Coding Hub.app"` 即可。 + +**网关端口是多少?如何确认网关在运行?** + +默认监听 `127.0.0.1:37123`。执行 `curl http://127.0.0.1:37123/health`,返回 `{"status":"ok"}` 即正常。 + +**我的 API Key 和请求数据会上传吗?** + +不会。网关只监听本机回环地址,所有配置与统计数据保存在本地 SQLite 数据库中。 + +**Linux Wayland 下白屏或启动崩溃?** + +参见 [Linux 安装说明](#linux) 中的 Wayland 排查折叠块,或改用 `*-wayland.AppImage`。 + +**哪些平台有自动更新?** + +官方支持矩阵内的 4 个目标(Windows x64、macOS Intel / Apple Silicon、Linux x64)进入 Release 与 updater 通道;`mac:universal`、`win:arm64` 仅提供本地构建脚本。 + +--- + +## 插件开发文档 + +插件系统面向社区扩展,社区插件统一使用 Extension Host。开发入口: - [插件开发总览](docs/plugins/README.md) - [插件开发总指南](docs/plugins/developer-guide.md) - [Plugin SDK](docs/plugins/reference/sdk.md) -- [声明式规则 Runtime](docs/plugins/reference/declarative-rules.md) - [官方示例插件](docs/plugins/examples/privacy-filter.md) - [插件 API 参考](docs/plugins/reference/README.md) - [Manifest v1 规范](docs/plugin-manifest-v1.md) @@ -254,36 +336,35 @@ curl http://127.0.0.1:37123/health --- -## 质量保证 +## 参与贡献 + +欢迎提交 Issue 和 PR!采用 [Conventional Commits](https://www.conventionalcommits.org/) 规范。 + +```bash +feat(ui): add usage heatmap +fix(gateway): handle timeout correctly +docs: update installation guide +``` + +提交 PR 前请本地跑一遍检查: ```bash pnpm check:precommit # 快速预提交检查(前端 + Rust check) pnpm check:precommit:full # 完整检查(格式 + clippy) pnpm check:prepush # 覆盖率 + 后端测试 + clippy -pnpm test:unit # 前端单元测试 -pnpm tauri:test # 后端测试 +pnpm test:unit # 前端单元测试 +pnpm tauri:test # 后端测试 ``` --- -## 不适用场景 +## 致谢 -- 公网部署 / 远程访问 / 多租户 -- 企业级 RBAC 权限管理 +本项目借鉴了以下优秀开源项目: -> 本项目定位为 **单机桌面工具 + 本地网关**,所有数据保存在本机。 - ---- - -## 参与贡献 - -欢迎提交 Issue 和 PR!采用 [Conventional Commits](https://www.conventionalcommits.org/) 规范。 - -```bash -feat(ui): add usage heatmap -fix(gateway): handle timeout correctly -docs: update installation guide -``` +- [cc-switch](https://github.com/farion1231/cc-switch) +- [claude-code-hub](https://github.com/ding113/claude-code-hub) +- [code-switch-R](https://github.com/Rogers-F/code-switch-R) --- @@ -291,6 +372,12 @@ docs: update installation guide [MIT License](LICENSE) ---- +## Star History -[![Stargazers over time](https://starchart.cc/dyndynjyxa/aio-coding-hub.svg?variant=adaptive)](https://starchart.cc/dyndynjyxa/aio-coding-hub) + + + + + Star History Chart + + diff --git a/README_EN.md b/README_EN.md index 73cd2f73..b3e39567 100644 --- a/README_EN.md +++ b/README_EN.md @@ -6,14 +6,16 @@ **Local AI CLI Unified Gateway** — Route Claude Code / Codex / Gemini CLI through a single entry point [![Release](https://img.shields.io/github/v/release/dyndynjyxa/aio-coding-hub?style=flat-square)](https://github.com/dyndynjyxa/aio-coding-hub/releases) +[![Downloads](https://img.shields.io/github/downloads/dyndynjyxa/aio-coding-hub/total?style=flat-square)](https://github.com/dyndynjyxa/aio-coding-hub/releases) [![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE) [![Platform](https://img.shields.io/badge/platform-Windows%20|%20macOS%20|%20Linux-lightgrey?style=flat-square)](#installation) +[![Tauri](https://img.shields.io/badge/built%20with-Tauri%202-24C8DB?style=flat-square&logo=tauri&logoColor=white)](https://tauri.app/) [简体中文](./README.md) | English - +[Installation](#installation) · [Quick Start](#quick-start) · [Features](#features) · [How It Works](#how-it-works) · [FAQ](#faq) · [Contributing](#contributing) -> **Credits** — Inspired by [cc-switch](https://github.com/farion1231/cc-switch), [claude-code-hub](https://github.com/ding113/claude-code-hub), and [code-switch-R](https://github.com/Rogers-F/code-switch-R). + --- @@ -27,6 +29,8 @@ | No idea how many tokens or how much it costs | **Full observability** — trace, usage stats, cost estimation | | Different projects need different Prompts / MCP configs | **Workspace isolation** — per-project CLI config, one-click switch | +> This is a **local desktop tool + local gateway**: it listens on `127.0.0.1` only, all data stays on your machine — no public deployment, remote access, or multi-tenancy. + --- ## Screenshots @@ -47,14 +51,14 @@ ## Features -### Gateway Proxy +### 🔀 Gateway Proxy - Single entry point for Claude Code / Codex / Gemini CLI - Per-CLI proxy toggle on Home, one-click on/off - Custom model name mapping - Auto-fix for SSE / JSON responses -### Smart Routing & Resilience +### 🛡️ Smart Routing & Resilience - Multi-provider priority ordering + automatic failover - Circuit breaker (configurable threshold & recovery time) @@ -62,7 +66,7 @@ - Sort templates: multiple provider sets, activated per CLI - Drag-to-reorder, per-provider toggle, instant switching -### Usage & Observability +### 📊 Usage & Observability - Token usage analytics (by CLI / provider / model) - Cost estimation + auto-synced model pricing @@ -71,32 +75,41 @@ - Cache trend chart: per-provider hit rate, 60% warning line - Availability: provider timeline dots, 15s auto-refresh -### Workspace Management +### 🗂️ Workspace Management - Per-project isolation for Prompts, MCP, and Skill configs - Workspace compare, clone, switch & rollback - Auto-sync configs to each CLI -### Skill Market +### 🧩 Skill Market - Discover and install Skills from Git repositories - Repository management, filtering, and sorting - Batch management linked to workspaces -### CLI Management +### 🔌 Plugin System + +- Official built-in plugin: Privacy Filter +- Extension Host plugins: commands, provider extension values, gateway hooks, protocol bridge skeleton, host-rendered UI +- Plugin permissions, config schema, audit log, enable / disable / uninstall +- SDK & scaffolding: `@aio-coding-hub/plugin-sdk`, `create-aio-plugin` + +Plugin authors should start from the [Plugin Developer Guide](docs/plugins/README.md). Community plugins use the Extension Host exclusively. + +### 🖥️ CLI Management - Direct editing of Claude Code settings - CodeMirror editor for Codex config.toml - Environment variable conflict detection - Local session history browser (project → session → messages) -### Model Validation +### ✅ Model Validation - Multi-dimensional validation templates (token truncation, Extended Thinking, etc.) - Cross-provider signature verification - Batch validation + history -### More +### ⚙️ More - Auto-update, autostart, single instance - Data import / export / reset @@ -106,8 +119,6 @@ ## Installation -### Download from Releases (Recommended) - Go to [Releases](https://github.com/dyndynjyxa/aio-coding-hub/releases) and download for your platform: @@ -121,10 +132,63 @@ Go to [Releases](https://github.com/dyndynjyxa/aio-coding-hub/releases) and down The official support matrix only covers those four targets. `mac:universal` and `win:arm64` remain local build scripts and do not ship in Release assets or `latest.json`. -
-Linux Arch / Wayland users +### macOS + +**Option 1: Homebrew (recommended)** + +```bash +brew tap dyndynjyxa/aio-coding-hub +brew install --cask aio-coding-hub +``` + +To upgrade later: + +```bash +brew update +brew upgrade --cask aio-coding-hub +``` + +**Option 2: Manual download** + +Download the `.zip` matching your chip from [Releases](https://github.com/dyndynjyxa/aio-coding-hub/releases) (`arm` for Apple Silicon, `intel` for Intel), unzip, and drag `AIO Coding Hub.app` into your Applications folder. + +> [!IMPORTANT] +> **Seeing "damaged and can't be opened" or "unverified developer" on first launch?** +> +> The macOS packages are **not signed or notarized with an Apple Developer certificate**, so Gatekeeper blocks the first launch. Pick any of the following: +> +> **① Remove the quarantine attribute (recommended, one command)** +> +> ```bash +> sudo xattr -cr "/Applications/AIO Coding Hub.app" +> ``` +> +> **② Allow via System Settings** +> +> After the first blocked launch, open **System Settings → Privacy & Security** and click **Open Anyway** at the bottom. +> +> **③ Self-sign locally (optional, permanent)** +> +> Replace the invalid signature with an ad-hoc one so macOS never prompts again: +> +> ```bash +> sudo codesign --force --deep --sign - "/Applications/AIO Coding Hub.app" +> ``` +> +> You only need to do this once per install or manual re-install. + +### Windows + +Download from [Releases](https://github.com/dyndynjyxa/aio-coding-hub/releases): -**Recommended: AUR package** (uses system libraries, best compatibility) +- `.msi` — standard installer with auto-update support +- `-portable.zip` — portable version, unzip and run + +### Linux + +Download `.deb` (Debian / Ubuntu) or `.AppImage` (universal) from [Releases](https://github.com/dyndynjyxa/aio-coding-hub/releases). + +**Arch Linux (AUR, recommended)** — uses system libraries, best compatibility: ```bash paru -S aio-coding-hub-bin @@ -132,7 +196,8 @@ paru -S aio-coding-hub-bin yay -S aio-coding-hub-bin ``` -**AppImage users** +
+Wayland blank window / startup crash The app automatically detects Wayland sessions and sets `WEBKIT_DISABLE_COMPOSITING_MODE=1` to prevent EGL display initialisation crashes (see [issue #93](https://github.com/dyndynjyxa/aio-coding-hub/issues/93)). @@ -146,17 +211,6 @@ If you still see a blank white window, use the `*-wayland.AppImage` artifact fro
-
-macOS security note - -If you see "can't be opened / unverified developer": - -```bash -sudo xattr -cr /Applications/"AIO Coding Hub.app" -``` - -
- ### Build from Source
@@ -171,7 +225,7 @@ sudo xattr -cr /Applications/"AIO Coding Hub.app" **Linux (Ubuntu/Debian):** ```bash sudo apt-get update -sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf +sudo apt-get install -y libasound2-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf ```
@@ -207,11 +261,10 @@ Only the "Official" rows above feed GitHub Releases and auto-update. The "Local ## Quick Start -``` -1. Providers page → Add upstream (official API / self-hosted proxy / company gateway) -2. Home page → Toggle "Proxy" switch for target CLI -3. Run CLI in terminal → View trace & stats in Console / Usage page -``` +1. **Add a provider** — Open the Providers page and add an upstream (official API / self-hosted proxy / company gateway) +2. **Turn on the proxy** — On the Home page, toggle the "Proxy" switch for the target CLI; requests now route through the local gateway +3. **Use your CLI as usual** — Run Claude Code / Codex / Gemini CLI in the terminal, nothing else changes +4. **Watch the numbers** — Check traces, token usage, and cost in the Console / Usage pages Verify the gateway is running: @@ -222,6 +275,56 @@ curl http://127.0.0.1:37123/health --- +## How It Works + +``` + Claude Code ──┐ + Codex ─┼──▶ AIO Coding Hub Gateway (127.0.0.1:37123) ──▶ Provider A (priority 1) + Gemini CLI ──┘ sort templates · circuit breaker · failover ├▶ Provider B (priority 2) + · usage metering └▶ Provider C (priority 3) +``` + +All three CLIs send requests to the local gateway. The gateway picks a provider based on the active sort template, trips the circuit breaker and fails over to the next provider on errors, and records traces, token usage, and cost along the way. + +--- + +## FAQ + +**macOS says the app "is damaged" or comes from an "unverified developer"?** + +Expected — the packages are not Apple-signed or notarized. See the [macOS install notes](#macos); running `sudo xattr -cr "/Applications/AIO Coding Hub.app"` fixes it. + +**What port does the gateway use? How do I check it's running?** + +It listens on `127.0.0.1:37123` by default. Run `curl http://127.0.0.1:37123/health` — `{"status":"ok"}` means it's up. + +**Do my API keys or request data ever leave my machine?** + +No. The gateway only listens on the loopback interface, and all config and stats live in a local SQLite database. + +**Blank window or crash on Linux Wayland?** + +See the Wayland troubleshooting section under [Linux installation](#linux), or use the `*-wayland.AppImage` artifact. + +**Which platforms get auto-updates?** + +The four official targets (Windows x64, macOS Intel / Apple Silicon, Linux x64) ship through Releases and the updater channel; `mac:universal` and `win:arm64` are local-build-only. + +--- + +## Plugin Development + +The plugin system is open to community extensions, built exclusively on the Extension Host: + +- [Plugin Overview](docs/plugins/README.md) +- [Developer Guide](docs/plugins/developer-guide.md) +- [Plugin SDK](docs/plugins/reference/sdk.md) +- [Official Example Plugin](docs/plugins/examples/privacy-filter.md) +- [Plugin API Reference](docs/plugins/reference/README.md) +- [Manifest v1 Spec](docs/plugin-manifest-v1.md) + +--- + ## Tech Stack | Layer | Technology | @@ -235,36 +338,35 @@ curl http://127.0.0.1:37123/health --- -## Quality Assurance +## Contributing + +Issues and PRs welcome! We follow [Conventional Commits](https://www.conventionalcommits.org/). + +```bash +feat(ui): add usage heatmap +fix(gateway): handle timeout correctly +docs: update installation guide +``` + +Please run the checks locally before opening a PR: ```bash pnpm check:precommit # Quick pre-commit (frontend + Rust check) pnpm check:precommit:full # Full check (formatting + clippy) pnpm check:prepush # Coverage + backend tests + clippy -pnpm test:unit # Frontend unit tests -pnpm tauri:test # Backend tests +pnpm test:unit # Frontend unit tests +pnpm tauri:test # Backend tests ``` --- -## Not Designed For - -- Public deployment / remote access / multi-tenant -- Enterprise RBAC - -> This is a **local desktop tool + local gateway**. All data stays on your machine. - ---- - -## Contributing +## Credits -Issues and PRs welcome! We follow [Conventional Commits](https://www.conventionalcommits.org/). +Inspired by these excellent open-source projects: -```bash -feat(ui): add usage heatmap -fix(gateway): handle timeout correctly -docs: update installation guide -``` +- [cc-switch](https://github.com/farion1231/cc-switch) +- [claude-code-hub](https://github.com/ding113/claude-code-hub) +- [code-switch-R](https://github.com/Rogers-F/code-switch-R) --- @@ -274,4 +376,12 @@ docs: update installation guide --- -[![Stargazers over time](https://starchart.cc/dyndynjyxa/aio-coding-hub.svg?variant=adaptive)](https://starchart.cc/dyndynjyxa/aio-coding-hub) +## Star History + + + + + + Star History Chart + + diff --git a/docs/plugin-manifest-v1.md b/docs/plugin-manifest-v1.md index f38d3aa7..c72121fa 100644 --- a/docs/plugin-manifest-v1.md +++ b/docs/plugin-manifest-v1.md @@ -1,6 +1,8 @@ # 插件 Manifest v1 -`plugin.json` 是插件与 AIO Coding Hub 之间稳定的 package contract。Manifest v1 优先支持声明式规则插件;当 host policy 启用时支持 WASM code plugins;同时保留少量 official-only native engines。 +`plugin.json` 是插件与 AIO Coding Hub 之间稳定的 package contract。Plugin API v1 的社区插件只有一种公开运行时:Extension Host。社区插件必须提供 `main`,声明 `runtime.kind = "extensionHost"`,并把 TypeScript 或 JavaScript 源码打包成宿主可加载的 JavaScript 输出。 + +Official Privacy Filter 也使用同一套 Extension Host manifest、capability、hook 和 lifecycle 规则。官方身份只体现在 bundled 分发和默认配置上,不体现在独立 runtime、独立权限或宿主特例上。 ## 1. 必填字段 @@ -10,16 +12,19 @@ | `name` | string | 展示给用户的名称。 | | `version` | string | 插件版本,使用 SemVer。 | | `apiVersion` | string | 插件 API 版本,例如 `1.0.0`。 | -| `runtime` | object | 运行时声明。 | -| `hooks` | array | Hook 注册信息。 | -| `permissions` | array | 请求的权限。 | +| `main` | string | Extension Host 入口文件;`main` points at bundled JavaScript output,例如 `dist/extension.js`。 | +| `runtime` | object | 必须是 `runtime.kind = "extensionHost"`,`language` 必须是 `typescript`。 | | `hostCompatibility` | object | 支持的 AIO Coding Hub 宿主版本范围。 | +Extension Host manifest 不再使用 top-level `hooks` 或 top-level `permissions`。Hook、command、provider UI 和 protocol bridge 通过 `contributes` 声明;宿主用 `capabilities` 控制这些贡献点是否可以生效。 + ## 2. 可选字段 | 字段 | 类型 | 说明 | | --- | --- | --- | -| `entry` | string | 运行时 artifact path,例如 `plugin.wasm`;声明式规则不需要该字段。 | +| `activationEvents` | array | Extension Host 激活事件,例如 `onGatewayHook:gateway.request.afterBodyRead`。 | +| `contributes` | object | `commands`、`providers`、`protocolBridges`、`gatewayHooks` 和 host-rendered `ui` 贡献点。 | +| `capabilities` | array | 插件声明自己需要的宿主能力,例如 `capabilities: ["gateway.hooks"]`。 | | `configSchema` | object | 用于用户配置的 JSON Schema subset。 | | `configVersion` | integer | 配置 schema 版本。 | | `description` | string | 展示给用户的简短摘要。 | @@ -44,43 +49,25 @@ Plugin IDs 使用 `publisher.plugin-name` 格式。 Versions 必须遵循 SemVer。Pre-release versions 可用于本地开发和 unsigned packages;marketplace stable releases 应使用 release versions。 -`apiVersion` 独立于 app version。宿主可以在同一 major API 内添加 backward-compatible fields。Breaking changes 需要新的 major API。 +`apiVersion` 独立于 app version。0.62.x 只支持 Plugin API major `1`,所以 manifest 的 `apiVersion` 必须是 `1.x.y`。宿主可以在同一 major API 内添加 backward-compatible fields。Breaking changes 需要新的 major API。 ## 4. Runtime -Runtime v1 支持社区声明式规则: - -```json -{ - "kind": "declarativeRules", - "rules": ["rules/main.json"] -} -``` - -WASM runtime: +Extension Host 是唯一 community runtime: ```json { - "kind": "wasm", - "abiVersion": "1.0.0", - "memoryLimitBytes": 16777216 + "main": "dist/extension.js", + "runtime": { + "kind": "extensionHost", + "language": "typescript" + } } ``` -WASM packages are installable only when host policy enables execution。未启用 WASM execution 的宿主必须拒绝或禁用 WASM plugins,不能把它们路由到其他 runtime。 - -短期 validation 必须拒绝 arbitrary JavaScript/TypeScript、Node.js、Deno、native dynamic libraries 和 WebView code。 +`main` 必须是包内相对路径,指向 `.js` 或 `.cjs` 文件。推荐源码使用 TypeScript 或 JavaScript,发布包只携带打包后的 JavaScript 输出。宿主负责加载、激活、超时控制、失败策略和 dispose;插件不能直接创建或持有宿主 runtime 实例。 -Official-only native runtime: - -```json -{ - "kind": "native", - "engine": "privacyFilter" -} -``` - -`native` 只保留给从 built-in official source 安装的 built-in official plugins。第三方包不能声明 host-native engines。 +旧的 WASM、process 和 native 运行时属于 unsupported pre-release legacy runtime。公开插件和官方 bundled 插件都不能声明这些运行时;迁移到 Extension Host 后通过 `contributes.gatewayHooks` 和 `api.gateway.registerHook` 实现网关扩展。 ## 5. Host Compatibility @@ -88,7 +75,7 @@ Official-only native runtime: ```json { - "app": ">=0.56.0 <1.0.0", + "app": ">=0.60.0 <1.0.0", "pluginApi": "^1.0.0", "platforms": ["macos", "windows", "linux"] } @@ -96,18 +83,40 @@ Official-only native runtime: 不兼容插件会被标记为 `incompatible`,不会进入 hook pipeline。 -## 6. Hook v1 +当前代码实际阻断的是 `hostCompatibility.app` 和 `hostCompatibility.pluginApi`。`platforms` 会被解析并在预检和元数据中展示,但本地包安装和市场索引兼容版本选择尚未按当前桌面操作系统阻断;发布者不应把它当成当前已强制执行的平台白名单。 + +## 6. Contributions 与 Capabilities + +Contribution points 只描述插件希望接入的位置;capability 是宿主开放对应贡献点/API surface 的 manifest contract。缺少依赖 capability 的 manifest 会被拒绝。 + +| Contribution | Required capability | +| --- | --- | +| `commands` | `commands -> commands.execute` | +| `providers` | `providers / provider UI -> provider.extensionValues` | +| `ui.providers.editor.sections` | `providers / provider UI -> provider.extensionValues` | +| `ui.providers.editor.fields` | `providers / provider UI -> provider.extensionValues` | +| UI button fields in host-rendered sections/panels | `commands -> commands.execute` | +| `gatewayHooks` | `gatewayHooks -> gateway.hooks` | +| `protocolBridges` | `protocolBridges -> protocol.bridge` | + +Gateway integration 必须使用 `contributes.gatewayHooks` + `capabilities: ["gateway.hooks"]` + Extension Host 入口中的 `api.gateway.registerHook`。 + +Protocol bridge MVP skeleton 只稳定 `manifest` 声明、能力依赖、贡献注册表元数据和安装预检展示。当前 Rust 执行入口会返回 `PLUGIN_EXTENSION_PROTOCOL_BRIDGE_NOT_IMPLEMENTED`;完整协议互换执行链仍属于未来宿主集成。插件不能只靠声明 `protocolBridges` 就接管 OpenAI、Gemini 或 Claude 协议转换。 + +契约、SDK 和 Rust 校验认识的 UI 插槽名称多于当前前端已挂载位置。当前已挂载并实际渲染的插槽是 `providers.editor.sections`、`settings.sections` 和 `logs.detail.tabs`。`providers.editor.fields` 会触发 `provider.extensionValues` 依赖校验,但当前前端没有对应类型化插槽挂载;`providers.card.badges`、`providers.card.actions` 和其他契约插槽目前只是 `manifest` 已知或仅用于元数据,不代表 UI 已显示。 + +## 7. Hook v1 Active hooks in plugin API v1 是当前已经接入 gateway 或 log pipeline 的 hooks。Reserved hooks for future host integration 会被记录下来以稳定命名;但在宿主实现对应调用点前,manifest validation 会用 `PLUGIN_RESERVED_HOOK` 拒绝它们。 -| Hook | 触发时机 | 可修改内容 | 默认超时 | 默认失败策略 | 匹配权限 | +| Hook | 触发时机 | 可修改内容 | 默认超时 | 默认失败策略 | Host-mediated context/mutation labels | | --- | --- | --- | --- | --- | --- | -| `gateway.request.afterBodyRead` | Body reader 完成 allowed body buffering 后 | JSON body、raw body metadata | 200 ms | fail-open | `request.body.read`, `request.body.write` | -| `gateway.request.beforeSend` | reqwest 发送 upstream request 前 | headers 和 body | 300 ms | fail-open 或 security fail-closed | `request.header.write`, `request.body.write` | -| `gateway.response.chunk` | CLI output 前的 stream chunk | chunk pass、replace、block、warn | 20 ms | security fail-closed、non-security fail-open | `stream.inspect`, `stream.modify` | -| `gateway.response.after` | 大小预算内的完整 non-stream response | body pass、replace、block、warn | 300 ms | security fail-closed、non-security fail-open | `response.body.read`, `response.body.write` | -| `gateway.error` | 观察到 host 或 upstream error 后 | 不隐藏 host error | 100 ms | fail-open | `request.meta.read` | -| `log.beforePersist` | Request 或 audit log 持久化前 | redacted log fields | 100 ms | fail-closed-to-host-redaction | `log.redact` | +| `gateway.request.afterBodyRead` | Body reader 完成 allowed body buffering 后 | headers 和 request body | 5000 ms | fail-open | `request.meta.read`, `request.header.read`, `request.header.readSensitive`, `request.body.read`, `request.header.write`, `request.body.write` | +| `gateway.request.beforeSend` | provider resolution 后、reqwest 发送 upstream request 前 | headers 和 request body | 5000 ms | fail-open | `request.meta.read`, `request.header.read`, `request.header.readSensitive`, `request.body.read`, `request.header.write`, `request.body.write` | +| `gateway.response.chunk` | 每个 bounded streaming response chunk | stream chunk | 5000 ms | fail-open | `stream.inspect`, `stream.modify` | +| `gateway.response.after` | 大小预算内的完整 non-stream response | headers 和 response body | 5000 ms | fail-open | `response.header.read`, `response.body.read`, `response.header.write`, `response.body.write` | +| `gateway.error` | gateway error response materialization 后、发送前 | headers 和 error response body | 5000 ms | fail-open | `response.header.read`, `response.body.read`, `response.header.write`, `response.body.write` | +| `log.beforePersist` | Request 或 audit log 持久化前 | log message | 5000 ms | fail-open | `log.redact` | Streaming hooks 接收 bounded chunks 和固定大小 sliding window,不会接收无限制完整响应。 @@ -117,11 +126,15 @@ Reserved hooks: - `gateway.request.beforeProviderResolution` - `gateway.response.headers` -## 7. Permission v1 +## 8. Host-mediated context and mutation labels + +Extension Host public manifest 不支持 top-level `permissions`。下面这些 labels 是 gateway hook visible context、mutation envelope、audit 和 legacy official runtime history 使用的内部 contract 名称;它们不是社区 Extension Host manifest 字段,也不是插件作者可以通过 `plugin.json` 申请的授权项。 + +宿主会按 hook、capability、context budget 和运行时策略决定实际提供哪些 context fields,并在应用 mutation 前再次校验输出 envelope。插件必须把缺失或被截断的 body、headers、stream chunk、log message 和 normalized messages 视为正常情况。 -Reserved permissions for future host-mediated APIs 会被记录下来以稳定命名;但在这些 API 存在前,manifest validation 会用 `PLUGIN_RESERVED_PERMISSION` 拒绝它们。 +Internal active labels: -| Permission | Risk | 说明 | +| Label | Risk | 说明 | | --- | --- | --- | | `request.meta.read` | low | 读取 method、path、CLI key、trace ID、provider hints。 | | `request.header.read` | medium | 读取非敏感 request headers。 | @@ -137,33 +150,26 @@ Reserved permissions for future host-mediated APIs 会被记录下来以稳定 | `stream.modify` | high | 替换或阻断 streamed chunks。 | | `log.redact` | medium | 持久化前脱敏 log fields。 | -Reserved permissions: +Reserved permissions for future host-mediated APIs 只作为内部命名保留。社区 Extension Host manifest 不能声明它们;如果 legacy official runtime history 中出现保留项,宿主会按内部 runtime policy 拒绝或隔离。 -| Permission | Risk | Future host-mediated API | +| Label | Risk | Future host-mediated API | | --- | --- | --- | -| `plugin.storage` | medium | 使用隔离 plugin storage。 | +| `plugin.storage` | medium | 为未来宿主中介插件存储预留的内部标签。当前 Extension Host `manifest` 能力是 `storage.plugin`,不是这个标签。 | | `network.fetch` | high | 发起 host-mediated network requests。 | | `file.read` | high | 读取 host-mediated files。 | | `file.write` | high | 写入 host-mediated files。 | | `secret.read` | critical | 读取 host-managed secrets。 | -高危权限需要二次授权。Critical permissions require second confirmation and stronger UI copy。 - -插件升级新增权限必须重新授权。The host must keep the plugin disabled or partially disabled until the new permissions are approved。 - -## 8. Hook 与 Permission 兼容性 +High-risk 和 critical labels 会用于宿主风险文案、审计和未来 host-mediated API 设计,但不会恢复为 Extension Host public manifest permissions。 Validation 会拒绝: - Unknown hook names。 - Reserved hook names。 -- Unknown permissions。 -- Reserved permissions。 -- 为不能修改的 hooks 请求 write permissions。 -- 没有 `request.header.readSensitive` 却读取 sensitive header。 -- 没有匹配 body read/write permission 却写 body。 -- 没有 `stream.modify` 却执行 `stream.modify` actions。 -- 在 host 提供对应 API 前请求 `network.fetch`、`file.read`、`file.write` 或 `secret.read`。 +- Extension Host manifest 中的 top-level `permissions`。 +- 缺少贡献点所需 capability。 +- Extension Host manifest 中的 top-level `hooks`。 +- 在 host 提供对应 API 前使用 unsupported legacy runtime 或 legacy gateway rule fields。 ## 9. Config Schema 子集 @@ -178,7 +184,9 @@ Validation 会拒绝: - `object` - `password` -插件不能提供 custom GUI code。宿主负责渲染表单、保存前校验,并在启用前再次校验。Sensitive values 不会以 plaintext 返回前端。 +插件不能提供自定义 GUI 代码。宿主负责渲染表单、保存前校验,并在启用前再次校验。`password` 只是输入控件类型;当前 vNext 不提供社区插件配置的宿主管理密钥存储,已保存配置仍是普通插件配置值。 + +当前 `storage.plugin` 能力暴露的 Extension Host storage API 会把插件状态保存在同一份插件配置 JSON 的顶层 `storage` 字段下,并通过插件配置持久化路径保存,整体 storage JSON 限制为 64 KiB。它还不是独立 KV 表;插件作者应避免在用户 `configSchema` 中定义顶层 `storage` 字段,以免语义冲突。 ## 10. 状态机 @@ -198,21 +206,21 @@ Validation 会拒绝: | From | To | Trigger | | --- | --- | --- | | `available` | `installed` | 用户安装 package 或 market plugin。 | -| `installed` | `enabled` | 用户授权 required permissions 且配置有效。 | +| `installed` | `enabled` | 用户确认插件且配置有效。 | | `installed` | `disabled` | 用户安装但不启用。 | | `enabled` | `disabled` | 用户禁用插件。 | | `disabled` | `enabled` | 用户在校验通过后启用插件。 | | `enabled` | `update_available` | Market 发现新的兼容版本。 | | `disabled` | `update_available` | Market 发现新的兼容版本。 | -| `update_available` | `enabled` | 更新成功且 permissions 仍有效。 | -| `update_available` | `disabled` | 更新成功但需要新的 permission approval。 | -| `installed` | `incompatible` | Host/API/platform version 不兼容。 | +| `update_available` | `enabled` | 更新成功且 capabilities 仍有效。 | +| `update_available` | `disabled` | 更新成功但新增 capability 需要用户确认。 | +| `installed` | `incompatible` | 宿主应用版本或 Plugin API 版本不兼容。当前 `platforms` 不触发该状态。 | | `enabled` | `quarantined` | 重复 crash、timeout、signature failure 或 revoked market status。 | | `disabled` | `quarantined` | Signature failure 或 revoked market status。 | | `quarantined` | `disabled` | 用户确认并在校验后恢复。 | | any active state | `uninstalled` | 用户卸载插件。 | -Upgrade failure 会恢复 previous version、config snapshot、permissions 和 enabled state。Signature failure 会让插件进入 `quarantined`。Runtime crash 和 repeated timeout 可以让 enabled plugin 进入 `quarantined`。 +Upgrade failure 会恢复 previous version、config snapshot、capabilities 和 enabled state。Signature failure 会让插件进入 `quarantined`。Runtime crash 和 repeated timeout 可以让 enabled plugin 进入 `quarantined`。 ## 11. Manifest 示例:社区 Prompt Helper @@ -222,20 +230,24 @@ Upgrade failure 会恢复 previous version、config snapshot、permissions 和 e "name": "Prompt Helper", "version": "1.0.0", "apiVersion": "1.0.0", + "main": "dist/extension.js", "runtime": { - "kind": "declarativeRules", - "rules": ["rules/main.json"] + "kind": "extensionHost", + "language": "typescript" }, - "hooks": [ - { - "name": "gateway.request.afterBodyRead", - "priority": 100, - "failurePolicy": "fail-open" - } - ], - "permissions": ["request.body.read", "request.body.write"], + "activationEvents": ["onGatewayHook:gateway.request.afterBodyRead"], + "contributes": { + "gatewayHooks": [ + { + "name": "gateway.request.afterBodyRead", + "priority": 100, + "failurePolicy": "fail-open" + } + ] + }, + "capabilities": ["gateway.hooks"], "hostCompatibility": { - "app": ">=0.56.0 <1.0.0", + "app": ">=0.60.0 <1.0.0", "pluginApi": "^1.0.0", "platforms": ["macos", "windows", "linux"] }, @@ -246,22 +258,33 @@ Upgrade failure 会恢复 previous version、config snapshot、permissions 和 e "mode": { "type": "string", "enum": ["append_instruction", "prepend_context"] - }, - "onlyModels": { - "type": "array", - "items": { "type": "string" } - }, - "onlyClis": { - "type": "array", - "items": { "type": "string", "enum": ["claude", "codex", "gemini"] } } } } } ``` +`dist/extension.js`: + +```js +module.exports.activate = function(api) { + api.gateway.registerHook("gateway.request.afterBodyRead", function(context) { + const body = String(context?.request?.body ?? ""); + if (!body) return { action: "continue" }; + return { + action: "replace", + requestBody: body.replace("DRAFT_PROMPT", "Please answer concisely.") + }; + }); +}; +``` + +这个示例不声明 `permissions`。Extension Host manifest 只能声明 `capabilities` 和 `contributes`;`context.request.body` 是否可见、`requestBody` mutation 是否被接受,都由宿主按当前 hook contract、capability 和 context/output budget 判断。插件必须在字段缺失或为空时继续运行。 + ## 12. Manifest 示例:Privacy Filter +`official.privacy-filter` 是 bundled official Extension Host 插件。它使用普通插件 manifest、普通 gateway hook 生命周期和普通 Extension Host disposal;官方身份只提供默认安装源和默认配置。 + ```json { "id": "official.privacy-filter", @@ -269,7 +292,7 @@ Upgrade failure 会恢复 previous version、config snapshot、permissions 和 e "version": "1.0.0", "apiVersion": "1.0.0", "category": "privacy", - "description": "Official native privacy filter aligned with packyme/privacy-filter for pre-upstream prompt and log redaction.", + "description": "Official Extension Host privacy filter aligned with packyme/privacy-filter for pre-upstream prompt and log redaction.", "homepage": "https://github.com/packyme/privacy-filter", "repository": { "type": "git", @@ -277,42 +300,27 @@ Upgrade failure 会恢复 previous version、config snapshot、permissions 和 e }, "license": "MIT", "runtime": { - "kind": "native", - "engine": "privacyFilter" + "kind": "extensionHost", + "language": "typescript" }, - "hooks": [ - { - "name": "gateway.request.afterBodyRead", - "priority": 5, - "failurePolicy": "fail-closed" - }, - { - "name": "log.beforePersist", - "priority": 1, - "failurePolicy": "fail-closed" - } + "main": "dist/extension.js", + "activationEvents": [ + "onGatewayHook:gateway.request.afterBodyRead", + "onGatewayHook:gateway.request.beforeSend", + "onGatewayHook:log.beforePersist" ], - "permissions": ["request.body.read", "request.body.write", "log.redact"], + "capabilities": ["gateway.hooks", "privacy.redact"], + "contributes": { + "gatewayHooks": [ + { "name": "gateway.request.afterBodyRead", "priority": 5, "failurePolicy": "fail-closed", "timeoutMs": 5000 }, + { "name": "gateway.request.beforeSend", "priority": 5, "failurePolicy": "fail-closed", "timeoutMs": 5000 }, + { "name": "log.beforePersist", "priority": 1, "failurePolicy": "fail-closed", "timeoutMs": 5000 } + ] + }, "hostCompatibility": { - "app": ">=0.56.0 <1.0.0", + "app": ">=0.60.0 <1.0.0", "pluginApi": "^1.0.0", "platforms": ["macos", "windows", "linux"] - }, - "configSchema": { - "type": "object", - "required": ["redactBeforeUpstream", "redactLogs", "profile"], - "properties": { - "redactBeforeUpstream": { - "type": "boolean" - }, - "redactLogs": { - "type": "boolean" - }, - "profile": { - "type": "string", - "enum": ["balanced"] - } - } } } ``` diff --git a/docs/plugin-system-development-plan.md b/docs/plugin-system-development-plan.md index a05941d3..5815afca 100644 --- a/docs/plugin-system-development-plan.md +++ b/docs/plugin-system-development-plan.md @@ -1,5 +1,13 @@ # aio coding hub 社区插件系统开发计划 +> Status: Superseded. +> +> This historical plan is retained for context only. The current public plugin +> direction is Extension Host-only, documented in `docs/plugin-manifest-v1.md` +> and `docs/plugins/`. Earlier alternate WASM and arbitrary process runtime +> plans are unsupported pre-release history and are not the current +> implementation plan. + ## 1. 目标与开发边界 ### 1.1 总体目标 @@ -39,9 +47,9 @@ - 网关请求/响应 hook pipeline。 - 非流式响应和流式响应插件处理。 - 插件审计日志、错误隔离、超时、熔断。 -- 声明式规则插件。 +- Extension Host 插件:命令、网关 hook、Provider 扩展值、协议桥骨架和宿主渲染 UI。 - 官方示例插件:提示词优化、安全检测、脱敏。 -- 中期引入 WebAssembly 插件运行时。 +- 旧 WebAssembly / 独立进程运行时不再作为当前社区插件路线。 - 中长期支持插件市场、GitHub release 安装、本地导入、离线安装。 - SDK、脚手架、开发文档和测试工具。 @@ -57,7 +65,7 @@ #### 关键假设 -- 短期插件以低风险声明式规则为主。 +- 社区插件以 Extension Host 为唯一公开运行时。 - 第三方代码插件必须运行在可隔离、可授权、可熔断的运行时中。 - 插件系统优先服务网关、日志、配置和 GUI 管理,不侵入各 CLI 内部实现。 - 插件系统必须同时兼容 macOS、Windows、Linux。 @@ -71,7 +79,7 @@ | M2 | 网关 Hook Pipeline | 插件可在请求/响应链路中读取、修改或阻断数据 | hook pipeline、上下文模型、权限裁剪、测试夹具 | P0 | | M3 | 官方示例插件 | 用真实场景验证插件 API | 提示词优化、安全检测、脱敏插件 | P0 | | M4 | 市场与分发 | 支持市场、本地、GitHub release、离线安装 | 插件包格式、market index、签名、更新、回滚 | P1 | -| M5 | 安全运行时 | 支持社区代码插件 | WASM runtime、独立进程 runtime 草案、资源限制 | P1 | +| M5 | Extension Host 生命周期 | 支持可回收的社区代码插件 | Extension Host registry、资源限制、诊断与回收 | P1 | | M6 | 开发生态 | 支持社区持续开发和发布 | SDK、脚手架、调试工具、文档、兼容测试 | P2 | ## 3. M0:架构冻结与 RFC @@ -644,7 +652,7 @@ ### 6.2 范围内 -- 三个官方插件均以声明式规则插件形式实现。 +- 示例插件均以 Extension Host 贡献和 host-owned official capabilities 验证系统边界。 - 三个插件均可安装、配置、启用、禁用、卸载。 - 三个插件均有测试。 @@ -656,23 +664,24 @@ ### 6.4 任务清单 -#### M3-T01:实现声明式规则 runtime +#### M3-T01:实现 Extension Host gateway hook runtime - 建议模块: - - `src-tauri/src/app/plugins/rule_runtime.rs` + - `src-tauri/src/app/plugins/extension_host.rs` + - `src-tauri/src/app/plugins/extension_host_registry.rs` - 能力: - - JSON path 定位。 - - regex 检测。 + - `api.gateway.registerHook`。 + - 150 ms gateway hook timeout。 - replace。 - block。 - warn。 - append system/developer message。 - 限制: - - 正则必须有长度限制和超时保护。 - - 不支持任意脚本表达式。 + - 只能通过宿主声明的 capability 访问 Host API。 + - 超时、禁用、卸载后必须清理 warm instances。 - 验收标准: - - 规则插件无代码执行能力。 - - 恶意复杂正则不会卡死请求。 + - Extension Host 插件可处理 gateway hook。 + - 超时或崩溃不会留下可复用坏实例。 #### M3-T02:提示词优化插件 @@ -859,19 +868,18 @@ - 更新失败不破坏当前可用版本。 - 用户可手动回滚。 -## 8. M5:安全插件运行时 +## 8. M5:Extension Host 安全运行时 ### 8.1 目标 -在权限和沙箱边界明确后,支持社区代码插件。 +在权限、生命周期和资源边界明确后,支持社区 Extension Host 插件。 ### 8.2 范围内 -- WASM runtime。 -- WASI capability 限制。 +- Extension Host process lifecycle。 - 内存和时间限制。 -- 独立进程插件技术预研。 -- JSON-RPC 协议草案。 +- JSON-RPC worker protocol。 +- gateway hook / command / protocol bridge dispatch skeleton。 ### 8.3 范围外 @@ -881,22 +889,19 @@ ### 8.4 任务清单 -#### M5-T01:引入 WASM runtime +#### M5-T01:引入 Extension Host registry -- 候选: - - Wasmtime。 - 要求: - - 限制内存。 - - 限制执行时间。 - - 禁用默认文件系统。 - - 禁用默认网络。 - - 只通过宿主导入函数访问授权能力。 + - 启动和调用超时。 + - active plugin 变化后清理 warm instances。 + - 禁用/卸载后 dispose。 + - 只暴露 manifest capability 授权的 Host API。 - 验收标准: - - WASM 插件可处理请求 body。 - - WASM 插件不能读取任意文件。 - - 死循环 WASM 会被终止。 + - Extension Host gateway hook 可处理请求 body。 + - 超时实例会被终止且不会复用。 + - 重复启停不会产生无界进程增长。 -#### M5-T02:定义 WASM ABI +#### M5-T02:定义 Extension Host API contract - 内容: - hook 输入格式。 @@ -905,13 +910,12 @@ - 日志接口。 - 配置读取接口。 - 验收标准: - - Rust 和 TypeScript SDK 能生成兼容插件。 - - ABI 有版本号。 + - TypeScript SDK 能表达兼容插件。 + - Plugin API 有版本号。 -#### M5-T03:实现 WASM 插件执行器 +#### M5-T03:实现 Extension Host 插件执行器 - 功能: - - 加载 wasm。 - 校验 manifest runtime。 - 注入裁剪后的 context。 - 执行 hook。 @@ -978,7 +982,7 @@ - 验收标准: - 示例插件可使用 SDK 类型通过编译。 -#### M6-T02:Rust/WASM SDK +#### M6-T02:TypeScript Extension Host SDK - 内容: - ABI 类型。 @@ -986,17 +990,17 @@ - helper macro。 - 测试 fixture。 - 验收标准: - - Rust 示例可编译为 WASM 并被宿主加载。 + - TypeScript 示例可打包为 `dist/extension.js` 并被宿主加载。 #### M6-T03:插件脚手架 - 命令建议: - `create-aio-plugin` - 模板: - - 声明式规则插件。 - - WASM prompt 插件。 - - WASM safety 插件。 - - WASM redactor 插件。 + - Extension Host command 插件。 + - Extension Host prompt helper。 + - Extension Host redactor。 + - Extension Host response guard。 - 验收标准: - 用户能一条命令生成插件项目。 - 生成项目包含测试和打包脚本。 @@ -1037,7 +1041,6 @@ - `docs/plugins/reference/hooks.md` - `docs/plugins/reference/permissions.md` - `docs/plugins/reference/config-schema.md` - - `docs/plugins/reference/declarative-rules.md` - `docs/plugins/reference/publishing.md` - `docs/plugins/reference/compatibility.md` - `docs/plugins/runtime/README.md` @@ -1181,9 +1184,8 @@ ### 12.2 运行时安全 -- 短期只支持声明式规则。 -- 中期 WASM 必须禁用默认文件系统和网络。 -- 独立进程插件必须最小环境变量启动。 +- 社区插件只支持 Extension Host。 +- Extension Host worker 必须最小环境变量启动。 - 不允许插件读取未授权请求体。 - 不允许插件读取未授权敏感 header。 @@ -1212,13 +1214,12 @@ - 插件 manifest 必须声明 `configVersion`。 - 插件升级如改变配置结构,必须提供从旧版本到新版本的迁移描述。 -- 声明式插件只允许声明式迁移: +- 插件配置迁移由 host-managed schema 和未来 Extension Host API 承担: - rename field。 - set default。 - remove field。 - enum value mapping。 - split string to array。 -- WASM 插件后续可提供迁移函数,但必须在同一沙箱和超时限制中执行。 - 迁移前宿主保存配置快照。 - 迁移后宿主再次执行 schema 校验。 - 迁移失败时恢复旧配置、旧插件版本和旧启用状态。 @@ -1245,9 +1246,9 @@ | M0 | 完成当前架构调研,确认插件目标和非目标 | RFC、manifest v1、hook v1、权限 v1 审核通过 | 无法确认 hook 边界或安全边界 | 无 | | M1 | M0 文档冻结 | 本地无代码插件可安装、配置、启用、禁用、卸载,状态持久化 | DB schema 不稳定、IPC 类型无法生成 | 前端页面与后端 repository 可并行 | | M2 | M1 插件状态和权限可用 | 请求、非流式响应、流式响应、日志 hook 均有测试 | 权限裁剪未完成、流式 hook 无测试 | 请求 hook 与日志 hook 可部分并行 | -| M3 | M2 hook pipeline 通过集成测试 | 三个官方插件均可安装、配置、运行、卸载 | 任一官方插件需要绕过权限模型 | 三个官方插件可并行,但共享规则 runtime | +| M3 | M2 hook pipeline 通过集成测试 | Extension Host 示例插件均可安装、配置、运行、卸载 | 任一示例插件需要绕过权限模型 | 示例插件可并行,但共享 Extension Host API | | M4 | M1 安装基础稳定,M3 至少一个官方插件稳定 | 市场、本地、GitHub release、离线安装策略可用,签名和回滚通过测试 | 签名校验未完成、包安全未完成 | market index 与本地包格式可并行 | -| M5 | M2 权限裁剪稳定,M4 包格式支持 runtime artifact | WASM 插件可安全执行,越权/死循环测试通过 | WASM 无法限制文件/网络/时间/内存 | WASM ABI 与 SDK 可并行 | +| M5 | M2 权限裁剪稳定,M4 包格式支持 runtime artifact | Extension Host 插件可安全执行,越权/超时测试通过 | Extension Host 无法限制进程/时间/内存 | Extension Host SDK 与宿主 API 可并行 | | M6 | M3/M5 API 基本稳定 | SDK、脚手架、文档、调试工具可支撑第三方开发 | API 仍频繁破坏性变更 | 文档、SDK、示例可并行 | M3 不得在 M2 的 `gateway.response.chunk` 流式测试完成前声明完成。M4 不得在签名校验完成前开放远程安装。M5 不得在权限裁剪和运行时资源限制通过测试前开放社区插件。 @@ -1328,7 +1329,7 @@ M3 不得在 M2 的 `gateway.response.chunk` 流式测试完成前声明完成 9. 插件错误、超时和崩溃不会导致主应用崩溃。 10. 官方提示词优化、安全检测、脱敏三个插件可作为示例稳定运行。 11. 插件包安装有 checksum 校验。 -12. manifest 的 `runtime` 字段、插件包格式和 hook ABI 已预留 WASM 运行时,并有兼容 fixture 证明声明式插件在该预留下不受影响。 +12. manifest 的 `runtime` 字段、插件包格式和 hook ABI 均以 Extension Host 为唯一公开社区运行时,并有兼容 fixture 证明旧预发布 runtime 会被拒绝。 ## 16. 暂缓事项 diff --git a/docs/plugin-system-rfc.md b/docs/plugin-system-rfc.md index f69fc4f6..e9f46fa4 100644 --- a/docs/plugin-system-rfc.md +++ b/docs/plugin-system-rfc.md @@ -2,7 +2,7 @@ ## 1. Purpose -The plugin system lets aio coding hub support community extensions for gateway request processing, gateway response inspection, log redaction, local configuration, and GUI management. The first production version focuses on low-risk declarative plugins and stable host contracts before any third-party code runtime is enabled. +The plugin system lets aio coding hub support community extensions for gateway request processing, gateway response inspection, log redaction, local configuration, and GUI management. The current public plugin direction is Extension Host-only: community plugins run through a host-managed Extension Host process, while the host owns rendering, lifecycle, permissions, diagnostics, and gateway mutation boundaries. The priority scenarios are: @@ -12,7 +12,7 @@ The priority scenarios are: ## 2. Non-Goals -- 短期不执行任意 JavaScript/TypeScript. +- 短期不执行任意 JavaScript/TypeScript;当前只允许宿主管理的 Extension Host bundle 通过声明的 Host API 运行。 - Node.js and Deno are not default plugin runtimes. - Third-party native dynamic libraries are not loaded into the Rust main process. - 第三方代码不得直接进入主进程或 WebView. @@ -87,47 +87,31 @@ Active request and response hooks execute through a timeout-bounded pipeline wit ## 5. Runtime Roadmap -### Short Term: Declarative Rules +### Public Community Runtime: Extension Host -The first runtime executes no community code. Plugins declare rules in JSON: +The public community runtime is a host-managed Extension Host. Plugins declare contributions in `plugin.json`, bundle their entry as `dist/extension.js`, and register handlers through host APIs such as: -- JSON path selection. -- Regex detection with bounded input and timeout protection. -- Replace, warn, block, and append-message actions. -- Configuration schema and permission declarations. +- `api.commands.registerCommand` +- `api.gateway.registerHook` +- provider extension value APIs +- future protocol bridge APIs -This runtime is enough for community prompt helpers, response safety checks, and log redactors that can be expressed as bounded JSON path and regex rules. +The host decides when handlers run, what permission-trimmed context they receive, which mutations are accepted, when warm instances are reused, and when instances are disposed. -### Medium Term: WASM +### Unsupported Legacy Runtime Ideas -WASM plugins run in a sandboxed runtime, initially Wasmtime. WASM execution must: - -- Disable default filesystem access. -- Disable default network access. -- Enforce memory limits. -- Enforce execution timeouts. -- Access host capabilities only through imported functions that check granted permissions. -- Use a versioned ABI for hook input, hook output, errors, logs, and config reads. - -### Long Term: Managed Process Runtime - -An independent process runtime may be used for a proof of concept, but only as a host-managed worker: - -- JSON-RPC over stdio. -- Every request carries a `trace_id`. -- Startup timeout, hook timeout, crash limits, and idle recycle are mandatory. -- The worker must not become a daemon, login item, scheduled task, or detached background service. -- The PoC does not mean marketplace plugins can use background tasks by default. +Earlier drafts considered alternate WASM and arbitrary process runtimes. They are not public community runtimes in the current contract. Old local packages using those shapes are treated as unsupported pre-release legacy packages and should migrate to Extension Host gateway hooks. ## 6. Security Principles -- Least privilege: plugins see only context allowed by their granted permissions and hook type. -- Sensitive headers such as `Authorization` and `Cookie` are hidden unless `request.header.readSensitive` is explicitly granted. -- Request and response bodies are hidden unless body-read permissions are granted. -- Write actions are rejected unless matching write permissions are granted. -- High-risk permissions require second confirmation. -- Upgrades that add permissions require renewed authorization. -- Audit logs record install, enable, disable, config changes, permission changes, hook errors, hook timeouts, blocks, and high-risk mutations. +- Least privilege: public manifests use `capabilities`; Extension Host public manifest 不支持 top-level `permissions`. +- The host trims hook context by capability, hook contract, runtime policy, and context budget before invoking plugin code. +- Sensitive headers such as `Authorization` and `Cookie` are visible only when the hook contract, context budget, and host policy allow that field for the installed capability set. +- Request and response body visibility is decided by the hook contract, context budget, and host policy. Internal context labels such as `request.body.read` or `request.header.readSensitive` describe host-side exposure decisions, not public top-level manifest permissions. +- Plugin mutation proposals are host-mediated. The host validates each proposed header/body/status/log mutation against the hook contract and runtime policy, then accepts, trims, or rejects it. +- High-risk capabilities require second confirmation. +- Upgrades that add capabilities require renewed authorization. +- Audit logs record install, enable, disable, config changes, capability changes, hook errors, hook timeouts, blocks, and high-risk mutations. - Audit logs must not store sensitive raw values. - Consecutive failures can quarantine a plugin. - Safety-class plugins may use fail-closed behavior. Decorative plugins default to fail-open. diff --git a/docs/plugins/README.md b/docs/plugins/README.md index 5dc986d1..c41219b2 100644 --- a/docs/plugins/README.md +++ b/docs/plugins/README.md @@ -2,13 +2,15 @@ 本目录是 AIO Coding Hub 插件系统的中文入口。这里不再平铺所有专题文档;新开发者先读主线指南,需要细节时再进入参考目录。 -插件可以扩展本地网关、请求和响应 hook、日志脱敏,以及由界面管理的配置表单。社区插件优先使用 `declarativeRules`;`native` 只保留给宿主内置官方插件。 +插件当前稳定扩展面是本地网关、请求和响应 hook、日志脱敏、命令、provider extension values,以及由宿主渲染的配置和部分 UI 插槽。社区插件统一使用 Extension Host:`main` 指向打包后的 JavaScript 输出,`runtime.kind = "extensionHost"`,贡献点通过 `contributes.*` 和 `capabilities` 声明。 + +当前文档以现有代码为准:`protocolBridges` 只是 `manifest` 声明、能力依赖、贡献元数据和安装预检展示,执行链尚未接入;契约中列出的 UI 插槽也不都等于前端已挂载。当前前端已挂载的宿主渲染 UI 插槽是 `providers.editor.sections`、`settings.sections` 和 `logs.detail.tabs`。 ## 先读什么 -- [插件开发总指南](./developer-guide.md):唯一主线入口,从创建插件到本地回放、配置表单、打包和发布。 -- [Privacy Filter 示例](./examples/privacy-filter.md):查看官方示例插件如何组织 manifest、配置和隐私过滤边界。 -- [插件 API 参考](./reference/README.md):查 `plugin.json`、hooks、permissions、config schema、SDK 和发布规则。 +- [插件开发总指南](./developer-guide.md):唯一主线入口,从创建 Extension Host 插件到本地回放、配置表单、打包和发布。 +- [Privacy Filter 示例](./examples/privacy-filter.md):查看官方 Extension Host 插件如何说明隐私过滤边界。 +- [插件 API 参考](./reference/README.md):查 `plugin.json`、hooks、capabilities、host-mediated context labels、config schema、SDK 和发布规则。 ## 按目标查找 @@ -18,33 +20,37 @@ | 给插件加配置项 | [Config Schema](./reference/config-schema.md) | | 处理 Claude/Codex 请求结构 | [插件开发总指南:Hooks 与请求形态](./developer-guide.md#hooks-与请求形态) | | 查 hook 触发时机 | [Hooks](./reference/hooks.md) | -| 查权限和风险等级 | [Permissions](./reference/permissions.md) | -| 写声明式规则 | [Declarative Rules](./reference/declarative-rules.md) | +| 查 capability 与贡献点依赖 | [Manifest](./reference/manifest.md) | +| 查 context/mutation label 风险等级 | [Permissions](./reference/permissions.md) | | 打包发布 `.aio-plugin` | [Publishing](./reference/publishing.md) | -| 理解 WASM 限制 | [WASM 运行时](./runtime/wasm.md) | +| 理解旧运行时为什么不开放 | [运行时说明](./runtime/README.md) | | 理解架构和边界 | [插件架构说明](./architecture/README.md) | ## 目录结构 - `developer-guide.md`:开发者主线手册。 -- `examples/`:官方示例和推荐社区插件形态。 +- `examples/`:官方 Extension Host 插件说明和社区示例方向。 - `reference/`:稳定 API 契约和工具链说明。 -- `runtime/`:WASM、进程运行时、流式响应等执行模型。 +- `runtime/`:Extension Host lifecycle、流式响应,以及旧 WASM/process notes。 - `architecture/`:维护者视角的安全、隔离、性能和稳定性说明。 - `plugin-api-v1-contract.json`:机器可读的插件 API v1 契约。 ## 推荐开发顺序 -1. 如果插件不需要执行代码,先选择 `declarativeRules`。 -2. 编写 `plugin.json`,只声明必需的 hooks 和 permissions。 -3. 添加聚焦的规则文件、fixture,或 WASM 入口代码。 -4. 使用 `create-aio-plugin` 校验真实插件目录。 -5. 在导入桌面应用前,用 replay fixture 覆盖 Claude 和 Codex/OpenAI Responses 请求形态。 -6. 本地行为稳定后再打包 `.aio-plugin`,需要可信分发时再补签名。 +1. 明确插件目标和需要的 contribution point。 +2. 编写 `plugin.json`,声明 `main`、`runtime.kind = "extensionHost"`、最少的 `contributes` 和 `capabilities`。 +3. 在 `dist/extension.js` 中用 SDK 形状实现 `activate(api)`,例如 `api.gateway.registerHook`。 +4. 准备 Claude 和 Codex/OpenAI Responses fixture。 +5. 使用 `create-aio-plugin` 校验真实插件目录。 +6. 在桌面应用中启用插件后,用宿主运行报告和导出的 replay fixture 覆盖目标 hook。 +7. 行为稳定后再打包 `.aio-plugin`,需要可信分发时再补签名。 ## 当前稳定性说明 -- 不支持任意 JavaScript 或 TypeScript 插件直接运行。 -- WASM 和进程运行时文档描述的是隔离契约;是否允许执行仍由宿主策略控制。 -- Manifest 校验只接受已激活 hooks 和 permissions;保留项仅用于未来兼容命名。 -- 当前只有 `official.privacy-filter` 是内置官方 `native` 插件。社区扩展应使用 `declarativeRules`、WASM,或未来隔离进程运行时。 +- Extension Host 是唯一 community runtime。 +- 第三方插件代码不在 Rust 主进程或 Tauri WebView 中执行。 +- Manifest 校验只接受 Extension Host runtime、已激活 hooks、已知 contributions 和 capability 组合;reserved permissions 只作为内部/legacy host-mediated labels 保留。 +- `protocolBridges` 当前是 MVP skeleton,不是可执行协议转换能力。 +- `hostCompatibility.platforms` 当前随 `manifest`、预检和市场元数据展示,但不参与本地安装阻断或市场兼容性筛选。 +- 当前只有 `official.privacy-filter` 是宿主内置官方隐私过滤插件。社区同类能力应实现为 Extension Host 插件。 +- WASM、process 和第三方 native 运行时只作为 unsupported pre-release legacy runtime 说明出现,不是当前推荐路径。 diff --git a/docs/plugins/architecture/README.md b/docs/plugins/architecture/README.md index 1b8e4909..84585527 100644 --- a/docs/plugins/architecture/README.md +++ b/docs/plugins/architecture/README.md @@ -2,5 +2,7 @@ 这里放维护者和高级插件作者需要理解的设计约束。普通插件开发优先阅读 [插件开发总指南](../developer-guide.md) 和 [API 参考](../reference/README.md)。 -- [安全与隔离](./security.md):最小权限、运行时隔离、fail-closed、quarantine 和默认 hook timeout。 +- [安全与隔离](./security.md):最小 host surface、运行时隔离、fail-closed、quarantine 和默认 hook timeout。 - [架构审计](./audit.md):官方插件收敛、信任边界、运行时选择、性能与稳定性建议。 + +0.62 的内部平台内核调整不会改变 Plugin API v1 外部契约。维护者评估兼容性时,先看 [兼容性说明](../reference/compatibility.md),再对照 [架构审计](./audit.md) 中的 0.62 决策记录。 diff --git a/docs/plugins/architecture/audit.md b/docs/plugins/architecture/audit.md index 163930fa..be8297e5 100644 --- a/docs/plugins/architecture/audit.md +++ b/docs/plugins/architecture/audit.md @@ -1,12 +1,14 @@ # 插件架构审计 -本文记录将官方 catalog 收敛到 `official.privacy-filter` 后,当前插件系统架构的审计结论。 +本文记录将官方 catalog 收敛到 `official.privacy-filter`,并将社区插件架构切到 Extension Host-only 后的审计结论。 ## 决策 -只保留 `official.privacy-filter` 作为 bundled official plugin。 +只保留 `official.privacy-filter` 作为 bundled official Extension Host plugin。 -移除之前官方 catalog 中的 built-in prompt optimizer、safety detector 和 generic redactor examples。它们仍然是有效扩展场景,但应通过 `declarativeRules`、WASM 或未来隔离进程运行时作为社区插件实现。 +移除之前官方 catalog 中的 built-in prompt optimizer、safety detector 和 generic redactor examples。它们仍然是有效扩展场景,但应通过 Extension Host gateway hooks 作为社区插件实现。 + +WASM、process 和第三方 native 属于 unsupported pre-release legacy runtime。它们可以出现在迁移说明或拒绝测试中,但不能作为当前公开社区插件路径推荐。 ## 架构依据 @@ -18,30 +20,30 @@ AIO Coding Hub 采用同样形态: -- `plugin.json` 声明 ID、runtime、hooks、permissions、config schema 和 host compatibility。 -- Hooks 是明确的 gateway/log extension points,带有 bounded timeouts 和 permission-trimmed contexts。 +- `plugin.json` 声明 ID、`main`、Extension Host runtime、contributions、capabilities、config schema 和 host compatibility。 +- Hooks 是明确的 gateway/log extension points,带有 bounded timeouts 和 host-trimmed contexts。 - 社区代码执行不会进入 Rust main process 和 WebView。 -- `native` 只保留给 built-in official engines。第三方包不能声明 host-native engines。 +- `official.privacy-filter` 使用普通 Extension Host runtime。第三方包和官方插件都不能声明 legacy native engines;官方身份只体现在 bundled 分发和默认配置。 ## 信任边界 当前 host trust boundary: -- Trusted:Rust host、gateway pipeline、database、packaged official native privacy engine。 +- Trusted:Rust host、gateway pipeline、database、packaged official privacy redaction service。 - Semi-trusted:signed marketplace metadata 和 package checksums。 -- Untrusted by default:local packages、marketplace packages、GitHub release packages、rule files、WASM bytecode、process runtime binaries。 +- Untrusted by default:local packages、marketplace packages、GitHub release packages、Extension Host bundled JavaScript output、legacy rule files、legacy WASM bytecode、legacy process runtime binaries。 `official.*` namespace 必须继续由宿主拥有。本地、marketplace 和 GitHub 包必须使用类似 `acme.plugin-name` 的 publisher namespace。 ## 扩展模型 -推荐 runtime 选择顺序: +推荐模型只有一个: + +1. Extension Host:使用 `main` 加载打包后的 JavaScript 输出,通过 `contributes.gatewayHooks`、`commands`、provider UI sections 和 capability dependency table 接入宿主。`protocolBridges` 当前只进入声明、元数据和安装预检,不进入协议转换执行链。 -1. `declarativeRules`:用于 JSON path selection、regex detection、replacement、warning、blocking 和 message append behavior。 -2. WASM:用于需要 rule files 之外逻辑的 deterministic code plugins。 -3. Managed process runtime:只用于未来无法适配 WASM 的场景,并且默认没有 marketplace enablement。 +Gateway hooks 必须使用 `contributes.gatewayHooks` 与 `api.gateway.registerHook`。Protocol bridge 元数据必须使用 `protocolBridges` 与 `protocol.bridge` 能力,但当前执行入口返回 `PLUGIN_EXTENSION_PROTOCOL_BRIDGE_NOT_IMPLEMENTED`。Provider extension values 和 provider UI sections/fields 必须使用 `provider.extensionValues` 能力;当前前端已挂载的 UI 插槽只有 `providers.editor.sections`、`settings.sections` 和 `logs.detail.tabs`。 -不要开放第三方 `native` 插件,除非先补齐独立 signed binary policy、ABI stability story、crash isolation model、upgrade story 和 platform-specific security review。 +不要开放第三方 native 插件,除非先补齐独立 signed binary policy、ABI stability story、crash isolation model、upgrade story 和 platform-specific security review。 ## 性能与稳定性建议 @@ -50,39 +52,39 @@ AIO Coding Hub 采用同样形态: - 按 priority 顺序执行 hooks,并使用固定 timeout budgets。 - 在暴露给插件前,对 request 和 response bodies 做大小边界控制。 - Stream hooks 保持 chunk-based,并提供 sliding-window context,而不是缓冲完整 stream。 -- 按 plugin ID、version 和 runtime key 缓存 parsed rule/native engine state。 -- 对非安全增强使用 fail open;只对用户明确启用的 security/privacy gates 使用 fail closed。 +- 按 plugin ID、version 和 runtime key 缓存 Extension Host worker state。 +- 对非安全增强使用 fail open;只对用户明确启用的安全/隐私关卡使用 fail closed。当前 `log.beforePersist` 失败或返回非法 payload 时会保留原始日志,没有宿主兜底脱敏。 - 记录 runtime failures 和 circuit-open skips,避免坏插件持续拖慢 gateway。 -- official native engines 要少而聚焦,控制 host startup、binary size 和维护风险。 +- official bundled plugins 要少而聚焦,控制 host startup、binary size 和维护风险。 ## v1.1 Performance Budgets - Empty plugin pipeline request hook:不应有 allocation-heavy runtime dispatch,在维护者笔记本 performance smoke 上低于 25 microseconds。 -- One noop declarative plugin request hook:在维护者笔记本 performance smoke 上低于 250 microseconds。 +- One noop Extension Host plugin request hook:在维护者笔记本 performance smoke 上低于 250 microseconds。 - 没有 `gateway.response.chunk` plugins 时:direct stream pass-through path 必须保持 active。 -- One declarative rule plugin:parsed rule runtime 必须在首次执行后缓存。 +- Extension Host worker/cache 必须在 plugin snapshot refresh 后清理。 - Privacy Filter:compiled detector 必须按 plugin ID、version、installed directory 和 runtime key 缓存。 ## 当前形态 Bundled official plugin: -- `official.privacy-filter`:与 `packyme/privacy-filter` 对齐的 native host engine,用于 irreversible pre-upstream privacy redaction 和 log redaction。 +- `official.privacy-filter`:与 `packyme/privacy-filter` 对齐的 Extension Host 插件,通过 `privacy.redact` 调用宿主脱敏服务,用于 irreversible pre-upstream privacy redaction 和 log redaction。 开放给社区的能力: -- Declarative prompt helpers。 -- Declarative response safety checks。 -- Declarative 或 WASM log redactors。 -- WASM examples 和 SDK contracts。 -- 默认关闭的 Process runtime proof-of-concept documentation。 +- Extension Host prompt helpers。 +- Extension Host response safety checks。 +- Extension Host log redactors。 +- Extension Host commands 和 gatewayHooks。 +- Protocol bridge 仅声明元数据;完整执行属于未来宿主集成。 +- Provider adapter facades remain internal;公开 provider 插件 API 尚未开放。 ## 后续审计点 在把 plugin API v1 标记为 stable 前: -- 确认 hook names 和 permission names 已足够稳定,可以进入 semantic versioning。 -- 补充 WASM enablement 和 package signing 的 marketplace policy。 +- 确认 hook names、capability names 和 host-mediated label names 已足够稳定,可以进入 semantic versioning。 - 把 official examples 保留为文档中的 community patterns,而不是 bundled host plugins。 - 增加 plugin hook overhead 和 Privacy Filter 在大型但允许 payload 上 redaction latency 的 benchmarks。 - 增加 telemetry-safe counters,记录 plugin timeouts、skips 和 quarantines,但不记录 sensitive payloads。 @@ -91,7 +93,12 @@ Bundled official plugin: - Plugin API v1.1 使用 `plugin-api-v1-contract.json` 作为 source of truth。 - Provider-neutral request context 通过 `request.normalizedMessages` 提供。 -- 当 host policy disables execution 时,WASM enablement remains rejected。 - Plugin refresh 时会清理 runtime caches。 - Plugin hot-path performance smoke tests 是 release readiness 的一部分。 -- `create-aio-plugin replay` 与受支持的 declarative rule subset 保持一致。 +- `create-aio-plugin replay` 当前不执行 Extension Host gateway hooks;fixture model 由宿主 `plugin_export_replay_fixture` 和运行报告保持一致。 + +## 0.62 Platform Kernel Decision + +0.62 保持 Plugin API v1 externally compatible,重点是收紧内部平台边界而不是扩张公开 API。Contract metadata 成为 drift checks 的来源;hook 行为通过 internal descriptors 路由;runtime dispatch 从 gateway pipeline orchestration 中拆出;provider-specific behavior 开始迁移到 provider adapter facades 后面。 + +0.62 does not add public provider plugin APIs. Provider adapter facades remain internal so gateway selection, failover, circuit breaking, limits, OAuth handling, and session binding stay owned by the Rust gateway core. diff --git a/docs/plugins/architecture/current-plugin-system-audit-2026-07-02.md b/docs/plugins/architecture/current-plugin-system-audit-2026-07-02.md new file mode 100644 index 00000000..194e69e7 --- /dev/null +++ b/docs/plugins/architecture/current-plugin-system-audit-2026-07-02.md @@ -0,0 +1,488 @@ +# Plugin 插件体系架构审计报告(2026-07-02) + +## 1. 审计结论 + +当前 Plugin 体系已经从早期多 runtime 设想,收敛到 **Extension Host-only 的社区插件模型**。这个方向是正确的:第三方代码不进入 Rust 主进程或 Tauri WebView,而是通过 host-managed Extension Host worker 执行;插件能力通过 `contributes.*` 与 `capabilities` 声明;gateway hook 由宿主裁剪上下文、限制 mutation、记录审计,并使用 timeout/circuit/failure-policy 约束执行。 + +总体判断: + +- 架构主线:健康,且安全边界清晰。 +- 契约收敛:较好,Rust、SDK、JSON contract、文档和脚手架大体一致。 +- 生命周期:运行中 gateway pipeline 与 Extension Host dispose/retain 链路基本闭合。 +- 性能:默认路径可控,已有 snapshot、budget、warm cache 与 circuit;但缺少自动化性能门槛。 +- 主要风险:不是“插件体系不可用”,而是几个边界需要继续收紧,尤其是日志脱敏失败策略、plugin storage 与 config 混用、protocol bridge 未来态声明、CI completion 检查漂移。 + +没有发现 P0 级问题。建议优先处理 4 个 P1 收敛项,然后再清理 P2 级文档、平台兼容和维护性问题。 + +## 2. 审计范围与方法 + +本报告按 systematic debugging 的方式做架构审计:先查证现状,再形成判断,不直接做代码修复。 + +审计范围: + +- Manifest 与契约:`src-tauri/src/domain/plugins.rs`、`docs/plugin-manifest-v1.md`、`docs/plugins/plugin-api-v1-contract.json` +- Gateway pipeline:`src-tauri/src/gateway/plugins/pipeline.rs`、`context.rs`、`mutation.rs`、`permissions.rs` +- Extension Host:`src-tauri/src/app/plugins/extension_host*.rs`、`runtime_executor.rs`、`extension_host_registry.rs` +- 生命周期命令:`src-tauri/src/commands/plugins.rs`、`src-tauri/src/app/plugin_service.rs`、`src-tauri/src/gateway/control_service.rs` +- 分发安全:`src-tauri/src/infra/plugins/package.rs`、`market.rs`、`signing.rs` +- 前端与开发者体验:`src/services/plugins.ts`、`src/query/plugins.ts`、`src/plugins/contributions/*`、`packages/plugin-sdk`、`packages/create-aio-plugin` +- 文档与治理:`docs/plugins/*`、`docs/plugin-system-rfc.md`、`docs/plugin-system-development-plan.md`、`.github/workflows/ci.yml`、`src-tauri/.trellis/*` + +关键验证命令: + +| 命令 | 结果 | +| --- | --- | +| `pnpm check:plugin-api-contract` | 通过 | +| `pnpm check:plugin-system-docs` | 通过 | +| `pnpm check:plugin-system-completion` | 失败:要求 CI 包含 `pnpm --filter create-aio-plugin test` | +| `pnpm plugin-sdk:test` | 通过,28 tests | +| `pnpm plugin-sdk:typecheck` | 通过 | +| `pnpm create-aio-plugin:test` | 通过,30 tests | +| `cargo test --lib`(`src-tauri`) | 通过,1618 passed / 0 failed / 3 ignored | + +## 3. 当前架构地图 + +```mermaid +flowchart TD + A["plugin.json / .aio-plugin"] --> B["package extraction / signing / market validation"] + B --> C["Rust manifest validation"] + C --> D["Plugin repository / config / lifecycle state"] + D --> E["Tauri plugin commands"] + E --> F["GatewayControlService.refresh_plugins"] + F --> G["GatewayRuntime.refresh_plugin_pipeline"] + G --> H["GatewayPluginPipeline snapshot"] + H --> I["RuntimeExecutor"] + I --> J["ExtensionHostRegistry"] + J --> K["Extension Host worker process"] + K --> L["host capability-gated APIs"] + D --> M["Frontend plugin page / query cache"] + D --> N["Active UI contributions"] +``` + +设计核心是 **host-owned boundary**: + +- 插件声明意图,不直接获得宿主对象。 +- 宿主决定可见上下文、可写 mutation、失败策略、生命周期和审计。 +- Extension Host 是唯一公开社区 runtime。 +- 官方插件不绕过 manifest/capability/lifecycle,只是在分发来源与默认配置上有特殊身份。 + +## 4. 已经收敛得比较好的部分 + +### 4.1 Runtime 方向明确 + +`docs/plugin-manifest-v1.md` 明确:Plugin API v1 只有一种公开社区 runtime,必须使用 `runtime.kind = "extensionHost"`,并提供 `main` 指向打包后的 JS 输出。旧 WASM、process 和 native 都属于 unsupported pre-release legacy runtime。 + +证据: + +- `docs/plugin-manifest-v1.md:3` 说明社区插件只有 Extension Host。 +- `docs/plugin-manifest-v1.md:19` 说明不再使用 top-level `hooks` 或 `permissions`。 +- `docs/plugin-manifest-v1.md:56` 到 `docs/plugin-manifest-v1.md:70` 明确 runtime 示例和 legacy runtime 拒绝策略。 +- `docs/plugins/plugin-api-v1-contract.json:264` 到 `docs/plugins/plugin-api-v1-contract.json:265` 把 `communityRuntimes` 固定为 `extensionHost`,并列出 unsupported legacy runtimes。 + +Rust 侧与 SDK 侧也一致: + +- `src-tauri/src/domain/plugins.rs:529` 到 `src-tauri/src/domain/plugins.rs:541` 只接受 `PluginRuntime::ExtensionHost`。 +- `src-tauri/src/domain/plugins.rs:716` 到 `src-tauri/src/domain/plugins.rs:752` 要求 `main`、`language == "typescript"`,拒绝 top-level hooks/permissions,并校验 contributions/capabilities。 +- `packages/plugin-sdk/src/index.ts:367` 到 `packages/plugin-sdk/src/index.ts:424` 做同构校验。 +- `packages/create-aio-plugin/src/devtools.ts:89` 把 `wasm/process/native` 作为不支持的 public templates。 +- `packages/create-aio-plugin/src/devtools.ts:642` 到 `packages/create-aio-plugin/src/devtools.ts:652` 对 legacy runtime 给出迁移提示。 + +### 4.2 Gateway pipeline 的防线比较完整 + +Pipeline 的默认配置是 5000 ms timeout、3 次失败打开 circuit、30 秒 cooldown: + +- `src-tauri/src/gateway/plugins/pipeline.rs:106` 到 `src-tauri/src/gateway/plugins/pipeline.rs:123` +- `docs/plugins/reference/hooks.md:5` 到 `docs/plugins/reference/hooks.md:9` + +Pipeline 刷新时不是在请求路径中动态扫描插件,而是替换 snapshot: + +- `src-tauri/src/gateway/plugins/pipeline.rs:1131` 到 `src-tauri/src/gateway/plugins/pipeline.rs:1146` 先让 executor retain/prune runtime caches,再替换 hook snapshot,并裁剪 circuit state。 +- `src-tauri/src/gateway/plugins/pipeline.rs:1482` 到 `src-tauri/src/gateway/plugins/pipeline.rs:1512` 只索引 enabled plugin,按 hook 聚合,并按 `(priority, plugin_id)` 排序。 + +这对可预测性很重要:同一批插件的执行顺序稳定,刷新是显式生命周期动作,不会在每个请求中重新解析 DB。 + +### 4.3 Extension Host 隔离边界明确 + +Extension Host 子进程使用 JSON-RPC-over-stdio: + +- `src-tauri/src/app/plugins/extension_host_process.rs:1` 声明用途。 +- `src-tauri/src/app/plugins/extension_host_process.rs:80` 到 `src-tauri/src/app/plugins/extension_host_process.rs:86` 使用 `env_clear()` 后再恢复 allowlist 环境,并只接管 stdin/stdout/stderr。 +- `src-tauri/src/app/plugins/extension_host_process.rs:157` 到 `src-tauri/src/app/plugins/extension_host_process.rs:210` 每次调用都通过 timeout 包裹,超时或协议错误会 kill child。 + +Host API 通过 capability gate 开放: + +- `src-tauri/src/app/plugins/extension_host.rs:352` 到 `src-tauri/src/app/plugins/extension_host.rs:365` 只暴露 storage、diagnostics、privacy 几组 host method。 +- `src-tauri/src/app/plugins/extension_host.rs:368` 到 `src-tauri/src/app/plugins/extension_host.rs:377` 对每个 API 做 capability 检查。 + +Warm cache key 包含插件身份、版本、安装路径、main、runtime、contribution hash 和 gateway call timeout: + +- `src-tauri/src/app/plugins/extension_host_registry.rs:29` 到 `src-tauri/src/app/plugins/extension_host_registry.rs:39` +- `src-tauri/src/app/plugins/extension_host_registry.rs:651` 到 `src-tauri/src/app/plugins/extension_host_registry.rs:686` +- contribution hash 覆盖 runtime/main/activationEvents/contributes/capabilities/permissions:`src-tauri/src/app/plugins/extension_host_registry.rs:814` 到 `src-tauri/src/app/plugins/extension_host_registry.rs:825` + +配置没有进入 warm cache key,但 gateway hook payload 每次会带最新 config: + +- `src-tauri/src/app/plugins/extension_host_registry.rs:349` 到 `src-tauri/src/app/plugins/extension_host_registry.rs:354` + +这个设计让配置更新不必重启 worker,只要插件逻辑不把配置永久缓存为不可更新状态即可。 + +### 4.4 生命周期刷新链路闭合 + +插件 lifecycle command 会刷新运行中的 gateway plugin pipeline: + +- `src-tauri/src/commands/plugins.rs:617` 到 `src-tauri/src/commands/plugins.rs:623` +- `src-tauri/src/app/gateway_control.rs:104` 到 `src-tauri/src/app/gateway_control.rs:110` +- `src-tauri/src/gateway/control_service.rs:203` 到 `src-tauri/src/gateway/control_service.rs:211` +- `src-tauri/src/gateway/runtime.rs:191` 到 `src-tauri/src/gateway/runtime.rs:193` + +disable/uninstall 还会 dispose 已初始化的 Extension Host 实例: + +- `src-tauri/src/commands/plugins.rs:626` 到 `src-tauri/src/commands/plugins.rs:633` +- `src-tauri/src/commands/plugins.rs:637` 到 `src-tauri/src/commands/plugins.rs:670` + +`plugin_save_config` 会刷新 pipeline,但不 dispose Extension Host: + +- `src-tauri/src/commands/plugins.rs:675` 到 `src-tauri/src/commands/plugins.rs:687` + +结合 hook payload 每次携带 `detail.config`,这是合理取舍。后续如果支持长期订阅或插件自维护状态,才需要更强的 config-change notification。 + +### 4.5 分发安全基础扎实 + +`.aio-plugin` 包有多层限制: + +- 默认 package 32 MiB、entry 256、解压后 64 MiB:`src-tauri/src/infra/plugins/package.rs:11` 到 `src-tauri/src/infra/plugins/package.rs:25` +- zip entry 路径防绝对路径和 `..`:`src-tauri/src/infra/plugins/package.rs:364` 到 `src-tauri/src/infra/plugins/package.rs:399` +- `main` 必须是包内相对路径,且 `.js` 或 `.cjs`,大小不超过 1 MiB:`src-tauri/src/infra/plugins/package.rs:262` 到 `src-tauri/src/infra/plugins/package.rs:301` +- checksum 使用 `sha256:` 且 constant-time compare:`src-tauri/src/infra/plugins/signing.rs:8` 到 `src-tauri/src/infra/plugins/signing.rs:25` +- Ed25519 signature 校验完整:`src-tauri/src/infra/plugins/signing.rs:27` 到 `src-tauri/src/infra/plugins/signing.rs:59` +- market 解析会拦截 revoked、incompatible、reserved official namespace:`src-tauri/src/infra/plugins/market.rs:62` 到 `src-tauri/src/infra/plugins/market.rs:72` + +### 4.6 前端没有执行任意插件 UI 代码 + +前端 host-rendered contribution 只解析宿主支持的 schema 类型: + +- `src/plugins/contributions/HostRenderedContribution.tsx:149` 到 `src/plugins/contributions/HostRenderedContribution.tsx:168` + +按钮字段只触发已声明 command,并附带 plugin/contribution 上下文: + +- `src/plugins/contributions/HostRenderedContribution.tsx:229` 到 `src/plugins/contributions/HostRenderedContribution.tsx:242` +- `src/plugins/contributions/ContributionSlot.tsx:36` 到 `src/plugins/contributions/ContributionSlot.tsx:44` + +这是对插件 UI 的重要安全约束:社区插件不能把任意 React/JS UI 注入 WebView。 + +## 5. 主要风险与潜在问题 + +### P1-1:`log.beforePersist` 默认 fail-open,隐私安全语义需要再收紧 + +现状: + +- 文档规定 v1 默认 failure policy 是 `fail-open`:`docs/plugins/reference/hooks.md:5` 到 `docs/plugins/reference/hooks.md:7` +- `log.beforePersist` 也默认 fail-open:`docs/plugins/reference/hooks.md:152` 到 `docs/plugins/reference/hooks.md:160` +- 代码中 log hook 返回非法 payload 或执行失败时,会保留原始日志继续入库:`src-tauri/src/gateway/proxy/logging.rs:263` 到 `src-tauri/src/gateway/proxy/logging.rs:303` + +风险: + +如果用户把官方 Privacy Filter 或同类插件理解为“日志持久化前的强隐私边界”,那么 fail-open 会让 hook 超时、崩溃、payload 无效时保留原始日志。对普通可用性插件,fail-open 是合理默认;对日志脱敏插件,安全语义偏弱。 + +建议: + +1. 将官方隐私过滤路径改成 host fallback redaction:插件失败时至少使用宿主内置 redaction service 兜底。 +2. 或对 `official.privacy-filter` 的 `log.beforePersist` 采用 fail-closed / drop-log / redact-to-placeholder 策略。 +3. 如果暂不改行为,文档和 UI 必须明确:默认 fail-open 是可用性优先,不能作为强制合规日志脱敏保证。 + +### P1-2:`storage.plugin` 复用 `plugin config.storage`,职责边界混合 + +现状: + +- Extension Host storage API 需要 `storage.plugin` capability:`src-tauri/src/app/plugins/extension_host.rs:379` 到 `src-tauri/src/app/plugins/extension_host.rs:394` +- 写入时把数据放进 `detail.config.storage`,并通过 `repository::save_plugin_config` 保存:`src-tauri/src/app/plugins/extension_host.rs:399` 到 `src-tauri/src/app/plugins/extension_host.rs:433` +- storage 有 64 KiB 限制:`src-tauri/src/app/plugins/extension_host.rs:419` 到 `src-tauri/src/app/plugins/extension_host.rs:429` + +风险: + +用户配置和插件内部状态共享同一 JSON config,容易带来: + +- schema 语义混合:`configSchema` 面向用户表单,`storage` 面向插件私有状态。 +- 配置迁移不清晰:`configVersion` 是用户配置版本,storage 是否跟随同一版本不明确。 +- UI/备份/审计语义混合:导出配置时会夹带运行状态。 +- 冲突风险:插件 schema 如果也定义 `storage` 字段,会和 host API 约定相撞。 + +建议: + +1. 中期拆出独立 `plugin_storage` 表,key/value 受 plugin id 隔离。 +2. 短期至少把 `storage` 作为保留字段写入 manifest/schema 文档,并让 config schema validator 禁止插件声明顶层 `storage`。 +3. 明确导入导出与卸载语义:卸载时保留还是删除 storage,应和 config/audit 分开定义。 + +### P1-3:Protocol bridge 是公开声明骨架,不是完整执行能力 + +现状: + +- JSON contract 标记 `protocolBridgeContribution.status = "mvp-skeleton"`,执行边界是 manifest declaration、capability dependency、contribution registry metadata 和 install preview;完整执行是 future host integration,当前执行入口仍返回 not implemented:`docs/plugins/plugin-api-v1-contract.json` +- Rust 侧 `ExtensionProtocolBridgeRegistry::execute` 返回 not implemented:`src-tauri/src/app/plugins/extension_protocol_bridge.rs:27` 到 `src-tauri/src/app/plugins/extension_protocol_bridge.rs:40` +- 文档也说明 protocol bridge MVP skeleton 只稳定声明、metadata 和预检展示:`docs/plugins/reference/manifest.md` + +风险: + +契约里已经有 `protocolBridges` 和 `protocol.bridge` capability,开发者可能误以为声明 bridge 即可接管 Claude/OpenAI/Gemini 协议转换。当前实际上还是未来态。 + +建议: + +1. 前端和脚手架对 protocol bridge 标记 “preview / declaration only”。 +2. publish-check 对 protocol bridge 包给出非阻断 warning。 +3. 在完整 dispatch 链路落地前,不把 protocol bridge 放入“稳定可执行能力”列表。 + +### P1-4:completion 检查与 CI 表达方式不一致 + +现状: + +- `pnpm check:plugin-system-completion` 失败,报 `.github/workflows/ci.yml` 缺少 `pnpm --filter create-aio-plugin test`。 +- completion 脚本要求精确包含该字符串:`scripts/check-plugin-system-completion.mjs:61` 到 `scripts/check-plugin-system-completion.mjs:72` +- 但 CI 实际运行的是根脚本 `pnpm create-aio-plugin:test`:`.github/workflows/ci.yml:132` 到 `.github/workflows/ci.yml:133` +- `package.json` 中该根脚本确实等价展开为 `pnpm --filter create-aio-plugin test`:`package.json:32` 到 `package.json:34` + +风险: + +这不是运行时风险,而是治理风险:completion contract 不能通过会降低它作为“体系收敛哨兵”的可信度。以后真实缺口可能被误认为脚本误报。 + +建议: + +二选一: + +1. CI 改为直接写 `pnpm --filter create-aio-plugin test`。 +2. completion 脚本接受根脚本 wrapper,并同时校验 `package.json` 脚本内容。 + +### P2-1:market `platforms` 字段解析但未参与兼容性判断 + +现状: + +- `RawHostCompatibility.platforms` 有字段,但 `is_compatible` 只检查 app version 和 plugin API:`src-tauri/src/infra/plugins/market.rs:137` 到 `src-tauri/src/infra/plugins/market.rs:153` +- 文档示例把 `platforms` 放进 hostCompatibility:`docs/plugin-manifest-v1.md:72` 到 `docs/plugin-manifest-v1.md:84` + +风险: + +跨平台插件可能在 marketplace 中被标为 compatible,但实际只有某个平台可用。对纯 JS Extension Host 风险较小;对未来涉及文件路径、外部 CLI、平台 API 的插件会放大。 + +建议: + +短期文档说明 `platforms` 当前是声明/展示字段;中期在 market 和 install/update preview 中纳入当前 OS 判断。 + +### P2-2:package 解压大小依赖 zip metadata,实际 copied 累计可再加固 + +现状: + +- 解压前累计 `file.size()` 检查总解压大小:`src-tauri/src/infra/plugins/package.rs:144` 到 `src-tauri/src/infra/plugins/package.rs:176` +- 实际 copy 后只检查单文件 `copied > max_extracted_bytes`:`src-tauri/src/infra/plugins/package.rs:198` 到 `src-tauri/src/infra/plugins/package.rs:224` + +风险: + +通常 zip crate 的 `file.size()` 是 uncompressed size,已有测试覆盖 oversized extracted package。但 defense-in-depth 角度,仍建议在 copy 阶段累计 actual copied bytes,并和 metadata 结果交叉检查,避免依赖 archive metadata 语义。 + +建议: + +在第二轮解压 copy 中累加 `actual_copied_total`,超过限制立即失败,并在测试中覆盖 metadata/copy 不一致的异常 archive。 + +### P2-3:`plugin_service.rs` 中心化过重 + +现状: + +- `src-tauri/src/app/plugin_service.rs` 约 5786 行。 +- 同一文件承载安装、更新、回滚、官方插件、market、signature、config、contribution impact、gateway active list 和大量测试。 + +风险: + +短期不会破坏功能,但会降低新增贡献点、调整生命周期和审查安全边界的速度。插件体系未来如果继续扩展 provider values、protocol bridge、runtime reports,中心文件会越来越难维护。 + +建议: + +按职责逐步拆分,不做一次性大重构: + +- `plugin_lifecycle_service` +- `plugin_install_service` +- `plugin_update_service` +- `plugin_config_service` +- `plugin_contribution_impact` +- `official_plugin_service` + +拆分时保持 public command API 不变,并先移动测试 helper。 + +### P2-4:前端 typed slot 小于 contract slot,属于接入边界而非 bug + +现状: + +- Contract 中 UI slots 有 12 个:`docs/plugins/plugin-api-v1-contract.json:40` 到 `docs/plugins/plugin-api-v1-contract.json:52` +- 前端 typed slot 只有 3 个:`src/plugins/contributions/types.ts:3` 到 `src/plugins/contributions/types.ts:6` + +风险: + +开发者看到 contract 中更大的 slot surface,可能误以为所有 slot 都已经在 UI 中可见。实际目前只接入 provider editor sections、settings sections、logs detail tabs。 + +建议: + +1. contract 增加 slot status:`active` / `reserved` / `future`。 +2. SDK validator 可继续接受未来 slot,但脚手架和文档要标出当前 UI 接入状态。 +3. 前端 slot registry 和 contract 生成/检查同步,减少手写漂移。 + +### P2-5:前端数据访问展示存在 drift 风险 + +现状: + +- `PluginsPage.tsx` 使用本地 `GATEWAY_HOOK_ACCESS` 映射计算展示权限:`src/pages/PluginsPage.tsx:127`、`src/pages/PluginsPage.tsx:332` 到 `src/pages/PluginsPage.tsx:342` +- 执行权限真实边界在后端 access policy / pipeline,不依赖前端展示。 + +风险: + +这不是安全绕过,因为前端不做授权裁决;但 UI 风险说明如果和后端 hook access drift,会误导用户。 + +建议: + +从生成的 contract 或后端导出的 metadata 派生 UI 展示映射,避免手写两份。 + +### P2-6:性能验证存在但不在默认门禁 + +现状: + +- package script 提供 `plugin:perf-smoke`:`package.json:31` +- pipeline 中有 ignored perf smoke:`src-tauri/src/gateway/plugins/pipeline.rs:2099` 到 `src-tauri/src/gateway/plugins/pipeline.rs:2114` + +风险: + +插件体系本身处在请求路径上。只靠功能测试无法发现 hook snapshot、context budget、JSON-RPC 或 warm cache 修改带来的延迟回归。 + +建议: + +1. 为空 pipeline、单 noop plugin、Extension Host cold/warm hook 建立固定 perf baseline。 +2. 默认 CI 可只跑轻量阈值测试;release 前跑 ignored perf smoke。 +3. runtime reports 中补充 p50/p95 聚合查询,便于用户发现慢插件。 + +### P2-7:Trellis 项目级入口缺失,后端规范仍是模板 + +现状: + +- 根目录没有 `.trellis/`,只有 `src-tauri/.trellis/`。 +- `src-tauri/.trellis/spec/backend/index.md` 仍写着 “Fill in each file with your project's specific conventions”:`src-tauri/.trellis/spec/backend/index.md:7` 到 `src-tauri/.trellis/spec/backend/index.md:21` +- `directory-structure.md` 仍是 “To be filled by the team”:`src-tauri/.trellis/spec/backend/directory-structure.md:7` 到 `src-tauri/.trellis/spec/backend/directory-structure.md:54` + +风险: + +这会让新贡献者或后续 agent 难以判断插件体系在 backend/domain/app/infra/gateway/commands 之间的职责边界。 + +建议: + +补一份插件体系专项规范: + +- manifest validation 属于 `domain` +- lifecycle orchestration 属于 `commands` + `app` +- persistence/package/signing/market 属于 `infra` +- gateway request path 属于 `gateway` +- Extension Host runtime 归 `app/plugins` + +## 6. 生命周期审计 + +生命周期阶段: + +1. Install / update preview:读取 package、校验 manifest、展示 contribution/config/permission impact。 +2. Install / update:验证 checksum/signature/source,写入 package cache 和 installed dir,持久化 DB state。 +3. Enable:验证 manifest 和 duplicate command ownership,切换状态。 +4. Gateway refresh:读取 enabled plugins,跳过 invalid/legacy plugin,替换 pipeline snapshot。 +5. Execute:pipeline 按 hook 查 snapshot,裁剪 context,调用 runtime executor。 +6. Extension Host:按 key 复用或 cold start worker,执行 hook/command。 +7. Disable / uninstall / rollback:刷新 pipeline,dispose 对应 Extension Host 实例,保留 audit logs。 +8. Save config:保存 config,刷新 pipeline;不强制 dispose worker。 + +生命周期优点: + +- enabled plugin 在进入 gateway 前会重新 validate,invalid manifest 会被跳过:`src-tauri/src/app/plugin_service.rs:344` 到 `src-tauri/src/app/plugin_service.rs:356` +- pipeline refresh 会 prune runtime caches 和 circuit state。 +- disable/uninstall 后会 dispose plugin worker。 +- config update 不重启 worker,但每次 hook payload 带最新 config。 + +生命周期注意点: + +- 如果未来插件支持 long-running subscription、background task、provider daemon,需要新增 config-change event 或强制 dispose 语义。 +- rollback/update 的 Extension Host dispose 应继续作为强制不变量保留,防止旧代码与新 manifest 混跑。 + +## 7. 性能审计 + +已有性能友好点: + +- Hook snapshot 预构建,按 hook 聚合,避免每次请求查 DB。 +- 空 pipeline / no active hook 可走快速路径。 +- Context 和 mutation 都有 budget,避免把完整大 body 暴露给插件。 +- Extension Host warm cache 默认最多 8 个实例,idle recycle 120s:`src-tauri/src/app/plugins/extension_host_registry.rs:24` 到 `src-tauri/src/app/plugins/extension_host_registry.rs:54` +- 子进程 JSON-RPC 有 max line bytes,worker 默认 max line 根据 request body cap 推导:`src-tauri/src/app/plugins/extension_host_worker.rs:16` 到 `src-tauri/src/app/plugins/extension_host_worker.rs:23` + +性能风险: + +- 5000 ms hook timeout 适合轻量 redaction/rewrite,不适合远程 I/O 或复杂模型调用;SDK/文档应继续强调 hook 中不应做长耗时操作。 +- Command 默认 JS timeout 是 30s:`src-tauri/src/app/plugins/extension_host_worker.rs:18`、`src-tauri/src/app/plugins/extension_host_worker.rs:1179` 到 `src-tauri/src/app/plugins/extension_host_worker.rs:1185`。这与 gateway hook 5000ms 是不同场景,不是漂移;但文档要避免混淆。 +- Extension Host cold start 成本没有默认 CI 性能阈值。 +- 同插件串行、跨插件并行的行为有利于隔离同一插件状态,但慢插件仍可能影响其 own hook/command 后续调用。 + +## 8. 文档与契约收敛审计 + +收敛良好: + +- `docs/plugin-system-development-plan.md` 已标为 Superseded,避免把早期 WASM/process 规划当成当前方向:`docs/plugin-system-development-plan.md:3` 到 `docs/plugin-system-development-plan.md:9` +- `docs/plugins/README.md` 当前稳定性说明非常清晰:`docs/plugins/README.md:46` 到 `docs/plugins/README.md:52` +- `scripts/check-plugin-api-contract.mjs` 会检查 legacy runtime、manual permission wrapper、SDK/frontend/docs 一致性。 +- `scripts/check-plugin-system-docs.mjs` 会防止旧 WASM/process 叙述重新进入主线文档。 + +仍需收敛: + +- `check-plugin-system-completion` 当前失败,必须修。 +- `protocolBridges`、provider extension values、UI slots 需要 status 标注,避免 contract 看起来比 runtime 接入更成熟。 +- Trellis 后端规范要从模板变成项目实际规范。 + +## 9. 建议路线图 + +### 短期(1 周内) + +1. 修复 `check-plugin-system-completion` 与 CI 表达方式不一致。 +2. 在文档和 UI 中明确 `log.beforePersist` fail-open 的安全语义;官方 privacy filter 至少加 warning。 +3. 明确 `protocolBridges` 是 declaration-only / MVP skeleton。 +4. 文档声明 `hostCompatibility.platforms` 当前是否参与实际筛选;如果不参与,标为 metadata-only。 + +### 中期(1 到 3 周) + +1. 拆出 `plugin_storage`,或至少保留顶层 `storage` 字段并禁止 schema 冲突。 +2. package extraction 增加实际 copied bytes 累计限制。 +3. 前端数据访问展示从 contract/generated metadata 派生。 +4. 给 UI slot 增加 active/future status,并让 SDK/文档/前端共享同一来源。 +5. 为 empty pipeline、noop plugin、Extension Host cold/warm hook 增加轻量性能门禁。 + +### 长期(一个版本周期) + +1. 将 `plugin_service.rs` 按 install/update/config/official/impact/lifecycle 拆分。 +2. 完整定义 protocol bridge runtime contract,包括 dispatch、streaming、error mapping、权限和 replay。 +3. 为 Extension Host background/lifecycle event 建立通用模型:activate、execute、configChanged、dispose。 +4. 建立插件体系 release checklist:contract check、docs check、SDK tests、scaffolder tests、Rust plugin tests、perf smoke、manual install/update/rollback smoke。 + +## 10. 风险总表 + +| 等级 | 问题 | 影响 | 建议动作 | +| --- | --- | --- | --- | +| P1 | `log.beforePersist` fail-open 对隐私语义偏弱 | hook 失败时可能保留原始日志 | host fallback redaction 或官方插件 fail-closed/drop-log | +| P1 | `storage.plugin` 和用户 config 共用 JSON | schema、迁移、导出语义混合 | 拆独立 storage 或保留字段并禁止冲突 | +| P1 | protocol bridge 是未来态但已出现在 contract | 开发者误解能力已完整可执行 | 标注 preview/declaration-only,publish-check warning | +| P1 | completion check 与 CI 不一致 | 收敛哨兵失效 | 修改 CI 或脚本接受 wrapper | +| P2 | market `platforms` 未参与判断 | 跨平台兼容误导 | 纳入 OS 判断或标为 metadata-only | +| P2 | 解压实际 copied total 未累计检查 | defense-in-depth 不足 | 增加 actual copied total 限制 | +| P2 | `plugin_service.rs` 过大 | 后续扩展维护成本高 | 按职责渐进拆分 | +| P2 | 前端 slot 小于 contract slot | 契约与 UI 接入面不等宽 | 增加 slot status 和生成同步 | +| P2 | 前端权限展示手写映射 | UI 风险说明可能 drift | 从后端/contract 派生 | +| P2 | 性能 smoke ignored | 请求路径回归不易发现 | 加轻量 perf gate | +| P2 | Trellis spec 模板化 | 新贡献者职责边界不清 | 补插件体系 backend spec | + +## 11. 最终判断 + +Plugin 体系已经从“多 runtime 可能性集合”收敛成“Extension Host 主线 + host-owned 安全边界”。这是一个值得继续投入的架构方向。 + +下一阶段不建议再扩 public surface,而应先把边界收紧: + +1. 隐私 hook 失败语义收紧。 +2. storage 与 config 分离。 +3. future/skeleton 能力显式标注。 +4. completion/CI/docs/contract 继续保持同源收敛。 +5. 给请求路径加性能门禁。 + +做到这些后,再扩 protocol bridge、provider extension values 和更多 UI slots,会更稳。 diff --git a/docs/plugins/architecture/security.md b/docs/plugins/architecture/security.md index b25c82ba..a1ad79b5 100644 --- a/docs/plugins/architecture/security.md +++ b/docs/plugins/architecture/security.md @@ -1,15 +1,17 @@ # 插件安全与隔离 -插件系统围绕最小权限和运行时隔离设计。默认 vNext hook timeout: 150 ms。 +插件系统围绕最小 host surface 和运行时隔离设计。默认 vNext hook timeout: 5000 ms。 核心规则: -- no arbitrary JavaScript:任意 JavaScript 不会在 Rust 主进程中执行。 -- no arbitrary JavaScript:任意 JavaScript 不会在 Tauri WebView 中执行。 -- WASM 不提供 WASI filesystem 或 network imports。 -- Process runtime PoC 默认关闭。 +- Extension Host 是唯一 community runtime。 +- 不在 Rust 主进程或 Tauri WebView 执行第三方插件代码。 +- Extension Host 只暴露 capability-gated APIs。 +- Gateway hook timeout 是 host-owned invocation budget,由 gateway pipeline 传入 runtime executor;Extension Host 使用该预算启动和执行 hook,executor 不另行固定或放大 timeout。 +- Legacy WASM、process 和第三方 native 都是 unsupported pre-release legacy runtime。 - Hook 失败必须记录审计事件。 - 高风险 hook 可以使用 fail-closed 策略。 +- 当前 `log.beforePersist` 默认 fail-open;hook 失败、超时或返回非法 payload 时保留原始日志继续入库。它不是强制合规日志脱敏边界,除非后续补宿主兜底脱敏、丢弃日志或专用策略。 - 重复 runtime failure 可以让插件进入 `quarantined` 状态。 -未签名离线包会受到限制。除非未来明确的可信策略允许,否则 high 和 critical 权限会被拒绝。 +未签名离线包会受到限制。High 和 critical host-mediated labels 只用于风险展示、审计和未来 API 设计;社区 Extension Host manifest 不能通过 top-level `permissions` 申请它们。 diff --git a/docs/plugins/developer-guide.md b/docs/plugins/developer-guide.md index 38400044..9845a101 100644 --- a/docs/plugins/developer-guide.md +++ b/docs/plugins/developer-guide.md @@ -2,6 +2,8 @@ 这份指南把 AIO Coding Hub 插件从开发到发布的主线串起来。需要查字段定义时看 [Manifest v1 完整规范](../plugin-manifest-v1.md),需要查某个运行时细节时再跳到 [API 参考](./reference/README.md) 或 [运行时说明](./runtime/README.md)。 +Extension Host 是唯一 community runtime。社区插件使用 `runtime.kind = "extensionHost"`,`main` 指向打包后的 JavaScript 输出,并通过 `contributes` 和 `capabilities` 声明自己要接入的 host surface。 + ## 适合做成插件的能力 插件适合扩展本地网关链路中可被明确 hook 的行为,例如: @@ -9,38 +11,59 @@ - 在请求发往上游模型前改写、追加或脱敏 prompt。 - 在响应返回给 CLI 前做有边界的检查、替换、阻断或告警。 - 在请求日志持久化前做不可逆脱敏。 +- 注册命令或 provider extension values。 +- 声明 protocol bridge 元数据,用于安装预检和未来执行链准备;当前不能执行协议转换。 - 通过 `configSchema` 给用户提供可视化配置项,比如开关、选择器、文本输入和复选组。 插件不适合接管 CLI 内部编辑器、读取宿主数据库连接、直接操作 WebView,或把第三方 native 动态库加载进 Rust 主进程。 ## 推荐开发路径 -1. 明确插件目标和目标 hook:请求前处理通常用 `gateway.request.afterBodyRead` 或 `gateway.request.beforeSend`,日志脱敏用 `log.beforePersist`。 -2. 优先判断能否用 `declarativeRules` 表达。正则匹配、替换、阻断、告警和追加消息都应先走规则运行时。 -3. 编写最小 `plugin.json`:只声明必要 runtime、hooks、permissions、hostCompatibility 和 configSchema。 -4. 准备 fixture:至少覆盖 Claude `messages[].content[].text` 和 Codex/OpenAI Responses `input[].content[].text` / `input_text` 形态。 -5. 使用 `pnpm create-aio-plugin validate` 做 manifest 与规则校验。 -6. 使用 `pnpm create-aio-plugin replay` 在本地 fixture 上验证行为。 -7. 使用 `pnpm create-aio-plugin pack` 打包为 `.aio-plugin`。 -8. 在 Plugins 页面本地导入,授权权限,启用插件,检查审计日志。 -9. 发布前计算 `sha256`,可信索引分发时补 Ed25519 签名。 +简写成一条主线就是: + +```text +doctor -> validate --strict -> pack -> publish-check -> install/update -> export replay fixture -> fix -> reinstall +``` + +1. 明确插件目标和 contribution point:请求前处理通常用 `contributes.gatewayHooks` 注册 `gateway.request.afterBodyRead` 或 `gateway.request.beforeSend`,日志脱敏用 `log.beforePersist`。 +2. 编写最小 `plugin.json`:声明 `main`、`runtime.kind = "extensionHost"`、必要的 `activationEvents`、`contributes`、`capabilities`、`hostCompatibility` 和 `configSchema`。 +3. 在 `dist/extension.js` 中导出 `activate(api)`,并使用 `api.gateway.registerHook`、commands 或其他 Extension Host API 注册行为。 +4. 准备 fixture:至少覆盖 Claude `messages[].content[].text` 和 Codex/OpenAI Responses `input[].content[].text` / `input_text` 形态,作为宿主 trace 复现和发布检查材料。 +5. 使用 `pnpm --filter create-aio-plugin cli doctor` 和 `validate --strict` 做 package health、manifest 与入口文件校验。 +6. 使用 `pnpm --filter create-aio-plugin cli pack` 打包为 `.aio-plugin`。 +7. 发布前运行 `pnpm --filter create-aio-plugin cli publish-check ./acme.redactor` 生成 release metadata。 +8. 在 Plugins 页面本地导入或从市场安装,先检查安装预检,再确认 capabilities、数据访问和贡献点影响,启用插件,检查审计日志。 +9. 对真实请求问题,从宿主导出 `plugin_export_replay_fixture`,用导出的 trace、attempts、runtime reports 和本地 body fixture 复现。 +10. 修复 Extension Host 入口后重新打包、安装并复测。 +11. 发布前计算 `sha256`,可信索引分发时补 Ed25519 签名。 ## 10 分钟快速开始 -先 scaffold 一个声明式规则插件: +直接从完整示例模板 scaffold 一个 Extension Host 插件: ```bash pnpm --filter create-aio-plugin test -pnpm create-aio-plugin acme.redactor rule +pnpm --filter create-aio-plugin cli acme.redactor example:redactor ``` -生成目录后,先校验 `plugin.json` 和规则文件: +也可以选择其他示例模板: + +```bash +pnpm --filter create-aio-plugin cli acme.prompt-helper example:prompt-helper +pnpm --filter create-aio-plugin cli acme.redactor example:redactor +pnpm --filter create-aio-plugin cli acme.response-guard example:response-guard +``` + +示例是开发模板,不是默认可安装市场包。它们用于学习 manifest、`dist/extension.js`、fixtures、`validate --strict`、`pack` 和 `publish-check` 的完整路径;Plugins 页面里的同名精选卡片仍保持示例状态,不会绕过宿主安装校验。 + +生成目录后,先检查 package health,再校验 `plugin.json` 和 Extension Host 入口: ```bash -pnpm create-aio-plugin validate ./acme.redactor +pnpm --filter create-aio-plugin cli doctor ./acme.redactor +pnpm --filter create-aio-plugin cli validate --strict ./acme.redactor ``` -添加 Claude 和 Codex request shapes 作为 replay fixtures。Claude fixture 示例: +添加 Claude 和 Codex request shapes 作为宿主 trace 复现材料。Claude fixture 示例: ```json { @@ -60,51 +83,53 @@ Codex/OpenAI Responses fixture 示例: } ``` -在本地回放两个 fixtures: +当前 `create-aio-plugin replay` 不在本地执行 Extension Host gateway hooks;该命令会返回 `PLUGIN_REPLAY_UNSUPPORTED`。本地工具只负责 package health、manifest、入口文件、打包和发布 metadata 校验,hook 行为以宿主运行报告、导出的 fixture 和桌面应用内复测为准。 -```bash -pnpm create-aio-plugin replay ./acme.redactor ./fixtures/claude-request.json gateway.request.afterBodyRead -pnpm create-aio-plugin replay ./acme.redactor ./fixtures/codex-request.json gateway.request.afterBodyRead -``` +如果问题来自真实网关请求,先在 Plugins 页面或 request log 操作里导出 replay fixture。宿主命令名是 `plugin_export_replay_fixture`;它会把 trace id、hook name、plugin id、attempts 和 `plugin_hook_execution_reports` 放进 fixture。当前 request logs 不持久化完整 body,所以导出结果会在 `notes` 里说明缺口,插件作者需要用本地 fixture 补齐要复现的 request/response body。 打包插件并从 Plugins 页面本地安装: ```bash -pnpm create-aio-plugin pack ./acme.redactor +pnpm --filter create-aio-plugin cli pack ./acme.redactor +pnpm --filter create-aio-plugin cli publish-check ./acme.redactor ``` -在 Plugins 页面选择本地包 `acme.redactor.aio-plugin`,确认 `request.body.read` 和 `request.body.write` permissions 后启用插件。命中请求后,插件详情面板应展示 hook completion 或 block/failure events,且不应存储 sensitive payload text。 +在 Plugins 页面选择本地包 `acme.redactor.aio-plugin` 后,宿主会先展示安装预检。确认插件 id、版本、runtime、contributions、capabilities、host compatibility、checksum/signature 和风险提示无误后,再执行真实安装。安装后确认 `gateway.hooks` capability 并启用插件。命中请求后,插件详情面板应展示 hook completion 或 block/failure events,且不应存储 sensitive payload text。 + +`publish-check` 只输出市场发布 metadata,不写入插件包,也不替代 `pack`、`sign`、`verify` 或宿主安装时的 checksum/signature/compatibility/revoked 检查。 + +## 插件市场入口 + +Plugins 页面默认展示“精选插件”,面向普通用户提供简洁安装入口。用户不需要理解 market index JSON、signature 或 trusted public key,就可以看到官方 Privacy Filter 和推荐社区示例方向。 + +“高级来源”用于插件开发者或自定义源用户。它保留 market index URL、index JSON 和索引签名输入,但默认折叠。高级来源加载出的条目仍然走同一套安装卡片和宿主安装校验。 SDK 检查命令: ```bash pnpm --filter @aio-coding-hub/plugin-sdk typecheck -pnpm plugin-wasm-sdk:test +pnpm --filter create-aio-plugin test ``` ## 插件目录结构 -声明式规则插件的最小结构: +最小 Extension Host 插件结构: ```text acme.redactor/ plugin.json - rules/ - main.json + dist/ + extension.js fixtures/ claude-request.json codex-request.json ``` -WASM 插件会额外包含 `plugin.wasm` 或 Rust 工程目录。`rules`、fixture 和源码目录可以按项目需要组织,但 `plugin.json` 必须位于包根目录,或 `.aio-plugin` 解压后的唯一顶层目录内。 - -## 最小声明式规则插件 - -一个声明式规则插件至少需要 `plugin.json` 和规则文件。规则插件通常适合请求体替换、日志脱敏、告警和阻断,不需要执行任意 JavaScript/TypeScript。 +`plugin.json` 必须位于包根目录,或 `.aio-plugin` 解压后的唯一顶层目录内。`main` 必须指向包内的 `.js` 或 `.cjs` 文件;TypeScript 源码可以保留在仓库里,但发布包应包含打包后的 JavaScript 输出。 -## `plugin.json` 核心字段 +## 最小 Extension Host 插件 -最小 manifest 示例: +`plugin.json`: ```json { @@ -112,45 +137,72 @@ WASM 插件会额外包含 `plugin.wasm` 或 Rust 工程目录。`rules`、fixtu "name": "Acme Redactor", "version": "0.1.0", "apiVersion": "1.0.0", + "main": "dist/extension.js", "runtime": { - "kind": "declarativeRules", - "rules": ["rules/main.json"] + "kind": "extensionHost", + "language": "typescript" }, - "hooks": [ - { - "name": "gateway.request.afterBodyRead", - "priority": 50, - "failurePolicy": "fail-open" - } - ], - "permissions": ["request.body.read", "request.body.write"], + "activationEvents": ["onGatewayHook:gateway.request.afterBodyRead"], + "contributes": { + "gatewayHooks": [ + { + "name": "gateway.request.afterBodyRead", + "priority": 50, + "failurePolicy": "fail-open", + "timeoutMs": 5000 + } + ] + }, + "capabilities": ["gateway.hooks"], "hostCompatibility": { - "app": ">=0.56.0 <1.0.0", + "app": ">=0.60.0 <1.0.0", "pluginApi": "^1.0.0", "platforms": ["macos", "windows", "linux"] } } ``` +`dist/extension.js`: + +```js +const secretPattern = /(api_key|token|password)=[A-Za-z0-9_-]+/gi; + +module.exports.activate = function(api) { + api.gateway.registerHook("gateway.request.afterBodyRead", function(context) { + const body = String(context?.request?.body ?? ""); + const redacted = body.replace(secretPattern, "$1=[REDACTED]"); + if (redacted === body) return { action: "continue" }; + return { action: "replace", requestBody: redacted }; + }); +}; +``` + 关键规则: - `id` 使用 `publisher.plugin-name`,不要使用 `official.*`,该命名空间只属于宿主内置插件。 - `version` 使用 SemVer。 - `apiVersion` 表示插件 API 契约版本,不等于应用版本。 -- `runtime.kind` 对社区插件优先使用 `declarativeRules`。 -- `hooks` 决定插件在哪些阶段被调用。 -- `permissions` 决定插件能看到和修改哪些上下文。 -- `hostCompatibility` 决定应用版本、插件 API 版本和平台兼容性。 - -## 运行时选择 +- `runtime.kind = "extensionHost"` 是社区插件唯一运行时。 +- `main` 指向打包后的 JavaScript 输出。 +- `contributes.gatewayHooks` 决定插件在哪些阶段被调用。 +- `capabilities` 必须包含对应贡献点需要的能力。 +- `hostCompatibility` 决定应用版本和插件 API 版本兼容性;`platforms` 当前只作为元数据展示,不参与安装阻断或市场兼容性筛选。 -`declarativeRules` 是默认社区运行时。它适合 JSONPath 子集选择、正则匹配、替换、阻断、告警和 `appendMessage`。 +## Capability 依赖 -WASM 适合需要确定性代码逻辑的插件,例如复杂校验、状态较小的解析器或规则运行时无法表达的转换。WASM 执行受宿主策略控制,未启用时会返回 `PLUGIN_RUNTIME_DISABLED`。 +| Contribution | Capability | +| --- | --- | +| `commands` | `commands.execute` | +| `providers` | `provider.extensionValues` | +| provider UI sections / fields | `provider.extensionValues` | +| UI button fields in host-rendered sections/panels | `commands.execute` | +| `gatewayHooks` | `gateway.hooks` | +| `protocolBridges` | `protocol.bridge` | -进程运行时仍是 PoC,默认关闭,也没有默认 marketplace enablement。它只用于未来无法放进 WASM ABI 的隔离进程场景。 +缺少依赖 capability 的 manifest 会被拒绝。不要把 capability 当成用户权限文案;它是宿主用来控制 Extension Host API surface 的契约。 +`protocolBridges` 当前只是 MVP skeleton:`manifest`、能力依赖、贡献元数据和安装预检展示已接入,运行时执行入口仍返回 `PLUGIN_EXTENSION_PROTOCOL_BRIDGE_NOT_IMPLEMENTED`。不要把它用于生产协议转换。 -`native` 只用于内置官方引擎,例如 `official.privacy-filter` 的 `native:privacyFilter`。第三方包不能声明 host-native engine。 +当前前端实际挂载的宿主渲染 UI 插槽是 `providers.editor.sections`、`settings.sections` 和 `logs.detail.tabs`。`providers.editor.fields` 会触发 provider 能力依赖校验,但当前前端没有对应类型化插槽挂载;`providers.card.badges` 和 `providers.card.actions` 当前不是能力依赖触发项,也不是已挂载 UI。 ## Hooks 与请求形态 @@ -184,19 +236,7 @@ Claude 和 Codex/OpenAI Responses 的请求结构不同。插件应避免只适 } ``` -宿主会提供 `request.normalizedMessages`,用于给插件一个 provider-neutral 的消息视图。需要修改原始请求体时,仍应返回 `requestBody`。 - -## 权限最小化 - -只申请插件真实需要的权限: - -- 只读请求元信息:`request.meta.read`。 -- 读取请求体:`request.body.read`。 -- 修改请求体:`request.body.write`。 -- 读取或修改流式响应:`stream.inspect` / `stream.modify`。 -- 日志脱敏:`log.redact`。 - -高风险和 critical 权限需要更明确的用户授权。插件升级新增权限后,宿主必须要求重新授权,不能静默继承。 +宿主会在预算允许且当前 hook contract 提供对应 context 时暴露 `request.normalizedMessages`,用于给插件一个 provider-neutral 的消息视图。Extension Host manifest 不能声明 top-level `permissions`;需要修改原始请求体时,插件仍应返回 `requestBody`,但宿主会按 hook、capability 和 output budget 决定是否接受该 mutation。 ## 配置表单 @@ -236,24 +276,32 @@ Claude 和 Codex/OpenAI Responses 的请求结构不同。插件应避免只适 优先使用 JSON Schema 标准字段 `title`、`description`、`default`、`enum`、`required`。需要界面提示时再使用 `x-aio-ui`。 -## 本地校验与回放 +`password` 字段只影响输入控件展示,不代表宿主管理的密钥存储。当前 `storage.plugin` 能力提供的 Extension Host storage API 会把插件状态写入同一份插件配置 JSON 的顶层 `storage` 字段,大小限制为 64 KiB;它不是独立 KV 表。插件配置 schema 不应声明顶层 `storage` 字段。 + +## 本地校验与宿主回放材料 常用命令: ```bash -pnpm create-aio-plugin validate ./acme.redactor -pnpm create-aio-plugin replay ./acme.redactor ./fixtures/claude-request.json gateway.request.afterBodyRead -pnpm create-aio-plugin replay ./acme.redactor ./fixtures/codex-request.json gateway.request.afterBodyRead -pnpm create-aio-plugin pack ./acme.redactor +pnpm --filter create-aio-plugin test +pnpm --filter create-aio-plugin cli acme.redactor example:redactor +pnpm --filter create-aio-plugin cli doctor ./acme.redactor +pnpm --filter create-aio-plugin cli validate --strict ./acme.redactor +pnpm --filter create-aio-plugin cli pack ./acme.redactor +pnpm --filter create-aio-plugin cli publish-check ./acme.redactor ``` +`doctor` checks package health and reports structured diagnostics with `severity`, `code`, `message`, `path`, and `hint`. +`validate --strict` keeps Plugin API v1 compatibility but adds package-level checks for Extension Host `main`, artifact paths, target compatibility, hook contributions, capabilities, and package layout. +Warnings do not fail the command in 0.62.1; any `error` severity diagnostic returns a non-zero exit code. + 验收一个插件时,至少确认: - `plugin.json` 能通过校验。 -- 所有规则文件路径合法,没有 `..` 或绝对路径。 -- Claude fixture 和 Codex/OpenAI Responses fixture 都能得到预期结果。 -- 未授权的读写不会生效。 -- 打包后的 `.aio-plugin` 能在 Plugins 页面导入、授权并启用。 +- `main` 指向的 Extension Host 输出文件存在、位于包内、大小在限制内。 +- Claude fixture 和 Codex/OpenAI Responses fixture 都能通过宿主运行报告、导出的 replay fixture 或集成测试覆盖预期行为。 +- 未声明 capability 的贡献点不会生效。 +- 打包后的 `.aio-plugin` 能在 Plugins 页面导入、确认并启用。 ## 发布与升级 @@ -262,9 +310,9 @@ pnpm create-aio-plugin pack ./acme.redactor 升级时要特别检查: - 新版本是否仍满足 `hostCompatibility`。 -- 是否新增 permissions。新增权限必须重新授权。 +- 是否新增 capabilities 或贡献点。新增能力必须由用户重新确认。 - 是否改变 `configSchema`。需要通过 `configVersion` 和默认值保证旧配置可迁移。 -- 回滚是否能恢复旧版本、旧配置快照、旧权限和启用状态。 +- 回滚是否能恢复旧版本、旧配置快照和启用状态。 ## 官方 Privacy Filter 示例 @@ -273,13 +321,18 @@ pnpm create-aio-plugin pack ./acme.redactor 这个示例说明了三件事: - 插件页面可以完全根据 `configSchema` 和 `x-aio-ui` 渲染配置,而不是写专用页面。 -- 官方 `native` 引擎应少而聚焦,避免扩大宿主维护面。 -- 社区版同类插件应优先从 `declarativeRules` 或 WASM 起步,而不是请求第三方 `native` 能力。 +- 官方 bundled 插件应少而聚焦,避免扩大宿主维护面。 +- 社区版同类插件应使用 Extension Host gateway hooks,而不是请求第三方 native 能力。 + +## Legacy runtime 迁移说明 + +WASM、process 和第三方 native 运行时属于 unsupported pre-release legacy runtime。当前公开 manifest validation 会拒绝这些运行时。旧插件应迁移为 Extension Host 插件,把逻辑放进 `dist/extension.js`,通过 `contributes.gatewayHooks` 声明 hook,再在 `activate(api)` 中调用 `api.gateway.registerHook`。 ## 常见排障 -- 插件无法启用:先看 `hostCompatibility`、runtime policy、manifest 校验错误和权限是否已授权。 -- WASM 插件提示 `PLUGIN_RUNTIME_DISABLED`:说明宿主策略尚未启用 WASM 执行。 -- 请求没有被改写:检查 hook 是否选对,是否同时拥有读写权限,fixture 是否覆盖实际 provider 请求结构。 +- 插件无法启用:先看 `hostCompatibility`、runtime policy、manifest 校验错误、capability 依赖和配置是否有效。 +- 报 `PLUGIN_MISSING_MAIN`:确认 `main` 指向包内 `.js` 或 `.cjs` 文件。 +- 报 `PLUGIN_UNSUPPORTED_RUNTIME`:说明插件仍使用 unsupported pre-release legacy runtime,需要迁移到 Extension Host。 +- 请求没有被改写:检查 hook 是否选对,是否声明 `contributes.gatewayHooks`、`capabilities: ["gateway.hooks"]`,fixture 是否覆盖实际 provider 请求结构。 - 只能看到原始本地请求:隐私过滤保护的是 gateway-to-upstream body 和持久化日志;client-to-gateway 的本地入站请求在 hook 前仍是原文。 -- 日志仍有敏感值:确认插件声明并授权 `log.redact`,且启用了 `log.beforePersist`。 +- 日志仍有敏感值:确认插件注册 `log.beforePersist`,且 Extension Host hook 返回 `logMessage` mutation。当前 `log.beforePersist` 默认 `fail-open`;hook 失败、超时或返回非法日志 payload 时,宿主会记录诊断并保留原始日志继续入库。 diff --git a/docs/plugins/examples/README.md b/docs/plugins/examples/README.md index 105b8ea8..7867c08f 100644 --- a/docs/plugins/examples/README.md +++ b/docs/plugins/examples/README.md @@ -2,4 +2,17 @@ 这里放官方示例和推荐社区插件形态。示例的目标是展示插件系统应该怎样被使用,而不是扩展宿主内置插件数量。 -- [Privacy Filter](./privacy-filter.md):当前唯一内置官方插件 `official.privacy-filter`,对齐 `packyme/privacy-filter` 的核心脱敏能力。 +- [Privacy Filter](./privacy-filter.md):当前唯一内置官方 Extension Host 插件 `official.privacy-filter`,对齐 `packyme/privacy-filter` 的核心脱敏能力。 + +## 示例清单 + +| 示例 ID | 生成模板 | 目标 | Hooks | Capabilities | Fixtures / 覆盖路径 | +| --- | --- | --- | --- | --- | --- | +| `official.privacy-filter` | 内置官方插件 | 请求和日志脱敏 | `gateway.request.afterBodyRead`, `gateway.request.beforeSend`, `log.beforePersist` | `gateway.hooks`, `privacy.redact` | 官方 fixture 存在于宿主资源目录;覆盖配置 UI、request replay export 和日志脱敏边界 | +| `examples/prompt-helper` | `example:prompt-helper` | 在请求进入 provider 前补充提示词约束 | `gateway.request.afterBodyRead` | `gateway.hooks` | 包含 `fixtures/claude-request.json` 和 `fixtures/codex-request.json`;覆盖 Claude messages 和 Codex/OpenAI Responses request mutation | +| `examples/redactor` | `example:redactor` | 展示 Extension Host gateway hook 脱敏形态 | `gateway.request.beforeSend`, `log.beforePersist` | `gateway.hooks` | 包含 request hit/miss 和 log redact fixtures;覆盖 pack、publish-check 和市场安装元数据 | +| `examples/response-guard` | `example:response-guard` | 在 non-stream 响应返回后做轻量检查或标记 | `gateway.response.after` | `gateway.hooks` | 包含 `fixtures/response-warn.json` 和 `fixtures/response-pass.json`;覆盖响应 mutation 和 pass 路径 | + +`examples/*` 是开发模板,不是默认可安装市场包。生成出的目录可以运行 `validate --strict`、`pack` 和 `publish-check`;Extension Host hook 行为要通过宿主运行报告、导出的 replay fixture 和桌面应用内复测确认。发布为真实 `.aio-plugin` artifact 仍需要单独的 checksum、signature、托管和市场索引流程。 + +这些示例都保持在 Plugin API v1 范围内。宿主负责运行诊断、fixture 导出、安装校验和市场索引解析;插件只声明 `main`、Extension Host runtime、contributions、capabilities 和自己的打包输出。 diff --git a/docs/plugins/examples/privacy-filter.md b/docs/plugins/examples/privacy-filter.md index c2de3b9b..ea28a4c5 100644 --- a/docs/plugins/examples/privacy-filter.md +++ b/docs/plugins/examples/privacy-filter.md @@ -2,7 +2,7 @@ 官方 catalog 会刻意保持很小。`official.privacy-filter` 是当前唯一 bundled official plugin。 -这样可以让 trusted host surface 保持收敛,同时继续通过 `declarativeRules`、WASM 和默认关闭的进程运行时 PoC 提供开放扩展能力。 +这样可以让 trusted host surface 保持收敛,同时把社区扩展统一放在 Extension Host。WASM、process 和第三方 native 只作为 unsupported pre-release legacy runtime 说明保留。 ## 当前官方 ID @@ -10,13 +10,17 @@ 用户可以在 Plugins 页面通过官方插件安装入口安装它。 +Plugins 页面也会展示 `examples/prompt-helper`、`examples/redactor` 和 `examples/response-guard` 作为社区示例方向。它们不是 bundled official plugin,也不会绕过宿主的安装、capability/contribution、兼容性或签名校验。 + +`examples/prompt-helper`、`examples/redactor` 和 `examples/response-guard` 现在由 `create-aio-plugin` 作为开发模板生成。它们帮助作者学习 Plugin API v1 和 devtools 闭环,但不是宿主内置插件,也不是默认可安装市场包。 + ## Privacy Filter ID: `official.privacy-filter` -Runtime: `native:privacyFilter` +Runtime: `extensionHost` -它对齐 [packyme/privacy-filter](https://github.com/packyme/privacy-filter) 的核心 redaction behavior。 +它是 bundled official Extension Host 插件,对齐 [packyme/privacy-filter](https://github.com/packyme/privacy-filter) 的核心 redaction behavior。 它展示了 prompts 和 request logs 的 pre-upstream privacy filtering。 @@ -28,8 +32,10 @@ Hooks: - `gateway.request.beforeSend` - `log.beforePersist` -Permissions: +Capabilities and host-mediated labels: +- `gateway.hooks` +- `privacy.redact` - `request.body.read` - `request.body.write` - `log.redact` @@ -45,7 +51,9 @@ Provider request shapes: `official.privacy-filter` 会按 `redactionScopes` 选择请求处理范围,并只脱敏协议白名单里的文本字段。默认范围包含系统/开发者指令、用户输入、工具返回结果,以及 legacy `prompt` / raw text bodies。Codex/OpenAI Responses payloads 会处理 `instructions`、`input` string、`input[].content[].text` 和 `function_call_output.output`;Claude-style payloads 会处理 `system`、`messages[].content[].text(type=text)` 和 `tool_result.content`;OpenAI-compatible chat payloads 会处理 `messages[].content` 和 role `tool` 的 content。工具定义、tool schema、tool call arguments、metadata、reasoning/thinking blocks、file/image IDs、URLs 和 base64 data 会保持原样。 -Gateway boundary note:Privacy Filter 会接收原始 client-to-gateway body,因为 gateway 必须先看到 prompt 才能脱敏。它的保护保证是:当插件启用并选中匹配策略和处理范围后,gateway-to-upstream provider request body 中的白名单字段和 persisted request logs 会被脱敏。日志脱敏由 `redactLogs` 和 `sensitiveTypes` 控制,不受 request `redactionScopes` 影响。如果你检查 hook 执行前的本地 client request,仍可能看到原始输入。 +Gateway boundary note:Privacy Filter 会接收原始 client-to-gateway body,因为 gateway 必须先看到 prompt 才能脱敏。它的保护保证是:当插件启用、hook 成功执行且选中匹配策略和处理范围后,gateway-to-upstream provider request body 中的白名单字段和 persisted request logs 会被脱敏。日志脱敏由 `redactLogs` 和 `sensitiveTypes` 控制,不受 request `redactionScopes` 影响。如果你检查 hook 执行前的本地 client request,仍可能看到原始输入。当前 `log.beforePersist` 失败或返回非法 payload 时会保留原始日志继续入库,所以它不是由宿主强制丢弃日志的合规边界。 + +Official privacy filter rules are loaded under a 1 MiB host byte budget。`official.privacy-filter` 通过 `api.privacy.redactRequestBody` 和 `api.privacy.redactText` 调用宿主脱敏服务,并在 manifest 的 gateway hooks 上声明 `timeoutMs: 5000`,避免大 payload 脱敏被默认轻量 hook 预算误杀;community redaction plugins should use Extension Host gateway hooks and ordinary host APIs, and may declare their own positive `timeoutMs` when needed. 重要限制: @@ -58,12 +66,20 @@ Gateway boundary note:Privacy Filter 会接收原始 client-to-gateway body, - 一个 minimal manifest。 - 一个 Claude messages fixture。 - 一个 Codex/OpenAI Responses input fixture。 -- 一个 local replay command。 +- 一个 host replay/export 验证说明。 - 一个 package command。 -- 精确列出它请求的 permissions。 +- 精确列出它依赖的 capabilities,以及 host-mediated context/mutation labels。 - 简短说明哪些行为是 intentionally unsupported。 +- 能被宿主导出的 trace replay fixture 覆盖至少一个正常路径和一个边界路径。 +- 能通过 `pnpm --filter create-aio-plugin cli publish-check` 生成市场发布 metadata。 + +社区示例应使用 Extension Host。Gateway 行为通过 `contributes.gatewayHooks` 和 `api.gateway.registerHook` 表达;旧运行时只用于迁移说明。 + +## Replay 与发布流程 + +`official.privacy-filter` 可以用宿主导出的 replay fixture 验证请求脱敏和日志脱敏边界。当前 request logs 不持久化完整 request/response body,所以导出的 fixture 会携带 trace、attempts、运行报告和 notes;插件作者需要用本地 fixture 补齐需要复现的 body。 -社区示例应优先使用 `declarativeRules`。只有当行为需要确定性代码执行且规则运行时无法表达时,才考虑 WASM。WASM examples 可以展示 ABI packaging,但 gateway execution 在宿主启用前仍受策略限制。 +发布到市场前,插件包仍应经过 `pack`、`sign` 或 `verify` 以及 `publish-check`。`publish-check` 只生成发布 metadata,不替代宿主安装时的 checksum、signature、兼容性和撤销状态检查。 ## 已移除的内置示例 @@ -71,9 +87,9 @@ Gateway boundary note:Privacy Filter 会接收原始 client-to-gateway body, 类似行为应实现为社区插件: -- Prompt rewriting:在 `gateway.request.afterBodyRead` 上使用 `declarativeRules`。 -- Response safety checks:在 `gateway.response.after` 或 `gateway.response.chunk` 上使用 `declarativeRules`。 -- Generic log redaction:在 `log.beforePersist` 上使用 `declarativeRules`;规则运行时表达力不够时再考虑 WASM。 +- Prompt rewriting:用 Extension Host 在 `gateway.request.afterBodyRead` 上注册 gateway hook。 +- Response safety checks:用 Extension Host 在 `gateway.response.after` 或 `gateway.response.chunk` 上注册 gateway hook。 +- Generic log redaction:用 Extension Host 在 `log.beforePersist` 上注册 gateway hook。 ## 代码位置 diff --git a/docs/plugins/plugin-api-v1-contract.json b/docs/plugins/plugin-api-v1-contract.json index 0ec21e10..b1e4ee2e 100644 --- a/docs/plugins/plugin-api-v1-contract.json +++ b/docs/plugins/plugin-api-v1-contract.json @@ -1,7 +1,129 @@ { "apiVersion": "1.0.0", - "defaultHookTimeoutMs": 150, + "manifestVersion": "1.0.0", + "defaultHookTimeoutMs": 5000, "defaultFailurePolicy": "fail-open", + "runtimes": { + "extensionHost": { + "language": "typescript", + "requiresMain": true, + "mainOutput": "bundled JavaScript or CommonJS file", + "allowedMainExtensions": [".js", ".cjs"], + "lifecycle": { + "activation": "host-managed worker activation from manifest main", + "hookTimeoutMs": 5000, + "failurePolicy": "fail-open by default", + "dispose": "host disposes worker state when plugin snapshot changes, disables, updates, or uninstalls" + }, + "status": "contract" + } + }, + "activationEvents": [ + "onStartup", + "onCommand:", + "onProviderEditor:", + "onProtocolBridge:", + "onGatewayHook:" + ], + "capabilities": [ + "commands.execute", + "storage.plugin", + "diagnostics.read", + "privacy.redact", + "provider.extensionValues", + "provider.requestPreparation", + "provider.modelDiscovery", + "provider.healthCheck", + "protocol.bridge", + "gateway.hooks" + ], + "uiContributionSlots": [ + "app.sidebar.items", + "home.overview.cards", + "providers.editor.sections", + "providers.editor.fields", + "providers.card.badges", + "providers.card.actions", + "settings.sections", + "logs.detail.tabs", + "logs.detail.actions", + "usage.panels", + "plugins.detail.panels" + ], + "activeUiContributionSlots": [ + "providers.editor.sections", + "settings.sections", + "logs.detail.tabs" + ], + "manifestOnlyUiContributionSlots": [ + "app.sidebar.items", + "home.overview.cards", + "providers.editor.fields", + "providers.card.badges", + "providers.card.actions", + "logs.detail.actions", + "usage.panels", + "plugins.detail.panels" + ], + "uiContributionSlotBoundary": "uiContributionSlots 是 SDK/Rust 已知的 manifest 插槽名;activeUiContributionSlots 是当前前端已挂载的插槽", + "contributionPoints": [ + "providers", + "protocols", + "protocolBridges", + "commands", + "gatewayHooks", + "ui" + ], + "capabilityDependencies": { + "commands": ["commands.execute"], + "providers": ["provider.extensionValues"], + "ui.providers.editor.sections": ["provider.extensionValues"], + "ui.providers.editor.fields": ["provider.extensionValues"], + "ui.buttonCommandFields": ["commands.execute"], + "gatewayHooks": ["gateway.hooks"], + "protocolBridges": ["protocol.bridge"] + }, + "providerContribution": { + "requiredFields": ["providerType", "displayName", "targetCliKeys", "extensionNamespace"], + "targetCliKeys": ["claude", "codex", "gemini"] + }, + "protocolContribution": { + "requiredFields": ["protocolId", "direction"], + "directions": ["inbound", "outbound", "both"] + }, + "protocolBridgeContribution": { + "requiredFields": ["bridgeType", "inboundProtocol", "outboundProtocol"], + "optionalFields": ["supportsStreaming"], + "status": "mvp-skeleton", + "executionBoundary": "仅支持 manifest 声明、能力依赖、贡献注册表元数据和安装预检展示;runtime execute 会返回 PLUGIN_EXTENSION_PROTOCOL_BRIDGE_NOT_IMPLEMENTED,直到完整 protocol bridge 执行链作为 future host integration 接入" + }, + "hostCompatibilityRuntimeBoundary": { + "app": "当前参与安装和市场兼容性检查", + "pluginApi": "当前参与安装和市场兼容性检查", + "platforms": "当前代码中只作为元数据和展示字段;不参与市场兼容版本选择或本地安装阻断" + }, + "storagePluginBoundary": { + "capability": "storage.plugin", + "status": "当前已接入的 Extension Host API", + "persistence": "存放在插件配置 JSON 的顶层 storage 字段,并通过插件配置持久化路径保存", + "limitBytes": 65536, + "reservedLabel": "plugin.storage 是内部或未来宿主中介标签,不是当前 active manifest capability" + }, + "commandContribution": { + "requiredFields": ["command", "title"], + "optionalFields": ["category"] + }, + "gatewayHookContribution": { + "requiredFields": ["name"], + "optionalFields": ["priority", "failurePolicy", "timeoutMs"], + "defaultTimeoutMs": 5000, + "timeoutMs": "positive integer milliseconds; omitted hooks use defaultHookTimeoutMs" + }, + "hostRenderedSchema": { + "schemaTypes": ["section", "panel", "badge"], + "fieldTypes": ["text", "password", "number", "boolean", "select", "textarea", "info", "button"], + "badgeTones": ["neutral", "success", "warning", "danger"] + }, "activeHooks": [ "gateway.request.afterBodyRead", "gateway.request.beforeSend", @@ -15,13 +137,7 @@ "gateway.request.beforeProviderResolution", "gateway.response.headers" ], - "activeMutationFields": [ - "requestBody", - "responseBody", - "streamChunk", - "logMessage", - "headers" - ], + "activeMutationFields": ["requestBody", "responseBody", "streamChunk", "logMessage", "headers"], "configSchemaTypes": ["object", "array", "string", "password", "number", "integer", "boolean"], "activePermissions": [ "request.meta.read", @@ -48,6 +164,11 @@ "hookMatrix": { "gateway.request.afterBodyRead": { "phase": "after request body read and before upstream provider send", + "kind": "request", + "status": "active", + "defaultFailurePolicy": "fail-open", + "timeoutMs": 5000, + "reservedHeaderPolicy": "block-gateway-owned", "readPermissions": [ "request.meta.read", "request.header.read", @@ -55,6 +176,9 @@ "request.body.read" ], "writePermissions": ["request.header.write", "request.body.write"], + "permissionDependencies": { + "request.body.write": ["request.body.read"] + }, "mutationFields": ["headers", "requestBody"], "contextFields": [ "traceId", @@ -70,6 +194,11 @@ }, "gateway.request.beforeSend": { "phase": "after provider resolution and before upstream provider send", + "kind": "request", + "status": "active", + "defaultFailurePolicy": "fail-open", + "timeoutMs": 5000, + "reservedHeaderPolicy": "block-gateway-owned", "readPermissions": [ "request.meta.read", "request.header.read", @@ -77,6 +206,7 @@ "request.body.read" ], "writePermissions": ["request.header.write", "request.body.write"], + "permissionDependencies": {}, "mutationFields": ["headers", "requestBody"], "contextFields": [ "traceId", @@ -92,44 +222,79 @@ }, "gateway.response.chunk": { "phase": "for each bounded streaming response chunk", + "kind": "stream", + "status": "active", + "defaultFailurePolicy": "fail-open", + "timeoutMs": 5000, + "reservedHeaderPolicy": "block-gateway-owned", "readPermissions": ["stream.inspect"], "writePermissions": ["stream.modify"], + "permissionDependencies": { + "stream.modify": ["stream.inspect"] + }, "mutationFields": ["streamChunk"], "contextFields": ["traceId", "stream.sequence", "stream.chunk"] }, "gateway.response.after": { "phase": "after a complete non-streaming upstream response body is available", + "kind": "response", + "status": "active", + "defaultFailurePolicy": "fail-open", + "timeoutMs": 5000, + "reservedHeaderPolicy": "block-gateway-owned", "readPermissions": ["response.header.read", "response.body.read"], "writePermissions": ["response.header.write", "response.body.write"], + "permissionDependencies": { + "response.body.write": ["response.body.read"] + }, "mutationFields": ["headers", "responseBody"], - "contextFields": [ - "traceId", - "response.status", - "response.headers", - "response.body" - ] + "contextFields": ["traceId", "response.status", "response.headers", "response.body"] }, "gateway.error": { "phase": "after gateway error response materialization and before it is sent", + "kind": "response", + "status": "active", + "defaultFailurePolicy": "fail-open", + "timeoutMs": 5000, + "reservedHeaderPolicy": "block-gateway-owned", "readPermissions": ["response.header.read", "response.body.read"], "writePermissions": ["response.header.write", "response.body.write"], + "permissionDependencies": {}, "mutationFields": ["headers", "responseBody"], - "contextFields": [ - "traceId", - "response.status", - "response.headers", - "response.body" - ] + "contextFields": ["traceId", "response.status", "response.headers", "response.body"] }, "log.beforePersist": { "phase": "before gateway request log persistence", + "kind": "log", + "status": "active", + "defaultFailurePolicy": "fail-open", + "timeoutMs": 5000, + "reservedHeaderPolicy": "block-gateway-owned", "readPermissions": ["log.redact"], "writePermissions": ["log.redact"], + "permissionDependencies": {}, "mutationFields": ["logMessage"], "contextFields": ["traceId", "log.message"] } }, - "communityRuntimes": ["declarativeRules"], - "policyGatedRuntimes": ["wasm"], - "officialRuntimes": ["native:privacyFilter"] + "extensionHostContract": { + "runtime": "extensionHost", + "language": "typescript", + "requiresMain": true, + "entryField": "main", + "mainOutput": "dist/extension.js", + "supportedSourceLanguages": ["typescript", "javascript"], + "bundleOutputLanguages": ["javascript", "commonjs"], + "lifecycle": { + "activate": "module exports activate(api)", + "gatewayRegistration": "api.gateway.registerHook", + "privacyRedaction": "api.privacy.redactText and api.privacy.redactRequestBody when privacy.redact is declared", + "defaultHookTimeoutMs": 5000, + "defaultFailurePolicy": "fail-open", + "dispose": "host-managed" + }, + "status": "mainline-contract" + }, + "communityRuntimes": ["extensionHost"], + "unsupportedLegacyRuntimes": ["wasm", "process", "native"] } diff --git a/docs/plugins/reference/README.md b/docs/plugins/reference/README.md index e082e9d9..f587f5f1 100644 --- a/docs/plugins/reference/README.md +++ b/docs/plugins/reference/README.md @@ -1,22 +1,26 @@ # 插件 API 参考 -这里放插件作者需要查的稳定契约。日常开发先读 [插件开发总指南](../developer-guide.md),遇到字段、hook、权限或打包规则不确定时,再回到本目录查询。 +这里放插件作者需要查的稳定契约。日常开发先读 [插件开发总指南](../developer-guide.md),遇到字段、hook、capability、host-mediated context labels 或打包规则不确定时,再回到本目录查询。 ## 必读契约 -- [Manifest](./manifest.md):`plugin.json` 必填字段、运行时声明、命名空间和兼容性入口。 +- [Manifest](./manifest.md):`plugin.json` 必填字段、Extension Host runtime、`main`、贡献点、capabilities 和兼容性入口。 - [Hooks](./hooks.md):网关与日志 hook 的触发时机、上下文字段、超时和可修改字段。 -- [Permissions](./permissions.md):权限名称、风险等级、授权和重新授权规则。 +- [Permissions](./permissions.md):内部 context/mutation label 名称、风险等级和 Extension Host 非 manifest-permission 边界。 - [Config Schema](./config-schema.md):配置表单 schema、`x-aio-ui` 和低代码渲染规则。 -- [Declarative Rules](./declarative-rules.md):规则文件结构、target、action、条件和运行时限制。 ## 工具与发布 -- [SDK](./sdk.md):`@aio-coding-hub/plugin-sdk` 和 `aio-plugin-wasm-sdk` 的边界与示例。 -- [Publishing](./publishing.md):`.aio-plugin`、`sha256`、Ed25519 签名、远程安装和 rollback。 -- [Compatibility](./compatibility.md):SemVer、`pluginApi`、platforms 和 WASM ABI 兼容规则。 +- [SDK](./sdk.md):`@aio-coding-hub/plugin-sdk` 的 Extension Host manifest、hook result 和 validation helper 边界。 +- [Publishing](./publishing.md):`.aio-plugin`、`sha256`、Ed25519 签名、`publish-check`、市场索引、远程安装和 rollback。 +- [Compatibility](./compatibility.md):SemVer、`pluginApi`、`platforms` 元数据和 legacy runtime 兼容规则。 + +## 调试与观测 + +- [Hooks](./hooks.md#observability-and-replay):`plugin_hook_execution_reports`、`plugin_export_replay_fixture` 和各 hook 的 replay 边界。 +- [运行时说明](../runtime/README.md):host-owned lifecycle、Extension Host activation/dispose 和 release guard。 ## 规范来源 - [Manifest v1 完整规范](../../plugin-manifest-v1.md):规范性 manifest 文档。 -- [plugin-api-v1-contract.json](../plugin-api-v1-contract.json):hook、permission 和 runtime 的机器可读契约。 +- [plugin-api-v1-contract.json](../plugin-api-v1-contract.json):hook、host-mediated labels、capability 和 runtime 的机器可读契约。 diff --git a/docs/plugins/reference/compatibility.md b/docs/plugins/reference/compatibility.md index 134f2d10..597101d3 100644 --- a/docs/plugins/reference/compatibility.md +++ b/docs/plugins/reference/compatibility.md @@ -1,6 +1,6 @@ # 插件兼容性 -插件兼容性使用 SemVer 描述。宿主安装、启用和升级插件时,会同时检查插件版本、插件 API 版本、应用版本、平台和运行时 ABI。 +插件兼容性使用 SemVer 描述。宿主安装、启用和升级插件时,会检查插件版本、插件 API 版本、应用版本、Extension Host runtime 和能力依赖。`platforms` 当前会被解析和展示,但不会参与本地安装阻断或市场兼容性筛选。 Manifest 中的关键字段: @@ -8,12 +8,42 @@ Manifest 中的关键字段: - `apiVersion`:该 manifest 使用的插件 API 版本。 - `hostCompatibility.app`:兼容的 AIO Coding Hub 应用版本范围。 - `hostCompatibility.pluginApi`:兼容的 pluginApi 版本范围。 -- `hostCompatibility.platforms`:可选的平台 allowlist。 +- `hostCompatibility.platforms`:可选平台元数据。当前代码不把它作为强制白名单。 +- `runtime.kind = "extensionHost"`:Plugin API v1 唯一 community runtime。 +- `main`:打包后的 JavaScript 输出入口。 -WASM 插件还需要声明 WASM ABI 版本: +宿主会拒绝不支持的主版本。0.62.x 只支持 Plugin API major `1`,因此 manifest 的 `apiVersion` 必须是 `1.x.y`,即使 `hostCompatibility.pluginApi` 声明支持 `^1.0.0` 也不能使用 `2.0.0`。未来插件 API 变更必须保持向后兼容;无法兼容时,需要提升主版本并让旧插件继续按旧契约运行或被明确标记为不兼容。 -```json -{ "kind": "wasm", "abiVersion": "1.0.0" } -``` +## 0.62 Internal Platform Kernel -宿主会拒绝不支持的主版本。未来插件 API 变更必须保持向后兼容;无法兼容时,需要提升主版本并让旧插件继续按旧契约运行或被明确标记为不兼容。 +Plugin API v1 remains externally compatible in 0.62. 这个版本重组的是宿主内部平台边界:contract metadata、hook descriptors、runtime policy、runtime cache lifecycle 和 provider adapter facades。 + +0.62 does not add public provider plugin APIs. Provider adapter 仍是 host-internal 设计,用于先降低 gateway/provider 分支扩散和维护成本;未来是否公开 provider 插件 API,需要另行设计版本化契约。 + +Extension Host is the only community runtime. Third-party JavaScript runs only in the managed Extension Host worker, not in the Rust main process or Tauri WebView. WASM、process 和第三方 native 只作为 unsupported pre-release legacy runtime 说明保留;当前公开 manifest validation 会拒绝这些运行时。 + +## 0.62.1 Developer Loop Boundary + +0.62.1 does not change Plugin API v1. `doctor`, `validate --strict`, `pack`, and `publish-check` are developer tooling around the same manifest and package contract. Extension Host hook behavior is verified through host runtime reports and exported replay fixtures, not local `create-aio-plugin replay` execution. + +Provider behavior remains host-owned. Provider ordering, failover, OAuth limits, token counting, cx2cc translation, and session binding are covered by internal acceptance tests, but no Provider Plugin API is exposed. + +Scaffold and pack flow create Extension Host packages with `main`, `runtime.kind = "extensionHost"`, `contributes.gatewayHooks` and capability dependencies. + +## 0.62.2 Lifecycle Boundary + +0.62.2 still keeps Plugin API v1 externally stable. 安装预检、更新 diff、rollback availability、quarantine reason 和 trust summary 都是宿主解释现有安装/更新规则的 lifecycle layer,不是新的 manifest schema,也不是插件可调用的新 API。 + +安装或更新前,宿主会把 `hostCompatibility`、runtime support、capabilities、contributions、checksum/signature 和 package source 组合成 preview/diff 给用户确认。确认后,真实 install/update 仍会重新执行完整校验;preview/diff 不能作为跳过兼容性、签名或 capability/contribution policy 的依据。 + +兼容性判断仍以这些字段为准: + +- `hostCompatibility.app` 必须匹配当前 AIO Coding Hub 版本。 +- `hostCompatibility.pluginApi` 必须匹配当前 Plugin API v1。 +- `hostCompatibility.platforms` 如果存在,会出现在预检和元数据中;当前不会因为缺少当前桌面平台而阻断本地安装或市场兼容版本选择。 +- runtime 必须是 Extension Host。 +- contribution 必须有对应 capability。 + +Quarantined 和 incompatible 插件不能启用。更新新增的 capabilities 会进入 pending,不会静默继承用户确认。 + +0.62.2 也不开放 browser-like plugin container。Linux、macOS、Windows 上的插件仍运行在宿主支持的 Extension Host 中;第三方插件不能把 AIO Coding Hub 内部变成浏览器或 WebView 插件容器。 diff --git a/docs/plugins/reference/config-schema.md b/docs/plugins/reference/config-schema.md index 6fbc655f..01b9a4e1 100644 --- a/docs/plugins/reference/config-schema.md +++ b/docs/plugins/reference/config-schema.md @@ -25,6 +25,8 @@ vNext does not provide host-managed secret storage for community plugin config。已保存的配置值仍然是普通插件配置值,可能出现在后端详情 payload 中。后端会在持久化前校验配置;前端校验只是便利层,不能作为唯一信任来源。 +`storage` 是当前 Extension Host storage API 的保留顶层字段:声明 `storage.plugin` 能力的插件通过 `api.storage` 写入的数据会保存在插件配置 JSON 的 `storage` object 中,并通过同一套插件配置持久化路径保存,大小限制为 64 KiB。`configSchema.properties.storage` 当前不会被校验器特别禁止,但插件作者不应定义这个顶层字段,否则会和宿主 API 状态混用。 + ## UI 元数据 宿主会把 `configSchema` 渲染为低代码设置面板。优先使用标准 JSON Schema 展示字段: diff --git a/docs/plugins/reference/declarative-rules.md b/docs/plugins/reference/declarative-rules.md deleted file mode 100644 index d26c071f..00000000 --- a/docs/plugins/reference/declarative-rules.md +++ /dev/null @@ -1,199 +0,0 @@ -# 声明式规则运行时 - -`declarativeRules` 是社区插件当前优先使用的运行时。它允许插件在不向宿主注入任意代码的前提下,检查和转换 request bodies、response bodies、stream chunks 和 log messages。 - -## Manifest 运行时声明 - -在 `plugin.json` 中声明规则文件: - -```json -{ - "runtime": { - "kind": "declarativeRules", - "rules": ["rules/main.json"] - } -} -``` - -规则路径相对于插件根目录。路径不能包含 `..`,也不能使用绝对路径前缀。 - -## 规则文件结构 - -```json -{ - "rules": [ - { - "id": "redact-api-key", - "hook": "gateway.request.afterBodyRead", - "target": { - "field": "request.body", - "jsonPath": "$.messages[*].content" - }, - "match": { - "regex": "sk-[A-Za-z0-9_-]{20,}", - "caseSensitive": true - }, - "action": { - "kind": "replace", - "replacement": "[REDACTED]" - } - } - ] -} -``` - -每条规则包含: - -- `id`:稳定的规则标识,用于诊断。 -- `hook`:[Hooks](./hooks.md) 中的一个 hook 名。 -- `target`:规则扫描的位置。 -- `match.regex`:Rust `regex` pattern。 -- `match.caseSensitive`:可选,默认 `true`。 -- `action`:命中后的动作。 -- `when`:可选的运行时过滤条件。 - -## Targets 目标字段 - -支持的 `target.field` 值: - -- `request.body` -- `response.body` -- `stream.chunk` -- `log.message` - -`request.body` 和 `response.body` 可以通过 `jsonPath` 指向 JSON payload 内部的字符串字段。 - -支持的 JSONPath 子集: - -- `$` -- `.key` -- `[*]` - -示例: - -- `$.input` -- `$.prompt` -- `$.messages[*].content` -- `$.choices[*].message.content` - -暂不支持 quoted keys、filters、recursive descent、numeric indexes 和任意 JSONPath 表达式。 - -## Actions 动作 - -### replace - -替换所选文本中的所有 regex 命中。 - -```json -{ - "kind": "replace", - "replacement": "[REDACTED]" -} -``` - -Capture groups 使用 Rust regex replacement syntax: - -```json -{ - "kind": "replace", - "replacement": "$1[SECRET]" -} -``` - -### block - -当 pipeline 和 hook 允许阻断时,停止当前 request、response 或 stream processing。 - -```json -{ - "kind": "block", - "reason": "Dangerous output blocked by plugin." -} -``` - -### warn - -记录 warning reason,但不修改 target。 - -```json -{ - "kind": "warn", - "message": "Suspicious content detected." -} -``` - -### appendMessage - -向 chat-style request bodies 追加 `system` 或 `developer` message。 - -```json -{ - "kind": "appendMessage", - "role": "system", - "content": "Clarify intent and preserve user constraints." -} -``` - -## 条件规则 - -使用 `when` 根据 CLI、model 或 config value 限制规则生效范围: - -```json -{ - "when": { - "cliKeys": ["codex", "claude"], - "models": ["gpt-4.1"], - "configEquals": { - "redactBeforeUpstream": true - } - } -} -``` - -提供的条件必须全部匹配。 - -## 权限 - -规则仍然需要 manifest 中声明匹配的 permissions: - -- 读取 request body:`request.body.read` -- 修改 request body:`request.body.write` -- 读取 response body:`response.body.read` -- 修改 response body:`response.body.write` -- 读取 stream chunks:`stream.inspect` -- 修改 stream chunks:`stream.modify` -- 日志脱敏:`log.redact` - -宿主会在规则执行前裁剪 hook context,并在规则执行后拒绝未授权 mutation。 - -## 运行时限制 - -- 最大 regex pattern length:4 KiB。 -- 最大 compiled regex size:2 MiB。 -- 每个 runtime 最多规则数:256。 -- Hook execution 受 gateway plugin timeout 约束。 -- 当 target 无法解析为 JSON syntax 时,会跳过 invalid JSON targets。 - -## 本地 Replay 兼容性 - -`create-aio-plugin replay` 为本地 fixtures 实现宿主支持的 v1.1 declarative rule subset。它刻意保持确定性,不执行 WASM、process plugins、network calls 或 host-only native engines。 - -Replay 支持社区规则运行时相同的 v1.1 rule actions:`replace`、`block`、`warn` 和 `appendMessage`。对 request body rewrites,它支持 raw text targets,也支持文档化 JSONPath 子集,例如 `$.messages[*].content`、`$.input[*].content[*].text` 和 `$.input`。 - -## 适合场景 - -- 通过追加 instructions 做 prompt optimization。 -- API key、token、email 和 log redaction。 -- 阻断已知危险命令模式的 safety checks。 -- 轻量 response warnings。 - -## 不适合场景 - -当插件需要以下能力时,应使用 WASM 或未来的隔离进程运行时: - -- entropy scoring。 -- Luhn validation。 -- external API calls。 -- model-based classification。 -- filesystem access。 -- complex stateful analysis。 diff --git a/docs/plugins/reference/hooks.md b/docs/plugins/reference/hooks.md index 998ce02f..dbf8fc6c 100644 --- a/docs/plugins/reference/hooks.md +++ b/docs/plugins/reference/hooks.md @@ -1,9 +1,14 @@ # 插件 Hooks -Hooks 是网关和日志 pipeline 中稳定的扩展点。Plugin API v1 刻意保持 active surface 小而明确,让社区插件能清楚判断调用时机、权限边界和 mutation 行为。 +Hooks 是网关和日志 pipeline 中稳定的扩展点。Plugin API v1 刻意保持 active surface 小而明确,让社区插件能清楚判断调用时机、capability 边界和 mutation 行为。 -默认 vNext hook timeout: 150 ms. -默认 vNext failure policy: `fail-open`. +默认 v1 hook timeout: 5000 ms. +默认 vNext hook timeout: 5000 ms. +默认 v1 failure policy: `fail-open`. + +`fail-open` 的实际语义是可用性优先:hook 失败、超时、输出超预算或输出 payload 无效时,宿主会记录诊断并继续原路径。对 `log.beforePersist`,当前代码会保留原始日志继续入库;没有宿主兜底脱敏或丢弃日志行为。 + +Timeout 是宿主为每次 hook invocation 选择的执行预算。Gateway pipeline 会把本次预算显式传给 runtime executor;Extension Host gateway hooks 使用同一个预算完成 activation、hook dispatch 和 runtime cleanup,不在 executor 内再叠加固定上限或 grace window。 Reserved hooks 在宿主实现对应调用点前,会被 manifest validation 拒绝: @@ -11,9 +16,32 @@ Reserved hooks 在宿主实现对应调用点前,会被 manifest validation - `gateway.request.beforeProviderResolution` - `gateway.response.headers` +## Resource Budgets + +Plugin hook contexts are host-trimmed and budget-trimmed. Extension Host manifests do not declare `permissions`; the host decides visible context from the hook, granted capability, runtime policy, and context budget. The gateway may accept request bodies larger than the plugin context budget, but plugins only receive bounded visible context. When a body, stream chunk, log message, or normalized message list exceeds the plugin budget, the host truncates the visible value and marks the matching `*Truncated` flag in the internal context model. + +Hook outputs are also bounded. Oversized body, stream, log, or header mutations are rejected with `PLUGIN_OUTPUT_TOO_LARGE`; the pipeline then applies the hook failure policy and circuit-breaker behavior. + +## Observability And Replay + +宿主会把 hook 执行结果写入 `plugin_hook_execution_reports`。这张表是 host-owned runtime evidence,不属于 Plugin API v1 的插件可调用能力。它记录 plugin id、trace id、hook name、runtime kind、status、duration、failure kind、failure policy、circuit state、context/output budget summary、mutation summary 和 replayable reason。 + +开发者可以通过宿主命令 `plugin_export_replay_fixture` 导出 trace replay fixture。fixture 会包含 request log metadata、attempts 和 matching runtime reports。当前 request logs 不持久化完整 request/response body,所以 fixture 可能只包含 body notes;复现 body 需要插件作者提供本地 fixture。 + +Replay 支持按 hook 分层: + +| Hook | Runtime report | Replay fixture 用途 | 限制 | +| --- | --- | --- | --- | +| `gateway.request.afterBodyRead` | 记录 completed、failedOpen、failedClosed、budgetRejected、policyRejected、circuitOpen 等状态 | 复现读取 body 后的 prompt rewrite、redaction、block 和 header/body mutation | 需要本地补齐未持久化 request body | +| `gateway.request.beforeSend` | 记录最终 upstream request 前的 hook 结果 | 复现 provider resolution 后的最终 body/header mutation | 只表示 semantic decoded body,不保证完整 wire-level encoding | +| `gateway.response.chunk` | 记录 chunk 级执行结果和 timeout/budget 状态 | 复现单个有界 chunk 或滑动窗口场景 | 不代表完整响应;需要 streamed fixture | +| `gateway.response.after` | 记录 non-streaming response body hook 结果 | 复现完整响应检查、替换或阻断 | 只适用于 non-streaming response body | +| `gateway.error` | 记录 gateway-generated error response hook 结果 | 复现错误响应脱敏或改写 | 不处理 provider success response | +| `log.beforePersist` | 记录日志入库前脱敏结果 | 复现日志 redaction 和 mutation summary | log payload 仍受日志持久化策略限制;失败或非法 payload 时保留原始日志 | + ## Hook 矩阵 -| Hook | 阶段 | 读权限 | 写权限 | Mutation fields | Context fields | +| Hook | 阶段 | Visible context labels | Mutation labels | Mutation fields | Context fields | | --- | --- | --- | --- | --- | --- | | `gateway.request.afterBodyRead` | 读取 request body 后、发送 upstream provider 前。 | `request.meta.read`, `request.header.read`, `request.header.readSensitive`, `request.body.read` | `request.header.write`, `request.body.write` | `headers`, `requestBody` | `traceId`, `request.cliKey`, `request.method`, `request.path`, `request.query`, `request.headers`, `request.body`, `request.requestedModel`, `request.normalizedMessages` | | `gateway.request.beforeSend` | provider resolution 后、发送 upstream provider 前。 | `request.meta.read`, `request.header.read`, `request.header.readSensitive`, `request.body.read` | `request.header.write`, `request.body.write` | `headers`, `requestBody` | `traceId`, `request.cliKey`, `request.method`, `request.path`, `request.query`, `request.headers`, `request.body`, `request.requestedModel`, `request.normalizedMessages` | @@ -25,14 +53,14 @@ Reserved hooks 在宿主实现对应调用点前,会被 manifest validation ## gateway.request.afterBodyRead - 阶段:读取 request body 后、发送 upstream provider 前。 -- 默认超时:150 ms。 +- 默认超时:5000 ms。 - 默认失败策略:`fail-open`。 -- 读权限:`request.meta.read`、`request.header.read`、`request.header.readSensitive`、`request.body.read`。 -- 写权限:`request.header.write`、`request.body.write`。 +- Visible context labels:`request.meta.read`、`request.header.read`、`request.header.readSensitive`、`request.body.read`。 +- Mutation labels:`request.header.write`、`request.body.write`。 - Mutation fields:`headers`、`requestBody`。 - Provider-neutral field:`request.normalizedMessages`。 -这个 hook 适合 prompt optimization、privacy filtering 和 request-body checks。只有插件拥有 `request.body.read` 时,宿主才会提供 `request.body` 和 `request.normalizedMessages`。 +这个 hook 适合 prompt optimization、privacy filtering 和 request-body checks。只有宿主为当前 hook 调用提供 body context 时,插件才会看到 `request.body` 和 `request.normalizedMessages`;插件不能通过 Extension Host manifest 申请 `request.body.read`。 Claude-style fixture: @@ -76,10 +104,10 @@ Codex/OpenAI Responses-style fixture: ## gateway.request.beforeSend - 阶段:provider resolution 后、发送 upstream provider 前。 -- 默认超时:150 ms。 +- 默认超时:5000 ms。 - 默认失败策略:`fail-open`。 -- 读权限:`request.meta.read`、`request.header.read`、`request.header.readSensitive`、`request.body.read`。 -- 写权限:`request.header.write`、`request.body.write`。 +- Visible context labels:`request.meta.read`、`request.header.read`、`request.header.readSensitive`、`request.body.read`。 +- Mutation labels:`request.header.write`、`request.body.write`。 - Mutation fields:`headers`、`requestBody`。 - Provider-neutral field:`request.normalizedMessages`。 @@ -90,10 +118,10 @@ Codex/OpenAI Responses-style fixture: ## gateway.response.chunk - 阶段:每个有边界的 streaming response chunk。 -- 默认超时:150 ms。 +- 默认超时:5000 ms。 - 默认失败策略:`fail-open`。 -- 读权限:`stream.inspect`。 -- 写权限:`stream.modify`。 +- Visible context labels:`stream.inspect`。 +- Mutation labels:`stream.modify`。 - Mutation fields:`streamChunk`。 - Context fields:`traceId`、`stream.sequence`、`stream.chunk`。 @@ -102,10 +130,10 @@ Codex/OpenAI Responses-style fixture: ## gateway.response.after - 阶段:完整 non-streaming upstream response body 可用后。 -- 默认超时:150 ms。 +- 默认超时:5000 ms。 - 默认失败策略:`fail-open`。 -- 读权限:`response.header.read`、`response.body.read`。 -- 写权限:`response.header.write`、`response.body.write`。 +- Visible context labels:`response.header.read`、`response.body.read`。 +- Mutation labels:`response.header.write`、`response.body.write`。 - Mutation fields:`headers`、`responseBody`。 - Context fields:`traceId`、`response.status`、`response.headers`、`response.body`。 @@ -114,10 +142,10 @@ Codex/OpenAI Responses-style fixture: ## gateway.error - 阶段:gateway error response materialization 后、发送前。 -- 默认超时:150 ms。 +- 默认超时:5000 ms。 - 默认失败策略:`fail-open`。 -- 读权限:`response.header.read`、`response.body.read`。 -- 写权限:`response.header.write`、`response.body.write`。 +- Visible context labels:`response.header.read`、`response.body.read`。 +- Mutation labels:`response.header.write`、`response.body.write`。 - Mutation fields:`headers`、`responseBody`。 - Context fields:`traceId`、`response.status`、`response.headers`、`response.body`。 @@ -126,11 +154,13 @@ Codex/OpenAI Responses-style fixture: ## log.beforePersist - 阶段:gateway request log persistence 前。 -- 默认超时:150 ms。 +- 默认超时:5000 ms。 - 默认失败策略:`fail-open`。 -- 读权限:`log.redact`。 -- 写权限:`log.redact`。 +- Visible context labels:`log.redact`。 +- Mutation labels:`log.redact`。 - Mutation fields:`logMessage`。 - Context fields:`traceId`、`log.message`。 这个 hook 用于 request logs 入队或写入前的不可逆脱敏。 + +当前失败边界是 fail-open:如果 hook 执行失败,或返回的 `logMessage` 不是宿主能解析回 request log payload 的 JSON object,宿主会保留原始日志继续入库。隐私插件不能把默认 `log.beforePersist` 当作强制合规日志脱敏保证;需要更强语义时必须先补宿主兜底脱敏、丢弃日志或官方插件专用策略。 diff --git a/docs/plugins/reference/manifest.md b/docs/plugins/reference/manifest.md index 49baa196..23a32962 100644 --- a/docs/plugins/reference/manifest.md +++ b/docs/plugins/reference/manifest.md @@ -8,33 +8,65 @@ - `name`:展示给用户的插件名称。 - `version`:使用 SemVer 的插件版本。 - `apiVersion`:使用 SemVer 的插件 API 版本。 -- `runtime`:社区插件优先使用 `declarativeRules`;`wasm` 受宿主策略控制;`native` 仅保留给内置官方插件。 -- `hooks`:插件注册的 hook 声明。 -- `permissions`:插件请求的权限名称。 +- `main`:Extension Host 入口文件,例如 `dist/extension.js`。 +- `runtime`:社区插件必须使用 `runtime.kind = "extensionHost"`,`language` 必须是 `typescript`。 - `hostCompatibility`:应用版本和插件 API 兼容性约束。 `official.*` 命名空间只保留给内置官方插件。本地包、marketplace 包和 GitHub 包必须使用自己的发布者命名空间。 -运行时示例: +最小 Extension Host runtime: ```json -{ "kind": "declarativeRules", "rules": ["rules/main.json"] } +{ + "main": "dist/extension.js", + "runtime": { + "kind": "extensionHost", + "language": "typescript" + } +} ``` +Gateway hook 贡献点示例;公开文档中的规范名是 `contributes.gatewayHooks`: + ```json -{ "kind": "wasm", "abiVersion": "1.0.0", "memoryLimitBytes": 16777216 } +{ + "activationEvents": ["onGatewayHook:gateway.request.afterBodyRead"], + "contributes": { + "gatewayHooks": [ + { + "name": "gateway.request.afterBodyRead", + "priority": 100, + "failurePolicy": "fail-open", + "timeoutMs": 5000 + } + ] + }, + "capabilities": ["gateway.hooks"] +} ``` -仅官方内置插件可用的 native 运行时示例: +`timeoutMs` 可选;不填时使用宿主默认 hook timeout。需要扫描大 payload 或调用宿主侧重处理逻辑的插件可以自行声明更长的正整数毫秒值。 -```json -{ "kind": "native", "engine": "privacyFilter" } -``` +Capability dependency table: -只有从官方源安装的内置官方插件可以使用 `native`。 +| Contribution | Required capability | +| --- | --- | +| `commands` | `commands.execute` | +| `providers` | `provider.extensionValues` | +| `ui.providers.editor.sections` | `provider.extensionValues` | +| `ui.providers.editor.fields` | `provider.extensionValues` | +| UI button fields in host-rendered sections/panels | `commands.execute` | +| `gatewayHooks` | `gateway.hooks` | +| `protocolBridges` | `protocol.bridge` | -`hostCompatibility` 必须包含 `app` 和 `pluginApi`;`platforms` 可以限制支持的操作系统。 +Protocol bridge MVP skeleton 只稳定 `protocolBridges` 的 `manifest` 声明、`protocol.bridge` 能力依赖、贡献注册表元数据和安装预检展示。当前执行入口会返回 `PLUGIN_EXTENSION_PROTOCOL_BRIDGE_NOT_IMPLEMENTED`;完整协议互换执行链仍属于未来宿主集成。插件不能只靠声明 bridge 就接管 OpenAI、Gemini 或 Claude 协议转换。 + +契约、SDK 和 Rust 校验认识的 UI 插槽名称多于当前前端已挂载位置。当前前端实际挂载 `providers.editor.sections`、`settings.sections` 和 `logs.detail.tabs`。`providers.editor.fields` 会被 SDK/Rust 校验为 provider UI contribution 并要求 `provider.extensionValues`,但当前前端没有对应类型化插槽挂载;`providers.card.badges`、`providers.card.actions` 和其他契约插槽目前只是 `manifest` 已知或仅用于元数据。 + +`hostCompatibility` 必须包含 `app` 和 `pluginApi`。`platforms` 当前是解析和展示元数据,不参与本地安装阻断或市场兼容性筛选;不要把它写成当前已强制执行的平台白名单。 `configSchema` 可以包含标准 JSON Schema 展示字段和 AIO `x-aio-ui` 元数据。详见 [Config Schema](./config-schema.md)。 -plugin API v1 的 active hooks 见 [Hooks](./hooks.md)。Reserved hooks 和 reserved permissions 只是为未来兼容命名保留;在宿主真正实现前,manifest 校验会拒绝它们。 +plugin API v1 的 active hooks 见 [Hooks](./hooks.md)。Reserved hooks 会在宿主真正实现前被 manifest 校验拒绝。Reserved permissions 只作为内部/legacy runtime history 的 host-mediated label 保留,不是 public Extension Host manifest 字段。 + +旧的 WASM、process 和第三方 native manifest 属于 unsupported pre-release legacy runtime。当前公开插件应迁移到 Extension Host,迁移说明见 [运行时说明](../runtime/README.md)。 diff --git a/docs/plugins/reference/permissions.md b/docs/plugins/reference/permissions.md index c5a4b180..6bdc30f6 100644 --- a/docs/plugins/reference/permissions.md +++ b/docs/plugins/reference/permissions.md @@ -1,8 +1,10 @@ -# 插件权限 +# Hook context 与 mutation labels -Permissions 必须显式声明,并按风险分级。宿主在调用插件前会按权限裁剪 hook context;插件返回未授权写入时,宿主会拒绝该 mutation。 +Extension Host public manifest 不支持 top-level `permissions`。社区插件只通过 `capabilities` 声明 Host API/contribution surface,例如 `gateway.hooks`、`commands.execute`、`provider.extensionValues` 和 `protocol.bridge`。 -常用权限: +本页列出的名称是 gateway hook visible context、mutation envelope、audit 记录和 legacy official runtime history 使用的内部 labels。宿主会按 hook、capability、context budget 和运行时策略裁剪 context;插件返回超出当前 hook envelope 或预算的 mutation 时,宿主会拒绝该输出。 + +Internal active labels: - `request.meta.read`:低风险,读取方法、路径、CLI key、trace ID 等元信息。 - `request.header.read`:中风险,读取非敏感请求头。 @@ -18,14 +20,14 @@ Permissions 必须显式声明,并按风险分级。宿主在调用插件前 - `stream.modify`:高风险,替换或阻断流式响应 chunk。 - `log.redact`:中风险,在日志持久化前脱敏。 -为未来 host-mediated APIs 保留的权限: +Reserved permissions for future host-mediated APIs 只作为内部命名保留,不是 public Extension Host manifest 字段: -- `plugin.storage`:中风险,使用隔离插件存储。 +- `plugin.storage`:中风险,未来宿主中介 storage 标签。当前 active Extension Host capability 是 `storage.plugin`;实现会把 `api.storage` 数据保存在插件配置 JSON 的顶层 `storage` 字段下,大小限制为 64 KiB,还不是独立隔离 KV 表。 - `network.fetch`:高风险,通过宿主代理发起网络请求。 - `file.read`:高风险,通过宿主代理读取文件。 - `file.write`:高风险,通过宿主代理写入文件。 - `secret.read`:critical 风险,读取宿主管理的密钥。 -Reserved permissions 在宿主实现对应 API 前会被 manifest 校验拒绝。 +如果 legacy official runtime history 中出现保留 label,宿主会按内部 runtime policy 拒绝或隔离。社区 Extension Host manifest 中出现 `permissions` 字段会被 `PLUGIN_INVALID_MANIFEST` 拒绝。 -高风险和 critical 权限需要清晰的用户授权文案。插件升级新增权限时,必须重新授权,插件才能带着这些新能力启用。 +面向用户的安装和更新确认以 capabilities、contributions、runtime、package trust 和风险标签为准;新增 capability 需要用户重新确认,Extension Host 不提供 manifest `permissions` 字段。 diff --git a/docs/plugins/reference/publishing.md b/docs/plugins/reference/publishing.md index cda21a12..6ec443b9 100644 --- a/docs/plugins/reference/publishing.md +++ b/docs/plugins/reference/publishing.md @@ -7,10 +7,41 @@ - 校验 `plugin.json`。 - 控制 package size 和 entry count。 - 对 package bytes 计算 `sha256`。 +- 使用 `publish-check` 输出 release metadata。 - 通过可信 index 发布时,用 Ed25519 签名 release metadata。 -- 对 breaking update 写清 rollback 说明。 +- 对新增 capabilities、runtime/hook/config 变化和 breaking update 写清升级与 rollback 说明。 -当前实现支持本地/离线包导入、受约束的远程 `.aio-plugin` 下载、checksum/signature verification、更新时的 permission delta 检查、已撤销插件 quarantine,以及 rollback snapshots。 +当前实现支持本地/离线包导入、受约束的远程 `.aio-plugin` 下载、checksum/signature verification、更新时的 capability/contribution diff、已撤销插件 quarantine,以及 rollback snapshots。 + +## 0.62.2 生命周期行为 + +0.62.2 把安装、更新、回滚和隔离相关信息收口为宿主侧 lifecycle explanation layer。它不改变 `plugin.json` v1 的字段形状,也不新增插件可调用 API。 + +### 安装预检 + +用户从 Plugins 页面导入本地 `.aio-plugin` 时,宿主会先读取包并展示安装预检。预检会说明插件身份、来源、runtime、contributions、capabilities、host-mediated risk labels、兼容性、checksum/signature、已有安装覆盖关系、warnings 和 blocking reasons。 + +预检通过不等于安装已经完成。用户确认后,真实安装仍会重新执行包解压安全检查、manifest 校验、checksum/signature verification、host compatibility、runtime policy 和 capability/contribution policy。发布者应该把预检视为“给用户解释将要发生什么”,而不是绕过安装校验的入口。 + +### 更新差异 + +本地更新前,宿主会比较当前已安装版本和待安装包,展示: + +- version direction。 +- runtime change。 +- hook added/removed/changed。 +- capability unchanged granted、unchanged pending、added pending、removed。 +- `configVersion` change。 +- compatibility 和 trust change。 +- 当前版本是否可回滚。 + +新增 capability 必须进入 pending。发布者可以在 release notes 中提前说明新增 capability 的原因,但宿主不会因为插件升级而静默授予新 capability。 + +### 回滚与隔离 + +更新会保留可回滚的历史快照。rollback 只允许回到仓库已记录且仍可用的历史版本;如果当前版本的安装目录已经缺失,宿主会在更新预览里标记不可回滚。 + +撤销或宿主判定危险的插件会进入 `quarantined`。隔离插件不能启用;用户需要卸载、回滚到可用版本,或等待可信来源发布新版本。隔离和回滚会保留 audit 记录,便于追踪生命周期状态变化。 远程包安装刻意保持窄能力: @@ -21,3 +52,47 @@ - 如果同时提供 signature 和 trusted public key,宿主会校验 Ed25519 signature。 开发者工具输出 base64 编码的 Ed25519 signature。Public key 是原始 32-byte Ed25519 public key 的 base64 编码,和宿主 verifier 输入保持一致。 + +## publish-check + +`pnpm --filter create-aio-plugin cli publish-check ` 会读取插件目录,复用打包/校验路径计算 package metadata,并输出适合放进市场索引的字段,例如 plugin id、version、checksum、signature 状态、runtime、contributions、capabilities、risk labels 和 compatibility summary。 + +示例模板可以运行 publish-check,例如 `example:prompt-helper`、`example:redactor` 和 `example:response-guard` 生成的目录都应能输出发布 metadata。这个 metadata 只说明包具备发布前检查信息;它不代表示例已经被上传、签名、加入默认 market index,或变成默认可安装市场包。 + +`publish-check` 不写 `.aio-plugin` artifact,不替代 `pack`、`sign` 或 `verify`。它的职责是让发布者在提交市场索引前看到宿主安装时会关心的 metadata。真实安装仍由宿主重新下载包、校验 checksum、校验 signature、判断 compatibility、应用 capability/contribution policy,并处理 revoked / incompatible install blocks。 + +## Market Index + +市场索引是一个发布清单,不是插件运行时 API。Plugins 页面可以加载 market index URL,也可以解析用户粘贴的 index JSON。索引条目应至少包含: + +- plugin id、name、latest version。 +- `.aio-plugin` download URL。 +- `sha256` checksum。 +- 可选 signature。 +- 可选 trusted public key。 +- compatibility summary。 +- risk labels。 +- revoked 状态和 install block reason。 + +默认市场视图会把精选插件展示成用户可读卡片,只显示用途、版本、风险、信任和安装状态。完整 checksum、signature 和 raw index fields 不应占据默认视图。 + +自定义 market index 属于高级来源。高级来源可以加载临时 URL 或粘贴 JSON,但它只是发布者和高级用户入口;真实安装仍由宿主重新执行 checksum、signature、compatibility、runtime policy 和 revoked checks。 + +market index URL 只用于定位索引来源。trusted public key 用于校验 release signature;它不能扩大插件 capabilities,也不能绕过 host compatibility、runtime policy、checksum、capability contract 或 quarantine 规则。 + +## Trust And Install Blocks + +远程或市场安装必须提供 checksum。宿主会下载 `.aio-plugin` 后重新计算 `sha256`,和索引中的 checksum 对比。提供 signature 和 trusted public key 时,宿主会校验 Ed25519 signature;没有 trusted public key 时,插件仍可能被当作 unsigned package 展示给用户。 + +revoked / incompatible install blocks 必须在市场 UI 和宿主安装路径同时生效。UI 可以提前禁用安装按钮并解释原因;宿主命令仍要在真实安装时重新检查 revoked、宿主应用版本、`pluginApi` 兼容性、运行时策略和包安全限制。`hostCompatibility.platforms` 当前只作为元数据展示,不参与这些安装阻断。 + +## Replay Fixtures In Publishing + +`plugin_export_replay_fixture` 导出的 replay fixture 是开发 workflow artifact,不是 release artifact。它适合放进 issue、PR 或本地 fixtures,用于复现某个 trace 的 hook 行为。由于 request logs 当前不持久化完整 request/response body,fixture 会携带 notes,发布者不应把它当成用户数据快照或市场证明材料。 + +推荐发布前至少保留: + +- 一个正常命中 fixture。 +- 一个未命中 fixture。 +- 一个边界或失败策略 fixture。 +- 对应的宿主运行报告、导出的 replay fixture 或 CI 检查。 diff --git a/docs/plugins/reference/sdk.md b/docs/plugins/reference/sdk.md index e4c3ecc6..dbe280df 100644 --- a/docs/plugins/reference/sdk.md +++ b/docs/plugins/reference/sdk.md @@ -1,8 +1,6 @@ # 插件 SDK -`@aio-coding-hub/plugin-sdk` 提供插件 manifest、hooks、permissions、runtimes 和 validation helpers 的共享 TypeScript 契约。 - -`aio-plugin-wasm-sdk` 提供与之匹配的 Rust/WASM ABI contracts,用于编译为 WebAssembly 的代码插件。 +`@aio-coding-hub/plugin-sdk` 提供 Extension Host 插件 manifest、hooks、capabilities、host-mediated context/mutation labels 和 validation helpers 的共享 TypeScript 契约。 SDK 面向这些场景: @@ -17,14 +15,12 @@ SDK 面向这些场景: ```text packages/plugin-sdk -packages/plugin-wasm-sdk ``` 运行 SDK 检查: ```bash pnpm --filter @aio-coding-hub/plugin-sdk typecheck -pnpm plugin-wasm-sdk:test ``` ## 主要类型 @@ -33,37 +29,46 @@ TypeScript SDK 导出: - `PluginManifest` - `PluginRuntime` +- `ExtensionRuntime` - `PluginHook` - `GatewayHookName` - `PluginPermission` - `PluginPermissionRisk` +- `PluginCapability` - `PluginHookContext` - `PluginHookResult` +- `PluginApi` +- `PrivacyApi` 同时导出辅助函数: - `permissionRisk(permission)` - `validateManifest(manifest)` +`PluginPermission` 和 `permissionRisk` 保留给 hook context/mutation label 风险展示、audit 和 legacy official runtime history。Extension Host manifest 不能声明 top-level `permissions`;`validateManifest` 会拒绝该字段。 + `create-aio-plugin` 使用 SDK 做 manifest validation,并针对真实插件目录提供本地开发命令: ```bash -pnpm create-aio-plugin validate ./acme.redactor -pnpm create-aio-plugin replay ./acme.redactor ./fixtures/request.json gateway.request.afterBodyRead -pnpm create-aio-plugin pack ./acme.redactor +pnpm --filter create-aio-plugin cli doctor ./acme.redactor +pnpm --filter create-aio-plugin cli validate --strict ./acme.redactor +pnpm --filter create-aio-plugin cli pack ./acme.redactor +pnpm --filter create-aio-plugin cli publish-check ./acme.redactor ``` -Rust/WASM SDK 导出: +`create-aio-plugin replay` 当前不在本地执行 Extension Host gateway hooks;hook 行为应通过宿主 `plugin_hook_execution_reports`、`plugin_export_replay_fixture` 和桌面应用内复测确认。 -- `PluginManifest` -- `PluginRuntime` -- `PluginHook` -- `PluginHostCompatibility` -- `HookRequest` -- `HookResult` -- `HookAction` -- `aio_plugin_entrypoint!` -- pointer/length helpers for the ABI return value +开发工具的诊断对象使用稳定 shape,便于 CLI、GUI 和测试共享: + +```json +{ + "severity": "error", + "code": "PLUGIN_MISSING_CAPABILITY", + "message": "gatewayHooks contribution requires gateway.hooks", + "path": "plugin.json#/capabilities", + "hint": "Add gateway.hooks to manifest.capabilities or remove contributes.gatewayHooks." +} +``` ## TypeScript 中的最小 Manifest @@ -76,11 +81,15 @@ const manifest: PluginManifest = { name: "Acme Redactor", version: "0.1.0", apiVersion: "1.0.0", - runtime: { kind: "declarativeRules", rules: ["rules/main.json"] }, - hooks: [{ name: "gateway.request.afterBodyRead", priority: 50 }], - permissions: ["request.body.read", "request.body.write"], + main: "dist/extension.js", + runtime: { kind: "extensionHost", language: "typescript" }, + activationEvents: ["onGatewayHook:gateway.request.afterBodyRead"], + contributes: { + gatewayHooks: [{ name: "gateway.request.afterBodyRead", priority: 50 }] + }, + capabilities: ["gateway.hooks"], hostCompatibility: { - app: ">=0.56.0 <1.0.0", + app: ">=0.60.0 <1.0.0", pluginApi: "^1.0.0", platforms: ["macos", "windows", "linux"] }, @@ -105,12 +114,46 @@ if (!result.ok) { } ``` +Extension Host 入口示例: + +```js +module.exports.activate = function(api) { + api.gateway.registerHook("gateway.request.afterBodyRead", function(context) { + const body = String(context?.request?.body ?? ""); + if (!body.includes("SECRET_TOKEN")) return { action: "continue" }; + return { + action: "replace", + requestBody: body.replaceAll("SECRET_TOKEN", "[REDACTED]") + }; + }); +}; +``` + +## Capability 依赖 + +`validateManifest` 会检查贡献点需要的 capability: + +| Contribution | Capability | +| --- | --- | +| `commands` | `commands.execute` | +| `providers` | `provider.extensionValues` | +| provider UI sections / fields | `provider.extensionValues` | +| UI button fields in host-rendered sections/panels | `commands.execute` | +| `gatewayHooks` | `gateway.hooks` | +| `protocolBridges` | `protocol.bridge` | + +`protocolBridges` 的 SDK 类型和能力依赖已存在,但当前宿主执行入口尚未接入,调用会停在 `PLUGIN_EXTENSION_PROTOCOL_BRIDGE_NOT_IMPLEMENTED` 边界;它只适合声明元数据和通过安装预检解释影响。 + +SDK 认识全部 `UiContributionSlot` 名称,Rust 也会校验已知插槽和宿主渲染 schema;这不等于前端都已挂载。当前前端类型化插槽只有 `providers.editor.sections`、`settings.sections` 和 `logs.detail.tabs`。`providers.editor.fields` 会触发 `provider.extensionValues` 依赖校验,但还没有前端渲染挂载;`providers.card.badges` 和 `providers.card.actions` 当前不是能力依赖触发项。 + +`privacy.redact` 是宿主提供的脱敏 API capability。声明后,Extension Host 入口可以通过 `api.privacy.redactText` 和 `api.privacy.redactRequestBody` 调用宿主脱敏服务;它不自动声明 gateway hook,仍需要和 `gateway.hooks`、`contributes.gatewayHooks` 配合使用。 + +`storage.plugin` 是当前 Extension Host storage API 能力。它通过 `api.storage` 暴露给插件,但持久化实现仍写入插件配置 JSON 的顶层 `storage` 字段,并有 64 KiB storage JSON 限制。`plugin.storage` 是内部/未来宿主中介标签,不是 active manifest capability。 + ## SDK 边界 SDK 是契约包。它不执行插件代码,也不会授予宿主能力。 -Rust/WASM SDK 遵循同样边界。它只负责序列化 ABI-compatible JSON、定义 hook result helpers,并提供 `aio_plugin_entrypoint!` macro 用于导出 `aio_plugin_handle`。 - `PluginHookResult` 使用与 gateway host 相同的 active mutation envelope: ```ts @@ -121,34 +164,20 @@ const result = { } satisfies PluginHookResult; ``` -替换内容时使用 `requestBody`、`responseBody`、`streamChunk`、`logMessage` 和 `headers`。`contextPatch` 不是 active vNext gateway mutation field。 +替换内容时使用 `requestBody`、`responseBody`、`streamChunk`、`logMessage` 和 `headers`。`contextPatch` 不是 active gateway mutation field。 真正的宿主强制检查仍发生在 Rust application 中: - manifest compatibility checks。 -- permission grants。 +- capability contract checks。 - hook context trimming。 -- mutation permission enforcement。 +- mutation envelope enforcement。 - runtime timeout 和 failure policy handling。 - package checksum/signature verification。 -## Rust/WASM 示例 - -仓库内包含一个最小 WASM redactor example: - -```text -packages/plugin-wasm-sdk/examples/redactor -``` - -可以这样测试: - -```bash -pnpm plugin-wasm-sdk:test -``` - ## 版本建议 - `apiVersion` 应与插件 API 主版本保持一致。 - 插件包版本使用 SemVer。 - 如果 SDK 只添加向后兼容的类型,插件 API 主版本可以不变。 -- Breaking hook、permission、runtime 或 manifest changes 需要新的插件 API 主版本。 +- Breaking hook、host-mediated label、runtime 或 manifest changes 需要新的插件 API 主版本。 diff --git a/docs/plugins/runtime/README.md b/docs/plugins/runtime/README.md index 17d65999..340a7dd9 100644 --- a/docs/plugins/runtime/README.md +++ b/docs/plugins/runtime/README.md @@ -1,9 +1,27 @@ # 插件运行时说明 -这里解释插件运行时如何执行,以及当前哪些能力已经开放。普通社区插件优先使用 `declarativeRules`;只有确实需要代码执行时,才阅读 WASM 或进程运行时说明。 +这里解释插件运行时如何执行,以及当前哪些能力已经开放。Extension Host 是唯一 community runtime。旧 WASM、process 和第三方 native 只作为 unsupported pre-release legacy runtime notes 保留,不是当前推荐路径。 -- [WASM 运行时](./wasm.md):WASM ABI v1、`PLUGIN_RUNTIME_DISABLED`、资源限制和失败策略。 -- [进程运行时 PoC](./process-poc.md):默认关闭的 JSON-RPC over stdio 进程隔离设计。 - [流式响应插件](./streaming.md):`gateway.response.chunk`、sliding window 和 `stream.modify` 的边界。 +- [WASM legacy note](./wasm.md):说明旧 WASM runtime 为什么不属于公开 community runtime。 +- [Process runtime legacy note](./process-poc.md):说明旧 JSON-RPC over stdio PoC 为什么不属于公开 community runtime。 -声明式规则属于插件作者最常用的社区运行时,契约文档在 [Declarative Rules](../reference/declarative-rules.md)。 +## Extension Host Runtime Lifecycle + +This is a host-owned lifecycle. Plugins declare `runtime.kind = "extensionHost"`, `main`, activation events, contributions, and capabilities in Plugin API v1, but they do not create, retain, dispose, or inspect runtime instances directly. + +0.62.3 treats runtime lifecycle as a host-owned internal contract: + +1. **Load**: parse manifest and load the bundled JavaScript output referenced by `main`. +2. **Activate**: call `activate(api)` in an Extension Host worker and expose only capability-gated APIs. +3. **Execute**: run a bounded hook invocation with timeout and mutation checks. +4. **Retain**: keep only worker/runtime state that corresponds to the current enabled plugin snapshot. +5. **Dispose**: clear runtime state when plugins are disabled, updated, uninstalled, or when the gateway plugin snapshot is replaced. + +Gateway hooks are registered with `api.gateway.registerHook`. Hook execution evidence is written to `plugin_hook_execution_reports`. These reports let the GUI and developer workflow show duration, status, timeout, circuit state, budget rejection, mutation summary, and replayability without exposing a new plugin-callable diagnostics API. + +The lifecycle boundary exists to prevent long-lived plugin state from outliving its installed snapshot. Any future runtime that allocates memory, opens handles, or starts child processes must implement the same Load / Activate / Execute / Retain / Dispose contract before it can participate in gateway execution. + +## Release Guard + +0.62.3 keeps Plugin API v1 externally stable while hardening host runtime internals. Run `pnpm check:plugin-hardening` before release branches that touch plugin runtime loading, hook context budgets, output mutation budgets, SDK validation, or manifest/runtime documentation. diff --git a/docs/plugins/runtime/process-poc.md b/docs/plugins/runtime/process-poc.md index 8ec9b0c6..c29942ee 100644 --- a/docs/plugins/runtime/process-poc.md +++ b/docs/plugins/runtime/process-poc.md @@ -1,80 +1,35 @@ -# 进程插件运行时 PoC +# Process Runtime Legacy Note -## 目标 +The process runtime PoC is an unsupported pre-release legacy runtime. It is not part of the public Plugin API v1 community runtime surface. Community plugins must use Extension Host. -process runtime PoC 探索通过 child process 和 JSON-RPC over stdio 实现插件隔离。它只是设计和生命周期基础:disabled by default,并且 no marketplace enablement by default。 +This page is retained only to explain why older JSON-RPC over stdio references are not a current authoring path. Process execution remains disabled by default and has no marketplace enablement. -这个运行时不是 WASM runtime 的替代品。它服务于未来无法放进 WASM ABI、但仍需要与 Rust main process 和 Tauri WebView 隔离的插件。 +## Historical Shape -## 边界 - -process runtime 会把插件 executable 作为 child process 运行,并满足: - -- stdin 和 stdout 只用于 JSON-RPC over stdio。 -- stderr 只作为 bounded diagnostics 捕获。 -- 不继承 app stdin。 -- 不能直接访问 Tauri WebView。 -- 不能直接访问 app SQLite connections。 -- 宿主不会隐式授予 network 或 filesystem 权限。 - -未来任何 filesystem、network 或 secret access 都必须通过显式 host-mediated APIs。M5 不暴露这些 API。 - -## JSON-RPC over stdio 协议 - -每个 request 是一个 newline-delimited JSON-RPC 2.0 object: +The old PoC explored child processes using JSON-RPC over stdio: ```json {"jsonrpc":"2.0","id":1,"method":"plugin.handleHook","params":{"hook":"gateway.request.afterBodyRead","context":{}}} ``` -每个 response 是一个 newline-delimited JSON-RPC 2.0 object: - -```json -{"jsonrpc":"2.0","id":1,"result":{"action":"pass"}} -``` - -宿主会拒绝: - -- malformed JSON。 -- mismatched IDs。 -- 超过 configured byte limit 的 response。 -- plugin-side JSON-RPC errors。 -- hook timeout 后输出的内容。 +That protocol is not accepted for community plugins in the current public contract. -## 生命周期 +## Current Migration Target -process lifecycle 有四个有边界阶段: +Use Extension Host instead: -1. 在 start timeout 内 spawn child。 -2. 发送 hook request,并在 hook timeout 内等待一个 response。 -3. 只在 idle recycle 过期前保持进程 warm。 -4. 在 timeout、crash、protocol error 或 idle recycle 时 kill 并 reap child。 - -初始 PoC 每个 test session 启动一个 process,并且只在它保持 healthy 且 idle time 低于配置阈值时复用。 - -## 必需限制 - -- start timeout 默认 500 ms。 -- hook timeout 默认 300 ms。 -- idle recycle 默认 30 seconds。 -- request 和 response lines 分别限制在 256 KiB。 -- stderr diagnostics 有边界,不会无上限 stream 到 UI。 - -## 安全策略 - -- The runtime is disabled by default。 -- There is no marketplace enablement by default。 -- 使用前,host policy 必须显式把 process plugins 标记为 experimental。 -- crash isolation 必须保证 child exit 不会导致 app crash。 -- Timeouts 必须 kill child process,并记录一条英文 diagnostic message。 -- 宿主必须把每个 protocol error 视为 runtime failure。 - -## M5 验收测试 - -M5 backend tests 覆盖: +```json +{ + "main": "dist/extension.js", + "runtime": { + "kind": "extensionHost", + "language": "typescript" + }, + "contributes": { + "gatewayHooks": [{ "name": "gateway.request.afterBodyRead" }] + }, + "capabilities": ["gateway.hooks"] +} +``` -- 合法 child process 能启动并返回 JSON-RPC hook result。 -- startup 期间 sleep 的 child 会触发 start timeout。 -- hook handling 期间 sleep 的 child 会触发 hook timeout 并被 kill。 -- 提前退出的 child 会被报告为 crash isolation,而不是 host crash。 -- healthy idle child 会在 idle recycle 后被回收。 +`dist/extension.js` registers behavior with `api.gateway.registerHook`. Host lifecycle, activation, per-invocation hook timeout budget, failure policy, and dispose remain host-owned. diff --git a/docs/plugins/runtime/streaming.md b/docs/plugins/runtime/streaming.md index 125e12fd..5a6992c1 100644 --- a/docs/plugins/runtime/streaming.md +++ b/docs/plugins/runtime/streaming.md @@ -7,8 +7,8 @@ Streaming plugins 使用 `gateway.response.chunk`。 - 当前 chunk 的 bytes 或 text。 - 用于跨 chunk 检测的有界 sliding window。 - trace metadata。 -- 已按权限裁剪的 context。 +- 已按 hook、capability 和 context budget 裁剪的 context。 -没有 `stream.inspect` 时,插件不能读取 stream 内容。没有 `stream.modify` 时,插件不能替换或阻断 chunk。 +`stream.inspect` 和 `stream.modify` 是内部 context/mutation labels,不是 Extension Host manifest permissions。宿主只会在当前 hook contract 和预算允许时提供 stream context,并只接受 envelope 内的 `streamChunk` mutation。 -流式插件不能假设自己能看到完整响应。它们应只检测有界模式,并根据已授权 permissions 返回 pass、warn、replace 或 block。 +流式插件不能假设自己能看到完整响应。它们应只检测有界模式,并根据当前可见 context 返回 pass、warn、replace 或 block。 diff --git a/docs/plugins/runtime/wasm.md b/docs/plugins/runtime/wasm.md index 4cf3348a..de97748a 100644 --- a/docs/plugins/runtime/wasm.md +++ b/docs/plugins/runtime/wasm.md @@ -1,134 +1,39 @@ -# WASM 插件运行时设计 +# WASM Legacy Runtime Note -## 目标 +WASM is an unsupported pre-release legacy runtime in the public plugin docs. It is not part of the public Plugin API v1 community runtime surface, and community plugins must migrate to Extension Host. -WASM runtime 是 AIO Coding Hub 面向社区 code-plugin 的 policy-gated runtime。它用于把插件逻辑放在 Rust main-process trust boundary 之外执行,同时保留 deterministic resource limits、permission trimming、auditability 和 cross-platform behavior。`declarativeRules` 是默认社区运行时;WASM 只用于宿主策略启用后,确实需要 code execution 的插件。 +Current community manifests must use `runtime.kind = "extensionHost"` with `main` pointing to bundled JavaScript output. Gateway integration belongs in `contributes.gatewayHooks`, and runtime behavior is registered from `activate(api)` with `api.gateway.registerHook`. -WASM packages are installable only when host policy enables execution。在 compatibility tests、signing policy 和 host allowlist 都到位前,该运行时不会允许任意 marketplace execution。`plugin.wasm` artifacts 会由 `create-aio-plugin pack` 作为 binary files 打包。 +## Why This Page Still Exists -## vNext 宿主策略 +Older design notes and experimental packages may mention WASM ABI shapes. Those packages are not a supported community distribution path for Plugin API v1. Keeping this page avoids broken links while making the current contract explicit. -在 vNext 中,WASM manifests 是 compatibility contract 的一部分,但 gateway execution 受策略控制。除非 host policy 显式设置 `wasm_enabled = true`,否则 `runtime.kind = "wasm"` 的插件不能启用;否则 gateway 返回 `PLUGIN_RUNTIME_DISABLED`。 +## Migration Shape -WASM enablement is rejected while host policy disables execution。插件仍可作为 ABI artifact 被打包和校验,但在 host policy 显式允许 WASM execution 前,用户不能在 gateway 中启用它。 - -## WASM ABI v1 - -WASM ABI v1 contract 刻意保持很窄: - -- guest module 导出一个名为 `aio_plugin_handle` 的 guest entrypoint。 -- host 向 guest memory 写入一个 UTF-8 JSON request。 -- guest 返回一个编码为 `u64` 的 UTF-8 JSON response pointer/length pair。 -- response 必须是与现有 gateway plugin pipeline 兼容的 hook result。 -- host only passes permission-trimmed JSON,绝不传递 internal Rust references、database handles、provider secrets 或 WebView state。 - -Rust 插件作者应使用 `packages/plugin-wasm-sdk` 中的 `aio-plugin-wasm-sdk` 获取这些 ABI shapes 和 `aio_plugin_entrypoint!` macro。 - -初始 JSON envelope: +Use Extension Host: ```json { - "abiVersion": "1.0.0", - "pluginId": "publisher.plugin-name", - "hook": "gateway.request.afterBodyRead", - "traceId": "optional-trace-id", - "config": {}, - "context": {} -} -``` - -guest response envelope: - -```json -{ - "action": "replace", - "requestBody": "{\"messages\":[]}", - "headers": { - "x-plugin-redacted": "1" + "main": "dist/extension.js", + "runtime": { + "kind": "extensionHost", + "language": "typescript" + }, + "contributes": { + "gatewayHooks": [{ "name": "gateway.request.afterBodyRead" }] }, - "audit": [] + "capabilities": ["gateway.hooks"] } ``` -只有当 hook 和 granted permissions 允许时,`action` 才可以是 `pass`、`replace`、`block` 或 `warn`。Replacement fields 使用与 host 相同的 active gateway envelope:`requestBody`、`responseBody`、`streamChunk`、`logMessage` 和 `headers`。Legacy `contextPatch` output 在 vNext 中会被拒绝。 - -## Guest Entrypoint 入口 +Move deterministic hook behavior into `dist/extension.js`: -guest entrypoint signature: - -```wat -(func (export "aio_plugin_handle") (param i32 i32) (result i64)) +```js +module.exports.activate = function(api) { + api.gateway.registerHook("gateway.request.afterBodyRead", function(context) { + return { action: "continue" }; + }); +}; ``` -两个参数分别是 request JSON 的 pointer 和 byte length。返回值把 response pointer 和 byte length 打包在一起: - -```text -return = (ptr << 32) | len -``` - -host 要求导出名为 `memory` 的 linear memory。ABI v1 中,host 不会传递 filesystem、network、environment variables、wall-clock access、process spawning 或 random data 的 host functions。 - -## memory/time/filesystem/network 限制 - -M5 强制执行这些默认限制: - -- Maximum input JSON bytes:256 KiB。 -- Maximum output JSON bytes:256 KiB。 -- Default guest memory limit:16 MiB,除非 manifest 提供更低限制。 -- Default hook timeout:继承 gateway hook timeout,并受 runtime 上限约束。 -- 每条 Wasmtime instruction 消耗 fuel,fuel 耗尽的 module 会被终止。 -- no WASI filesystem imports are provided。 -- no network imports are provided。 -- no environment variable imports are provided。 -- no host clock import is provided。 - -host 绝不会把 app data、plugin data、logs、cache 或 user directories mount 到 WASM。未来任何 storage API 都必须是专用、permission-gated 的 host function,并带有 size limits 和 audit logs。 - -## 执行模型 - -M5 中 host 会为每次 hook call 创建 fresh Wasmtime store。这比 pooling 慢,但在 foundation phase 更简单、更稳。等 deterministic reset semantics 经过测试后,可以再加入 pooling。 - -每次执行: - -1. 校验 manifest runtime kind 是 `wasm`。 -2. 从已安装插件目录读取 module。 -3. 在没有 WASI imports 的情况下 compile 和 instantiate module。 -4. 把 permission-trimmed JSON envelope 写入 exported memory。 -5. 执行 `aio_plugin_handle`。 -6. 读取并 bounds-check response JSON。 -7. 把 timeout、trap、bad pointer、malformed JSON 和 missing export 转成 structured runtime failures。 - -## 安全要求 - -- host only passes permission-trimmed JSON。 -- 除非已授权 `request.header.readSensitive` 且 hook 允许,否则插件不能读取 sensitive headers。 -- 除非已授权对应 write/modify permission,否则插件不能写 body、headers 或 stream chunks。 -- 插件不能访问文件,因为 no WASI filesystem imports are available。 -- 插件不能访问网络,因为 no network imports are available。 -- fuel-based termination 是 dead-loop protection 的强制要求。 -- 所有 runtime failures 都必须能通过英文 diagnostic messages 审计。 - -## 失败策略 - -WASM runtime failures 会隔离在当前 hook invocation 内: - -- Missing export:runtime failure,plugin result 视为 hook error。 -- Trap 或 fuel exhaustion:runtime failure,plugin result 视为 hook error。 -- Oversized input 或 output:runtime failure,plugin result 视为 hook error。 -- Malformed output JSON:runtime failure,plugin result 视为 hook error。 - -gateway pipeline 仍根据 hook policy 决定 fail-open 或 fail-closed。runtime 自身绝不会静默忽略错误。 - -## M5 验收测试 - -M5 backend tests 覆盖: - -- 合法 WASM module 可以 echo 一个小 hook response。 -- 导入 WASI filesystem APIs 的 module 会在 instantiation 被拒绝。 -- dead-loop module 会因 fuel exhaustion 终止,而不是阻塞 host。 - -SDK 和示例检查命令: - -```bash -pnpm plugin-wasm-sdk:test -``` +Any future binary or sandboxed runtime would need a new public contract, lifecycle registry ownership, signing policy, compatibility story, timeout model, resource limits, and marketplace policy before it could be presented as a community runtime. diff --git a/docs/release-homebrew.md b/docs/release-homebrew.md new file mode 100644 index 00000000..b9a70234 --- /dev/null +++ b/docs/release-homebrew.md @@ -0,0 +1,45 @@ +# Homebrew Cask Release Notes + +This project generates the Homebrew Cask from the release support matrix, then optionally syncs it to a separate tap repository during the release workflow. + +## One-time setup + +Create the tap repository expected by the README command: + +```bash +gh repo create dyndynjyxa/homebrew-aio-coding-hub --public +``` + +Then configure this repository: + +- Secret `HOMEBREW_TAP_TOKEN`: a token that can push to the tap repository. +- Optional variable `HOMEBREW_TAP_REPOSITORY`: defaults to `dyndynjyxa/homebrew-aio-coding-hub`. + +The tap repository should contain the generated file at: + +```text +Casks/aio-coding-hub.rb +``` + +## Manual generation + +Use the latest release asset digests: + +```bash +node scripts/support-matrix.mjs homebrew-cask \ + --tag aio-coding-hub-v0.60.4 \ + --repo dyndynjyxa/aio-coding-hub \ + --macos-arm-sha256 6b126f39ec625e97d182301fafcbfff81ce6f332e297880aef2b0eab0a3c0c4a \ + --macos-intel-sha256 18f376bc6266e8cef4fb3978240ba0247c56b703370f6a95269443c2adbbbcc6 \ + --output Casks/aio-coding-hub.rb +``` + +Validate before pushing to the tap: + +```bash +brew style --cask Casks/aio-coding-hub.rb +``` + +## Release behavior + +On a successful release, `.github/workflows/release.yml` reads the two macOS zip asset digests from GitHub, generates `Casks/aio-coding-hub.rb`, and pushes it to the tap when `HOMEBREW_TAP_TOKEN` is configured. If the token is missing, the release still succeeds and prints a skip message. diff --git a/package.json b/package.json index d6dd23c3..90d27ec4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "aio-coding-hub", "private": true, - "version": "0.60.2", + "version": "0.60.11", "packageManager": "pnpm@10.34.3", "type": "module", "scripts": { @@ -11,31 +11,28 @@ "format": "prettier --write .", "format:check": "prettier --check .", "test:unit": "vitest run", - "test:unit:shard:1": "vitest run --shard=1/4", - "test:unit:shard:2": "vitest run --shard=2/4", - "test:unit:shard:3": "vitest run --shard=3/4", - "test:unit:shard:4": "vitest run --shard=4/4", - "test:unit:shards": "pnpm test:unit:shard:1 && pnpm test:unit:shard:2 && pnpm test:unit:shard:3 && pnpm test:unit:shard:4", "test:unit:coverage": "vitest run --coverage", + "test:unit:coverage:shards": "node scripts/run-coverage-shards.mjs", "test:unit:watch": "vitest watch", + "test:e2e": "vitest run src/e2e", "lint": "eslint src/", "lint:fix": "eslint src/ --fix", "audit:deps": "node scripts/check-pnpm-audit.mjs", "check:no-instant-now-sub": "node scripts/check-no-instant-now-sub.mjs", "check:release-pr-changelog": "node scripts/check-release-pr-changelog.mjs", "check:spec-links": "node scripts/check-spec-links.mjs", + "check:support-matrix": "node scripts/support-matrix.mjs check", + "check:homebrew-cask": "node scripts/support-matrix.homebrew-cask.selftest.mjs", + "check:gateway-error-codes": "node scripts/check-gateway-error-codes.mjs", + "check:generated-bindings": "node scripts/check-generated-bindings.mjs", "check:plugin-api-contract": "node scripts/check-plugin-api-contract.mjs", "check:plugin-system-docs": "node scripts/check-plugin-system-docs.mjs", "check:plugin-system-completion": "node scripts/check-plugin-system-completion.mjs", + "check:precommit": "node scripts/run-checks.mjs precommit", + "check:precommit:full": "node scripts/run-checks.mjs precommit-full", + "check:prepush": "node scripts/run-checks.mjs prepush", + "check:plugin-hardening": "node scripts/run-checks.mjs plugin-hardening", "plugin:perf-smoke": "cd src-tauri && cargo test perf_ --lib -- --ignored --nocapture", - "plugin-sdk:typecheck": "pnpm --filter @aio-coding-hub/plugin-sdk typecheck", - "create-aio-plugin:test": "pnpm --filter create-aio-plugin test", - "plugin-wasm-sdk:test": "cargo test --manifest-path packages/plugin-wasm-sdk/Cargo.toml && cargo test --manifest-path packages/plugin-wasm-sdk/examples/redactor/Cargo.toml", - "test:e2e": "vitest run src/e2e", - "check:support-matrix": "node scripts/support-matrix.mjs check", - "check:precommit": "pnpm check:precommit:src && pnpm check:precommit:tauri", - "check:prepush": "pnpm test:unit:shards && pnpm check:generated-bindings && pnpm tauri:test && pnpm tauri:clippy", - "hooks:install": "node scripts/install-git-hooks.mjs", "postinstall": "node scripts/install-git-hooks.mjs", "preview": "vite preview", "tauri": "tauri", @@ -51,12 +48,7 @@ "tauri:build:win:x64": "node scripts/tauri-build.mjs --target x86_64-pc-windows-msvc", "tauri:build:win:arm64": "node scripts/tauri-build.mjs --target aarch64-pc-windows-msvc", "tauri:build:linux:x64": "node scripts/tauri-build.mjs --target x86_64-unknown-linux-gnu", - "tauri:gen-types": "node scripts/tauri-gen-types.mjs", - "check:precommit:src": "pnpm lint && pnpm typecheck && pnpm check:no-instant-now-sub", - "check:precommit:tauri": "pnpm tauri:check", - "check:precommit:full": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm check:no-instant-now-sub && pnpm check:release-pr-changelog && pnpm check:spec-links && pnpm check:support-matrix && pnpm check:gateway-error-codes && pnpm tauri:fmt && pnpm tauri:check && pnpm check:generated-bindings && pnpm tauri:clippy", - "check:generated-bindings": "node scripts/check-generated-bindings.mjs", - "check:gateway-error-codes": "node scripts/check-gateway-error-codes.mjs" + "tauri:gen-types": "node scripts/tauri-gen-types.mjs" }, "dependencies": { "@codemirror/language": "^6.12.1", @@ -68,16 +60,11 @@ "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.2.2", "@mdxeditor/editor": "^3.54.0", - "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.15", - "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.3.8", - "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", @@ -85,7 +72,6 @@ "@tanstack/react-query": "^5.90.3", "@tanstack/react-virtual": "^3.13.18", "@tauri-apps/api": "^2", - "@tauri-apps/plugin-clipboard-manager": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-notification": "^2", @@ -113,7 +99,7 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", - "@vitest/coverage-v8": "3.2.4", + "@vitest/coverage-v8": "4.1.9", "autoprefixer": "^10.4.23", "eslint": "^9.39.2", "eslint-plugin-react-hooks": "^7.0.1", @@ -125,20 +111,7 @@ "typescript": "~5.8.3", "typescript-eslint": "^8.54.0", "vite": "^7.3.2", - "vitest": "^3.2.4" - }, - "pnpm": { - "overrides": { - "brace-expansion@1.1.12": "1.1.13", - "brace-expansion@2.0.2": "2.0.3", - "flatted@3.3.3": "3.4.2", - "lodash": "^4.18.1", - "minimatch@3.1.2": "3.1.4", - "minimatch@9.0.5": "9.0.7", - "picomatch@2.3.1": "2.3.2", - "picomatch@4.0.3": "4.0.4", - "rollup": "^4.60.1" - } + "vitest": "^4.1.9" }, "prettier": { "printWidth": 100, diff --git a/packages/create-aio-plugin/package.json b/packages/create-aio-plugin/package.json index 590b5aec..8836fadd 100644 --- a/packages/create-aio-plugin/package.json +++ b/packages/create-aio-plugin/package.json @@ -7,6 +7,7 @@ "create-aio-plugin": "./src/cli.ts" }, "scripts": { + "cli": "tsx scripts/run-cli.mjs", "test": "vitest run", "typecheck": "tsc -p tsconfig.json --noEmit" }, @@ -15,6 +16,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", + "tsx": "^4.21.0", "typescript": "~5.8.3", "vitest": "^3.2.4" } diff --git a/packages/create-aio-plugin/scripts/run-cli.mjs b/packages/create-aio-plugin/scripts/run-cli.mjs new file mode 100644 index 00000000..700ab485 --- /dev/null +++ b/packages/create-aio-plugin/scripts/run-cli.mjs @@ -0,0 +1,7 @@ +const initialCwd = process.env.INIT_CWD; + +if (initialCwd) { + process.chdir(initialCwd); +} + +await import("../src/cli.ts"); diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index a6428370..d860c42d 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -29,26 +29,122 @@ export type CliIo = { error: (line: string) => void; }; +export type DiagnosticSeverity = "error" | "warn" | "info"; + +export type PluginDiagnostic = { + severity: DiagnosticSeverity; + code: string; + message: string; + path?: string; + hint?: string; +}; + +export type DoctorResult = { + ok: boolean; + diagnostics: PluginDiagnostic[]; + manifest?: { + id: string; + name: string; + version: string; + runtime: PluginManifest["runtime"]["kind"]; + }; +}; + +export type StrictValidationResult = + | { ok: true; diagnostics: PluginDiagnostic[] } + | { ok: false; error: { code: string; message: string }; diagnostics: PluginDiagnostic[] }; + +export type ReplayExplainResult = never; + +export type PublishCheckResult = { + ok: boolean; + checksum: string; + expectedChecksum: string; + checksumVerified: boolean; + signatureVerified: boolean; + unsigned: boolean; + manifestId: string; + name: string; + version: string; + runtime: string; + capabilities: string[]; + hooks: string[]; + hostCompatibility: PluginManifest["hostCompatibility"]; + sizeBytes: number; +}; + +type DoctorOptions = { + strict?: boolean; +}; + +type NormalizedPackagePathResult = { ok: true; path: string } | { ok: false; message: string }; + +const SUPPORTED_TEMPLATES: readonly ScaffoldTemplate[] = [ + "command", + "rule", + "example:prompt-helper", + "example:redactor", + "example:response-guard", +]; +const UNSUPPORTED_PUBLIC_TEMPLATES = new Set(["wasm", "process", "native"]); const USAGE = [ "Usage:", - " create-aio-plugin [rule|wasm]", - " create-aio-plugin validate ", - " create-aio-plugin replay ", + " create-aio-plugin [command|rule|example:prompt-helper|example:redactor|example:response-guard]", + " rule is a legacy alias for command and generates an Extension Host command template.", + " create-aio-plugin doctor ", + " create-aio-plugin validate [--strict] ", + " create-aio-plugin replay [--explain] (unsupported for Extension Host; returns PLUGIN_REPLAY_UNSUPPORTED)", " create-aio-plugin pack ", + " create-aio-plugin publish-check ", ].join("\n"); export function runCreateAioPluginCli(args: string[], cwd: string, io: CliIo = console): number { const [commandOrId, firstArg, secondArg, thirdArg] = args; - if (!commandOrId) { - io.error(USAGE); - return 1; + if (!commandOrId || commandOrId === "--help" || commandOrId === "-h" || commandOrId === "help") { + io.log(USAGE); + return commandOrId ? 0 : 1; + } + + if (commandOrId === "doctor") { + try { + const result = doctorPluginDirectory(resolve(cwd, firstArg ?? ".")); + const text = JSON.stringify(result); + if (result.ok) { + io.log(text); + return 0; + } + io.error(text); + return 1; + } catch (error) { + io.error(`failed to inspect plugin directory: ${errorMessage(error)}`); + return 1; + } } if (commandOrId === "validate") { + const strict = firstArg === "--strict"; + const pluginDir = strict ? secondArg : firstArg; try { - io.log(JSON.stringify(validatePluginDirectory(resolve(cwd, firstArg ?? ".")))); - return 0; + const root = resolve(cwd, pluginDir ?? "."); + if (strict) { + const result = validatePluginDirectoryStrict(root); + const text = JSON.stringify(result); + if (result.ok) { + io.log(text); + return 0; + } + io.error(text); + return 1; + } + const result = validatePluginDirectory(root); + const text = JSON.stringify(result); + if (result.ok) { + io.log(text); + return 0; + } + io.error(text); + return 1; } catch (error) { io.error(`failed to validate plugin directory: ${errorMessage(error)}`); return 1; @@ -56,14 +152,24 @@ export function runCreateAioPluginCli(args: string[], cwd: string, io: CliIo = c } if (commandOrId === "replay") { - if (!firstArg || !secondArg || !thirdArg) { - io.error("Usage: create-aio-plugin replay "); + const explain = firstArg === "--explain"; + const pluginDir = explain ? secondArg : firstArg; + const fixturePath = explain ? thirdArg : secondArg; + const hookName = explain ? args[4] : thirdArg; + if (!pluginDir || !fixturePath || !hookName) { + io.error( + "Usage: create-aio-plugin replay (unsupported for Extension Host; returns PLUGIN_REPLAY_UNSUPPORTED)" + ); return 1; } try { - const files = readPluginDirectory(resolve(cwd, firstArg)); - const fixture = JSON.parse(readFileSync(resolve(cwd, secondArg), "utf8")) as unknown; - io.log(JSON.stringify(replayHook(files, thirdArg as GatewayHookName, fixture))); + const files = readPluginDirectory(resolve(cwd, pluginDir)); + JSON.parse(readFileSync(resolve(cwd, fixturePath), "utf8")) as unknown; + if (explain) { + replayHookExplain(files, hookName as GatewayHookName, {}); + } else { + replayHook(files, hookName as GatewayHookName, {}); + } return 0; } catch (error) { io.error(`failed to replay plugin hook: ${errorMessage(error)}`); @@ -75,9 +181,7 @@ export function runCreateAioPluginCli(args: string[], cwd: string, io: CliIo = c try { const root = resolve(cwd, firstArg ?? "."); const files = readPluginDirectoryBytes(root); - const manifest = JSON.parse( - textFromBytes(files["plugin.json"]) ?? "{}" - ) as Partial; + const manifest = parseManifestText(textFromBytes(files["plugin.json"]) ?? "{}"); const packed = packPluginBytes(files); const outputPath = join(cwd, `${manifest.id ?? firstArg ?? "plugin"}.aio-plugin`); writeFileSync(outputPath, packed.bytes); @@ -95,6 +199,29 @@ export function runCreateAioPluginCli(args: string[], cwd: string, io: CliIo = c } } + if (commandOrId === "publish-check") { + try { + const root = resolve(cwd, firstArg ?? "."); + const files = readPluginDirectoryBytes(root); + const manifestText = textFromBytes(files["plugin.json"]) ?? "{}"; + const manifest = parseManifestText(manifestText); + const packed = packPluginBytes(files); + io.log( + JSON.stringify({ + artifactPath: join(cwd, `${manifest.id ?? firstArg ?? "plugin"}.aio-plugin`), + ...publishCheckPluginBytes(packed.bytes, { + checksum: packed.checksum, + manifest: manifestText, + }), + }) + ); + return 0; + } catch (error) { + io.error(`failed to publish-check plugin directory: ${errorMessage(error)}`); + return 1; + } + } + if (commandOrId === "sign") { const bytes = new TextEncoder().encode(firstArg ?? ""); const keyPair = secondArg @@ -114,12 +241,23 @@ export function runCreateAioPluginCli(args: string[], cwd: string, io: CliIo = c return 0; } + const templateArg = firstArg ?? "command"; + if (UNSUPPORTED_PUBLIC_TEMPLATES.has(templateArg)) { + io.error( + `PLUGIN_TEMPLATE_UNSUPPORTED: ${templateArg} templates are not supported. Use command, the legacy rule alias, or an Extension Host example.` + ); + return 1; + } + if (!isScaffoldTemplate(templateArg)) { + io.error(`PLUGIN_TEMPLATE_UNKNOWN: unknown Extension Host template: ${templateArg}\n${USAGE}`); + return 1; + } + const idArg = commandOrId; - const template = (firstArg ?? "rule") as ScaffoldTemplate; const files = createPluginScaffold({ id: idArg, name: titleFromId(idArg), - template, + template: templateArg, }); for (const [path, content] of Object.entries(files)) { @@ -131,33 +269,17 @@ export function runCreateAioPluginCli(args: string[], cwd: string, io: CliIo = c } export function validatePluginFiles(files: ScaffoldFiles): ValidationResult { - const manifestText = files["plugin.json"]; - if (!manifestText) { - return { - ok: false, - error: { code: "PLUGIN_MISSING_MANIFEST", message: "missing plugin.json" }, - }; - } - try { - return validateManifest(JSON.parse(manifestText) as PluginManifest); - } catch (error) { - return { - ok: false, - error: { - code: "PLUGIN_INVALID_MANIFEST", - message: error instanceof Error ? error.message : "invalid manifest", - }, - }; - } + const result = validatePluginFilesStrict(files); + return result.ok ? { ok: true } : { ok: false, error: result.error }; } -export function readPluginDirectory(root: string): ScaffoldFiles { +function readPluginDirectory(root: string): ScaffoldFiles { const files: ScaffoldFiles = {}; walkPluginDirectory(root, root, files); return files; } -export function readPluginDirectoryBytes(root: string): PluginFileBytes { +function readPluginDirectoryBytes(root: string): PluginFileBytes { const files: PluginFileBytes = {}; walkPluginDirectoryBytes(root, root, files); return files; @@ -167,56 +289,139 @@ export function validatePluginDirectory(root: string): ValidationResult { return validatePluginFiles(readPluginDirectory(root)); } -export function packPluginDirectory(root: string): PackedPlugin { - return packPluginBytes(readPluginDirectoryBytes(root)); +function validatePluginDirectoryStrict(root: string): StrictValidationResult { + return validatePluginFilesStrict(readPluginDirectory(root)); } -function walkPluginDirectory(root: string, dir: string, files: ScaffoldFiles): void { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (entry.name === "node_modules" || entry.name === ".git") continue; - const fullPath = join(dir, entry.name); - const relativePath = relative(root, fullPath).replace(/\\/g, "/"); - if (entry.isDirectory()) { - walkPluginDirectory(root, fullPath, files); - } else if (entry.isFile()) { - files[relativePath] = readFileSync(fullPath, "utf8"); - } +export function validatePluginFilesStrict(files: ScaffoldFiles): StrictValidationResult { + const result = doctorPluginFiles(files, { strict: true }); + const firstError = result.diagnostics.find((diagnostic) => diagnostic.severity === "error"); + if (!firstError) { + return { ok: true, diagnostics: result.diagnostics }; } + return { + ok: false, + error: { code: firstError.code, message: firstError.message }, + diagnostics: result.diagnostics, + }; } -function walkPluginDirectoryBytes(root: string, dir: string, files: PluginFileBytes): void { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (entry.name === "node_modules" || entry.name === ".git") continue; - const fullPath = join(dir, entry.name); - const relativePath = relative(root, fullPath).replace(/\\/g, "/"); - if (entry.isDirectory()) { - walkPluginDirectoryBytes(root, fullPath, files); - } else if (entry.isFile()) { - files[relativePath] = new Uint8Array(readFileSync(fullPath)); - } - } +export function doctorPluginDirectory(root: string, options: DoctorOptions = {}): DoctorResult { + return doctorPluginFiles(readPluginDirectory(root), options); } -export function replayHook(files: ScaffoldFiles, hook: GatewayHookName, context: unknown): unknown { - const validation = validatePluginFiles(files); - if (!validation.ok) { - throw new Error(`${validation.error.code}: ${validation.error.message}`); +export function doctorPluginFiles( + files: ScaffoldFiles, + _options: DoctorOptions = {} +): DoctorResult { + const diagnostics: PluginDiagnostic[] = []; + const manifestText = files["plugin.json"]; + + if (manifestText == null) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_MISSING_MANIFEST", + message: "missing plugin.json", + path: "plugin.json", + hint: "Run create-aio-plugin or add an Extension Host manifest.", + }); + return { ok: false, diagnostics }; + } + + let manifest: Partial; + try { + const parsed = JSON.parse(manifestText) as unknown; + if (!asRecord(parsed)) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_INVALID_MANIFEST", + message: "plugin.json must contain a manifest object", + path: "plugin.json", + hint: "Fix plugin.json so it matches Plugin API v1.", + }); + return { ok: false, diagnostics }; + } + manifest = parsed as Partial; + } catch (error) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_INVALID_MANIFEST_JSON", + message: `plugin.json is not valid JSON: ${errorMessage(error)}`, + path: "plugin.json", + hint: "Fix plugin.json before running validate, pack, or publish-check.", + }); + return { ok: false, diagnostics }; } - const manifest = JSON.parse(files["plugin.json"] ?? "{}") as PluginManifest; - if (manifest.runtime.kind !== "declarativeRules") { - return { action: "pass" }; + + const legacyRuntime = unsupportedLegacyRuntimeDiagnostic(manifest); + if (legacyRuntime) { + diagnostics.push(legacyRuntime); + return { ok: false, diagnostics }; } - for (const rulePath of manifest.runtime.rules) { - const document = JSON.parse(files[rulePath] ?? '{"rules":[]}') as { rules?: unknown[] }; - for (const rule of document.rules ?? []) { - const result = replayDeclarativeRule(rule, hook, context); - if (result.action !== "pass") return result; + + const validation = safelyValidateManifest(manifest); + if (!validation.ok) { + diagnostics.push({ + severity: "error", + code: validation.error.code, + message: validation.error.message, + path: "plugin.json", + hint: "Update the manifest so it matches Plugin API v1 Extension Host shape.", + }); + } + + if (isExtensionHostManifest(manifest) && typeof manifest.main === "string") { + const mainPath = normalizeExtensionMainPath(manifest.main); + if (!mainPath.ok) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_INVALID_MAIN", + message: mainPath.message, + path: "plugin.json#/main", + hint: 'Use a relative JavaScript entry point such as "dist/extension.js".', + }); + } else if (!hasPluginFile(files, mainPath.path)) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_MAIN_FILE_MISSING", + message: `extension host main file is missing: ${mainPath.path}`, + path: mainPath.path, + hint: "Build or include dist/extension.js before packing the plugin.", + }); } } - return { action: "pass" }; + + const manifestSummary = doctorManifestSummary(manifest, manifestRuntimeKind(manifest)); + + return { + ok: !hasErrorDiagnostics(diagnostics), + diagnostics, + ...(manifestSummary ? { manifest: manifestSummary } : {}), + }; +} + +export function packPluginDirectory(root: string): PackedPlugin { + return packPluginBytes(readPluginDirectoryBytes(root)); +} + +export function replayHook( + _files: ScaffoldFiles, + _hook: GatewayHookName, + _context: unknown +): never { + throw replayUnsupportedError(); +} + +export function replayHookExplain( + _files: ScaffoldFiles, + _hook: GatewayHookName, + _context: unknown +): ReplayExplainResult { + throw replayUnsupportedError(); } export function packPlugin(files: ScaffoldFiles): PackedPlugin { + assertPackageShape(files); const bytes = createStoredZipBytes(textFilesToBytes(files)); return { bytes, @@ -225,6 +430,7 @@ export function packPlugin(files: ScaffoldFiles): PackedPlugin { } export function packPluginBytes(files: PluginFileBytes): PackedPlugin { + assertPackageShape(textFilesFromBytes(files)); const bytes = createStoredZipBytes( Object.entries(files) .sort(([a], [b]) => a.localeCompare(b)) @@ -236,16 +442,6 @@ export function packPluginBytes(files: PluginFileBytes): PackedPlugin { }; } -function textFilesToBytes(files: ScaffoldFiles): readonly (readonly [string, Uint8Array])[] { - return Object.entries(files) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([path, content]) => [path, new TextEncoder().encode(content)] as const); -} - -function textFromBytes(bytes: Uint8Array | undefined): string | undefined { - return bytes == null ? undefined : new TextDecoder().decode(bytes); -} - export function generateSigningKeyPair(): SigningKeyPair { const { privateKey, publicKey } = generateKeyPairSync("ed25519"); return { @@ -295,262 +491,276 @@ export function verifyPackage( }; } -function createPublicKeyFromPrivateKey(privateKey: string): string { - return signPackage(new Uint8Array(), privateKey).publicKey; -} +export function publishCheckPluginBytes( + bytes: Uint8Array, + input: { + checksum: string; + signature?: string | null; + publicKey?: string | null; + manifest: string; + } +): PublishCheckResult { + const manifest = parseManifestText(input.manifest); + const validation = safelyValidateManifest(manifest); + const zipEntries = storedZipEntryNames(bytes); + const entryNames = zipEntries.names; + const mainPath = + typeof manifest.main === "string" ? normalizeExtensionMainPath(manifest.main) : null; + const checksum = sha256(bytes); + const checksumVerified = checksum === input.checksum; + const signatureVerified = + input.signature && input.publicKey + ? verifyPackage(bytes, input.signature, input.publicKey).ok + : false; + const unsigned = !input.signature || !input.publicKey; + const hasPackageShape = + zipEntries.ok && + entryNames.has("plugin.json") && + mainPath != null && + mainPath.ok && + entryNames.has(mainPath.path); -function titleFromId(id: string): string { - const segments = id.split("."); - const slug = segments[segments.length - 1] ?? id; - return slug - .split("-") - .map((part: string) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) - .join(" "); + return { + ok: validation.ok && hasPackageShape && checksumVerified && (unsigned || signatureVerified), + checksum, + expectedChecksum: input.checksum, + checksumVerified, + signatureVerified, + unsigned, + manifestId: typeof manifest.id === "string" ? manifest.id : "", + name: typeof manifest.name === "string" ? manifest.name : "", + version: typeof manifest.version === "string" ? manifest.version : "", + runtime: manifestRuntimeMetadataKind(manifest), + capabilities: Array.isArray(manifest.capabilities) ? [...manifest.capabilities] : [], + hooks: manifest.contributes?.gatewayHooks?.map((hook) => hook.name) ?? [], + hostCompatibility: manifest.hostCompatibility ?? { app: "", pluginApi: "" }, + sizeBytes: bytes.length, + }; } -function replayDeclarativeRule( - rawRule: unknown, - hook: GatewayHookName, - context: unknown -): ReplayRuleResult { - const rule = asRecord(rawRule); - if (rule?.hook !== hook) return { action: "pass" }; - const target = asRecord(rule.target); - const matcher = asRecord(rule.matcher) ?? asRecord(rule.match); - const action = asRecord(rule.action); - if (!target || !matcher || !action) return { action: "pass" }; - const regex = compileReplayRegex(matcher); - if (!regex) return { action: "pass" }; - - const targetField = typeof target.field === "string" ? target.field : "request.body"; - const text = textFromFixture(context, targetField); - if (!text) return { action: "pass" }; - - const path = - typeof target.jsonPath === "string" - ? target.jsonPath - : target.kind === "jsonPath" && typeof target.path === "string" - ? target.path - : undefined; - if ( - !path || - (target.field && target.field !== "request.body" && target.field !== "response.body") - ) { - return replayTextAction(text, regex, action, targetField); +function safelyValidateManifest(manifest: Partial): ValidationResult { + try { + return validateManifest(manifest as PluginManifest); + } catch (error) { + return { + ok: false, + error: { code: "PLUGIN_INVALID_MANIFEST", message: errorMessage(error) }, + }; } - return replayJsonPathAction(text, path, regex, action, targetField); } -type ReplayRuleResult = - | { action: "pass" } - | { action: "replace"; requestBody: string } - | { action: "replace"; responseBody: string } - | { action: "replace"; streamChunk: string } - | { action: "replace"; logMessage: string } - | { action: "block"; reason: string } - | { action: "warn"; message: string }; +function assertPackageShape(files: ScaffoldFiles): void { + const result = validatePluginFilesStrict(files); + if (result.ok) return; + throw new Error(`${result.error.code}: ${result.error.message}`); +} -type JsonPathSegment = { kind: "key"; key: string } | { kind: "wildcardArray" }; +function hasErrorDiagnostics(diagnostics: readonly PluginDiagnostic[]): boolean { + return diagnostics.some((diagnostic) => diagnostic.severity === "error"); +} -function compileReplayRegex(matcher: Record): RegExp | null { - if (typeof matcher.regex !== "string") return null; - try { - return new RegExp(matcher.regex, matcher.caseSensitive === false ? "gi" : "g"); - } catch { - return null; +function hasPluginFile(files: ScaffoldFiles, path: string): boolean { + for (const filePath of Object.keys(files)) { + const normalized = normalizePackagePath(filePath, "plugin package entry path"); + if (normalized.ok && normalized.path === path) { + return true; + } } + return false; } -function textFromFixture(context: unknown, field: string): string | undefined { - if (typeof context === "string") return context; - const contextRecord = asRecord(context); - if (field === "request.body") { - if (typeof contextRecord?.body === "string") return contextRecord.body; - const request = asRecord(contextRecord?.request); - return typeof request?.body === "string" ? request.body : undefined; - } - if (field === "response.body") { - const response = asRecord(contextRecord?.response); - return typeof response?.body === "string" ? response.body : undefined; - } - if (field === "stream.chunk") { - const stream = asRecord(contextRecord?.stream); - return typeof stream?.chunk === "string" ? stream.chunk : undefined; - } - if (field === "log.message") { - const log = asRecord(contextRecord?.log); - return typeof log?.message === "string" ? log.message : undefined; +function normalizeExtensionMainPath(rawPath: string): NormalizedPackagePathResult { + const normalized = normalizePackagePath(rawPath); + if (!normalized.ok) return normalized; + if (!/\.c?js$/.test(normalized.path)) { + return { + ok: false, + message: "extensionHost main must point to a .js or .cjs file inside the package", + }; } - return undefined; + return normalized; } -function replayTextAction( - body: string, - regex: RegExp, - action: Record, - field: string -): ReplayRuleResult { - if (!regex.test(body)) return { action: "pass" }; - regex.lastIndex = 0; - if (action.kind === "replace" && typeof action.replacement === "string") { - return replaceResult(field, body.replace(regex, action.replacement)); - } - if (action.kind === "block" && typeof action.reason === "string") { - return { action: "block", reason: action.reason }; +function normalizePackagePath( + rawPath: string, + label = "extensionHost main" +): NormalizedPackagePathResult { + const trimmed = rawPath.trim(); + if (!trimmed) { + return { + ok: false, + message: + label === "extensionHost main" + ? "extensionHost runtime requires main" + : `${label} is required`, + }; } - if (action.kind === "warn" && typeof action.message === "string") { - return { action: "warn", message: action.message }; + if (hasWindowsDrivePrefix(trimmed) || trimmed.startsWith("/") || trimmed.startsWith("\\")) { + return { + ok: false, + message: `${label} must be a relative path inside the package`, + }; } - if ( - action.kind === "appendMessage" && - typeof action.role === "string" && - typeof action.content === "string" - ) { - return appendMessageToBody(body, action.role, action.content); + if (trimmed.includes("//") || trimmed.includes("\\\\")) { + return { + ok: false, + message: `${label} must not contain repeated path separators`, + }; } - return { action: "pass" }; -} -function replayJsonPathAction( - body: string, - path: string, - regex: RegExp, - action: Record, - field: string -): ReplayRuleResult { - const segments = parseReplayJsonPath(path); - if (!segments) return { action: "pass" }; - let root: unknown; - try { - root = JSON.parse(body) as unknown; - } catch { - return { action: "pass" }; - } - - let matched = false; - let changed = false; - applyToJsonStrings(root, segments, (candidate) => { - if (!regex.test(candidate.value)) return; - regex.lastIndex = 0; - matched = true; - if (action.kind === "replace" && typeof action.replacement === "string") { - const next = candidate.value.replace(regex, action.replacement); - if (next !== candidate.value) { - candidate.set(next); - changed = true; - } + const normalized = trimmed.replace(/\\/g, "/"); + const segments: string[] = []; + for (const segment of normalized.split("/")) { + if (segment === ".") continue; + if (segment === "" || segment === "..") { + return { + ok: false, + message: `${label} must be a relative path inside the package`, + }; } - }); - - if (!matched) return { action: "pass" }; - if (action.kind === "replace") { - return changed ? replaceResult(field, JSON.stringify(root)) : { action: "pass" }; + segments.push(segment); } - if (action.kind === "block" && typeof action.reason === "string") { - return { action: "block", reason: action.reason }; + if (segments.length === 0) { + return { + ok: false, + message: + label === "extensionHost main" + ? "extensionHost runtime requires main" + : `${label} is required`, + }; } - if (action.kind === "warn" && typeof action.message === "string") { - return { action: "warn", message: action.message }; + return { ok: true, path: segments.join("/") }; +} + +function hasWindowsDrivePrefix(value: string): boolean { + return /^[A-Za-z]:/.test(value); +} + +function unsupportedLegacyRuntimeDiagnostic( + manifest: Partial +): PluginDiagnostic | null { + const runtime = asRecord(manifest.runtime); + const kind = typeof runtime?.kind === "string" ? runtime.kind : ""; + if (!UNSUPPORTED_PUBLIC_TEMPLATES.has(kind)) { + return null; } + return { + severity: "error", + code: "PLUGIN_UNSUPPORTED_LEGACY_RUNTIME", + message: `runtime ${kind} is no longer supported for community plugins; use Extension Host with main: "dist/extension.js".`, + path: "plugin.json#/runtime", + hint: 'Set runtime to { "kind": "extensionHost", "language": "typescript" }, add capabilities: ["gateway.hooks"], and move hook behavior into contributes.gatewayHooks.', + }; +} + +function manifestRuntimeKind( + manifest: Partial +): PluginManifest["runtime"]["kind"] | null { + const runtime = asRecord(manifest.runtime); + return runtime?.kind === "extensionHost" && runtime.language === "typescript" + ? "extensionHost" + : null; +} + +function manifestRuntimeMetadataKind(manifest: Partial): string { + const runtime = asRecord(manifest.runtime); + return typeof runtime?.kind === "string" && runtime.kind.trim() !== "" ? runtime.kind : "unknown"; +} + +function isExtensionHostManifest(manifest: Partial): manifest is PluginManifest { + return manifestRuntimeKind(manifest) === "extensionHost"; +} + +function doctorManifestSummary( + manifest: Partial, + runtimeKind: PluginManifest["runtime"]["kind"] | null +): DoctorResult["manifest"] | undefined { if ( - action.kind === "appendMessage" && - typeof action.role === "string" && - typeof action.content === "string" + typeof manifest.id !== "string" || + typeof manifest.name !== "string" || + typeof manifest.version !== "string" || + !runtimeKind ) { - return appendMessageToBody(body, action.role, action.content); + return undefined; } - return { action: "pass" }; + return { + id: manifest.id, + name: manifest.name, + version: manifest.version, + runtime: runtimeKind, + }; } -function replaceResult(field: string, value: string): ReplayRuleResult { - switch (field) { - case "response.body": - return { action: "replace", responseBody: value }; - case "stream.chunk": - return { action: "replace", streamChunk: value }; - case "log.message": - return { action: "replace", logMessage: value }; - case "request.body": - default: - return { action: "replace", requestBody: value }; +function walkPluginDirectory(root: string, dir: string, files: ScaffoldFiles): void { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === "node_modules" || entry.name === ".git") continue; + const fullPath = join(dir, entry.name); + const relativePath = relative(root, fullPath).replace(/\\/g, "/"); + if (entry.isDirectory()) { + walkPluginDirectory(root, fullPath, files); + } else if (entry.isFile()) { + files[relativePath] = readFileSync(fullPath, "utf8"); + } } } -function appendMessageToBody(body: string, role: string, content: string): ReplayRuleResult { - if (role !== "system" && role !== "developer") return { action: "pass" }; - if (!content.trim()) return { action: "pass" }; - let root: unknown; - try { - root = JSON.parse(body) as unknown; - } catch { - return { action: "pass" }; - } - const record = asRecord(root); - const messages = Array.isArray(record?.messages) ? record.messages : null; - if (!messages) return { action: "pass" }; - messages.push({ role, content }); - return { action: "replace", requestBody: JSON.stringify(root) }; -} - -function parseReplayJsonPath(path: string): JsonPathSegment[] | null { - if (!path.startsWith("$")) return null; - const segments: JsonPathSegment[] = []; - let index = 1; - while (index < path.length) { - if (path[index] === ".") { - index += 1; - const start = index; - while (index < path.length && path[index] !== "." && path[index] !== "[") { - index += 1; - } - if (start === index) return null; - const key = path.slice(start, index); - if (key.includes('"') || key.includes("'")) return null; - segments.push({ kind: "key", key }); - continue; - } - if (path.slice(index, index + 3) === "[*]") { - segments.push({ kind: "wildcardArray" }); - index += 3; - continue; - } - return null; - } - return segments; -} - -function applyToJsonStrings( - value: unknown, - path: JsonPathSegment[], - visit: (candidate: { value: string; set: (next: string) => void }) => void -): void { - if (path.length === 0) return; - const [segment, ...rest] = path; - if (!segment) return; - - if (segment.kind === "key") { - const record = asRecord(value); - if (!record || !(segment.key in record)) return; - if (rest.length === 0) { - const current = record[segment.key]; - if (typeof current === "string") { - visit({ - value: current, - set: (next) => { - record[segment.key] = next; - }, - }); - } - return; +function walkPluginDirectoryBytes(root: string, dir: string, files: PluginFileBytes): void { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === "node_modules" || entry.name === ".git") continue; + const fullPath = join(dir, entry.name); + const relativePath = relative(root, fullPath).replace(/\\/g, "/"); + if (entry.isDirectory()) { + walkPluginDirectoryBytes(root, fullPath, files); + } else if (entry.isFile()) { + files[relativePath] = new Uint8Array(readFileSync(fullPath)); } - applyToJsonStrings(record[segment.key], rest, visit); - return; } +} + +function textFilesToBytes(files: ScaffoldFiles): readonly (readonly [string, Uint8Array])[] { + return Object.entries(files) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([path, content]) => [path, new TextEncoder().encode(content)] as const); +} - if (!Array.isArray(value)) return; - for (const item of value) { - applyToJsonStrings(item, rest, visit); +function textFilesFromBytes(files: PluginFileBytes): ScaffoldFiles { + const textFiles: ScaffoldFiles = {}; + for (const path of Object.keys(files)) { + textFiles[path] = textFromBytes(files[path]) ?? ""; } + return textFiles; +} + +function textFromBytes(bytes: Uint8Array | undefined): string | undefined { + return bytes == null ? undefined : new TextDecoder().decode(bytes); +} + +function parseManifestText(text: string): Partial { + const parsed = JSON.parse(text) as unknown; + return asRecord(parsed) ? (parsed as Partial) : {}; +} + +function createPublicKeyFromPrivateKey(privateKey: string): string { + return signPackage(new Uint8Array(), privateKey).publicKey; +} + +function titleFromId(id: string): string { + const segments = id.split("."); + const slug = segments[segments.length - 1] ?? id; + return slug + .split("-") + .map((part: string) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) + .join(" "); +} + +function isScaffoldTemplate(value: string): value is ScaffoldTemplate { + return SUPPORTED_TEMPLATES.some((template) => template === value); +} + +function replayUnsupportedError(): Error { + return new Error( + "PLUGIN_REPLAY_UNSUPPORTED: Extension Host gateway hook replay is not executed locally by create-aio-plugin. Use validate, pack, publish-check, and host runtime traces for hook behavior." + ); } function asRecord(value: unknown): Record | null { @@ -567,13 +777,43 @@ function sha256(bytes: Uint8Array): string { return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; } +function storedZipEntryNames(bytes: Uint8Array): { names: Set; ok: boolean } { + const names = new Set(); + let ok = true; + let offset = 0; + while (offset + 30 <= bytes.length) { + const signature = readU32(bytes, offset); + if (signature !== 0x04034b50) break; + const compressedSize = readU32(bytes, offset + 18); + const nameLength = readU16(bytes, offset + 26); + const extraLength = readU16(bytes, offset + 28); + const nameStart = offset + 30; + const dataStart = nameStart + nameLength + extraLength; + const normalizedName = normalizePackagePath( + new TextDecoder().decode(bytes.subarray(nameStart, nameStart + nameLength)), + "plugin package entry path" + ); + if (normalizedName.ok) { + names.add(normalizedName.path); + } else { + ok = false; + } + offset = dataStart + compressedSize; + } + return { names, ok }; +} + function createStoredZipBytes(entries: readonly (readonly [string, Uint8Array])[]): Uint8Array { const chunks: Uint8Array[] = []; const centralDirectory: Uint8Array[] = []; let offset = 0; for (const [path, data] of entries) { - const name = new TextEncoder().encode(path.replace(/\\/g, "/")); + const normalizedPath = normalizePackagePath(path, "plugin package entry path"); + if (!normalizedPath.ok) { + throw new Error(`PLUGIN_INVALID_PACKAGE_PATH: ${normalizedPath.message}`); + } + const name = new TextEncoder().encode(normalizedPath.path); const crc = crc32(data); const localHeader = concatBytes([ u32(0x04034b50), @@ -667,6 +907,20 @@ function u32(value: number): Uint8Array { ]); } +function readU16(bytes: Uint8Array, offset: number): number { + return (bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8); +} + +function readU32(bytes: Uint8Array, offset: number): number { + return ( + ((bytes[offset] ?? 0) | + ((bytes[offset + 1] ?? 0) << 8) | + ((bytes[offset + 2] ?? 0) << 16) | + ((bytes[offset + 3] ?? 0) << 24)) >>> + 0 + ); +} + function rawPublicKeyFromSpki(spki: Buffer): Buffer { const prefix = Buffer.from("302a300506032b6570032100", "hex"); if (spki.length !== prefix.length + 32 || !spki.subarray(0, prefix.length).equals(prefix)) { diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index 310f578c..c3ca67bf 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -1,101 +1,154 @@ import { createPublicKey, verify } from "node:crypto"; -import { dirname } from "node:path"; import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; import { createPluginScaffold } from "./scaffold"; import { + doctorPluginDirectory, + doctorPluginFiles, generateSigningKeyPair, packPlugin, packPluginBytes, packPluginDirectory, + publishCheckPluginBytes, replayHook, + replayHookExplain, runCreateAioPluginCli, signPackage, validatePluginDirectory, validatePluginFiles, + validatePluginFilesStrict, verifyPackage, } from "./devtools"; describe("create-aio-plugin scaffold", () => { - it("creates a declarative rule plugin template", () => { + it("creates an extension host command template", () => { const files = createPluginScaffold({ id: "acme.redactor", name: "Redactor", - template: "rule", + template: "command", }); + const manifest = readManifest(files); - expect(files["plugin.json"]).toContain('"id": "acme.redactor"'); - expect(files["plugin.json"]).toContain('"kind": "declarativeRules"'); - expect(files["rules/main.json"]).toContain('"kind": "replace"'); + expect(manifest).toMatchObject({ + id: "acme.redactor", + name: "Redactor", + version: "0.1.0", + apiVersion: "1.0.0", + main: "dist/extension.js", + runtime: { kind: "extensionHost", language: "typescript" }, + activationEvents: ["onCommand:acme.redactor.hello"], + contributes: { + commands: [ + { + command: "acme.redactor.hello", + title: "Hello from Redactor", + }, + ], + }, + capabilities: ["commands.execute"], + hostCompatibility: { app: ">=0.60.0 <1.0.0", pluginApi: "^1.0.0" }, + }); + expect(manifest).not.toHaveProperty("hooks"); + expect(manifest).not.toHaveProperty("permissions"); + expect(files["dist/extension.js"]).toContain( + 'api.commands.registerCommand("acme.redactor.hello"' + ); expect(files["README.md"]).toContain("acme.redactor"); + expectNoGeneratedLegacyFields(files); + expect(validatePluginFilesStrict(files).ok).toBe(true); }); - it("creates a declarative rule template with the host rule ABI", () => { + it("keeps the legacy rule alias on the extension host command template", () => { const files = createPluginScaffold({ - id: "acme.redactor", - name: "Redactor", + id: "acme.legacy", + name: "Legacy Alias", template: "rule", }); + const manifest = readManifest(files); - const document = JSON.parse(files["rules/main.json"] ?? "{}") as { - rules?: Array>; - }; - const rule = document.rules?.[0] ?? {}; + expect(manifest.runtime).toEqual({ kind: "extensionHost", language: "typescript" }); + expect(manifest.main).toBe("dist/extension.js"); + expect(files).not.toHaveProperty("rules/main.json"); + expect(files["dist/extension.js"]).toContain( + 'api.commands.registerCommand("acme.legacy.hello"' + ); + expectNoGeneratedLegacyFields(files); + }); - expect(rule.target).toEqual({ - field: "request.body", - jsonPath: "$.messages[*].content", - }); - expect(rule.match).toMatchObject({ - regex: "SECRET_[A-Za-z0-9_]+", - caseSensitive: true, - }); - expect(rule).not.toHaveProperty("matcher"); + it("documents the legacy rule template as a command alias in CLI usage", () => { + const output: string[] = []; + + expect( + runCreateAioPluginCli([], process.cwd(), { + log: (line) => output.push(line), + error: (line) => output.push(line), + }) + ).toBe(1); + + expect(output[0]).toContain("rule"); + expect(output[0]).toContain("legacy alias for command"); }); - it("creates a WASM plugin template without enabling marketplace execution", () => { - const files = createPluginScaffold({ - id: "acme.policy", - name: "Policy", - template: "wasm", - }); + it("rejects wasm as a public CLI template", () => { + const cwd = mkdtempSync(join(tmpdir(), "aio-plugin-wasm-unsupported-")); + const output: string[] = []; - expect(files["plugin.json"]).toContain('"kind": "wasm"'); - expect(files["src/lib.rs"]).toContain("aio_plugin_handle"); - expect(files["README.md"]).toContain("gateway execution remains policy-gated"); - expect(files["README.md"]).toContain("PLUGIN_RUNTIME_DISABLED"); + expect( + runCreateAioPluginCli(["acme.policy", "wasm"], cwd, { + log: (line) => output.push(line), + error: (line) => output.push(line), + }) + ).toBe(1); + + expect(output[0]).toContain("PLUGIN_TEMPLATE_UNSUPPORTED"); + expect(output[0]).toContain("Extension Host"); + expect(existsSync(join(cwd, "acme.policy", "plugin.json"))).toBe(false); }); - it("packs binary wasm artifacts without utf8 rewriting", () => { - const wasmBytes = new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0xff, 0x00, 0x80]); + it("writes the default command template through the CLI helper", () => { + const cwd = mkdtempSync(join(tmpdir(), "aio-plugin-default-")); + + expect( + runCreateAioPluginCli(["acme.default"], cwd, { + log: () => undefined, + error: () => undefined, + }) + ).toBe(0); + + expect(existsSync(join(cwd, "acme.default", "plugin.json"))).toBe(true); + expect(existsSync(join(cwd, "acme.default", "dist/extension.js"))).toBe(true); + expect(existsSync(join(cwd, "acme.default", "rules/main.json"))).toBe(false); + }); + + it("packs binary artifacts without utf8 rewriting", () => { + const binaryBytes = new Uint8Array([0x00, 0x61, 0x69, 0x6f, 0xff, 0x00, 0x80]); const packed = packPluginBytes({ - "plugin.json": new TextEncoder().encode(JSON.stringify(validWasmManifest())), - "plugin.wasm": wasmBytes, + "plugin.json": new TextEncoder().encode(JSON.stringify(validExtensionManifest())), + "dist/extension.js": new TextEncoder().encode("module.exports.activate = function() {};\n"), + "dist/payload.bin": binaryBytes, }); expect(packed.checksum).toMatch(/^sha256:/); - expect(readStoredZipEntry(packed.bytes, "plugin.wasm")).toEqual(wasmBytes); + expect(readStoredZipEntry(packed.bytes, "dist/payload.bin")).toEqual(binaryBytes); }); - it("validates manifests, replays hook fixtures, and verifies package signatures", () => { + it("validates manifests, packs extension source, and verifies package signatures", () => { const files = createPluginScaffold({ id: "acme.redactor", name: "Redactor", - template: "rule", + template: "command", }); expect(validatePluginFiles(files).ok).toBe(true); - expect(replayHook(files, "gateway.request.afterBodyRead", { body: "SECRET_TOKEN" })).toEqual({ - action: "pass", - }); const packed = packPlugin(files); const entries = unpackStoredZipEntries(packed.bytes); - expect(entries.get("plugin.json")).toContain('"id": "acme.redactor"'); - expect(entries.get("rules/main.json")).toContain('"kind": "replace"'); + expect(entries.get("plugin.json")).toContain('"kind": "extensionHost"'); + expect(entries.get("dist/extension.js")).toContain("registerCommand"); + expect(entries.has("rules/main.json")).toBe(false); const keyPair = generateSigningKeyPair(); const signed = signPackage(packed.bytes, keyPair.privateKey); @@ -128,6 +181,62 @@ describe("create-aio-plugin scaffold", () => { }); }); + it("publish-check emits extension host package metadata", () => { + const files = createPluginScaffold({ + id: "acme.redactor", + name: "Redactor", + template: "example:redactor", + }); + const packed = packPlugin(files); + const keyPair = generateSigningKeyPair(); + const signed = signPackage(packed.bytes, keyPair.privateKey); + + const result = publishCheckPluginBytes(packed.bytes, { + checksum: signed.checksum, + signature: signed.signature, + publicKey: signed.publicKey, + manifest: files["plugin.json"] ?? "", + }); + + expect(result).toMatchObject({ + ok: true, + checksum: signed.checksum, + signatureVerified: true, + manifestId: "acme.redactor", + version: "0.1.0", + runtime: "extensionHost", + capabilities: ["gateway.hooks"], + hooks: ["gateway.request.beforeSend", "log.beforePersist"], + }); + }); + + it("publish-check reports runtime metadata from invalid manifests", () => { + const files = createPluginScaffold({ + id: "acme.metadata", + name: "Metadata", + template: "command", + }); + const packed = packPlugin(files); + + const wasmRuntime = publishCheckPluginBytes(packed.bytes, { + checksum: packed.checksum, + manifest: JSON.stringify({ + ...validExtensionManifest(), + runtime: { kind: "wasm", abiVersion: "1.0.0" }, + }), + }); + const missingRuntime = publishCheckPluginBytes(packed.bytes, { + checksum: packed.checksum, + manifest: JSON.stringify({ + ...validExtensionManifest(), + runtime: undefined, + }), + }); + + expect(wasmRuntime).toMatchObject({ ok: false, runtime: "wasm" }); + expect(missingRuntime).toMatchObject({ ok: false, runtime: "unknown" }); + }); + it("signs and verifies package bytes through the CLI helper", () => { const keyPair = generateSigningKeyPair(); const signedOutput: string[] = []; @@ -162,37 +271,139 @@ describe("create-aio-plugin scaffold", () => { }); }); - it("packs a scaffold into an .aio-plugin file through the CLI helper", () => { - const cwd = mkdtempSync(join(tmpdir(), "aio-plugin-pack-")); + it("pack and publish-check commands require extension host package shape", () => { + const cwd = mkdtempSync(join(tmpdir(), "aio-plugin-pack-shape-")); writeScaffold( join(cwd, "acme.redactor"), createPluginScaffold({ id: "acme.redactor", name: "Redactor", - template: "rule", + template: "command", }) ); - const output: string[] = []; + const packOutput: string[] = []; + const publishCwd = mkdtempSync(join(tmpdir(), "aio-plugin-publish-shape-")); + writeScaffold( + join(publishCwd, "acme.redactor"), + createPluginScaffold({ + id: "acme.redactor", + name: "Redactor", + template: "command", + }) + ); + const publishOutput: string[] = []; expect( runCreateAioPluginCli(["pack", "./acme.redactor"], cwd, { - log: (line) => output.push(line), + log: (line) => packOutput.push(line), error: () => undefined, }) ).toBe(0); - const result = JSON.parse(output[0] ?? "{}") as { + const packResult = JSON.parse(packOutput[0] ?? "{}") as { path: string; checksum: string; sizeBytes: number; }; - expect(result.path).toBe(join(cwd, "acme.redactor.aio-plugin")); - expect(result.checksum).toMatch(/^sha256:[a-f0-9]{64}$/); - expect(result.sizeBytes).toBeGreaterThan(0); - expect(existsSync(result.path)).toBe(true); + expect(packResult.path).toBe(join(cwd, "acme.redactor.aio-plugin")); + expect(packResult.checksum).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(packResult.sizeBytes).toBeGreaterThan(0); + expect(existsSync(packResult.path)).toBe(true); + + expect( + runCreateAioPluginCli(["publish-check", "./acme.redactor"], publishCwd, { + log: (line) => publishOutput.push(line), + error: () => undefined, + }) + ).toBe(0); + + const publishResult = JSON.parse(publishOutput[0] ?? "{}") as { + artifactPath: string; + checksum: string; + manifestId: string; + runtime: string; + signatureVerified: boolean; + }; + + expect(publishResult).toMatchObject({ + artifactPath: join(publishCwd, "acme.redactor.aio-plugin"), + manifestId: "acme.redactor", + runtime: "extensionHost", + signatureVerified: false, + }); + expect(publishResult.checksum).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(existsSync(publishResult.artifactPath)).toBe(false); + }); + + it("pack rejects packages missing the extension host main file", () => { + const files = createPluginScaffold({ + id: "acme.broken", + name: "Broken", + template: "command", + }); + delete files["dist/extension.js"]; + + expect(() => packPlugin(files)).toThrow(/PLUGIN_MAIN_FILE_MISSING/); }); + it("normalizes dot-prefixed extension host main paths for doctor, pack, and publish-check", () => { + const files = createPluginScaffold({ + id: "acme.normalized", + name: "Normalized", + template: "command", + }); + const manifest = readManifest(files); + manifest.main = "./dist/extension.js"; + writeManifest(files, manifest); + + expect(doctorPluginFiles(files).ok).toBe(true); + expect(validatePluginFiles(files).ok).toBe(true); + + const packed = packPlugin(files); + const result = publishCheckPluginBytes(packed.bytes, { + checksum: packed.checksum, + manifest: files["plugin.json"] ?? "", + }); + + expect(result).toMatchObject({ + ok: true, + manifestId: "acme.normalized", + runtime: "extensionHost", + }); + }); + + it.each(["dist/extension.txt", "../extension.js"] as const)( + "rejects unsafe or non-JavaScript extension host main path %s", + (main) => { + const files = createPluginScaffold({ + id: "acme.invalid-main", + name: "Invalid Main", + template: "command", + }); + const manifest = readManifest(files); + manifest.main = main; + writeManifest(files, manifest); + files[main] = "module.exports.activate = function() {};\n"; + + const doctorResult = doctorPluginFiles(files); + const validateResult = validatePluginFiles(files); + + expect(doctorResult.ok).toBe(false); + expect(doctorResult.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_INVALID_MAIN", + }) + ); + expect(validateResult).toMatchObject({ + ok: false, + error: { code: "PLUGIN_INVALID_MAIN" }, + }); + expect(() => packPlugin(files)).toThrow(/PLUGIN_INVALID_MAIN/); + } + ); + it("validate command reads plugin.json from a real plugin directory", () => { const root = mkdtempSync(join(tmpdir(), "aio-plugin-")); writeScaffold(root, createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" })); @@ -202,162 +413,398 @@ describe("create-aio-plugin scaffold", () => { expect(result).toEqual({ ok: true }); }); - it("pack command writes package bytes from a real plugin directory", () => { - const root = mkdtempSync(join(tmpdir(), "aio-plugin-")); - writeScaffold(root, createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" })); + it("validate strict rejects legacy runtime and contribution fields", () => { + const legacy = validatePluginFilesStrict(legacyWasmFiles()); - const packed = packPluginDirectory(root); + expect(legacy.ok).toBe(false); + expect(legacy.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_UNSUPPORTED_LEGACY_RUNTIME", + path: "plugin.json#/runtime", + }) + ); - expect(packed.checksum).toMatch(/^sha256:/); - expect(packed.bytes.length).toBeGreaterThan(64); + const unknownContribution = validatePluginFilesStrict({ + "plugin.json": `${JSON.stringify( + { + ...validExtensionManifest(), + contributes: { legacyRules: [{ rules: ["rules/main.json"] }] }, + }, + null, + 2 + )}\n`, + "dist/extension.js": "module.exports.activate = function() {};\n", + }); + + expect(unknownContribution.ok).toBe(false); + expect(unknownContribution.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_INVALID_CONTRIBUTION", + path: "plugin.json", + }) + ); }); - it("replay command applies scaffold rule to fixture context", () => { + it("validate command rejects packages missing the extension host main file", () => { + const root = mkdtempSync(join(tmpdir(), "aio-plugin-strict-")); const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + delete files["dist/extension.js"]; + writeScaffold(root, files); + const normalOutput: string[] = []; + const strictOutput: string[] = []; - const result = replayHook(files, "gateway.request.afterBodyRead", { - request: { - body: JSON.stringify({ - messages: [{ role: "user", content: "SECRET_TOKEN" }], - }), - }, + expect( + runCreateAioPluginCli(["validate", root], process.cwd(), { + log: (line) => normalOutput.push(line), + error: (line) => normalOutput.push(line), + }) + ).toBe(1); + expect(JSON.parse(normalOutput[0] ?? "{}")).toMatchObject({ + ok: false, + error: { code: "PLUGIN_MAIN_FILE_MISSING" }, }); - expect(result).toMatchObject({ action: "replace" }); - expect(JSON.stringify(result)).toContain("[REDACTED]"); + expect( + runCreateAioPluginCli(["validate", "--strict", root], process.cwd(), { + log: (line) => strictOutput.push(line), + error: (line) => strictOutput.push(line), + }) + ).toBe(1); + expect(JSON.parse(strictOutput[0] ?? "{}")).toMatchObject({ + ok: false, + diagnostics: [expect.objectContaining({ code: "PLUGIN_MAIN_FILE_MISSING" })], + }); }); - it("replay command emits the active vNext mutation envelope", () => { - const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + it("doctor reports unsupported diagnostics for legacy manifests", () => { + const legacyResult = doctorPluginFiles(legacyWasmFiles()); - const result = replayHook(files, "gateway.request.afterBodyRead", { - request: { - body: JSON.stringify({ - messages: [{ role: "user", content: "SECRET_TOKEN" }], - }), - }, - }); + expect(legacyResult.ok).toBe(false); + expect(legacyResult.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_UNSUPPORTED_LEGACY_RUNTIME", + message: expect.stringContaining("Extension Host"), + path: "plugin.json#/runtime", + }) + ); + expect(legacyResult.diagnostics).not.toContainEqual( + expect.objectContaining({ code: "PLUGIN_RULE_FILE_MISSING" }) + ); - expect(result).toMatchObject({ - action: "replace", - requestBody: expect.stringContaining("[REDACTED]"), + const wasmResult = doctorPluginFiles({ + "plugin.json": `${JSON.stringify( + { + id: "acme.policy", + name: "Policy", + version: "0.1.0", + apiVersion: "1.0.0", + runtime: { kind: "wasm", abiVersion: "1.0.0" }, + entry: "plugin.wasm", + hostCompatibility: { app: ">=0.62.0 <1.0.0", pluginApi: "^1.0.0" }, + }, + null, + 2 + )}\n`, }); - expect(result).not.toHaveProperty("contextPatch"); - }); - it("replay applies same-target JSONPath replacement like the host rule runtime", () => { - const files = createPluginScaffold({ id: "acme.redactor", name: "Redactor", template: "rule" }); + expect(wasmResult.ok).toBe(false); + expect(wasmResult.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_UNSUPPORTED_LEGACY_RUNTIME", + path: "plugin.json#/runtime", + }) + ); + expect(wasmResult.diagnostics).not.toContainEqual( + expect.objectContaining({ code: "PLUGIN_WASM_ENTRY_MISSING" }) + ); + }); - const result = replayHook(files, "gateway.request.afterBodyRead", { - request: { - body: JSON.stringify({ - messages: [{ role: "user", content: "SECRET_TOKEN" }], - }), - }, + it("doctor accepts extension host manifests and reports missing main files", () => { + const files = createPluginScaffold({ + id: "acme.real", + name: "Real", + template: "command", }); - expect(result).toEqual({ - action: "replace", - requestBody: JSON.stringify({ - messages: [{ role: "user", content: "[REDACTED]" }], - }), + const result = doctorPluginFiles(files); + + expect(result.ok).toBe(true); + expect(result.manifest).toMatchObject({ + id: "acme.real", + runtime: "extensionHost", }); + + delete files["dist/extension.js"]; + const missingMain = doctorPluginFiles(files); + + expect(missingMain.ok).toBe(false); + expect(missingMain.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_MAIN_FILE_MISSING", + path: "dist/extension.js", + }) + ); }); - it("replay supports block, warn, and appendMessage actions", () => { - const blockFiles = rulePluginFilesWithAction({ kind: "block", reason: "blocked" }); + it("doctor command reads a real plugin directory and returns non-zero for errors", () => { + const root = mkdtempSync(join(tmpdir(), "aio-plugin-doctor-")); + writeScaffold(root, createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" })); + const output: string[] = []; + expect(doctorPluginDirectory(root).ok).toBe(true); expect( - replayHook(blockFiles, "gateway.request.afterBodyRead", { request: { body: "danger" } }) - ).toEqual({ action: "block", reason: "blocked" }); + runCreateAioPluginCli(["doctor", root], process.cwd(), { + log: (line) => output.push(line), + error: (line) => output.push(line), + }) + ).toBe(0); + expect(JSON.parse(output[0] ?? "{}")).toMatchObject({ ok: true }); - const warnFiles = rulePluginFilesWithAction({ kind: "warn", message: "careful" }); + const brokenRoot = mkdtempSync(join(tmpdir(), "aio-plugin-doctor-broken-")); + writeFileSync(join(brokenRoot, "README.md"), "# broken\n"); + const brokenOutput: string[] = []; expect( - replayHook(warnFiles, "gateway.request.afterBodyRead", { request: { body: "danger" } }) - ).toEqual({ action: "warn", message: "careful" }); - - const appendFiles = rulePluginFilesWithAction({ - kind: "appendMessage", - role: "developer", - content: "Use safe mode", + runCreateAioPluginCli(["doctor", brokenRoot], process.cwd(), { + log: (line) => brokenOutput.push(line), + error: (line) => brokenOutput.push(line), + }) + ).toBe(1); + expect(JSON.parse(brokenOutput[0] ?? "{}")).toMatchObject({ + ok: false, + diagnostics: [expect.objectContaining({ code: "PLUGIN_MISSING_MANIFEST" })], }); + }); - const result = replayHook(appendFiles, "gateway.request.afterBodyRead", { - request: { body: JSON.stringify({ messages: [{ role: "user", content: "hello" }] }) }, - }); + it("pack command writes package bytes from a real plugin directory", () => { + const root = mkdtempSync(join(tmpdir(), "aio-plugin-")); + writeScaffold(root, createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" })); + + const packed = packPluginDirectory(root); - expect(JSON.stringify(result)).toContain("Use safe mode"); - expect(result).not.toHaveProperty("contextPatch"); + expect(packed.checksum).toMatch(/^sha256:/); + expect(packed.bytes.length).toBeGreaterThan(64); }); - it("replay replaces Codex/OpenAI Responses input text like the host rule runtime", () => { - const files = rulePluginFilesWithTarget("$.input[*].content[*].text"); - - const result = replayHook(files, "gateway.request.afterBodyRead", { - request: { - body: JSON.stringify({ - input: [ - { - type: "message", - role: "user", - content: [{ type: "input_text", text: "SECRET_TOKEN" }], - }, - ], - }), - }, + it("replay is disabled for extension host and legacy packages", () => { + const extensionFiles = createPluginScaffold({ + id: "acme.real", + name: "Real", + template: "example:redactor", }); - expect(result).toEqual({ - action: "replace", - requestBody: JSON.stringify({ - input: [ - { - type: "message", - role: "user", - content: [{ type: "input_text", text: "[REDACTED]" }], - }, - ], - }), - }); + expect(() => + replayHook(extensionFiles, "gateway.request.beforeSend", { request: { body: "token=abc" } }) + ).toThrow(/PLUGIN_REPLAY_UNSUPPORTED/); + expect(() => + replayHookExplain(extensionFiles, "gateway.request.beforeSend", { + request: { body: "token=abc" }, + }) + ).toThrow(/PLUGIN_REPLAY_UNSUPPORTED/); + expect(() => + replayHook(legacyWasmFiles(), "gateway.request.afterBodyRead", { + request: { body: "SECRET_TOKEN" }, + }) + ).toThrow(/PLUGIN_REPLAY_UNSUPPORTED/); }); - it("replay maps response, stream, and log targets to their host mutation fields", () => { + it("replay command reports that extension host gateway hooks are not run locally", () => { + const root = mkdtempSync(join(tmpdir(), "aio-plugin-replay-")); + const fixturePath = join(root, "fixture.json"); + writeScaffold(root, createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" })); + writeFileSync(fixturePath, JSON.stringify({ request: { body: "token=abc" } })); + const output: string[] = []; + expect( - replayHook( - rulePluginFilesWithRule({ - hook: "gateway.response.after", - target: { field: "response.body" }, - }), - "gateway.response.after", - { response: { body: "SECRET_TOKEN" } } + runCreateAioPluginCli( + ["replay", "--explain", root, fixturePath, "gateway.request.beforeSend"], + process.cwd(), + { + log: (line) => output.push(line), + error: (line) => output.push(line), + } ) - ).toEqual({ action: "replace", responseBody: "[REDACTED]" }); + ).toBe(1); + + expect(output[0]).toContain("PLUGIN_REPLAY_UNSUPPORTED"); + expect(output[0]).toContain("Extension Host gateway hook replay is not executed locally"); + }); +}); + +describe("create-aio-plugin example templates", () => { + it.each([ + [ + "acme.prompt-helper", + "Prompt Helper", + "example:prompt-helper", + ["gateway.request.afterBodyRead"], + ], + [ + "acme.redactor", + "Redactor", + "example:redactor", + ["gateway.request.beforeSend", "log.beforePersist"], + ], + ["acme.response-guard", "Response Guard", "example:response-guard", ["gateway.response.after"]], + ] as const)( + "generates extension host gateway hook example %s", + (pluginId, name, template, hooks) => { + const files = createPluginScaffold({ + id: pluginId, + name, + template, + }); + const manifest = readManifest(files); + + expect(manifest).toMatchObject({ + id: pluginId, + main: "dist/extension.js", + runtime: { kind: "extensionHost", language: "typescript" }, + capabilities: ["gateway.hooks"], + hostCompatibility: { app: ">=0.60.0 <1.0.0", pluginApi: "^1.0.0" }, + }); + expect(manifest.contributes.gatewayHooks.map((hook: { name: string }) => hook.name)).toEqual( + hooks + ); + for (const hook of hooks) { + expect(files["dist/extension.js"]).toContain(`api.gateway.registerHook("${hook}"`); + } + expect(files["dist/extension.js"]).toContain('action: "pass"'); + expect(validatePluginFilesStrict(files).ok).toBe(true); + expectNoGeneratedLegacyFields(files); + expectExampleReadmeDocumentsDevtoolsLoop(files); + expectExampleCanPackAndPublishCheck(files, pluginId, hooks); + } + ); + + it.each([ + ["acme.prompt-helper", "example:prompt-helper"], + ["acme.redactor", "example:redactor"], + ["acme.response-guard", "example:response-guard"], + ] as const)("writes %s through the CLI example template %s", (pluginId, template) => { + const cwd = mkdtempSync(join(tmpdir(), "aio-plugin-example-")); + const validateOutput: string[] = []; + const packOutput: string[] = []; + const publishOutput: string[] = []; expect( - replayHook( - rulePluginFilesWithRule({ - hook: "gateway.response.chunk", - target: { field: "stream.chunk" }, - }), - "gateway.response.chunk", - { stream: { chunk: "data: SECRET_TOKEN\n\n" } } - ) - ).toEqual({ action: "replace", streamChunk: "data: [REDACTED]\n\n" }); + runCreateAioPluginCli([pluginId, template], cwd, { + log: () => undefined, + error: () => undefined, + }) + ).toBe(0); + + expect(existsSync(join(cwd, pluginId, "plugin.json"))).toBe(true); + expect(existsSync(join(cwd, pluginId, "dist/extension.js"))).toBe(true); + expect(existsSync(join(cwd, pluginId, "rules/main.json"))).toBe(false); expect( - replayHook( - rulePluginFilesWithRule({ - hook: "log.beforePersist", - target: { field: "log.message" }, - }), - "log.beforePersist", - { log: { message: "apiKey=SECRET_TOKEN" } } - ) - ).toEqual({ action: "replace", logMessage: "apiKey=[REDACTED]" }); + runCreateAioPluginCli(["validate", "--strict", `./${pluginId}`], cwd, { + log: (line) => validateOutput.push(line), + error: () => undefined, + }) + ).toBe(0); + expect(JSON.parse(validateOutput[0] ?? "{}")).toMatchObject({ ok: true }); + + expect( + runCreateAioPluginCli(["pack", `./${pluginId}`], cwd, { + log: (line) => packOutput.push(line), + error: () => undefined, + }) + ).toBe(0); + + const packResult = JSON.parse(packOutput[0] ?? "{}") as { + path: string; + checksum: string; + sizeBytes: number; + }; + expect(packResult.path).toBe(join(cwd, `${pluginId}.aio-plugin`)); + expect(packResult.checksum).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(packResult.sizeBytes).toBeGreaterThan(0); + expect(existsSync(packResult.path)).toBe(true); + + expect( + runCreateAioPluginCli(["publish-check", `./${pluginId}`], cwd, { + log: (line) => publishOutput.push(line), + error: () => undefined, + }) + ).toBe(0); + + const publishResult = JSON.parse(publishOutput[0] ?? "{}") as { + artifactPath: string; + manifestId: string; + checksum: string; + runtime: string; + signatureVerified: boolean; + }; + expect(publishResult).toMatchObject({ + artifactPath: join(cwd, `${pluginId}.aio-plugin`), + manifestId: pluginId, + runtime: "extensionHost", + signatureVerified: false, + }); + expect(publishResult.checksum).toMatch(/^sha256:[a-f0-9]{64}$/); }); }); +function expectExampleCanPackAndPublishCheck( + files: Record, + manifestId: string, + hooks: readonly string[] +) { + const packed = packPlugin(files); + const result = publishCheckPluginBytes(packed.bytes, { + checksum: packed.checksum, + manifest: files["plugin.json"] ?? "", + }); + + expect(packed.checksum).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(result).toMatchObject({ + ok: true, + manifestId, + runtime: "extensionHost", + checksumVerified: true, + signatureVerified: false, + unsigned: true, + capabilities: ["gateway.hooks"], + hooks, + }); +} + +function expectExampleReadmeDocumentsDevtoolsLoop(files: Record) { + const readme = files["README.md"] ?? ""; + + expect(readme).toContain("development template, not a default installable marketplace package"); + expect(readme).toContain("pnpm --filter create-aio-plugin cli validate --strict ."); + expect(readme).toContain("pnpm --filter create-aio-plugin cli pack ."); + expect(readme).toContain("pnpm --filter create-aio-plugin cli publish-check ."); + expect(readme).not.toContain("create-aio-plugin replay"); +} + +function expectNoGeneratedLegacyFields(files: Record): void { + for (const path of Object.keys(files)) { + expect(path).not.toContain("rules/"); + } + const manifest = readManifest(files); + expect(manifest.runtime).toEqual({ + kind: "extensionHost", + language: "typescript", + }); +} + +function readManifest(files: Record): Record { + return JSON.parse(files["plugin.json"] ?? "{}") as Record; +} + +function writeManifest(files: Record, manifest: Record): void { + files["plugin.json"] = `${JSON.stringify(manifest, null, 2)}\n`; +} + function writeScaffold(root: string, files: Record): void { for (const [path, content] of Object.entries(files)) { const fullPath = join(root, path); @@ -366,86 +813,40 @@ function writeScaffold(root: string, files: Record): void { } } -function validWasmManifest() { +function validExtensionManifest() { return { - id: "acme.policy", - name: "Policy", + id: "acme.binary", + name: "Binary Payload", version: "0.1.0", apiVersion: "1.0.0", - runtime: { kind: "wasm", abiVersion: "1.0.0", memoryLimitBytes: 16777216 }, - hooks: [{ name: "gateway.request.afterBodyRead", priority: 100 }], - permissions: ["request.meta.read"], - hostCompatibility: { app: ">=0.56.0 <1.0.0", pluginApi: "^1.0.0" }, - entry: "plugin.wasm", - }; -} - -function rulePluginFilesWithAction(action: Record): Record { - const files = rulePluginFilesWithTarget(undefined); - const document = JSON.parse(files["rules/main.json"] ?? "{}") as { - rules?: Array>; - }; - const rule = document.rules?.[0]; - if (rule) { - rule.target = { field: "request.body" }; - rule.match = { regex: "danger|hello", caseSensitive: true }; - rule.action = action; - } - return { - ...files, - "rules/main.json": `${JSON.stringify(document, null, 2)}\n`, + main: "dist/extension.js", + runtime: { kind: "extensionHost", language: "typescript" }, + activationEvents: ["onStartup"], + capabilities: [], + hostCompatibility: { app: ">=0.60.0 <1.0.0", pluginApi: "^1.0.0" }, }; } -function rulePluginFilesWithTarget(jsonPath: string | undefined): Record { - return rulePluginFilesWithRule({ - hook: "gateway.request.afterBodyRead", - target: jsonPath ? { field: "request.body", jsonPath } : { field: "request.body" }, - }); -} - -function rulePluginFilesWithRule(options: { - hook: string; - target: Record; -}): Record { - const files = createPluginScaffold({ id: "acme.redactor", name: "Redactor", template: "rule" }); - const manifest = JSON.parse(files["plugin.json"] ?? "{}") as { - hooks?: Array>; - permissions?: string[]; - }; - if (manifest.hooks?.[0]) { - manifest.hooks[0].name = options.hook; - } - manifest.permissions = permissionsForReplayTarget(options.target.field); - const document = JSON.parse(files["rules/main.json"] ?? "{}") as { - rules?: Array>; - }; - const rule = document.rules?.[0]; - if (rule) { - rule.hook = options.hook; - rule.target = options.target; - } +function legacyWasmFiles(): Record { return { - ...files, - "plugin.json": `${JSON.stringify(manifest, null, 2)}\n`, - "rules/main.json": `${JSON.stringify(document, null, 2)}\n`, + "plugin.json": `${JSON.stringify( + { + id: "acme.legacy", + name: "Legacy", + version: "0.1.0", + apiVersion: "1.0.0", + runtime: { kind: "wasm", abiVersion: "1.0.0" }, + hooks: [{ name: "gateway.request.afterBodyRead", priority: 100 }], + permissions: ["request.body.read", "request.body.write"], + hostCompatibility: { app: ">=0.56.0 <1.0.0", pluginApi: "^1.0.0" }, + }, + null, + 2 + )}\n`, + "plugin.wasm": "", }; } -function permissionsForReplayTarget(field: unknown): string[] { - switch (field) { - case "response.body": - return ["response.body.read", "response.body.write"]; - case "stream.chunk": - return ["stream.inspect", "stream.modify"]; - case "log.message": - return ["log.redact"]; - case "request.body": - default: - return ["request.body.read", "request.body.write"]; - } -} - function unpackStoredZipEntries(bytes: Uint8Array): Map { const entries = new Map(); let offset = 0; @@ -490,7 +891,7 @@ function readStoredZipEntry(bytes: Uint8Array, expectedName: string): Uint8Array } function readU16(bytes: Uint8Array, offset: number): number { - return bytes[offset] | ((bytes[offset + 1] ?? 0) << 8); + return (bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8); } function readU32(bytes: Uint8Array, offset: number): number { diff --git a/packages/create-aio-plugin/src/scaffold.ts b/packages/create-aio-plugin/src/scaffold.ts index 6b4fd033..6b1a962c 100644 --- a/packages/create-aio-plugin/src/scaffold.ts +++ b/packages/create-aio-plugin/src/scaffold.ts @@ -1,6 +1,11 @@ -import type { PluginManifest } from "@aio-coding-hub/plugin-sdk"; +import type { GatewayHookName, PluginManifest } from "@aio-coding-hub/plugin-sdk"; -export type ScaffoldTemplate = "rule" | "wasm"; +export type ScaffoldTemplate = + | "command" + | "rule" + | "example:prompt-helper" + | "example:redactor" + | "example:response-guard"; export type ScaffoldInput = { id: string; @@ -13,64 +18,287 @@ export type ScaffoldFiles = Record; export function createPluginScaffold(input: ScaffoldInput): ScaffoldFiles { const id = normalizeId(input.id); const name = normalizeName(input.name); - return input.template === "wasm" ? wasmTemplate(id, name) : ruleTemplate(id, name); + + switch (input.template) { + case "example:prompt-helper": + return promptHelperExampleTemplate(id, name); + case "example:redactor": + return redactorExampleTemplate(id, name); + case "example:response-guard": + return responseGuardExampleTemplate(id, name); + case "command": + case "rule": + default: + return commandTemplate(id, name); + } } -function ruleTemplate(id: string, name: string): ScaffoldFiles { - const manifest: PluginManifest = { +function commandTemplate(id: string, name: string): ScaffoldFiles { + const command = `${id}.hello`; + const manifest = baseManifest(id, name, "Extension Host command plugin scaffold."); + manifest.activationEvents = [`onCommand:${command}`]; + manifest.contributes = { + commands: [ + { + command, + title: `Hello from ${name}`, + }, + ], + }; + manifest.capabilities = ["commands.execute"]; + + return { + "plugin.json": jsonFile(manifest), + "dist/extension.js": commandExtensionSource(command, name), + "README.md": readme( + name, + id, + "This scaffold registers one Extension Host command.", + [ + "pnpm --filter create-aio-plugin cli validate --strict .", + "pnpm --filter create-aio-plugin cli pack .", + ] + ), + }; +} + +function promptHelperExampleTemplate(id: string, name: string): ScaffoldFiles { + const hook: GatewayHookName = "gateway.request.afterBodyRead"; + const manifest = gatewayHookManifest( id, name, - version: "0.1.0", - apiVersion: "1.0.0", - runtime: { kind: "declarativeRules", rules: ["rules/main.json"] }, - hooks: [{ name: "gateway.request.afterBodyRead", priority: 100 }], - permissions: ["request.body.read", "request.body.write"], - hostCompatibility: { app: ">=0.56.0 <1.0.0", pluginApi: "^1.0.0" }, - description: "Declarative rule plugin scaffold.", + "Prompt helper example for request body policy hints.", + [hook] + ); + const claudeRequestBody = JSON.stringify( + { + model: "claude-3-5-sonnet", + messages: [{ role: "user", content: "Summarize this release note." }], + }, + null, + 2 + ); + const codexRequestBody = JSON.stringify( + { + model: "gpt-5-codex", + input: [ + { + role: "user", + content: [ + { + type: "input_text", + text: "CODEX_PROMPT_HELPER: explain this patch.", + }, + ], + }, + ], + }, + null, + 2 + ); + + return { + "plugin.json": jsonFile(manifest), + "dist/extension.js": `module.exports.activate = function(api) { + api.gateway.registerHook("${hook}", function(context) { + const body = String(context && context.request && context.request.body || ""); + if (body.includes("CODEX_PROMPT_HELPER")) { + return { + action: "replace", + requestBody: body.replace(/CODEX_PROMPT_HELPER:?\\s*/g, "Keep answers concise. ") + }; + } + if (/claude-[A-Za-z0-9.-]+/i.test(body)) { + return { + action: "warn", + message: "Prompt helper matched a Claude request." + }; + } + return { action: "pass" }; + }); +}; +`, + "fixtures/claude-request.json": jsonFile({ + request: { body: claudeRequestBody }, + }), + "fixtures/codex-request.json": jsonFile({ + request: { body: codexRequestBody }, + }), + "README.md": exampleReadme( + name, + id, + "Adds lightweight prompt guidance to supported request bodies before the gateway sends them upstream." + ), }; +} + +function redactorExampleTemplate(id: string, name: string): ScaffoldFiles { + const requestHook: GatewayHookName = "gateway.request.beforeSend"; + const logHook: GatewayHookName = "log.beforePersist"; + const manifest = gatewayHookManifest( + id, + name, + "Redactor example for request bodies and log messages.", + [requestHook, logHook] + ); return { - "plugin.json": `${JSON.stringify(manifest, null, 2)}\n`, - "rules/main.json": `${JSON.stringify( - { - rules: [ - { - id: "redact-placeholder", - hook: "gateway.request.afterBodyRead", - target: { field: "request.body", jsonPath: "$.messages[*].content" }, - match: { regex: "SECRET_[A-Za-z0-9_]+", caseSensitive: true }, - action: { kind: "replace", replacement: "[REDACTED]" }, - }, - ], - }, - null, - 2 - )}\n`, - "README.md": `# ${name}\n\nPlugin ID: \`${id}\`.\n\nThis scaffold uses declarative rules and does not execute JavaScript in the host.\n`, + "plugin.json": jsonFile(manifest), + "dist/extension.js": `const secretPattern = /(api_key|token|password)=[A-Za-z0-9_-]+/gi; + +module.exports.activate = function(api) { + api.gateway.registerHook("${requestHook}", function(context) { + const body = String(context && context.request && context.request.body || ""); + const redacted = body.replace(secretPattern, "[REDACTED]"); + if (redacted !== body) { + return { action: "replace", requestBody: redacted }; + } + return { action: "pass" }; + }); + + api.gateway.registerHook("${logHook}", function(context) { + const message = String(context && context.log && context.log.message || ""); + const redacted = message.replace(secretPattern, "[REDACTED]"); + if (redacted !== message) { + return { action: "replace", logMessage: redacted }; + } + return { action: "pass" }; + }); +}; +`, + "fixtures/request-hit.json": jsonFile({ + request: { body: "POST /v1/chat api_key=sk_live_12345 payload=hello" }, + }), + "fixtures/request-miss.json": jsonFile({ + request: { body: "POST /v1/chat payload=hello" }, + }), + "fixtures/log-redact.json": jsonFile({ + log: { message: "provider retry used token=debug_98765" }, + }), + "README.md": exampleReadme( + name, + id, + "Redacts simple secret-shaped values from request bodies and log messages." + ), }; } -function wasmTemplate(id: string, name: string): ScaffoldFiles { - const manifest: PluginManifest = { +function responseGuardExampleTemplate(id: string, name: string): ScaffoldFiles { + const hook: GatewayHookName = "gateway.response.after"; + const manifest = gatewayHookManifest( + id, + name, + "Response guard example for review markers in provider output.", + [hook] + ); + + return { + "plugin.json": jsonFile(manifest), + "dist/extension.js": `const riskyPattern = /(delete production|rm -rf|drop database)/i; + +module.exports.activate = function(api) { + api.gateway.registerHook("${hook}", function(context) { + const body = String(context && context.response && context.response.body || ""); + if (riskyPattern.test(body)) { + return { + action: "replace", + responseBody: body.replace(riskyPattern, "[REVIEW_REQUIRED]") + }; + } + return { action: "pass" }; + }); +}; +`, + "fixtures/response-warn.json": jsonFile({ + response: { body: "The suggested next step is to run rm -rf /tmp/cache." }, + }), + "fixtures/response-pass.json": jsonFile({ + response: { body: "The suggested next step is to review the diff and run tests." }, + }), + "README.md": exampleReadme( + name, + id, + "Marks risky response text for review after the gateway receives the provider response." + ), + }; +} + +function baseManifest(id: string, name: string, description: string): PluginManifest { + return { id, name, version: "0.1.0", apiVersion: "1.0.0", - runtime: { kind: "wasm", abiVersion: "1.0.0", memoryLimitBytes: 16 * 1024 * 1024 }, - hooks: [{ name: "gateway.request.afterBodyRead", priority: 100 }], - permissions: ["request.meta.read"], - hostCompatibility: { app: ">=0.56.0 <1.0.0", pluginApi: "^1.0.0" }, - entry: "plugin.wasm", - description: "Experimental WASM plugin scaffold.", + main: "dist/extension.js", + runtime: { kind: "extensionHost", language: "typescript" }, + capabilities: [], + hostCompatibility: { app: ">=0.60.0 <1.0.0", pluginApi: "^1.0.0" }, + description, }; +} +function gatewayHookManifest( + id: string, + name: string, + description: string, + hooks: readonly GatewayHookName[] +): PluginManifest { return { - "plugin.json": `${JSON.stringify(manifest, null, 2)}\n`, - "src/lib.rs": `#[no_mangle]\npub extern "C" fn aio_plugin_handle(_ptr: i32, _len: i32) -> i64 {\n 0\n}\n`, - "README.md": `# ${name}\n\nPlugin ID: \`${id}\`.\n\nThis template packages a WASM artifact and validates the ABI, but gateway execution remains policy-gated. The host rejects enablement with PLUGIN_RUNTIME_DISABLED until WASM execution is enabled by host policy.\n`, + ...baseManifest(id, name, description), + activationEvents: hooks.map((hook) => `onGatewayHook:${hook}` as const), + contributes: { + gatewayHooks: hooks.map((hook) => ({ name: hook, priority: 100 })), + }, + capabilities: ["gateway.hooks"], }; } +function commandExtensionSource(command: string, name: string): string { + return `module.exports.activate = function(api) { + api.commands.registerCommand("${command}", function(args) { + return { + ok: true, + message: "Hello from ${escapeJavaScriptString(name)}", + args: args || null + }; + }); +}; +`; +} + +function jsonFile(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function exampleReadme(name: string, id: string, summary: string): string { + return readme(name, id, summary, [ + "pnpm --filter create-aio-plugin cli validate --strict .", + "pnpm --filter create-aio-plugin cli pack .", + "pnpm --filter create-aio-plugin cli publish-check .", + ]); +} + +function readme( + name: string, + id: string, + summary: string, + commands: readonly string[] +): string { + const commandList = commands.map((command) => `- \`${command}\``).join("\n"); + return `# ${name} + +Plugin ID: \`${id}\`. + +${summary} + +This scaffold is a development template, not a default installable marketplace package. + +## Try it + +${commandList} +`; +} + function normalizeId(value: string): string { const id = value.trim(); if (!/^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)+$/.test(id)) { @@ -86,3 +314,7 @@ function normalizeName(value: string): string { } return name; } + +function escapeJavaScriptString(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} diff --git a/packages/plugin-sdk/src/index.test.ts b/packages/plugin-sdk/src/index.test.ts index 91e29399..3e5b6e1e 100644 --- a/packages/plugin-sdk/src/index.test.ts +++ b/packages/plugin-sdk/src/index.test.ts @@ -1,127 +1,689 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, test } from "vitest"; import contract from "../../../docs/plugins/plugin-api-v1-contract.json"; import { + type ActivationEvent, + type GatewayHookName, + type PluginCapability, + type PluginContributes, type PluginHookContext, type PluginHookResult, + type PluginApi, type PluginManifest, + type PluginPermission, + type PluginRuntime, + type UiContributionSlot, permissionRisk, validateManifest, } from "./index"; -const manifest: PluginManifest = { - id: "acme.redactor", - name: "Redactor", - version: "1.0.0", +const openRouterManifest: PluginManifest = { + id: "acme.openrouter", + name: "OpenRouter Provider", + version: "0.1.0", apiVersion: "1.0.0", - runtime: { kind: "declarativeRules", rules: ["rules/main.json"] }, - hooks: [{ name: "gateway.request.afterBodyRead", priority: 10 }], - permissions: ["request.body.read", "request.body.write"], - hostCompatibility: { app: ">=0.56.0 <1.0.0", pluginApi: "^1.0.0" }, + main: "dist/extension.js", + runtime: { kind: "extensionHost", language: "typescript" }, + activationEvents: ["onStartup", "onProviderEditor:openrouter"], + contributes: { + providers: [ + { + providerType: "openrouter", + displayName: "OpenRouter", + targetCliKeys: ["claude", "codex"], + extensionNamespace: "openrouter", + }, + ], + ui: { + "providers.editor.sections": [ + { + id: "openrouter-routing", + title: "OpenRouter 路由", + order: 100, + schema: { + type: "section", + fields: [ + { type: "text", key: "route", label: "Route" }, + { type: "boolean", key: "fallbackEnabled", label: "启用模型兜底" }, + ], + }, + }, + ], + }, + commands: [ + { + command: "acme.openrouter.refreshModels", + title: "刷新 OpenRouter 模型", + category: "Provider", + }, + ], + }, + capabilities: ["provider.extensionValues", "commands.execute"], + hostCompatibility: { + app: ">=0.62.0 <1.0.0", + pluginApi: "^1.0.0", + platforms: ["macos", "windows", "linux"], + }, }; describe("validateManifest", () => { - it("rejects reserved hooks until the host wires them", () => { - const result = validateManifest({ - ...manifest, - hooks: [{ name: "gateway.request.received" }], - permissions: ["request.meta.read"], + test("keeps plugin API contribution types representable", () => { + const gatewayHook: GatewayHookName = "gateway.request.afterBodyRead"; + const permission: PluginPermission = "request.body.read"; + const activationEvent: ActivationEvent = "onProviderEditor:openrouter"; + const capability: PluginCapability = "provider.extensionValues"; + const privacyCapability: PluginCapability = "privacy.redact"; + const slot: UiContributionSlot = "providers.editor.sections"; + + expect(permissionRisk(permission)).toBe("high"); + + const manifest: PluginManifest = { + id: "acme.openrouter", + name: "OpenRouter Provider", + version: "0.1.0", + apiVersion: "1.0.0", + main: "dist/extension.js", + runtime: { kind: "extensionHost", language: "typescript" }, + activationEvents: [ + "onStartup", + "onCommand:acme.openrouter.refreshModels", + activationEvent, + "onGatewayHook:gateway.request.afterBodyRead", + "onProtocolBridge:acme.openrouter.openai-gemini", + ], + contributes: { + providers: [ + { + providerType: "openrouter", + displayName: "OpenRouter", + targetCliKeys: ["claude", "codex"], + extensionNamespace: "openrouter", + }, + ], + commands: [ + { + command: "acme.openrouter.refreshModels", + title: "Refresh OpenRouter models", + category: "Provider", + }, + ], + gatewayHooks: [{ name: gatewayHook, priority: 10 }], + protocolBridges: [ + { + bridgeType: "acme.openrouter.openai-gemini", + inboundProtocol: "openai.chat", + outboundProtocol: "gemini.generateContent", + supportsStreaming: true, + }, + ], + ui: { + [slot]: [ + { + id: "openrouter-routing", + title: "OpenRouter routing", + schema: { + type: "section", + fields: [{ type: "text", key: "route", label: "Route" }], + }, + }, + ], + "settings.sections": [ + { + id: "openrouter-refresh", + title: "OpenRouter refresh", + schema: { + type: "panel", + fields: [ + { + type: "button", + key: "refresh", + label: "Refresh", + command: "acme.openrouter.refreshModels", + }, + ], + }, + }, + ], + }, + }, + capabilities: [ + capability, + privacyCapability, + "commands.execute", + "gateway.hooks", + "protocol.bridge", + ], + hostCompatibility: { app: ">=0.62.0 <1.0.0", pluginApi: "^1.0.0" }, + }; + + const runtime: PluginRuntime = manifest.runtime; + const contributes: PluginContributes = manifest.contributes ?? {}; + const replaceRequestResult: PluginHookResult = { + action: "replace", + requestBody: '{"messages":[]}', + }; + const replaceResponseHeadersResult: PluginHookResult = { + action: "replace", + headers: { "x-plugin-redacted": "1" }, + responseBody: '{"ok":true}', + }; + const passResult: PluginHookResult = { action: "pass" }; + const pluginApi: PluginApi = { + commands: { + registerCommand: (_command, _handler) => undefined, + }, + gateway: { + registerHook: (_name, _handler) => undefined, + }, + privacy: { + redactText: (text) => ({ hit: true, count: 1, redacted: text }), + redactRequestBody: (body) => ({ hit: false, count: 0, redacted: body }), + }, + }; + + expect(runtime.kind).toBe("extensionHost"); + expect(contributes.commands?.[0]?.command).toBe("acme.openrouter.refreshModels"); + expect(contributes.ui?.[slot]?.[0]?.schema.type).toBe("section"); + expect(contributes.providers?.[0]?.extensionNamespace).toBe("openrouter"); + expect(contributes.gatewayHooks?.[0]?.name).toBe(gatewayHook); + expect(contributes.protocolBridges?.[0]?.bridgeType).toBe("acme.openrouter.openai-gemini"); + expect(validateManifest(manifest)).toEqual({ ok: true }); + expect(replaceRequestResult.action).toBe("replace"); + expect(replaceResponseHeadersResult.headers?.["x-plugin-redacted"]).toBe("1"); + expect(passResult.action).toBe("pass"); + expect(pluginApi.privacy?.redactText("secret").count).toBe(1); + }); + + test("validates extension host provider manifest", () => { + expect(validateManifest(openRouterManifest)).toEqual({ ok: true }); + }); + + test("rejects wasm as unsupported public runtime", () => { + const manifest = { + ...openRouterManifest, + runtime: { kind: "wasm", abiVersion: "1.0.0" }, + hooks: [{ name: "gateway.request.afterBodyRead" }], + permissions: ["request.body.read"], + } as unknown as PluginManifest; + + expect(validateManifest(manifest)).toEqual({ + ok: false, + error: { + code: "PLUGIN_UNSUPPORTED_RUNTIME", + message: "community plugins must use extensionHost runtime", + }, }); + }); - expect(result).toEqual({ + test("rejects unknown contribution fields", () => { + const manifest = { + ...openRouterManifest, + contributes: { + legacyRules: [{ rules: ["rules/main.json"] }], + }, + }; + + expect(validateManifest(manifest as PluginManifest)).toEqual({ ok: false, error: { - code: "PLUGIN_RESERVED_HOOK", - message: - "hook is reserved for a future host integration and is not active in plugin API v1: gateway.request.received", + code: "PLUGIN_INVALID_CONTRIBUTION", + message: "unsupported contribution field: legacyRules", }, }); }); - it("rejects every reserved hook from the contract", () => { - for (const hook of contract.reservedHooks) { - const result = validateManifest({ - ...manifest, - hooks: [{ name: hook as never }], - permissions: ["request.meta.read"], - }); + test("rejects extension host manifest without main", () => { + const manifest = { ...openRouterManifest, main: undefined }; + expect(validateManifest(manifest as PluginManifest)).toEqual({ + ok: false, + error: { + code: "PLUGIN_MISSING_MAIN", + message: "extensionHost runtime requires main", + }, + }); + }); - expect(result).toMatchObject({ - ok: false, - error: { code: "PLUGIN_RESERVED_HOOK" }, - }); - } + test("rejects unknown UI contribution slot", () => { + const manifest = { + ...openRouterManifest, + contributes: { + ui: { + "providers.editor.unknown": [], + }, + }, + }; + expect(validateManifest(manifest as PluginManifest).ok).toBe(false); }); - it("rejects reserved permissions until host-mediated APIs exist", () => { - const result = validateManifest({ - ...manifest, - permissions: ["request.body.read", "network.fetch"], + test("validates protocol bridge manifest", () => { + const manifest: PluginManifest = { + id: "acme.bridge", + name: "Claude OpenAI Gemini Bridge", + version: "0.1.0", + apiVersion: "1.0.0", + main: "dist/extension.js", + runtime: { kind: "extensionHost", language: "typescript" }, + activationEvents: ["onProtocolBridge:acme.bridge.openai-gemini"], + contributes: { + protocols: [ + { protocolId: "openai.chat", direction: "both" }, + { protocolId: "gemini.generateContent", direction: "both" }, + ], + protocolBridges: [ + { + bridgeType: "acme.bridge.openai-gemini", + inboundProtocol: "openai.chat", + outboundProtocol: "gemini.generateContent", + supportsStreaming: true, + }, + ], + }, + capabilities: ["protocol.bridge"], + hostCompatibility: { app: ">=0.62.0 <1.0.0", pluginApi: "^1.0.0" }, + }; + + expect(validateManifest(manifest)).toEqual({ ok: true }); + }); + + test("rejects non-namespaced protocol bridge contribution", () => { + const manifest: PluginManifest = { + id: "acme.bridge", + name: "Claude OpenAI Gemini Bridge", + version: "0.1.0", + apiVersion: "1.0.0", + main: "dist/extension.js", + runtime: { kind: "extensionHost", language: "typescript" }, + contributes: { + protocolBridges: [ + { + bridgeType: "openai-gemini", + inboundProtocol: "openai.chat", + outboundProtocol: "gemini.generateContent", + }, + ], + }, + capabilities: ["protocol.bridge"], + hostCompatibility: { app: ">=0.62.0 <1.0.0", pluginApi: "^1.0.0" }, + }; + + expect(validateManifest(manifest)).toMatchObject({ + ok: false, + error: { code: "PLUGIN_INVALID_PROTOCOL_BRIDGE_CONTRIBUTION" }, }); + }); - expect(result).toEqual({ + test("rejects invalid protocol bridge contribution id", () => { + const manifest: PluginManifest = { + id: "acme.bridge", + name: "Claude OpenAI Gemini Bridge", + version: "0.1.0", + apiVersion: "1.0.0", + main: "dist/extension.js", + runtime: { kind: "extensionHost", language: "typescript" }, + contributes: { + protocolBridges: [ + { + bridgeType: "acme.bridge.OpenAI", + inboundProtocol: "openai.chat", + outboundProtocol: "gemini.generateContent", + }, + ], + }, + capabilities: ["protocol.bridge"], + hostCompatibility: { app: ">=0.62.0 <1.0.0", pluginApi: "^1.0.0" }, + }; + + expect(validateManifest(manifest)).toMatchObject({ ok: false, - error: { - code: "PLUGIN_RESERVED_PERMISSION", - message: - "permission is reserved for a future host-mediated API and is not active in plugin API v1: network.fetch", + error: { code: "PLUGIN_INVALID_PROTOCOL_BRIDGE_CONTRIBUTION" }, + }); + }); + + test("rejects malformed provider contribution", () => { + const manifest = { + ...openRouterManifest, + contributes: { + providers: [ + { + providerType: "", + displayName: "OpenRouter", + targetCliKeys: ["claude", "openai"], + extensionNamespace: "openrouter", + }, + ], }, + }; + + expect(validateManifest(manifest as PluginManifest)).toMatchObject({ + ok: false, + error: { code: "PLUGIN_INVALID_PROVIDER_CONTRIBUTION" }, }); }); - it("rejects write permissions without their required read permissions", () => { - expect( - validateManifest({ - ...manifest, - permissions: ["request.body.write"], - }) - ).toMatchObject({ + test("rejects non-object contributes", () => { + const manifest = { + ...openRouterManifest, + contributes: [], + }; + + expect(validateManifest(manifest as unknown as PluginManifest)).toMatchObject({ + ok: false, + error: { code: "PLUGIN_INVALID_CONTRIBUTES" }, + }); + }); + + test("rejects malformed protocol bridge contribution", () => { + const manifest = { + ...openRouterManifest, + contributes: { + protocolBridges: [ + { + bridgeType: "acme.bridge.openai-gemini", + inboundProtocol: "openai.chat", + outboundProtocol: "gemini.generateContent", + supportsStreaming: "yes", + }, + ], + }, + }; + + expect(validateManifest(manifest as unknown as PluginManifest)).toMatchObject({ ok: false, - error: { code: "PLUGIN_INVALID_PERMISSION_SET" }, + error: { code: "PLUGIN_INVALID_PROTOCOL_BRIDGE_CONTRIBUTION" }, }); + }); + test("rejects invalid UI field schema", () => { + const manifest = { + ...openRouterManifest, + contributes: { + ui: { + "providers.editor.sections": [ + { + id: "openrouter-routing", + schema: { + type: "section", + fields: [{ type: "button", key: "refresh", label: "Refresh" }], + }, + }, + ], + }, + }, + }; + + expect(validateManifest(manifest as PluginManifest)).toMatchObject({ + ok: false, + error: { code: "PLUGIN_INVALID_UI_CONTRIBUTION" }, + }); + }); + + test("rejects invalid activation event", () => { + const manifest = { + ...openRouterManifest, + activationEvents: ["onStartup", "onCommand:"], + }; + + expect(validateManifest(manifest as PluginManifest)).toMatchObject({ + ok: false, + error: { code: "PLUGIN_INVALID_ACTIVATION_EVENT" }, + }); + }); + + test("validates gatewayHooks manifest", () => { + const manifest: PluginManifest = { + ...openRouterManifest, + contributes: { + gatewayHooks: [{ name: "gateway.request.afterBodyRead", priority: 10 }], + }, + capabilities: ["gateway.hooks"], + }; + + expect(validateManifest(manifest)).toEqual({ ok: true }); + }); + + test("validates privacy redaction capability for extension host plugins", () => { + const manifest: PluginManifest = { + ...openRouterManifest, + contributes: { + gatewayHooks: [ + { + name: "gateway.request.afterBodyRead", + priority: 5, + failurePolicy: "fail-closed", + timeoutMs: 5000, + }, + { name: "log.beforePersist", priority: 1, failurePolicy: "fail-closed" }, + ], + }, + capabilities: ["gateway.hooks", "privacy.redact"], + }; + + expect(validateManifest(manifest)).toEqual({ ok: true }); + }); + + test("rejects gatewayHooks with reserved or unknown hook", () => { + const reservedHookManifest = { + ...openRouterManifest, + contributes: { + gatewayHooks: [{ name: "gateway.request.received" }], + }, + capabilities: ["gateway.hooks"], + }; + expect(validateManifest(reservedHookManifest as PluginManifest)).toMatchObject({ + ok: false, + error: { code: "PLUGIN_RESERVED_HOOK" }, + }); + + const unknownHookManifest = { + ...openRouterManifest, + contributes: { + gatewayHooks: [{ name: "gateway.request.missing" }], + }, + capabilities: ["gateway.hooks"], + }; + expect(validateManifest(unknownHookManifest as unknown as PluginManifest)).toMatchObject({ + ok: false, + error: { code: "PLUGIN_UNKNOWN_HOOK" }, + }); + }); + + test("rejects gatewayHooks with invalid timeoutMs", () => { + const manifest = { + ...openRouterManifest, + contributes: { + gatewayHooks: [{ name: "gateway.request.afterBodyRead", timeoutMs: 0 }], + }, + capabilities: ["gateway.hooks"], + }; + + expect(validateManifest(manifest as unknown as PluginManifest)).toMatchObject({ + ok: false, + error: { code: "PLUGIN_INVALID_HOOK_TIMEOUT" }, + }); + }); + + test("rejects top-level legacy hooks and permissions", () => { expect( validateManifest({ - ...manifest, - hooks: [{ name: "gateway.response.after" }], - permissions: ["response.body.write"], - }) + ...openRouterManifest, + hooks: [{ name: "gateway.request.afterBodyRead" }], + } as unknown as PluginManifest) ).toMatchObject({ ok: false, - error: { code: "PLUGIN_INVALID_PERMISSION_SET" }, + error: { code: "PLUGIN_INVALID_MANIFEST" }, }); expect( validateManifest({ - ...manifest, - hooks: [{ name: "gateway.response.chunk" }], - permissions: ["stream.modify"], - }) + ...openRouterManifest, + permissions: ["request.body.read"], + } as unknown as PluginManifest) ).toMatchObject({ ok: false, - error: { code: "PLUGIN_INVALID_PERMISSION_SET" }, + error: { code: "PLUGIN_INVALID_MANIFEST" }, }); }); - it("rejects permissions that do not apply to declared hooks", () => { - const scopedManifest = { - ...manifest, - hooks: [{ name: "log.beforePersist" as const, priority: 10 }], - permissions: ["request.body.read", "log.redact"] as const, - }; + it("rejects every reserved hook from the contract", () => { + for (const hook of contract.reservedHooks) { + const result = validateManifest({ + ...openRouterManifest, + contributes: { gatewayHooks: [{ name: hook as never }] }, + capabilities: ["gateway.hooks"], + } as PluginManifest); - expect(validateManifest(scopedManifest as never)).toEqual({ - ok: false, - error: { - code: "PLUGIN_PERMISSION_SCOPE_MISMATCH", - message: "permission request.body.read does not apply to any declared hook", + expect(result).toMatchObject({ + ok: false, + error: { code: "PLUGIN_RESERVED_HOOK" }, + }); + } + }); + + test("rejects contributions without required capabilities", () => { + const cases: Array<{ manifest: PluginManifest; message: string }> = [ + { + manifest: { + ...openRouterManifest, + contributes: { + commands: [{ command: "acme.openrouter.refreshModels", title: "Refresh models" }], + }, + capabilities: [], + }, + message: "commands contribution requires commands.execute", }, - }); + { + manifest: { + ...openRouterManifest, + contributes: { + providers: [ + { + providerType: "openrouter", + displayName: "OpenRouter", + targetCliKeys: ["claude", "codex"], + extensionNamespace: "openrouter", + }, + ], + }, + capabilities: [], + }, + message: "provider contribution requires provider.extensionValues", + }, + { + manifest: { + ...openRouterManifest, + contributes: { + gatewayHooks: [{ name: "gateway.request.afterBodyRead" }], + }, + capabilities: [], + }, + message: "gatewayHooks contribution requires gateway.hooks", + }, + { + manifest: { + ...openRouterManifest, + contributes: { + protocolBridges: [ + { + bridgeType: "acme.openrouter.openai-gemini", + inboundProtocol: "openai.chat", + outboundProtocol: "gemini.generateContent", + }, + ], + }, + capabilities: [], + }, + message: "protocolBridges contribution requires protocol.bridge", + }, + { + manifest: { + ...openRouterManifest, + contributes: { + ui: { + "providers.editor.sections": [ + { + id: "openrouter-routing", + schema: { + type: "section", + fields: [{ type: "text", key: "route", label: "Route" }], + }, + }, + ], + }, + }, + capabilities: [], + }, + message: "providers.editor.sections UI contribution requires provider.extensionValues", + }, + { + manifest: { + ...openRouterManifest, + contributes: { + ui: { + "providers.editor.fields": [ + { + id: "openrouter-models", + schema: { + type: "section", + fields: [ + { + type: "select", + key: "model", + label: "Model", + options: [{ value: "auto", label: "Auto" }], + }, + ], + }, + }, + ], + }, + }, + capabilities: [], + }, + message: "providers.editor.fields UI contribution requires provider.extensionValues", + }, + { + manifest: { + ...openRouterManifest, + contributes: { + ui: { + "settings.sections": [ + { + id: "openrouter-refresh", + schema: { + type: "section", + fields: [ + { + type: "button", + key: "refresh", + label: "Refresh", + command: "acme.openrouter.refreshModels", + }, + ], + }, + }, + ], + }, + }, + capabilities: [], + }, + message: "UI command field requires commands.execute", + }, + ]; + + for (const { manifest, message } of cases) { + expect(validateManifest(manifest)).toEqual({ + ok: false, + error: { + code: "PLUGIN_MISSING_CAPABILITY", + message, + }, + }); + } }); it("rejects manifests without a supported host compatibility range", () => { expect( validateManifest({ - ...manifest, + ...openRouterManifest, hostCompatibility: { app: "", pluginApi: "^1.0.0" }, }) ).toMatchObject({ @@ -131,8 +693,8 @@ describe("validateManifest", () => { expect( validateManifest({ - ...manifest, - hostCompatibility: { app: ">=0.56.0 <1.0.0", pluginApi: "^2.0.0" }, + ...openRouterManifest, + hostCompatibility: { app: ">=0.62.0 <1.0.0", pluginApi: "^2.0.0" }, }) ).toMatchObject({ ok: false, @@ -140,15 +702,28 @@ describe("validateManifest", () => { }); }); - it("rejects wasm ABI versions outside v1", () => { + it("rejects future manifest apiVersion majors even when hostCompatibility supports v1", () => { const result = validateManifest({ - ...manifest, - runtime: { kind: "wasm", abiVersion: "2.0.0" }, + ...openRouterManifest, + apiVersion: "2.0.0", + hostCompatibility: { app: ">=0.62.0 <1.0.0", pluginApi: "^1.0.0" }, + }); + + expect(result).toMatchObject({ + ok: false, + error: { code: "PLUGIN_INCOMPATIBLE_API" }, }); + }); + + it("rejects wasm as an unsupported public runtime", () => { + const result = validateManifest({ + ...openRouterManifest, + runtime: { kind: "wasm", abiVersion: "2.0.0" }, + } as unknown as PluginManifest); expect(result).toMatchObject({ ok: false, - error: { code: "PLUGIN_UNSUPPORTED_WASM_ABI" }, + error: { code: "PLUGIN_UNSUPPORTED_RUNTIME" }, }); }); }); @@ -195,6 +770,27 @@ describe("PluginHookContext", () => { }); }); +describe("PluginApi", () => { + it("represents the host command, gateway, and privacy APIs", () => { + const api: PluginApi = { + commands: { + registerCommand: (_command, _handler) => undefined, + }, + gateway: { + registerHook: (_name, _handler) => undefined, + }, + privacy: { + redactText: (text) => ({ hit: true, count: 1, redacted: text.replace("secret", "[密钥]") }), + redactRequestBody: (body) => ({ hit: false, count: 0, redacted: body }), + }, + }; + + expect(api.commands).toBeDefined(); + expect(api.gateway).toBeDefined(); + expect(api.privacy?.redactText("secret").redacted).toBe("[密钥]"); + }); +}); + describe("permissionRisk", () => { it("keeps permissionRisk defined for every v1 permission", () => { for (const permission of [...contract.activePermissions, ...contract.reservedPermissions]) { diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 8196c844..db52203d 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -35,14 +35,18 @@ export type PluginPermission = | "file.write" | "secret.read"; -export type PluginRuntime = - | { kind: "declarativeRules"; rules: string[] } - | { kind: "wasm"; abiVersion: string; memoryLimitBytes?: number }; +export type ExtensionRuntime = { + kind: "extensionHost"; + language: "typescript"; +}; + +export type PluginRuntime = ExtensionRuntime; export type PluginHook = { name: GatewayHookName; priority?: number; failurePolicy?: "fail-open" | "fail-closed"; + timeoutMs?: number; }; export type PluginHostCompatibility = { @@ -51,6 +55,97 @@ export type PluginHostCompatibility = { platforms?: string[]; }; +export type ActivationEvent = + | "onStartup" + | `onCommand:${string}` + | `onProviderEditor:${string}` + | `onProtocolBridge:${string}` + | `onGatewayHook:${string}`; + +export type UiContributionSlot = + | "app.sidebar.items" + | "home.overview.cards" + | "providers.editor.sections" + | "providers.editor.fields" + | "providers.card.badges" + | "providers.card.actions" + | "settings.sections" + | "logs.detail.tabs" + | "logs.detail.actions" + | "usage.panels" + | "plugins.detail.panels"; + +export type PluginCapability = + | "commands.execute" + | "storage.plugin" + | "diagnostics.read" + | "privacy.redact" + | "provider.extensionValues" + | "provider.requestPreparation" + | "provider.modelDiscovery" + | "provider.healthCheck" + | "protocol.bridge" + | "gateway.hooks"; + +export type HostRenderedField = + | { type: "text"; key: string; label: string; placeholder?: string; required?: boolean } + | { type: "password"; key: string; label: string; placeholder?: string; required?: boolean } + | { type: "number"; key: string; label: string; min?: number; max?: number; step?: number } + | { type: "boolean"; key: string; label: string } + | { type: "select"; key: string; label: string; options: Array<{ value: string; label: string }> } + | { type: "textarea"; key: string; label: string; rows?: number } + | { type: "info"; key: string; label: string; value: string } + | { type: "button"; key: string; label: string; command: string }; + +export type HostRenderedSchema = + | { type: "section"; fields: HostRenderedField[] } + | { type: "panel"; fields: HostRenderedField[] } + | { type: "badge"; label: string; tone?: "neutral" | "success" | "warning" | "danger" }; + +export type UiContribution = { + id: string; + title?: string; + order?: number; + schema: HostRenderedSchema; + when?: string; +}; + +export type ProviderContribution = { + providerType: string; + displayName: string; + targetCliKeys: Array<"claude" | "codex" | "gemini">; + extensionNamespace: string; +}; + +export type ProtocolContribution = { + protocolId: string; + direction: "inbound" | "outbound" | "both"; +}; + +export type ProtocolBridgeContribution = { + bridgeType: string; + inboundProtocol: string; + outboundProtocol: string; + supportsStreaming?: boolean; +}; + +export type CommandContribution = { + command: string; + title: string; + category?: string; +}; + +export type GatewayHookContribution = PluginHook; + +export type PluginContributes = { + providers?: ProviderContribution[]; + protocols?: ProtocolContribution[]; + protocolBridges?: ProtocolBridgeContribution[]; + commands?: CommandContribution[]; + gatewayHooks?: GatewayHookContribution[]; + ui?: Partial>; +}; + export type JsonValue = | null | boolean @@ -59,15 +154,16 @@ export type JsonValue = | JsonValue[] | { [key: string]: JsonValue | undefined }; -export type PluginManifest = { +type PluginManifestBase = { id: string; name: string; version: string; apiVersion: string; - runtime: PluginRuntime; - hooks: PluginHook[]; - permissions: PluginPermission[]; hostCompatibility: PluginHostCompatibility; + main?: string; + activationEvents?: ActivationEvent[]; + contributes?: PluginContributes; + capabilities?: PluginCapability[]; entry?: string; configSchema?: JsonValue; configVersion?: number; @@ -81,6 +177,10 @@ export type PluginManifest = { category?: string; }; +export type PluginManifest = PluginManifestBase & { + runtime: ExtensionRuntime; +}; + export type GatewayNormalizedMessage = { role: string; text: string; @@ -141,6 +241,41 @@ export type PluginHookResult = audit?: JsonValue[]; }; +export type PrivacyRedactionOptions = { + sensitiveTypes?: string[]; + redactionScopes?: string[]; +}; + +export type PrivacyRedactionOutput = { + hit: boolean; + count: number; + redacted: string; +}; + +export type PrivacyApi = { + redactText(text: string, options?: PrivacyRedactionOptions): PrivacyRedactionOutput; + redactRequestBody(body: string, options?: PrivacyRedactionOptions): PrivacyRedactionOutput; +}; + +export type GatewayApi = { + registerHook( + name: ActiveGatewayHookName, + handler: (context: PluginHookContext) => PluginHookResult + ): void; +}; + +export type CommandHandler = (args: JsonValue) => JsonValue; + +export type CommandsApi = { + registerCommand(command: string, handler: CommandHandler): void; +}; + +export type PluginApi = { + commands?: CommandsApi; + gateway?: GatewayApi; + privacy?: PrivacyApi; +}; + export type ValidationResult = | { ok: true } | { ok: false; error: { code: string; message: string } }; @@ -184,17 +319,47 @@ const RESERVED_HOOKS = new Set([ "gateway.response.headers", ]); -const KNOWN_PERMISSIONS = new Set( - Object.keys(PERMISSION_RISKS) as PluginPermission[] -); +const KNOWN_UI_SLOTS = new Set([ + "app.sidebar.items", + "home.overview.cards", + "providers.editor.sections", + "providers.editor.fields", + "providers.card.badges", + "providers.card.actions", + "settings.sections", + "logs.detail.tabs", + "logs.detail.actions", + "usage.panels", + "plugins.detail.panels", +]); + +const KNOWN_CAPABILITIES = new Set([ + "commands.execute", + "storage.plugin", + "diagnostics.read", + "privacy.redact", + "provider.extensionValues", + "provider.requestPreparation", + "provider.modelDiscovery", + "provider.healthCheck", + "protocol.bridge", + "gateway.hooks", +]); -const RESERVED_PERMISSIONS = new Set([ - "plugin.storage", - "network.fetch", - "file.read", - "file.write", - "secret.read", +const KNOWN_TARGET_CLI_KEYS = new Set(["claude", "codex", "gemini"]); +const KNOWN_PROTOCOL_DIRECTIONS = new Set(["inbound", "outbound", "both"]); +const KNOWN_UI_SCHEMA_TYPES = new Set(["section", "panel", "badge"]); +const KNOWN_UI_FIELD_TYPES = new Set([ + "text", + "password", + "number", + "boolean", + "select", + "textarea", + "info", + "button", ]); +const KNOWN_BADGE_TONES = new Set(["neutral", "success", "warning", "danger"]); export function permissionRisk(permission: PluginPermission): PluginPermissionRisk { return PERMISSION_RISKS[permission]; @@ -204,17 +369,29 @@ export function validateManifest(manifest: PluginManifest): ValidationResult { if (!/^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)+$/.test(manifest.id)) { return invalid("PLUGIN_INVALID_ID", "plugin id must look like publisher.plugin-name"); } - if (!isSemver(manifest.version) || !isSemver(manifest.apiVersion)) { - return invalid("PLUGIN_INVALID_VERSION", "version and apiVersion must be SemVer"); + if (!isSemver(manifest.version)) { + return invalid("PLUGIN_INVALID_VERSION", "version must be SemVer"); } - if (manifest.runtime.kind === "declarativeRules" && manifest.runtime.rules.length === 0) { - return invalid("PLUGIN_INVALID_RUNTIME", "declarativeRules runtime requires rules"); + if (!isSemver(manifest.apiVersion)) { + return invalid("PLUGIN_INVALID_API_VERSION", "apiVersion must be SemVer"); } - if (manifest.runtime.kind === "wasm" && !isSemver(manifest.runtime.abiVersion)) { - return invalid("PLUGIN_INVALID_RUNTIME", "wasm runtime requires SemVer abiVersion"); + if (semverMajor(manifest.apiVersion) !== 1) { + return invalid("PLUGIN_INCOMPATIBLE_API", "apiVersion must use plugin API major version 1"); } - if (manifest.runtime.kind === "wasm" && semverMajor(manifest.runtime.abiVersion) !== 1) { - return invalid("PLUGIN_UNSUPPORTED_WASM_ABI", "wasm abiVersion must be compatible with v1"); + + const rawManifest = manifest as Record; + const runtime = asRecord(rawManifest.runtime); + if (!runtime || runtime.kind !== "extensionHost") { + return invalid( + "PLUGIN_UNSUPPORTED_RUNTIME", + "community plugins must use extensionHost runtime" + ); + } + if (hasOwn(rawManifest, "hooks")) { + return invalid("PLUGIN_INVALID_MANIFEST", "top-level hooks are no longer supported"); + } + if (hasOwn(rawManifest, "permissions")) { + return invalid("PLUGIN_INVALID_MANIFEST", "top-level permissions are no longer supported"); } if (!isSimpleCompatibilityRange(manifest.hostCompatibility.app)) { return invalid( @@ -228,38 +405,400 @@ export function validateManifest(manifest: PluginManifest): ValidationResult { "hostCompatibility.pluginApi must support plugin API v1" ); } - if (manifest.hooks.length === 0) { - return invalid("PLUGIN_MISSING_HOOKS", "plugin must declare at least one hook"); + + if (!manifest.main || manifest.main.trim() === "") { + return invalid("PLUGIN_MISSING_MAIN", "extensionHost runtime requires main"); + } + if (manifest.runtime.language !== "typescript") { + return invalid("PLUGIN_INVALID_RUNTIME", "extensionHost language must be typescript"); + } + const activationError = validateActivationEvents(manifest.activationEvents); + if (activationError) return activationError; + const contributes = manifest.contributes ?? {}; + const contributionError = validateContributes(contributes, manifest.id); + if (contributionError) return contributionError; + const capabilities = manifest.capabilities ?? []; + const capabilityError = validateCapabilities(capabilities); + if (!capabilityError.ok) return capabilityError; + const dependencyError = validateCapabilityDependencies(contributes, capabilities); + if (dependencyError) return dependencyError; + return { ok: true }; +} + +function validateActivationEvents(activationEvents: unknown): ValidationResult | null { + if (activationEvents == null) return null; + if (!Array.isArray(activationEvents)) { + return invalid("PLUGIN_INVALID_ACTIVATION_EVENT", "activationEvents must be an array"); + } + + for (const event of activationEvents) { + if (event === "onStartup") continue; + if (typeof event !== "string") { + return invalid("PLUGIN_INVALID_ACTIVATION_EVENT", "activation event must be a string"); + } + + const hasKnownPrefix = [ + "onCommand:", + "onProviderEditor:", + "onProtocolBridge:", + "onGatewayHook:", + ].some((prefix) => event.startsWith(prefix) && event.slice(prefix.length).trim() !== ""); + if (!hasKnownPrefix) { + return invalid("PLUGIN_INVALID_ACTIVATION_EVENT", `invalid activation event: ${event}`); + } + } + return null; +} + +function validateContributes( + contributes: PluginContributes, + pluginId: string +): ValidationResult | null { + const raw = asRecord(contributes); + if (!raw) { + return invalid("PLUGIN_INVALID_CONTRIBUTES", "contributes must be an object"); + } + const supportedContributionKeys = new Set([ + "providers", + "protocols", + "protocolBridges", + "commands", + "gatewayHooks", + "ui", + ]); + for (const key of Object.keys(raw)) { + if (!supportedContributionKeys.has(key)) { + return invalid("PLUGIN_INVALID_CONTRIBUTION", `unsupported contribution field: ${key}`); + } + } + const providerError = validateProviderContributions(raw.providers); + if (providerError) return providerError; + const protocolError = validateProtocolContributions(raw.protocols); + if (protocolError) return protocolError; + const protocolBridgeError = validateProtocolBridgeContributions(raw.protocolBridges, pluginId); + if (protocolBridgeError) return protocolBridgeError; + const commandError = validateCommandContributions(raw.commands); + if (commandError) return commandError; + const gatewayHookError = validateGatewayHookContributions(raw.gatewayHooks); + if (gatewayHookError) return gatewayHookError; + return validateUiContributions(raw.ui); +} + +function validateProviderContributions(providers: unknown): ValidationResult | null { + if (providers == null) return null; + if (!Array.isArray(providers)) { + return invalid("PLUGIN_INVALID_PROVIDER_CONTRIBUTION", "providers must be an array"); } - for (const hook of manifest.hooks) { - if (RESERVED_HOOKS.has(hook.name)) { + for (const provider of providers) { + const record = asRecord(provider); + if ( + !record || + !isNonEmptyString(record.providerType) || + !isNonEmptyString(record.displayName) || + !isNonEmptyString(record.extensionNamespace) || + !Array.isArray(record.targetCliKeys) || + record.targetCliKeys.length === 0 || + !record.targetCliKeys.every( + (key) => typeof key === "string" && KNOWN_TARGET_CLI_KEYS.has(key) + ) + ) { return invalid( - "PLUGIN_RESERVED_HOOK", - `hook is reserved for a future host integration and is not active in plugin API v1: ${hook.name}` + "PLUGIN_INVALID_PROVIDER_CONTRIBUTION", + "provider contribution requires providerType, displayName, extensionNamespace, and targetCliKeys" ); } - if (!KNOWN_HOOKS.has(hook.name)) { - return invalid("PLUGIN_UNKNOWN_HOOK", `unknown hook: ${hook.name}`); + } + return null; +} + +function validateProtocolContributions(protocols: unknown): ValidationResult | null { + if (protocols == null) return null; + if (!Array.isArray(protocols)) { + return invalid("PLUGIN_INVALID_PROTOCOL_CONTRIBUTION", "protocols must be an array"); + } + for (const protocol of protocols) { + const record = asRecord(protocol); + if ( + !record || + !isNonEmptyString(record.protocolId) || + typeof record.direction !== "string" || + !KNOWN_PROTOCOL_DIRECTIONS.has(record.direction) + ) { + return invalid( + "PLUGIN_INVALID_PROTOCOL_CONTRIBUTION", + "protocol contribution requires protocolId and direction" + ); } } - for (const permission of manifest.permissions) { - if (RESERVED_PERMISSIONS.has(permission)) { + return null; +} + +function validateProtocolBridgeContributions( + protocolBridges: unknown, + pluginId: string +): ValidationResult | null { + if (protocolBridges == null) return null; + if (!Array.isArray(protocolBridges)) { + return invalid( + "PLUGIN_INVALID_PROTOCOL_BRIDGE_CONTRIBUTION", + "protocolBridges must be an array" + ); + } + for (const bridge of protocolBridges) { + const record = asRecord(bridge); + if ( + !record || + !isNonEmptyString(record.bridgeType) || + !isNonEmptyString(record.inboundProtocol) || + !isNonEmptyString(record.outboundProtocol) || + (record.supportsStreaming !== undefined && typeof record.supportsStreaming !== "boolean") + ) { + return invalid( + "PLUGIN_INVALID_PROTOCOL_BRIDGE_CONTRIBUTION", + "protocol bridge contribution requires bridgeType, inboundProtocol, and outboundProtocol" + ); + } + if (!isNamespacedContributionId(pluginId, record.bridgeType)) { + return invalid( + "PLUGIN_INVALID_PROTOCOL_BRIDGE_CONTRIBUTION", + "protocol bridge bridgeType must be lower-case and namespaced by plugin id" + ); + } + } + return null; +} + +function validateCommandContributions(commands: unknown): ValidationResult | null { + if (commands == null) return null; + if (!Array.isArray(commands)) { + return invalid("PLUGIN_INVALID_COMMAND_CONTRIBUTION", "commands must be an array"); + } + for (const command of commands) { + const record = asRecord(command); + if (!record || !isNonEmptyString(record.command) || !isNonEmptyString(record.title)) { + return invalid( + "PLUGIN_INVALID_COMMAND_CONTRIBUTION", + "command contribution requires command and title" + ); + } + } + return null; +} + +function validateGatewayHookContributions(gatewayHooks: unknown): ValidationResult | null { + if (gatewayHooks == null) return null; + if (!Array.isArray(gatewayHooks)) { + return invalid("PLUGIN_UNKNOWN_HOOK", "gatewayHooks must be an array"); + } + for (const hook of gatewayHooks) { + const record = asRecord(hook); + if (!record || typeof record.name !== "string") { + return invalid("PLUGIN_UNKNOWN_HOOK", "gateway hook contribution requires name"); + } + if (RESERVED_HOOKS.has(record.name as GatewayHookName)) { return invalid( - "PLUGIN_RESERVED_PERMISSION", - `permission is reserved for a future host-mediated API and is not active in plugin API v1: ${permission}` + "PLUGIN_RESERVED_HOOK", + `hook is reserved for a future host integration and is not active in plugin API v1: ${record.name}` + ); + } + if (!KNOWN_HOOKS.has(record.name as GatewayHookName)) { + return invalid("PLUGIN_UNKNOWN_HOOK", `unknown hook: ${record.name}`); + } + const timeoutMs = record.timeoutMs; + if ( + timeoutMs != null && + (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) + ) { + return invalid( + "PLUGIN_INVALID_HOOK_TIMEOUT", + "gateway hook timeoutMs must be a positive integer" ); } - if (!KNOWN_PERMISSIONS.has(permission)) { - return invalid("PLUGIN_UNKNOWN_PERMISSION", `unknown permission: ${permission}`); + } + return null; +} + +function validateUiContributions(ui: unknown): ValidationResult | null { + if (ui == null) return null; + const uiRecord = asRecord(ui); + if (!uiRecord) { + return invalid("PLUGIN_INVALID_UI_CONTRIBUTION", "ui contributions must be an object"); + } + + for (const [slot, contributions] of Object.entries(uiRecord)) { + if (!KNOWN_UI_SLOTS.has(slot as UiContributionSlot)) { + return invalid("PLUGIN_UNKNOWN_UI_SLOT", `unknown UI contribution slot: ${slot}`); + } + if (!Array.isArray(contributions)) { + return invalid("PLUGIN_INVALID_UI_CONTRIBUTION", "UI slot contributions must be an array"); + } + for (const contribution of contributions) { + const contributionError = validateUiContribution(contribution); + if (contributionError) return contributionError; + } + } + return null; +} + +function validateUiContribution(contribution: unknown): ValidationResult | null { + const record = asRecord(contribution); + if (!record || !isNonEmptyString(record.id)) { + return invalid("PLUGIN_INVALID_UI_CONTRIBUTION", "UI contribution requires id"); + } + const schema = asRecord(record.schema); + if (!schema || typeof schema.type !== "string" || !KNOWN_UI_SCHEMA_TYPES.has(schema.type)) { + return invalid("PLUGIN_INVALID_UI_CONTRIBUTION", "UI contribution requires supported schema"); + } + if (schema.type === "section" || schema.type === "panel") { + if (!Array.isArray(schema.fields)) { + return invalid("PLUGIN_INVALID_UI_CONTRIBUTION", "section and panel schemas require fields"); + } + for (const field of schema.fields) { + const fieldError = validateHostRenderedField(field); + if (fieldError) return fieldError; + } + return null; + } + if (!isNonEmptyString(schema.label)) { + return invalid("PLUGIN_INVALID_UI_CONTRIBUTION", "badge schema requires label"); + } + if ( + schema.tone !== undefined && + (typeof schema.tone !== "string" || !KNOWN_BADGE_TONES.has(schema.tone)) + ) { + return invalid("PLUGIN_INVALID_UI_CONTRIBUTION", "badge schema tone is not supported"); + } + return null; +} + +function validateHostRenderedField(field: unknown): ValidationResult | null { + const record = asRecord(field); + if ( + !record || + typeof record.type !== "string" || + !KNOWN_UI_FIELD_TYPES.has(record.type) || + !isNonEmptyString(record.key) || + !isNonEmptyString(record.label) + ) { + return invalid("PLUGIN_INVALID_UI_CONTRIBUTION", "UI field requires type, key, and label"); + } + if (record.type === "button" && !isNonEmptyString(record.command)) { + return invalid("PLUGIN_INVALID_UI_CONTRIBUTION", "button field requires command"); + } + if (record.type === "select") { + if (!Array.isArray(record.options) || record.options.length === 0) { + return invalid("PLUGIN_INVALID_UI_CONTRIBUTION", "select field requires options"); + } + for (const option of record.options) { + const optionRecord = asRecord(option); + if ( + !optionRecord || + !isNonEmptyString(optionRecord.value) || + !isNonEmptyString(optionRecord.label) + ) { + return invalid("PLUGIN_INVALID_UI_CONTRIBUTION", "select option requires value and label"); + } + } + } + return null; +} + +function validateCapabilities(capabilities: readonly PluginCapability[]): ValidationResult { + for (const capability of capabilities) { + if (!KNOWN_CAPABILITIES.has(capability)) { + return invalid("PLUGIN_UNKNOWN_CAPABILITY", `unknown capability: ${capability}`); } } - const permissionSetError = validatePermissionSet(manifest.permissions); - if (permissionSetError) return permissionSetError; - const permissionScopeError = validatePermissionScope(manifest); - if (permissionScopeError) return permissionScopeError; return { ok: true }; } +function validateCapabilityDependencies( + contributes: PluginContributes, + capabilities: readonly PluginCapability[] +): ValidationResult | null { + const capabilitySet = new Set(capabilities); + const requireCapability = (needed: PluginCapability, reason: string) => { + if (!capabilitySet.has(needed)) { + return invalid("PLUGIN_MISSING_CAPABILITY", `${reason} requires ${needed}`); + } + return null; + }; + + if ((contributes.commands?.length ?? 0) > 0) { + const error = requireCapability("commands.execute", "commands contribution"); + if (error) return error; + } + if ((contributes.providers?.length ?? 0) > 0) { + const error = requireCapability("provider.extensionValues", "provider contribution"); + if (error) return error; + } + if ((contributes.gatewayHooks?.length ?? 0) > 0) { + const error = requireCapability("gateway.hooks", "gatewayHooks contribution"); + if (error) return error; + } + if ((contributes.protocolBridges?.length ?? 0) > 0) { + const error = requireCapability("protocol.bridge", "protocolBridges contribution"); + if (error) return error; + } + if ((contributes.ui?.["providers.editor.sections"]?.length ?? 0) > 0) { + const error = requireCapability( + "provider.extensionValues", + "providers.editor.sections UI contribution" + ); + if (error) return error; + } + if ((contributes.ui?.["providers.editor.fields"]?.length ?? 0) > 0) { + const error = requireCapability( + "provider.extensionValues", + "providers.editor.fields UI contribution" + ); + if (error) return error; + } + if (uiHasButtonCommand(contributes.ui)) { + const error = requireCapability("commands.execute", "UI command field"); + if (error) return error; + } + return null; +} + +function uiHasButtonCommand(ui: PluginContributes["ui"]): boolean { + if (!ui) return false; + return Object.values(ui).some((contributions) => + (contributions ?? []).some((contribution) => { + const schema = contribution.schema; + if (schema.type !== "section" && schema.type !== "panel") return false; + return schema.fields.some((field) => field.type === "button"); + }) + ); +} + +function asRecord(value: unknown): Record | null { + if (value == null || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function hasOwn(value: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim() !== ""; +} + +function isNamespacedContributionId(pluginId: string, value: string): boolean { + if (!isValidContributionId(value)) return false; + if (value === pluginId) return true; + if (!value.startsWith(pluginId)) return false; + const suffix = value.slice(pluginId.length); + return suffix.length > 1 && [".", "/", ":"].includes(suffix[0]); +} + +function isValidContributionId(value: string): boolean { + return /^[a-z0-9][a-z0-9-]*(?:[./:][a-z0-9][a-z0-9-]*)*$/.test(value); +} + function invalid(code: string, message: string): ValidationResult { return { ok: false, error: { code, message } }; } @@ -285,62 +824,3 @@ function supportsPluginApiV1(value: string): boolean { const trimmed = value.trim(); return trimmed === "^1.0.0" || trimmed === "1.x.x" || trimmed === ">=1.0.0 <2.0.0"; } - -function validatePermissionSet(permissions: PluginPermission[]): ValidationResult | null { - const set = new Set(permissions); - if (set.has("request.body.write") && !set.has("request.body.read")) { - return invalid( - "PLUGIN_INVALID_PERMISSION_SET", - "request.body.write requires request.body.read" - ); - } - if (set.has("response.body.write") && !set.has("response.body.read")) { - return invalid( - "PLUGIN_INVALID_PERMISSION_SET", - "response.body.write requires response.body.read" - ); - } - if (set.has("stream.modify") && !set.has("stream.inspect")) { - return invalid("PLUGIN_INVALID_PERMISSION_SET", "stream.modify requires stream.inspect"); - } - return null; -} - -function hookAllowsPermission(hookName: GatewayHookName, permission: PluginPermission): boolean { - if ( - permission === "request.meta.read" || - permission === "request.header.read" || - permission === "request.header.readSensitive" || - permission === "request.header.write" || - permission === "request.body.read" || - permission === "request.body.write" - ) { - return hookName === "gateway.request.afterBodyRead" || hookName === "gateway.request.beforeSend"; - } - if ( - permission === "response.header.read" || - permission === "response.header.write" || - permission === "response.body.read" || - permission === "response.body.write" - ) { - return hookName === "gateway.response.after" || hookName === "gateway.error"; - } - if (permission === "stream.inspect" || permission === "stream.modify") { - return hookName === "gateway.response.chunk"; - } - if (permission === "log.redact") return hookName === "log.beforePersist"; - return false; -} - -function validatePermissionScope(manifest: PluginManifest): ValidationResult | null { - for (const permission of manifest.permissions) { - if (RESERVED_PERMISSIONS.has(permission)) continue; - if (!manifest.hooks.some((hook) => hookAllowsPermission(hook.name, permission))) { - return invalid( - "PLUGIN_PERMISSION_SCOPE_MISMATCH", - `permission ${permission} does not apply to any declared hook` - ); - } - } - return null; -} diff --git a/packages/plugin-sdk/src/index.typecheck.ts b/packages/plugin-sdk/src/index.typecheck.ts deleted file mode 100644 index 93d2f0ce..00000000 --- a/packages/plugin-sdk/src/index.typecheck.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - type GatewayHookName, - type PluginHookResult, - type PluginManifest, - type PluginPermission, - type PluginRuntime, - permissionRisk, - validateManifest, -} from "./index"; - -const manifest: PluginManifest = { - id: "acme.redactor", - name: "Redactor", - version: "1.0.0", - apiVersion: "1.0.0", - runtime: { kind: "declarativeRules", rules: ["rules/main.json"] }, - hooks: [{ name: "gateway.request.afterBodyRead", priority: 10 }], - permissions: ["request.body.read", "log.redact"], - hostCompatibility: { app: ">=0.56.0 <1.0.0", pluginApi: "^1.0.0" }, -}; - -const runtime: PluginRuntime = manifest.runtime; -const hook: GatewayHookName = manifest.hooks[0].name; -const permission: PluginPermission = "request.body.read"; - -if (runtime.kind !== "declarativeRules") { - throw new Error("unexpected runtime"); -} - -if (hook !== "gateway.request.afterBodyRead") { - throw new Error("unexpected hook"); -} - -if (permissionRisk(permission) !== "high") { - throw new Error("unexpected risk"); -} - -const result = validateManifest(manifest); -if (!result.ok) { - throw new Error(result.error.message); -} - -const reservedHookManifest: PluginManifest = { - ...manifest, - hooks: [{ name: "gateway.request.received" }], - permissions: ["request.meta.read"], -}; -const reservedHookResult = validateManifest(reservedHookManifest); -if (reservedHookResult.ok || reservedHookResult.error.code !== "PLUGIN_RESERVED_HOOK") { - throw new Error("reserved hook should be rejected by SDK validation"); -} - -const reservedPermissionManifest: PluginManifest = { - ...manifest, - permissions: ["request.body.read", "network.fetch"], -}; -const reservedPermissionResult = validateManifest(reservedPermissionManifest); -if ( - reservedPermissionResult.ok || - reservedPermissionResult.error.code !== "PLUGIN_RESERVED_PERMISSION" -) { - throw new Error("reserved permission should be rejected by SDK validation"); -} - -const replaceRequestResult: PluginHookResult = { - action: "replace", - requestBody: "{\"messages\":[]}", -}; - -const replaceResponseHeadersResult: PluginHookResult = { - action: "replace", - headers: { "x-plugin-redacted": "1" }, - responseBody: "{\"ok\":true}", -}; - -if (replaceRequestResult.action !== "replace" || !replaceResponseHeadersResult.headers) { - throw new Error("host mutation hook results should be representable"); -} diff --git a/packages/plugin-wasm-sdk/Cargo.lock b/packages/plugin-wasm-sdk/Cargo.lock deleted file mode 100644 index 003419b8..00000000 --- a/packages/plugin-wasm-sdk/Cargo.lock +++ /dev/null @@ -1,107 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aio-plugin-wasm-sdk" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "memchr" -version = "2.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/packages/plugin-wasm-sdk/Cargo.toml b/packages/plugin-wasm-sdk/Cargo.toml deleted file mode 100644 index cda4b96c..00000000 --- a/packages/plugin-wasm-sdk/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "aio-plugin-wasm-sdk" -version = "0.1.0" -edition = "2021" -description = "Rust/WASM SDK contracts for AIO Coding Hub plugins" -license = "MIT" -publish = false - -[lib] -crate-type = ["cdylib", "rlib"] - -[dependencies] -serde = { version = "1", features = ["derive"] } -serde_json = "1" diff --git a/packages/plugin-wasm-sdk/examples/redactor/Cargo.lock b/packages/plugin-wasm-sdk/examples/redactor/Cargo.lock deleted file mode 100644 index 399ef822..00000000 --- a/packages/plugin-wasm-sdk/examples/redactor/Cargo.lock +++ /dev/null @@ -1,115 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aio-plugin-wasm-redactor-example" -version = "0.1.0" -dependencies = [ - "aio-plugin-wasm-sdk", - "serde_json", -] - -[[package]] -name = "aio-plugin-wasm-sdk" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "memchr" -version = "2.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/packages/plugin-wasm-sdk/examples/redactor/Cargo.toml b/packages/plugin-wasm-sdk/examples/redactor/Cargo.toml deleted file mode 100644 index f1071e67..00000000 --- a/packages/plugin-wasm-sdk/examples/redactor/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "aio-plugin-wasm-redactor-example" -version = "0.1.0" -edition = "2021" -publish = false - -[lib] -crate-type = ["cdylib", "rlib"] - -[dependencies] -aio-plugin-wasm-sdk = { path = "../.." } -serde_json = "1" diff --git a/packages/plugin-wasm-sdk/examples/redactor/src/lib.rs b/packages/plugin-wasm-sdk/examples/redactor/src/lib.rs deleted file mode 100644 index 64217429..00000000 --- a/packages/plugin-wasm-sdk/examples/redactor/src/lib.rs +++ /dev/null @@ -1,46 +0,0 @@ -use aio_plugin_wasm_sdk::{aio_plugin_entrypoint, HookRequest, HookResult}; - -fn handle(request: HookRequest) -> HookResult { - let Some(input) = request - .context - .pointer("/request/body/input") - .and_then(serde_json::Value::as_str) - else { - return HookResult::pass(); - }; - - if !input.contains("SECRET_") { - return HookResult::pass(); - } - - HookResult::replace_request_body(format!( - "{{\"input\":{}}}", - serde_json::to_string(&input.replace("SECRET_", "[REDACTED]_")) - .unwrap_or_else(|_| "\"[REDACTED]\"".to_string()) - )) -} - -aio_plugin_entrypoint!(handle); - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn redactor_example_replaces_secret_marker() { - let result = handle(HookRequest { - abi_version: "1.0.0".to_string(), - plugin_id: "acme.redactor".to_string(), - hook: "gateway.request.afterBodyRead".to_string(), - trace_id: None, - config: json!({}), - context: json!({ "request": { "body": { "input": "hello SECRET_TOKEN" } } }), - }); - - let body: serde_json::Value = - serde_json::from_str(result.request_body.as_deref().expect("request body")) - .expect("request body json"); - assert_eq!(body["input"], "hello [REDACTED]_TOKEN"); - } -} diff --git a/packages/plugin-wasm-sdk/src/lib.rs b/packages/plugin-wasm-sdk/src/lib.rs deleted file mode 100644 index 6ed94b37..00000000 --- a/packages/plugin-wasm-sdk/src/lib.rs +++ /dev/null @@ -1,245 +0,0 @@ -//! Rust/WASM SDK contracts for AIO Coding Hub plugins. -//! -//! The SDK mirrors plugin manifest and hook ABI shapes. It does not grant host -//! capabilities; the desktop host still trims context and enforces permissions. - -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::BTreeMap; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PluginManifest { - pub id: String, - pub name: String, - pub version: String, - pub api_version: String, - pub runtime: PluginRuntime, - pub hooks: Vec, - pub permissions: Vec, - pub host_compatibility: PluginHostCompatibility, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub entry: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config_schema: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config_version: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "camelCase")] -pub enum PluginRuntime { - DeclarativeRules { - rules: Vec, - }, - Wasm { - #[serde(rename = "abiVersion")] - abi_version: String, - #[serde( - rename = "memoryLimitBytes", - default, - skip_serializing_if = "Option::is_none" - )] - memory_limit_bytes: Option, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PluginHook { - pub name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub priority: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub failure_policy: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PluginHostCompatibility { - pub app: String, - pub plugin_api: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub platforms: Option>, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct HookRequest { - pub abi_version: String, - pub plugin_id: String, - pub hook: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub trace_id: Option, - #[serde(default)] - pub config: Value, - #[serde(default)] - pub context: Value, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct HookResult { - pub action: HookAction, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub message: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reason: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub request_body: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub response_body: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub stream_chunk: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub log_message: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub headers: Option>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub audit: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum HookAction { - Pass, - Warn, - Block, - Replace, -} - -impl HookResult { - pub fn pass() -> Self { - Self { - action: HookAction::Pass, - message: None, - reason: None, - request_body: None, - response_body: None, - stream_chunk: None, - log_message: None, - headers: None, - audit: Vec::new(), - } - } - - pub fn warn(message: impl Into) -> Self { - Self { - action: HookAction::Warn, - message: Some(message.into()), - reason: None, - request_body: None, - response_body: None, - stream_chunk: None, - log_message: None, - headers: None, - audit: Vec::new(), - } - } - - pub fn block(reason: impl Into) -> Self { - Self { - action: HookAction::Block, - message: None, - reason: Some(reason.into()), - request_body: None, - response_body: None, - stream_chunk: None, - log_message: None, - headers: None, - audit: Vec::new(), - } - } - - fn replace() -> Self { - Self { - action: HookAction::Replace, - message: None, - reason: None, - request_body: None, - response_body: None, - stream_chunk: None, - log_message: None, - headers: None, - audit: Vec::new(), - } - } - - pub fn replace_request_body(body: impl Into) -> Self { - Self { - request_body: Some(body.into()), - ..Self::replace() - } - } - - pub fn replace_response_body(body: impl Into) -> Self { - Self { - response_body: Some(body.into()), - ..Self::replace() - } - } - - pub fn replace_stream_chunk(chunk: impl Into) -> Self { - Self { - stream_chunk: Some(chunk.into()), - ..Self::replace() - } - } - - pub fn replace_log_message(message: impl Into) -> Self { - Self { - log_message: Some(message.into()), - ..Self::replace() - } - } - - pub fn with_header(mut self, name: impl Into, value: impl Into) -> Self { - self.headers - .get_or_insert_with(BTreeMap::new) - .insert(name.into(), value.into()); - self - } -} - -pub fn pack_ptr_len(ptr: u32, len: u32) -> u64 { - ((ptr as u64) << 32) | len as u64 -} - -pub fn unpack_ptr_len(value: u64) -> (u32, u32) { - ((value >> 32) as u32, value as u32) -} - -pub fn serialize_hook_result(result: &HookResult) -> Vec { - serde_json::to_vec(result) - .unwrap_or_else(|_| br#"{"action":"block","reason":"serialize error"}"#.to_vec()) -} - -pub fn handle_hook_bytes(input: &[u8], handler: F) -> Vec -where - F: FnOnce(HookRequest) -> HookResult, -{ - let result = match serde_json::from_slice::(input) { - Ok(request) => handler(request), - Err(error) => HookResult::block(format!("invalid hook request: {error}")), - }; - serialize_hook_result(&result) -} - -pub fn leak_output_bytes(bytes: Vec) -> u64 { - let len = bytes.len() as u32; - let ptr = Box::into_raw(bytes.into_boxed_slice()) as *mut u8 as u32; - pack_ptr_len(ptr, len) -} - -#[macro_export] -macro_rules! aio_plugin_entrypoint { - ($handler:path) => { - #[no_mangle] - pub extern "C" fn aio_plugin_handle(ptr: i32, len: i32) -> i64 { - let input = - unsafe { core::slice::from_raw_parts(ptr as *const u8, len.max(0) as usize) }; - let output = $crate::handle_hook_bytes(input, $handler); - $crate::leak_output_bytes(output) as i64 - } - }; -} diff --git a/packages/plugin-wasm-sdk/tests/sdk_contract.rs b/packages/plugin-wasm-sdk/tests/sdk_contract.rs deleted file mode 100644 index f6cc59ba..00000000 --- a/packages/plugin-wasm-sdk/tests/sdk_contract.rs +++ /dev/null @@ -1,86 +0,0 @@ -use aio_plugin_wasm_sdk::{ - handle_hook_bytes, pack_ptr_len, unpack_ptr_len, HookAction, HookRequest, HookResult, - PluginHook, PluginHostCompatibility, PluginManifest, PluginRuntime, -}; -use serde_json::json; - -#[test] -fn sdk_contract_serializes_manifest_and_hook_result_with_host_field_names() { - let manifest = PluginManifest { - id: "acme.redactor".to_string(), - name: "Acme Redactor".to_string(), - version: "0.1.0".to_string(), - api_version: "1.0.0".to_string(), - runtime: PluginRuntime::Wasm { - abi_version: "1.0.0".to_string(), - memory_limit_bytes: Some(16 * 1024 * 1024), - }, - hooks: vec![PluginHook { - name: "gateway.request.afterBodyRead".to_string(), - priority: Some(50), - failure_policy: Some("fail-open".to_string()), - }], - permissions: vec!["request.body.read".to_string()], - host_compatibility: PluginHostCompatibility { - app: ">=0.56.0 <1.0.0".to_string(), - plugin_api: "^1.0.0".to_string(), - platforms: Some(vec![ - "macos".to_string(), - "windows".to_string(), - "linux".to_string(), - ]), - }, - entry: Some("plugin.wasm".to_string()), - config_schema: None, - config_version: Some(1), - }; - - let json = serde_json::to_value(&manifest).expect("manifest serializes"); - assert_eq!(json["apiVersion"], "1.0.0"); - assert_eq!(json["runtime"]["kind"], "wasm"); - assert_eq!(json["runtime"]["abiVersion"], "1.0.0"); - assert_eq!(json["hostCompatibility"]["pluginApi"], "^1.0.0"); - - let result = HookResult::replace_request_body("{\"input\":\"[REDACTED]\"}"); - let result_json = serde_json::to_value(&result).expect("result serializes"); - assert_eq!(result_json["action"], "replace"); - assert_eq!(result_json["requestBody"], "{\"input\":\"[REDACTED]\"}"); - assert!(result_json.get("contextPatch").is_none()); -} - -#[test] -fn hook_result_serializes_host_mutation_fields() { - let result = HookResult::replace_request_body("{\"messages\":[]}"); - let json = serde_json::to_value(result).expect("serialize hook result"); - - assert_eq!(json["action"], "replace"); - assert_eq!(json["requestBody"], "{\"messages\":[]}"); - assert!(json.get("contextPatch").is_none()); -} - -#[test] -fn sdk_contract_handles_hook_json_without_host_capabilities() { - let request = HookRequest { - abi_version: "1.0.0".to_string(), - plugin_id: "acme.redactor".to_string(), - hook: "gateway.request.afterBodyRead".to_string(), - trace_id: Some("trace-1".to_string()), - config: json!({}), - context: json!({ "request": { "body": { "input": "secret" } } }), - }; - let input = serde_json::to_vec(&request).expect("request bytes"); - - let output = handle_hook_bytes(&input, |request| { - assert_eq!(request.plugin_id, "acme.redactor"); - HookResult::warn("redaction candidate") - }); - let result: HookResult = serde_json::from_slice(&output).expect("hook output"); - assert_eq!(result.action, HookAction::Warn); - assert_eq!(result.message.as_deref(), Some("redaction candidate")); -} - -#[test] -fn sdk_contract_packs_wasm_pointer_and_length() { - let packed = pack_ptr_len(123, 456); - assert_eq!(unpack_ptr_len(packed), (123, 456)); -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db1d5ea9..e53167e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,7 @@ overrides: picomatch@2.3.1: 2.3.2 picomatch@4.0.3: 4.0.4 rollup: ^4.60.1 + semver@6.3.1: 7.8.5 importers: @@ -46,36 +47,21 @@ importers: '@mdxeditor/editor': specifier: ^3.54.0 version: 3.54.0(@codemirror/language@6.12.1)(@lezer/highlight@1.2.3)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(yjs@13.6.30) - '@radix-ui/react-accordion': - specifier: ^1.2.12 - version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-dropdown-menu': - specifier: ^2.1.16 - version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-label': specifier: ^2.1.7 version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-popover': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-progress': - specifier: ^1.1.8 - version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-radio-group': specifier: ^1.3.8 version: 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-scroll-area': - specifier: ^1.2.10 - version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-select': specifier: ^2.2.6 version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-separator': - specifier: ^1.1.8 - version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-slot': specifier: ^1.2.3 version: 1.2.4(@types/react@19.2.7)(react@19.2.3) @@ -97,9 +83,6 @@ importers: '@tauri-apps/api': specifier: ^2 version: 2.9.1 - '@tauri-apps/plugin-clipboard-manager': - specifier: ^2 - version: 2.3.2 '@tauri-apps/plugin-dialog': specifier: ^2.6.0 version: 2.6.0 @@ -147,14 +130,14 @@ importers: version: 3.4.0 tailwindcss-animate: specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.4.19) + version: 1.0.7(tailwindcss@3.4.19(tsx@4.22.4)) zod: specifier: ^4.1.12 version: 4.3.6 devDependencies: '@tailwindcss/typography': specifier: ^0.5.19 - version: 0.5.19(tailwindcss@3.4.19) + version: 0.5.19(tailwindcss@3.4.19(tsx@4.22.4)) '@tauri-apps/cli': specifier: ^2 version: 2.9.6 @@ -175,10 +158,10 @@ importers: version: 19.2.3(@types/react@19.2.7) '@vitejs/plugin-react': specifier: ^4.6.0 - version: 4.7.0(vite@7.3.2(@types/node@24.13.1)(jiti@1.21.7)) + version: 4.7.0(vite@7.3.2(@types/node@24.13.1)(jiti@1.21.7)(tsx@4.22.4)) '@vitest/coverage-v8': - specifier: 3.2.4 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@24.13.1)(jiti@1.21.7)(jsdom@25.0.1)(msw@2.12.7(@types/node@24.13.1)(typescript@5.8.3))) + specifier: 4.1.9 + version: 4.1.9(vitest@4.1.9) autoprefixer: specifier: ^10.4.23 version: 10.4.23(postcss@8.5.6) @@ -202,7 +185,7 @@ importers: version: 3.7.4 tailwindcss: specifier: ^3.4.19 - version: 3.4.19 + version: 3.4.19(tsx@4.22.4) typescript: specifier: ~5.8.3 version: 5.8.3 @@ -211,10 +194,10 @@ importers: version: 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3) vite: specifier: ^7.3.2 - version: 7.3.2(@types/node@24.13.1)(jiti@1.21.7) + version: 7.3.2(@types/node@24.13.1)(jiti@1.21.7)(tsx@4.22.4) vitest: - specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@24.13.1)(jiti@1.21.7)(jsdom@25.0.1)(msw@2.12.7(@types/node@24.13.1)(typescript@5.8.3)) + specifier: ^4.1.9 + version: 4.1.9(@types/node@24.13.1)(@vitest/coverage-v8@4.1.9)(jsdom@25.0.1)(msw@2.12.7(@types/node@24.13.1)(typescript@5.8.3))(vite@7.3.2(@types/node@24.13.1)(jiti@1.21.7)(tsx@4.22.4)) packages/create-aio-plugin: dependencies: @@ -225,12 +208,15 @@ importers: '@types/node': specifier: ^24.0.0 version: 24.13.1 + tsx: + specifier: ^4.21.0 + version: 4.22.4 typescript: specifier: ~5.8.3 version: 5.8.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@24.13.1)(jiti@1.21.7)(jsdom@25.0.1)(msw@2.12.7(@types/node@24.13.1)(typescript@5.8.3)) + version: 3.2.4(@types/debug@4.1.13)(@types/node@24.13.1)(jiti@1.21.7)(jsdom@25.0.1)(msw@2.12.7(@types/node@24.13.1)(typescript@5.8.3))(tsx@4.22.4) packages/plugin-sdk: devDependencies: @@ -239,7 +225,7 @@ importers: version: 5.8.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@24.13.1)(jiti@1.21.7)(jsdom@25.0.1)(msw@2.12.7(@types/node@24.13.1)(typescript@5.8.3)) + version: 3.2.4(@types/debug@4.1.13)(@types/node@24.13.1)(jiti@1.21.7)(jsdom@25.0.1)(msw@2.12.7(@types/node@24.13.1)(typescript@5.8.3))(tsx@4.22.4) packages: @@ -250,10 +236,6 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@ampproject/remapping@2.3.0': - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} - '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} @@ -261,8 +243,8 @@ packages: resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} '@babel/compat-data@7.28.5': @@ -303,10 +285,18 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -320,6 +310,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} @@ -336,6 +331,10 @@ packages: resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -348,6 +347,10 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -513,156 +516,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.27.2': resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.27.2': resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.27.2': resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.27.2': resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.27.2': resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.27.2': resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.2': resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.27.2': resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.27.2': resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.27.2': resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.27.2': resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.27.2': resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.27.2': resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.27.2': resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.27.2': resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.27.2': resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.27.2': resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.2': resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.27.2': resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.2': resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.27.2': resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.27.2': resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.27.2': resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.27.2': resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.27.2': resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -793,14 +952,6 @@ packages: '@types/node': optional: true - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - - '@istanbuljs/schema@0.1.3': - resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} - engines: {node: '>=8'} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -981,10 +1132,6 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} - '@radix-ui/colors@3.0.0': resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==} @@ -994,19 +1141,6 @@ packages: '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} - '@radix-ui/react-accordion@1.2.12': - resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-arrow@1.1.7': resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} peerDependencies: @@ -1020,19 +1154,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-collapsible@1.1.12': - resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-collection@1.1.7': resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} peerDependencies: @@ -1064,15 +1185,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-context@1.1.3': - resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-dialog@1.1.15': resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} peerDependencies: @@ -1108,19 +1220,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-dropdown-menu@2.1.16': - resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-focus-guards@1.1.3': resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} peerDependencies: @@ -1170,19 +1269,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-menu@2.1.16': - resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-popover@1.1.15': resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} peerDependencies: @@ -1261,19 +1347,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-progress@1.1.8': - resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-radio-group@1.3.8': resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} peerDependencies: @@ -1300,19 +1373,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-scroll-area@1.2.10': - resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-select@2.2.6': resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} peerDependencies: @@ -1339,19 +1399,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-separator@1.1.8': - resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -1687,6 +1734,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} @@ -1794,9 +1844,6 @@ packages: engines: {node: '>= 10'} hasBin: true - '@tauri-apps/plugin-clipboard-manager@2.3.2': - resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==} - '@tauri-apps/plugin-dialog@2.6.0': resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==} @@ -1992,11 +2039,11 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@vitest/coverage-v8@3.2.4': - resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + '@vitest/coverage-v8@4.1.9': + resolution: {integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==} peerDependencies: - '@vitest/browser': 3.2.4 - vitest: 3.2.4 + '@vitest/browser': 4.1.9 + vitest: 4.1.9 peerDependenciesMeta: '@vitest/browser': optional: true @@ -2004,6 +2051,9 @@ packages: '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + '@vitest/mocker@3.2.4': resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} peerDependencies: @@ -2015,21 +2065,47 @@ packages: vite: optional: true + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + '@vitest/runner@3.2.4': resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + '@vitest/snapshot@3.2.4': resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2054,10 +2130,6 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -2066,10 +2138,6 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -2098,8 +2166,8 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - ast-v8-to-istanbul@0.3.11: - resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} + ast-v8-to-istanbul@1.0.4: + resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -2174,6 +2242,10 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2403,18 +2475,12 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -2430,6 +2496,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.2.0: + resolution: {integrity: sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -2454,6 +2523,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -2540,6 +2614,10 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + ext@1.7.0: resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} @@ -2594,10 +2672,6 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -2645,11 +2719,6 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true - globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -2793,17 +2862,10 @@ packages: resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} engines: {node: '>=10'} - istanbul-lib-source-maps@5.0.6: - resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} - engines: {node: '>=10'} - istanbul-reports@3.2.0: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jiti@1.21.7: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true @@ -2913,8 +2975,8 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magicast@0.3.5: - resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} @@ -3098,10 +3160,6 @@ packages: resolution: {integrity: sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==} engines: {node: '>=16 || 14 >=14.17'} - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} - engines: {node: '>=16 || 14 >=14.17'} - mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -3155,6 +3213,10 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3173,9 +3235,6 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3197,10 +3256,6 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} - path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -3474,15 +3529,16 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - semver@7.7.3: resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -3524,6 +3580,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + strict-event-emitter@0.4.6: resolution: {integrity: sha512-12KWeb+wixJohmnwNFerbyiBrAlq5qJLwIt38etRtKtmmHyDSoGlIqFE9wx+4IwG0aDjI7GV8tc8ZccjWZZtTg==} @@ -3534,10 +3593,6 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -3545,10 +3600,6 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.1.2: - resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} - engines: {node: '>=12'} - strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -3599,10 +3650,6 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - test-exclude@7.0.1: - resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} - engines: {node: '>=18'} - thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -3619,10 +3666,18 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinypool@1.1.1: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -3631,6 +3686,10 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tinyspy@4.0.4: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} @@ -3677,6 +3736,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3840,6 +3904,47 @@ packages: jsdom: optional: true + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -3886,10 +3991,6 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - ws@8.19.0: resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} @@ -3954,11 +4055,6 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@ampproject/remapping@2.3.0': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - '@asamuzakjp/css-color@3.2.0': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -3973,9 +4069,9 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/code-frame@7.29.0': + '@babel/code-frame@7.29.7': dependencies: - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 js-tokens: 4.0.0 picocolors: 1.1.1 @@ -3997,7 +4093,7 @@ snapshots: debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 - semver: 6.3.1 + semver: 7.8.5 transitivePeerDependencies: - supports-color @@ -4015,7 +4111,7 @@ snapshots: '@babel/helper-validator-option': 7.27.1 browserslist: 4.28.1 lru-cache: 5.1.1 - semver: 6.3.1 + semver: 7.8.5 '@babel/helper-globals@7.28.0': {} @@ -4039,8 +4135,12 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.28.4': @@ -4052,6 +4152,10 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -4064,6 +4168,8 @@ snapshots: '@babel/runtime@7.28.6': {} + '@babel/runtime@7.29.7': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -4087,6 +4193,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@bcoe/v8-coverage@1.0.2': {} '@codemirror/autocomplete@6.20.0': @@ -4431,81 +4542,159 @@ snapshots: '@esbuild/aix-ppc64@0.27.2': optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + '@esbuild/android-arm64@0.27.2': optional: true + '@esbuild/android-arm64@0.28.1': + optional: true + '@esbuild/android-arm@0.27.2': optional: true + '@esbuild/android-arm@0.28.1': + optional: true + '@esbuild/android-x64@0.27.2': optional: true + '@esbuild/android-x64@0.28.1': + optional: true + '@esbuild/darwin-arm64@0.27.2': optional: true + '@esbuild/darwin-arm64@0.28.1': + optional: true + '@esbuild/darwin-x64@0.27.2': optional: true + '@esbuild/darwin-x64@0.28.1': + optional: true + '@esbuild/freebsd-arm64@0.27.2': optional: true + '@esbuild/freebsd-arm64@0.28.1': + optional: true + '@esbuild/freebsd-x64@0.27.2': optional: true + '@esbuild/freebsd-x64@0.28.1': + optional: true + '@esbuild/linux-arm64@0.27.2': optional: true + '@esbuild/linux-arm64@0.28.1': + optional: true + '@esbuild/linux-arm@0.27.2': optional: true + '@esbuild/linux-arm@0.28.1': + optional: true + '@esbuild/linux-ia32@0.27.2': optional: true + '@esbuild/linux-ia32@0.28.1': + optional: true + '@esbuild/linux-loong64@0.27.2': optional: true + '@esbuild/linux-loong64@0.28.1': + optional: true + '@esbuild/linux-mips64el@0.27.2': optional: true + '@esbuild/linux-mips64el@0.28.1': + optional: true + '@esbuild/linux-ppc64@0.27.2': optional: true + '@esbuild/linux-ppc64@0.28.1': + optional: true + '@esbuild/linux-riscv64@0.27.2': optional: true + '@esbuild/linux-riscv64@0.28.1': + optional: true + '@esbuild/linux-s390x@0.27.2': optional: true + '@esbuild/linux-s390x@0.28.1': + optional: true + '@esbuild/linux-x64@0.27.2': optional: true + '@esbuild/linux-x64@0.28.1': + optional: true + '@esbuild/netbsd-arm64@0.27.2': optional: true + '@esbuild/netbsd-arm64@0.28.1': + optional: true + '@esbuild/netbsd-x64@0.27.2': optional: true + '@esbuild/netbsd-x64@0.28.1': + optional: true + '@esbuild/openbsd-arm64@0.27.2': optional: true + '@esbuild/openbsd-arm64@0.28.1': + optional: true + '@esbuild/openbsd-x64@0.27.2': optional: true + '@esbuild/openbsd-x64@0.28.1': + optional: true + '@esbuild/openharmony-arm64@0.27.2': optional: true + '@esbuild/openharmony-arm64@0.28.1': + optional: true + '@esbuild/sunos-x64@0.27.2': optional: true + '@esbuild/sunos-x64@0.28.1': + optional: true + '@esbuild/win32-arm64@0.27.2': optional: true + '@esbuild/win32-arm64@0.28.1': + optional: true + '@esbuild/win32-ia32@0.27.2': optional: true + '@esbuild/win32-ia32@0.28.1': + optional: true + '@esbuild/win32-x64@0.27.2': optional: true + '@esbuild/win32-x64@0.28.1': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@1.21.7))': dependencies: eslint: 9.39.2(jiti@1.21.7) @@ -4638,17 +4827,6 @@ snapshots: optionalDependencies: '@types/node': 24.13.1 - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.2 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - - '@istanbuljs/schema@0.1.3': {} - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5012,32 +5190,12 @@ snapshots: '@open-draft/until@2.1.0': {} - '@pkgjs/parseargs@0.11.0': - optional: true - '@radix-ui/colors@3.0.0': {} '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - optionalDependencies: - '@types/react': 19.2.7 - '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -5047,22 +5205,6 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.3) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - optionalDependencies: - '@types/react': 19.2.7 - '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) @@ -5087,12 +5229,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-context@1.1.3(@types/react@19.2.7)(react@19.2.3)': - dependencies: - react: 19.2.3 - optionalDependencies: - '@types/react': 19.2.7 - '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -5134,21 +5270,6 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.3) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - optionalDependencies: - '@types/react': 19.2.7 - '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.7)(react@19.2.3)': dependencies: react: 19.2.3 @@ -5186,32 +5307,6 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.3) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.7)(react@19.2.3) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.3) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.3) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3) - aria-hidden: 1.2.6 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - react-remove-scroll: 2.7.2(@types/react@19.2.7)(react@19.2.3) - optionalDependencies: - '@types/react': 19.2.7 - '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -5291,16 +5386,6 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@radix-ui/react-context': 1.1.3(@types/react@19.2.7)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - optionalDependencies: - '@types/react': 19.2.7 - '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -5336,23 +5421,6 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.3) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - optionalDependencies: - '@types/react': 19.2.7 - '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/number': 1.1.1 @@ -5391,15 +5459,6 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - optionalDependencies: - '@types/react': 19.2.7 - '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-slot@1.2.3(@types/react@19.2.7)(react@19.2.3)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) @@ -5658,14 +5717,16 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true + '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} '@stitches/core@1.2.8': {} - '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19)': + '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(tsx@4.22.4))': dependencies: postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.19 + tailwindcss: 3.4.19(tsx@4.22.4) '@tanstack/query-core@5.90.20': {} @@ -5731,10 +5792,6 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.9.6 '@tauri-apps/cli-win32-x64-msvc': 2.9.6 - '@tauri-apps/plugin-clipboard-manager@2.3.2': - dependencies: - '@tauri-apps/api': 2.9.1 - '@tauri-apps/plugin-dialog@2.6.0': dependencies: '@tauri-apps/api': 2.9.1 @@ -5753,8 +5810,8 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.28.6 + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -5970,7 +6027,7 @@ snapshots: '@typescript-eslint/types': 8.54.0 eslint-visitor-keys: 4.2.1 - '@vitejs/plugin-react@4.7.0(vite@7.3.2(@types/node@24.13.1)(jiti@1.21.7))': + '@vitejs/plugin-react@4.7.0(vite@7.3.2(@types/node@24.13.1)(jiti@1.21.7)(tsx@4.22.4))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -5978,28 +6035,23 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.3.2(@types/node@24.13.1)(jiti@1.21.7) + vite: 7.3.2(@types/node@24.13.1)(jiti@1.21.7)(tsx@4.22.4) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@24.13.1)(jiti@1.21.7)(jsdom@25.0.1)(msw@2.12.7(@types/node@24.13.1)(typescript@5.8.3)))': + '@vitest/coverage-v8@4.1.9(vitest@4.1.9)': dependencies: - '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 - ast-v8-to-istanbul: 0.3.11 - debug: 4.4.3 + '@vitest/utils': 4.1.9 + ast-v8-to-istanbul: 1.0.4 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 istanbul-reports: 3.2.0 - magic-string: 0.30.21 - magicast: 0.3.5 - std-env: 3.10.0 - test-exclude: 7.0.1 - tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.13)(@types/node@24.13.1)(jiti@1.21.7)(jsdom@25.0.1)(msw@2.12.7(@types/node@24.13.1)(typescript@5.8.3)) - transitivePeerDependencies: - - supports-color + magicast: 0.5.3 + obug: 2.1.3 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.9(@types/node@24.13.1)(@vitest/coverage-v8@4.1.9)(jsdom@25.0.1)(msw@2.12.7(@types/node@24.13.1)(typescript@5.8.3))(vite@7.3.2(@types/node@24.13.1)(jiti@1.21.7)(tsx@4.22.4)) '@vitest/expect@3.2.4': dependencies: @@ -6009,41 +6061,83 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(msw@2.12.7(@types/node@24.13.1)(typescript@5.8.3))(vite@7.3.2(@types/node@24.13.1)(jiti@1.21.7))': + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@3.2.4(msw@2.12.7(@types/node@24.13.1)(typescript@5.8.3))(vite@7.3.2(@types/node@24.13.1)(jiti@1.21.7)(tsx@4.22.4))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.7(@types/node@24.13.1)(typescript@5.8.3) - vite: 7.3.2(@types/node@24.13.1)(jiti@1.21.7) + vite: 7.3.2(@types/node@24.13.1)(jiti@1.21.7)(tsx@4.22.4) + + '@vitest/mocker@4.1.9(msw@2.12.7(@types/node@24.13.1)(typescript@5.8.3))(vite@7.3.2(@types/node@24.13.1)(jiti@1.21.7)(tsx@4.22.4))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.7(@types/node@24.13.1)(typescript@5.8.3) + vite: 7.3.2(@types/node@24.13.1)(jiti@1.21.7)(tsx@4.22.4) '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + '@vitest/runner@3.2.4': dependencies: '@vitest/utils': 3.2.4 pathe: 2.0.3 strip-literal: 3.1.0 + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + '@vitest/snapshot@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.4 + '@vitest/spy@4.1.9': {} + '@vitest/utils@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 loupe: 3.2.1 tinyrainbow: 2.0.0 + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -6063,16 +6157,12 @@ snapshots: ansi-regex@5.0.1: {} - ansi-regex@6.2.2: {} - ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 ansi-styles@5.2.0: {} - ansi-styles@6.2.3: {} - any-promise@1.3.0: {} anymatch@3.1.3: @@ -6096,7 +6186,7 @@ snapshots: assertion-error@2.0.1: {} - ast-v8-to-istanbul@0.3.11: + ast-v8-to-istanbul@1.0.4: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 @@ -6172,6 +6262,8 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -6379,14 +6471,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - eastasianwidth@0.2.0: {} - electron-to-chromium@1.5.267: {} emoji-regex@8.0.0: {} - emoji-regex@9.2.2: {} - entities@6.0.1: {} es-define-property@1.0.1: {} @@ -6395,6 +6483,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.2.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -6453,6 +6543,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} escape-carriage@1.3.1: {} @@ -6567,6 +6686,8 @@ snapshots: expect-type@1.3.0: {} + expect-type@1.4.0: {} + ext@1.7.0: dependencies: type: 2.7.3 @@ -6619,11 +6740,6 @@ snapshots: flatted@3.4.2: {} - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -6673,15 +6789,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@10.5.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.7 - minipass: 7.1.2 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - globals@14.0.0: {} gopd@1.2.0: {} @@ -6796,25 +6903,11 @@ snapshots: make-dir: 4.0.0 supports-color: 7.2.0 - istanbul-lib-source-maps@5.0.6: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - debug: 4.4.3 - istanbul-lib-coverage: 3.2.2 - transitivePeerDependencies: - - supports-color - istanbul-reports@3.2.0: dependencies: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - jiti@1.21.7: {} js-tokens@10.0.0: {} @@ -6918,15 +7011,15 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - magicast@0.3.5: + magicast@0.5.3: dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 source-map-js: 1.2.1 make-dir@4.0.0: dependencies: - semver: 7.7.3 + semver: 7.8.5 markdown-table@3.0.4: {} @@ -7355,8 +7448,6 @@ snapshots: dependencies: brace-expansion: 5.0.5 - minipass@7.1.2: {} - mri@1.2.0: {} ms@2.1.3: {} @@ -7410,6 +7501,8 @@ snapshots: object-hash@3.0.0: {} + obug@2.1.3: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -7431,8 +7524,6 @@ snapshots: dependencies: p-limit: 3.1.0 - package-json-from-dist@1.0.1: {} - parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -7457,11 +7548,6 @@ snapshots: path-parse@1.0.7: {} - path-scurry@1.11.1: - dependencies: - lru-cache: 10.4.3 - minipass: 7.1.2 - path-to-regexp@6.3.0: {} pathe@2.0.3: {} @@ -7490,12 +7576,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.6 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.22.4): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.6 + tsx: 4.22.4 postcss-nested@6.2.0(postcss@8.5.6): dependencies: @@ -7723,10 +7810,10 @@ snapshots: scheduler@0.27.0: {} - semver@6.3.1: {} - semver@7.7.3: {} + semver@7.8.5: {} + set-cookie-parser@2.7.2: {} shebang-command@2.0.0: @@ -7759,6 +7846,8 @@ snapshots: std-env@3.10.0: {} + std-env@4.1.0: {} + strict-event-emitter@0.4.6: {} strict-event-emitter@0.5.1: {} @@ -7769,12 +7858,6 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.1.2 - stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -7784,10 +7867,6 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.1.2: - dependencies: - ansi-regex: 6.2.2 - strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -7824,11 +7903,11 @@ snapshots: tailwind-merge@3.4.0: {} - tailwindcss-animate@1.0.7(tailwindcss@3.4.19): + tailwindcss-animate@1.0.7(tailwindcss@3.4.19(tsx@4.22.4)): dependencies: - tailwindcss: 3.4.19 + tailwindcss: 3.4.19(tsx@4.22.4) - tailwindcss@3.4.19: + tailwindcss@3.4.19(tsx@4.22.4): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -7847,7 +7926,7 @@ snapshots: postcss: 8.5.6 postcss-import: 15.1.0(postcss@8.5.6) postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.22.4) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 resolve: 1.22.11 @@ -7856,12 +7935,6 @@ snapshots: - tsx - yaml - test-exclude@7.0.1: - dependencies: - '@istanbuljs/schema': 0.1.3 - glob: 10.5.0 - minimatch: 9.0.7 - thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -7876,15 +7949,24 @@ snapshots: tinyexec@0.3.2: {} + tinyexec@1.2.4: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + tinypool@1.1.1: {} tinyrainbow@2.0.0: {} + tinyrainbow@3.1.0: {} + tinyspy@4.0.4: {} tldts-core@6.1.86: {} @@ -7923,6 +8005,12 @@ snapshots: tslib@2.8.1: {} + tsx@4.22.4: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -8033,13 +8121,13 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-node@3.2.4(@types/node@24.13.1)(jiti@1.21.7): + vite-node@3.2.4(@types/node@24.13.1)(jiti@1.21.7)(tsx@4.22.4): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.2(@types/node@24.13.1)(jiti@1.21.7) + vite: 7.3.2(@types/node@24.13.1)(jiti@1.21.7)(tsx@4.22.4) transitivePeerDependencies: - '@types/node' - jiti @@ -8054,7 +8142,7 @@ snapshots: - tsx - yaml - vite@7.3.2(@types/node@24.13.1)(jiti@1.21.7): + vite@7.3.2(@types/node@24.13.1)(jiti@1.21.7)(tsx@4.22.4): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.4) @@ -8066,12 +8154,13 @@ snapshots: '@types/node': 24.13.1 fsevents: 2.3.3 jiti: 1.21.7 + tsx: 4.22.4 - vitest@3.2.4(@types/debug@4.1.13)(@types/node@24.13.1)(jiti@1.21.7)(jsdom@25.0.1)(msw@2.12.7(@types/node@24.13.1)(typescript@5.8.3)): + vitest@3.2.4(@types/debug@4.1.13)(@types/node@24.13.1)(jiti@1.21.7)(jsdom@25.0.1)(msw@2.12.7(@types/node@24.13.1)(typescript@5.8.3))(tsx@4.22.4): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.12.7(@types/node@24.13.1)(typescript@5.8.3))(vite@7.3.2(@types/node@24.13.1)(jiti@1.21.7)) + '@vitest/mocker': 3.2.4(msw@2.12.7(@types/node@24.13.1)(typescript@5.8.3))(vite@7.3.2(@types/node@24.13.1)(jiti@1.21.7)(tsx@4.22.4)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -8089,8 +8178,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.2(@types/node@24.13.1)(jiti@1.21.7) - vite-node: 3.2.4(@types/node@24.13.1)(jiti@1.21.7) + vite: 7.3.2(@types/node@24.13.1)(jiti@1.21.7)(tsx@4.22.4) + vite-node: 3.2.4(@types/node@24.13.1)(jiti@1.21.7)(tsx@4.22.4) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.13 @@ -8110,6 +8199,35 @@ snapshots: - tsx - yaml + vitest@4.1.9(@types/node@24.13.1)(@vitest/coverage-v8@4.1.9)(jsdom@25.0.1)(msw@2.12.7(@types/node@24.13.1)(typescript@5.8.3))(vite@7.3.2(@types/node@24.13.1)(jiti@1.21.7)(tsx@4.22.4)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(msw@2.12.7(@types/node@24.13.1)(typescript@5.8.3))(vite@7.3.2(@types/node@24.13.1)(jiti@1.21.7)(tsx@4.22.4)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.2.0 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 7.3.2(@types/node@24.13.1)(jiti@1.21.7)(tsx@4.22.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.1 + '@vitest/coverage-v8': 4.1.9(vitest@4.1.9) + jsdom: 25.0.1 + transitivePeerDependencies: + - msw + w3c-keyname@2.2.8: {} w3c-xmlserializer@5.0.0: @@ -8152,12 +8270,6 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.1.2 - ws@8.19.0: {} xml-name-validator@5.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 42975c33..5a5097c2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,9 @@ packages: - . - packages/* +minimumReleaseAge: 10080 +trustPolicy: no-downgrade + allowBuilds: es5-ext: false esbuild: true @@ -20,3 +23,4 @@ overrides: picomatch@2.3.1: 2.3.2 picomatch@4.0.3: 4.0.4 rollup: ^4.60.1 + semver@6.3.1: 7.8.5 diff --git a/scripts/check-gateway-error-codes.mjs b/scripts/check-gateway-error-codes.mjs index e14e954d..ef40471a 100644 --- a/scripts/check-gateway-error-codes.mjs +++ b/scripts/check-gateway-error-codes.mjs @@ -36,7 +36,7 @@ function parseTsCodes(source) { function parseTsShortLabelCodes(source) { const block = source.match( - /export const GatewayErrorShortLabels\s*=\s*\{([\s\S]*?)\}\s*satisfies/ + /(?:export\s+)?const GatewayErrorShortLabels\s*=\s*\{([\s\S]*?)\}\s*satisfies/ ); if (!block) { throw new Error("Cannot parse GatewayErrorShortLabels object in TS file."); diff --git a/scripts/check-plugin-api-contract.mjs b/scripts/check-plugin-api-contract.mjs index d1429c70..ab388d9b 100644 --- a/scripts/check-plugin-api-contract.mjs +++ b/scripts/check-plugin-api-contract.mjs @@ -17,6 +17,12 @@ function readText(path) { return readFileSync(fullPath, "utf8"); } +function requirePathMissing(path, label) { + if (existsSync(join(repoRoot, path))) { + failures.push(`${path} must not exist as ${label}`); + } +} + function readJson(path) { const text = readText(path); if (!text) return null; @@ -59,96 +65,498 @@ function requireRegex(path, text, regex, label) { } } -function runtimeTokens(contract) { - return [...contract.communityRuntimes, ...contract.policyGatedRuntimes]; +function escapeRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -function officialRuntimeTokens(contract) { - return contract.officialRuntimes.flatMap((runtime) => runtime.split(":")); +function rustIntegerLiteral(value) { + return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, "_"); } -function snakeCase(value) { - return value.replace(/[A-Z]/g, (match) => `_${match.toLowerCase()}`); +function functionBody(text, functionName) { + const signature = new RegExp(`function\\s+${escapeRegex(functionName)}\\s*\\(`).exec(text); + if (!signature) return null; + const openBrace = text.indexOf("{", signature.index); + if (openBrace === -1) return null; + + let depth = 0; + for (let index = openBrace; index < text.length; index += 1) { + const char = text[index]; + if (char === "{") depth += 1; + if (char === "}") { + depth -= 1; + if (depth === 0) { + return text.slice(openBrace + 1, index); + } + } + } + return null; +} + +function requireObject(path, value) { + if (value == null || typeof value !== "object" || Array.isArray(value)) { + failures.push(`${path} must be an object`); + return null; + } + return value; +} + +function requireArray(path, value) { + if (!Array.isArray(value)) { + failures.push(`${path} must be an array`); + return []; + } + return value; +} + +function requireUniqueArray(path, values) { + const seen = new Set(); + for (const value of values) { + if (seen.has(value)) { + failures.push(`${path} contains duplicate ${value}`); + } + seen.add(value); + } +} + +function requireArrayEquals(path, values, expected) { + const array = requireArray(path, values); + if (array.length !== expected.length || array.some((value, index) => value !== expected[index])) { + failures.push(`${path} must equal [${expected.join(", ")}]`); + } + return array; +} + +function requireOneOf(path, value, allowed) { + if (!allowed.includes(value)) { + failures.push(`${path} must be one of ${allowed.join(", ")}`); + } } const contractPath = "docs/plugins/plugin-api-v1-contract.json"; const contract = readJson(contractPath); if (contract) { + const runtimes = requireObject(`${contractPath}.runtimes`, contract.runtimes) ?? {}; + for (const legacyRuntime of ["wasm", "process", "native"]) { + if (Object.prototype.hasOwnProperty.call(runtimes, legacyRuntime)) { + failures.push(`${contractPath}.runtimes must not expose ${legacyRuntime}`); + } + } + const extensionHostRuntime = requireObject( + `${contractPath}.runtimes.extensionHost`, + runtimes.extensionHost + ); + if (extensionHostRuntime) { + if (extensionHostRuntime.language !== "typescript") { + failures.push(`${contractPath}.runtimes.extensionHost.language must be typescript`); + } + if (extensionHostRuntime.requiresMain !== true) { + failures.push(`${contractPath}.runtimes.extensionHost.requiresMain must be true`); + } + requireIncludes( + `${contractPath}.runtimes.extensionHost`, + JSON.stringify(extensionHostRuntime), + ["mainOutput", "allowedMainExtensions", "lifecycle", "hookTimeoutMs", "dispose"], + "Extension Host runtime lifecycle" + ); + } + const extensionHostContract = requireObject( + `${contractPath}.extensionHostContract`, + contract.extensionHostContract + ); + if (extensionHostContract) { + if (extensionHostContract.runtime !== "extensionHost") { + failures.push(`${contractPath}.extensionHostContract.runtime must be extensionHost`); + } + if (extensionHostContract.language !== "typescript") { + failures.push(`${contractPath}.extensionHostContract.language must be typescript`); + } + if (extensionHostContract.requiresMain !== true) { + failures.push(`${contractPath}.extensionHostContract.requiresMain must be true`); + } + requireIncludes( + `${contractPath}.extensionHostContract`, + JSON.stringify(extensionHostContract), + [ + "entryField", + "mainOutput", + "supportedSourceLanguages", + "api.gateway.registerHook", + "api.privacy.redactRequestBody", + ], + "Extension Host contract fields" + ); + } + requireArrayEquals(`${contractPath}.communityRuntimes`, contract.communityRuntimes, [ + "extensionHost", + ]); + const unsupportedLegacyRuntimes = requireArray( + `${contractPath}.unsupportedLegacyRuntimes`, + contract.unsupportedLegacyRuntimes + ); + for (const legacyRuntime of ["wasm", "process", "native"]) { + if (!unsupportedLegacyRuntimes.includes(legacyRuntime)) { + failures.push(`${contractPath}.unsupportedLegacyRuntimes must include ${legacyRuntime}`); + } + } + if (contract.policyGatedRuntimes !== undefined) { + failures.push(`${contractPath}.policyGatedRuntimes must not expose public community runtimes`); + } + const capabilities = requireArray(`${contractPath}.capabilities`, contract.capabilities); + const reservedHostMediatedLabels = ["network.fetch", "file.read", "file.write", "secret.read"]; + for (const label of reservedHostMediatedLabels) { + if (capabilities.includes(label)) { + failures.push( + `${contractPath}.capabilities must not include reserved host-mediated label ${label}` + ); + } + if ((contract.activePermissions ?? []).includes(label)) { + failures.push( + `${contractPath}.activePermissions must not include reserved host-mediated label ${label}` + ); + } + } + for (const capability of [ + "gateway.hooks", + "protocol.bridge", + "commands.execute", + "provider.extensionValues", + "privacy.redact", + ]) { + if (!capabilities.includes(capability)) { + failures.push(`${contractPath}.capabilities must include ${capability}`); + } + } + const contributionPoints = requireArray( + `${contractPath}.contributionPoints`, + contract.contributionPoints + ); + for (const contribution of ["gatewayHooks", "protocolBridges", "commands"]) { + if (!contributionPoints.includes(contribution)) { + failures.push(`${contractPath}.contributionPoints must include ${contribution}`); + } + } + const capabilityDependencies = + requireObject(`${contractPath}.capabilityDependencies`, contract.capabilityDependencies) ?? {}; + const expectedCapabilityDependencies = { + commands: ["commands.execute"], + providers: ["provider.extensionValues"], + "ui.providers.editor.sections": ["provider.extensionValues"], + "ui.providers.editor.fields": ["provider.extensionValues"], + "ui.buttonCommandFields": ["commands.execute"], + gatewayHooks: ["gateway.hooks"], + protocolBridges: ["protocol.bridge"], + }; + for (const key of Object.keys(capabilityDependencies)) { + if (!Object.prototype.hasOwnProperty.call(expectedCapabilityDependencies, key)) { + failures.push(`${contractPath}.capabilityDependencies must not include ${key}`); + } + } + for (const [contribution, requiredCapabilities] of Object.entries( + expectedCapabilityDependencies + )) { + requireArrayEquals( + `${contractPath}.capabilityDependencies.${contribution}`, + capabilityDependencies[contribution], + requiredCapabilities + ); + } + const protocolBridgeContribution = requireObject( + `${contractPath}.protocolBridgeContribution`, + contract.protocolBridgeContribution + ); + if (protocolBridgeContribution) { + if (protocolBridgeContribution.status !== "mvp-skeleton") { + failures.push(`${contractPath}.protocolBridgeContribution.status must be mvp-skeleton`); + } + if ( + typeof protocolBridgeContribution.executionBoundary !== "string" || + !protocolBridgeContribution.executionBoundary.includes("future host integration") + ) { + failures.push( + `${contractPath}.protocolBridgeContribution.executionBoundary must describe future host integration` + ); + } + } + + const matrix = requireObject(`${contractPath}.hookMatrix`, contract.hookMatrix) ?? {}; + for (const hook of contract.activeHooks ?? []) { + const entry = requireObject(`hookMatrix.${hook}`, matrix[hook]); + if (!entry) continue; + requireOneOf(`hookMatrix.${hook}.kind`, entry.kind, ["request", "response", "stream", "log"]); + requireOneOf(`hookMatrix.${hook}.status`, entry.status, ["active", "reserved"]); + if (entry.status !== "active") { + failures.push(`hookMatrix.${hook}.status must be active`); + } + const readPermissions = requireArray( + `hookMatrix.${hook}.readPermissions`, + entry.readPermissions + ); + const writePermissions = requireArray( + `hookMatrix.${hook}.writePermissions`, + entry.writePermissions + ); + requireUniqueArray(`hookMatrix.${hook}.readPermissions`, readPermissions); + requireUniqueArray(`hookMatrix.${hook}.writePermissions`, writePermissions); + const permissionDependencies = + requireObject(`hookMatrix.${hook}.permissionDependencies`, entry.permissionDependencies) ?? + {}; + for (const [permission, requires] of Object.entries(permissionDependencies)) { + if (!writePermissions.includes(permission)) { + failures.push( + `hookMatrix.${hook}.permissionDependencies.${permission} must be a write permission` + ); + } + const requiredPermissions = requireArray( + `hookMatrix.${hook}.permissionDependencies.${permission}`, + requires + ); + for (const requiredPermission of requiredPermissions) { + if (!readPermissions.includes(requiredPermission)) { + failures.push( + `hookMatrix.${hook}.permissionDependencies.${permission} requires unknown read permission ${requiredPermission}` + ); + } + } + } + const mutationFields = requireArray(`hookMatrix.${hook}.mutationFields`, entry.mutationFields); + const contextFields = requireArray(`hookMatrix.${hook}.contextFields`, entry.contextFields); + requireUniqueArray(`hookMatrix.${hook}.mutationFields`, mutationFields); + requireUniqueArray(`hookMatrix.${hook}.contextFields`, contextFields); + if (entry.defaultFailurePolicy !== contract.defaultFailurePolicy) { + failures.push(`hookMatrix.${hook}.defaultFailurePolicy must equal defaultFailurePolicy`); + } + if (entry.timeoutMs !== contract.defaultHookTimeoutMs) { + failures.push(`hookMatrix.${hook}.timeoutMs must equal defaultHookTimeoutMs`); + } + requireOneOf(`hookMatrix.${hook}.reservedHeaderPolicy`, entry.reservedHeaderPolicy, [ + "block-gateway-owned", + ]); + } + const sdk = readText("packages/plugin-sdk/src/index.ts"); - requireIncludes("packages/plugin-sdk/src/index.ts", sdk, contract.activeHooks, "active hook"); - requireIncludes("packages/plugin-sdk/src/index.ts", sdk, contract.reservedHooks, "reserved hook"); + const generatedBindings = readText("src/generated/bindings.ts"); requireIncludes( - "packages/plugin-sdk/src/index.ts", - sdk, - contract.activePermissions, - "active permission" + "src/generated/bindings.ts", + generatedBindings, + ['kind: "extensionHost"'], + "generated Extension Host runtime" ); - requireIncludes( - "packages/plugin-sdk/src/index.ts", - sdk, - contract.reservedPermissions, - "reserved permission" + requireNotIncludes( + "src/generated/bindings.ts", + generatedBindings, + ['kind: "native"', 'kind: "wasm"', 'kind: "process"'], + "public runtime" + ); + const pluginModules = readText("src-tauri/src/app/plugins/mod.rs"); + requireNotIncludes( + "src-tauri/src/app/plugins/mod.rs", + pluginModules, + ["mod process_runtime", "mod wasm_runtime"], + "alternate plugin runtime module" + ); + const cargoToml = readText("src-tauri/Cargo.toml"); + requireNotIncludes( + "src-tauri/Cargo.toml", + cargoToml, + ["wasmtime", "wat ="], + "WASM runtime dependency" + ); + const packageJson = readText("package.json"); + requireNotIncludes( + "package.json", + packageJson, + ["plugin-wasm-sdk:test"], + "WASM SDK script" + ); + requirePathMissing("packages/plugin-wasm-sdk", "WASM SDK package"); + const pluginService = readText("src/services/plugins.ts"); + requireNotIncludes( + "src/services/plugins.ts", + pluginService, + ["pluginGrantPermissions", "pluginRevokePermission", "plugin_grant_permissions", "plugin_revoke_permission"], + "manual permission frontend service wrapper" + ); + const pluginQuery = readText("src/query/plugins.ts"); + requireNotIncludes( + "src/query/plugins.ts", + pluginQuery, + ["usePluginGrantPermissionsMutation", "usePluginRevokePermissionMutation"], + "manual permission query mutation" ); - requireIncludes("packages/plugin-sdk/src/index.ts", sdk, runtimeTokens(contract), "runtime"); requireIncludes( "packages/plugin-sdk/src/index.ts", sdk, - contract.activeMutationFields ?? [], - "active mutation field" + [ + "export type ExtensionRuntime", + 'kind: "extensionHost"', + "export type PluginRuntime = ExtensionRuntime", + "export type PluginCapability", + '"gateway.hooks"', + '"privacy.redact"', + '"protocol.bridge"', + "export type GatewayHookContribution", + "export type ProtocolBridgeContribution", + "export type PrivacyApi", + "gatewayHooks?: GatewayHookContribution[]", + "protocolBridges?: ProtocolBridgeContribution[]", + "validateCapabilityDependencies", + ], + "Extension Host SDK contract" ); + const capabilityDependencyBody = functionBody(sdk, "validateCapabilityDependencies"); + if (capabilityDependencyBody == null) { + failures.push( + "packages/plugin-sdk/src/index.ts is missing validateCapabilityDependencies body" + ); + } else { + requireIncludes( + "packages/plugin-sdk/src/index.ts", + capabilityDependencyBody, + [ + "requireCapability", + '"commands.execute"', + "commands contribution", + '"provider.extensionValues"', + "provider contribution", + '"providers.editor.sections"', + "providers.editor.sections UI contribution", + '"providers.editor.fields"', + "providers.editor.fields UI contribution", + "uiHasButtonCommand", + "UI command field", + '"gateway.hooks"', + "gatewayHooks contribution", + '"protocol.bridge"', + "protocolBridges contribution", + ], + "capability dependency validation" + ); + } const scaffold = readText("packages/create-aio-plugin/src/scaffold.ts"); requireIncludes( "packages/create-aio-plugin/src/scaffold.ts", scaffold, - contract.communityRuntimes, - "community runtime" + [ + 'main: "dist/extension.js"', + 'runtime: { kind: "extensionHost", language: "typescript" }', + 'hostCompatibility: { app: ">=0.60.0 <1.0.0", pluginApi: "^1.0.0" }', + 'capabilities: ["gateway.hooks"]', + "contributes", + "gatewayHooks:", + "api.gateway.registerHook", + 'capabilities = ["commands.execute"]', + ], + "Extension Host scaffold package shape" ); + + const devtools = readText("packages/create-aio-plugin/src/devtools.ts"); requireIncludes( - "packages/create-aio-plugin/src/scaffold.ts", - scaffold, - contract.policyGatedRuntimes, - "policy-gated runtime" + "packages/create-aio-plugin/src/devtools.ts", + devtools, + [ + "doctorPluginFiles", + "validatePluginFilesStrict", + "packPluginBytes", + "publishCheckPluginBytes", + "normalizeExtensionMainPath", + "storedZipEntryNames", + "dist/extension.js", + "gateway.hooks", + "contributes.gatewayHooks", + "PLUGIN_INVALID_MAIN", + "PLUGIN_UNSUPPORTED_LEGACY_RUNTIME", + "PLUGIN_REPLAY_UNSUPPORTED", + ], + "Extension Host developer tool package shape" + ); + requireNotIncludes( + "packages/create-aio-plugin/src/devtools.ts", + devtools, + ["contextPatch"], + "legacy mutation field" ); + + const rustContract = readText("src-tauri/src/gateway/plugins/contract.rs"); requireIncludes( - "packages/create-aio-plugin/src/scaffold.ts", - scaffold, - ["gateway.request.afterBodyRead", "request.body.read", "request.body.write"], - "default scaffold contract token" + "src-tauri/src/gateway/plugins/contract.rs", + rustContract, + contract.activeHooks, + "active hook" + ); + requireIncludes( + "src-tauri/src/gateway/plugins/contract.rs", + rustContract, + contract.reservedHooks, + "reserved hook" + ); + requireIncludes( + "src-tauri/src/gateway/plugins/contract.rs", + rustContract, + contract.activePermissions, + "active permission" + ); + requireIncludes( + "src-tauri/src/gateway/plugins/contract.rs", + rustContract, + contract.reservedPermissions, + "reserved permission" ); const rust = readText("src-tauri/src/domain/plugins.rs"); - requireIncludes("src-tauri/src/domain/plugins.rs", rust, contract.activeHooks, "active hook"); - requireIncludes("src-tauri/src/domain/plugins.rs", rust, contract.reservedHooks, "reserved hook"); requireIncludes( "src-tauri/src/domain/plugins.rs", rust, - contract.activePermissions, - "active permission" + [...contract.activePermissions, ...contract.reservedPermissions], + "permission risk" ); requireIncludes( "src-tauri/src/domain/plugins.rs", rust, - contract.reservedPermissions, - "reserved permission" + [ + "crate::gateway::plugins::contract::is_active_hook", + "crate::gateway::plugins::contract::is_reserved_hook", + "crate::gateway::plugins::contract::hook_contract", + "providers.editor.sections", + "providers.editor.fields", + "UI command field", + ], + "contract metadata call-through" ); requireIncludesCaseInsensitive( "src-tauri/src/domain/plugins.rs", rust, - [...runtimeTokens(contract), ...officialRuntimeTokens(contract)], + ["extensionHost"], "runtime" ); + requireIncludes( + "src-tauri/src/gateway/plugins/contract.rs", + readText("src-tauri/src/gateway/plugins/contract.rs"), + [ + `DEFAULT_HOOK_TIMEOUT_MS: u64 = ${rustIntegerLiteral(contract.defaultHookTimeoutMs)}`, + 'DEFAULT_FAILURE_POLICY: &str = "fail-open"', + "timeout_ms: DEFAULT_HOOK_TIMEOUT_MS", + "default_failure_policy: DEFAULT_FAILURE_POLICY", + ], + "default hook contract policy" + ); requireIncludes( "src-tauri/src/gateway/plugins/pipeline.rs", readText("src-tauri/src/gateway/plugins/pipeline.rs"), - [`Duration::from_millis(${contract.defaultHookTimeoutMs})`, "FailurePolicy::FailOpen"], - "default hook policy" + ["Duration::from_millis(DEFAULT_HOOK_TIMEOUT_MS)", "FailurePolicy::FailOpen"], + "default hook pipeline policy" ); const manifestSpec = readText("docs/plugin-manifest-v1.md"); requireIncludes("docs/plugin-manifest-v1.md", manifestSpec, contract.activeHooks, "active hook"); - requireIncludes("docs/plugin-manifest-v1.md", manifestSpec, contract.reservedHooks, "reserved hook"); + requireIncludes( + "docs/plugin-manifest-v1.md", + manifestSpec, + contract.reservedHooks, + "reserved hook" + ); requireIncludes( "docs/plugin-manifest-v1.md", manifestSpec, @@ -161,6 +569,21 @@ if (contract) { contract.reservedPermissions, "reserved permission" ); + for (const hook of contract.activeHooks ?? []) { + const entry = matrix?.[hook]; + if (!entry) continue; + const timeoutToken = `${entry.timeoutMs} ms`; + const hookRow = manifestSpec.split("\n").find((line) => line.includes(`| \`${hook}\``)); + if (!hookRow) { + failures.push(`docs/plugin-manifest-v1.md is missing hook table row for ${hook}`); + continue; + } + if (!hookRow.includes(timeoutToken)) { + failures.push( + `docs/plugin-manifest-v1.md hook ${hook} row must include timeout ${timeoutToken}` + ); + } + } const hooksDocPath = "docs/plugins/reference/hooks.md"; const hooksDoc = readText(hooksDocPath); @@ -182,19 +605,6 @@ if (contract) { "reserved permission" ); - const manifestGuidePath = "docs/plugins/reference/manifest.md"; - const manifestGuide = readText(manifestGuidePath); - requireIncludes( - manifestGuidePath, - manifestGuide, - [...runtimeTokens(contract), ...officialRuntimeTokens(contract)], - "runtime" - ); - - const wasmGuidePath = "docs/plugins/runtime/wasm.md"; - const wasmGuide = readText(wasmGuidePath); - requireIncludes(wasmGuidePath, wasmGuide, ["wasm", "PLUGIN_RUNTIME_DISABLED"], "WASM policy token"); - requireRegex( "packages/plugin-sdk/src/index.ts", sdk, @@ -216,36 +626,27 @@ if (contract) { requireRegex( "src-tauri/src/domain/plugins.rs", rust, - /pub fn is_reserved_gateway_hook\(hook: &str\)([\s\S]*?)pub fn is_reserved_permission/, + /pub fn is_reserved_gateway_hook\(hook: &str\)([\s\S]*?)\}/, "reserved hook validation helper" ); requireIncludes( "src-tauri/src/domain/plugins.rs", rust, - ["PLUGIN_RESERVED_HOOK", "PLUGIN_RESERVED_PERMISSION"], + ["PLUGIN_RESERVED_HOOK"], "reserved validation error" ); - requireNotIncludes("packages/plugin-sdk/src/index.ts", sdk, ["contextPatch"], "legacy mutation field"); + requireNotIncludes( + "packages/plugin-sdk/src/index.ts", + sdk, + ["contextPatch"], + "legacy mutation field" + ); requireNotIncludes( "packages/create-aio-plugin/src/scaffold.ts", scaffold, ["contextPatch"], "legacy mutation field" ); - - const wasmSdk = readText("packages/plugin-wasm-sdk/src/lib.rs"); - requireIncludes( - "packages/plugin-wasm-sdk/src/lib.rs", - wasmSdk, - (contract.activeMutationFields ?? []).map(snakeCase), - "active mutation field" - ); - requireIncludes( - "packages/plugin-wasm-sdk/src/lib.rs", - wasmSdk, - ['#[serde(rename_all = "camelCase")]'], - "camelCase serde ABI" - ); } if (failures.length > 0) { diff --git a/scripts/check-plugin-api-contract.selftest.mjs b/scripts/check-plugin-api-contract.selftest.mjs index 35f4e554..f6816ec7 100644 --- a/scripts/check-plugin-api-contract.selftest.mjs +++ b/scripts/check-plugin-api-contract.selftest.mjs @@ -1,63 +1,1095 @@ import { spawnSync } from "node:child_process"; -import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -const root = join(tmpdir(), `aio-plugin-contract-${Date.now()}`); -mkdirSync(join(root, "docs/plugins"), { recursive: true }); -mkdirSync(join(root, "docs/plugins/reference"), { recursive: true }); -mkdirSync(join(root, "docs/plugins/runtime"), { recursive: true }); -mkdirSync(join(root, "packages/plugin-sdk/src"), { recursive: true }); -mkdirSync(join(root, "packages/create-aio-plugin/src"), { recursive: true }); -mkdirSync(join(root, "src-tauri/src/domain"), { recursive: true }); - -writeFileSync( - join(root, "docs/plugins/plugin-api-v1-contract.json"), - JSON.stringify( - { - apiVersion: "1.0.0", - defaultHookTimeoutMs: 150, - defaultFailurePolicy: "fail-open", - activeHooks: ["gateway.request.afterBodyRead"], - reservedHooks: ["gateway.response.headers"], - activeMutationFields: ["requestBody"], - configSchemaTypes: ["object"], - activePermissions: ["request.body.read"], - reservedPermissions: ["network.fetch"], - communityRuntimes: ["declarativeRules"], - policyGatedRuntimes: ["wasm"], - officialRuntimes: ["native:privacyFilter"], - }, - null, - 2 +function writeJson(root, path, value) { + const output = + path === "docs/plugins/plugin-api-v1-contract.json" ? withContractDefaults(value) : value; + writeFileSync(join(root, path), JSON.stringify(output, null, 2)); +} + +function withContractDefaults(value) { + const mergeUnique = (left, right) => [...new Set([...(left ?? []), ...(right ?? [])])]; + return { + ...value, + runtimes: { + extensionHost: { + language: "typescript", + requiresMain: true, + mainOutput: "bundled JavaScript or CommonJS file", + allowedMainExtensions: [".js", ".cjs"], + lifecycle: { + hookTimeoutMs: 150, + dispose: "host-managed", + }, + status: "mainline-contract", + }, + ...(value.runtimes ?? {}), + }, + extensionHostContract: { + runtime: "extensionHost", + language: "typescript", + requiresMain: true, + entryField: "main", + mainOutput: "dist/extension.js", + supportedSourceLanguages: ["typescript", "javascript"], + lifecycle: { + gatewayRegistration: "api.gateway.registerHook", + privacyRedaction: "api.privacy.redactRequestBody", + }, + status: "mainline-contract", + ...(value.extensionHostContract ?? {}), + }, + capabilities: mergeUnique( + ["gateway.hooks", "protocol.bridge", "commands.execute", "provider.extensionValues", "privacy.redact"], + value.capabilities + ), + contributionPoints: mergeUnique( + ["gatewayHooks", "protocolBridges", "commands"], + value.contributionPoints + ), + protocolBridgeContribution: { + requiredFields: ["bridgeType", "inboundProtocol", "outboundProtocol"], + optionalFields: ["supportsStreaming"], + status: "mvp-skeleton", + executionBoundary: + "manifest declaration, capability dependency, and install preview only; full protocol bridge execution is future host integration", + ...(value.protocolBridgeContribution ?? {}), + }, + }; +} + +function makeRoot(name) { + const root = join(tmpdir(), `aio-plugin-contract-${name}-${Date.now()}`); + mkdirSync(join(root, "docs/plugins"), { recursive: true }); + mkdirSync(join(root, "docs/plugins/reference"), { recursive: true }); + mkdirSync(join(root, "docs/plugins/runtime"), { recursive: true }); + mkdirSync(join(root, "packages/plugin-sdk/src"), { recursive: true }); + mkdirSync(join(root, "packages/create-aio-plugin/src"), { recursive: true }); + mkdirSync(join(root, "src/services"), { recursive: true }); + mkdirSync(join(root, "src/query"), { recursive: true }); + mkdirSync(join(root, "src/generated"), { recursive: true }); + mkdirSync(join(root, "src-tauri/src/app/plugins"), { recursive: true }); + mkdirSync(join(root, "src-tauri/src/domain"), { recursive: true }); + mkdirSync(join(root, "src-tauri/src/gateway/plugins"), { recursive: true }); + writeFileSync( + join(root, "src/generated/bindings.ts"), + 'export type PluginRuntime = { kind: "extensionHost"; language: string };\n' + ); + writeFileSync( + join(root, "src-tauri/src/app/plugins/mod.rs"), + "pub(crate) mod extension_host;\npub(crate) mod extension_host_process;\n" + ); + writeFileSync( + join(root, "src-tauri/Cargo.toml"), + "[dependencies]\nrquickjs = \"0.12.0\"\n[dev-dependencies]\ntempfile = \"3\"\n" + ); + writeFileSync(join(root, "package.json"), "{ \"scripts\": {} }\n"); + writeFileSync(join(root, "src/services/plugins.ts"), "export function pluginEnable() {}\n"); + writeFileSync(join(root, "src/query/plugins.ts"), "export function usePluginEnableMutation() {}\n"); + return root; +} + +function runCheck(root) { + return spawnSync("node", ["scripts/check-plugin-api-contract.mjs"], { + cwd: process.cwd(), + env: { ...process.env, AIO_PLUGIN_CONTRACT_TEST_ROOT: root }, + encoding: "utf8", + }); +} + +function writePassingDevtools(root) { + writeFileSync( + join(root, "packages/create-aio-plugin/src/devtools.ts"), + [ + "doctorPluginFiles validatePluginFilesStrict packPluginBytes publishCheckPluginBytes", + "normalizeExtensionMainPath storedZipEntryNames", + "dist/extension.js gateway.hooks contributes.gatewayHooks", + "PLUGIN_INVALID_MAIN PLUGIN_UNSUPPORTED_LEGACY_RUNTIME PLUGIN_REPLAY_UNSUPPORTED", + ].join("\n") + ); +} + +function writePassingManifestDocs(root) { + writeFileSync( + join(root, "docs/plugin-manifest-v1.md"), + [ + "| `gateway.request.afterBodyRead` | phase | mutation | 150 ms | fail-open | host mediated |", + "| `gateway.request.beforeSend` | phase | mutation | 150 ms | fail-open | host mediated |", + "| `gateway.response.chunk` | phase | mutation | 150 ms | fail-open | host mediated |", + "gateway.response.headers", + "request.meta.read request.header.read request.header.readSensitive", + "request.body.read request.body.write stream.inspect stream.modify network.fetch", + ].join("\n") + ); + writeFileSync( + join(root, "docs/plugins/reference/hooks.md"), + "gateway.request.afterBodyRead gateway.request.beforeSend gateway.response.chunk gateway.response.headers" + ); + writeFileSync( + join(root, "docs/plugins/reference/permissions.md"), + "request.meta.read request.header.read request.header.readSensitive request.body.read request.body.write stream.inspect stream.modify network.fetch" + ); + writeFileSync( + join(root, "docs/plugins/reference/manifest.md"), + "extensionHost wasm process native privacyFilter" + ); +} + +function writePassingScaffold(root) { + writeFileSync( + join(root, "packages/plugin-sdk/src/index.ts"), + [ + [ + "gateway.request.afterBodyRead", + "gateway.request.beforeSend", + "gateway.response.chunk", + "gateway.response.after", + "gateway.error", + "log.beforePersist", + "gateway.response.headers", + ].join(" "), + [ + "request.meta.read", + "request.header.read", + "request.header.readSensitive", + "request.header.write", + "request.body.read", + "request.body.write", + "response.header.read", + "response.header.write", + "response.body.read", + "response.body.write", + "stream.inspect", + "stream.modify", + "log.redact", + "network.fetch", + ].join(" "), + "requestBody responseBody streamChunk logMessage headers", + 'export type ExtensionRuntime = { kind: "extensionHost"; language: "typescript" };', + "export type PluginRuntime = ExtensionRuntime;", + 'export type PluginCapability = "gateway.hooks" | "protocol.bridge" | "commands.execute" | "provider.extensionValues" | "privacy.redact";', + "export type GatewayHookContribution = { name: string };", + "export type ProtocolBridgeContribution = { bridgeType: string };", + "export type PrivacyApi = { redactText(text: string): unknown; redactRequestBody(body: string): unknown };", + "type PluginContributes = { commands?: unknown[]; providers?: unknown[]; gatewayHooks?: GatewayHookContribution[]; protocolBridges?: ProtocolBridgeContribution[]; ui?: Record };", + [ + "export type ActiveGatewayHookName =", + "'gateway.request.afterBodyRead' |", + "'gateway.request.beforeSend' |", + "'gateway.response.chunk' |", + "'gateway.response.after' |", + "'gateway.error' |", + "'log.beforePersist';", + ].join(" "), + "export type ReservedGatewayHookName = 'gateway.response.headers';", + "export type GatewayHookName = ActiveGatewayHookName | ReservedGatewayHookName;", + "type PluginManifest = { permissions: string[]; hooks: { name: string }[] };", + "function validateManifest(manifest: PluginManifest) {", + " return validatePermissionSet(manifest);", + "}", + "function validatePermissionSet(manifest: PluginManifest) {", + " const set = new Set(manifest.permissions);", + " const hooks = new Set(manifest.hooks.map((hook) => hook.name));", + " if (hooks.has('gateway.request.afterBodyRead') && set.has('request.body.write') && !set.has('request.body.read')) return 'request.body.write requires request.body.read';", + " if (hooks.has('gateway.response.after') && set.has('response.body.write') && !set.has('response.body.read')) return 'response.body.write requires response.body.read';", + " if (hooks.has('gateway.response.chunk') && set.has('stream.modify') && !set.has('stream.inspect')) return 'stream.modify requires stream.inspect';", + " return null;", + "}", + "function validateCapabilityDependencies(contributes: PluginContributes, capabilities: PluginCapability[]) {", + " const requireCapability = (capability: PluginCapability, reason: string) => capabilities.includes(capability) ? null : `${reason} requires ${capability}`;", + " if ((contributes.commands?.length ?? 0) > 0) {", + ' const error = requireCapability("commands.execute", "commands contribution");', + " if (error) return error;", + " }", + " if ((contributes.providers?.length ?? 0) > 0) {", + ' const error = requireCapability("provider.extensionValues", "provider contribution");', + " if (error) return error;", + " }", + " if ((contributes.gatewayHooks?.length ?? 0) > 0) {", + ' const error = requireCapability("gateway.hooks", "gatewayHooks contribution");', + " if (error) return error;", + " }", + " if ((contributes.protocolBridges?.length ?? 0) > 0) {", + ' const error = requireCapability("protocol.bridge", "protocolBridges contribution");', + " if (error) return error;", + " }", + ' if ((contributes.ui?.["providers.editor.sections"]?.length ?? 0) > 0) {', + ' const error = requireCapability("provider.extensionValues", "providers.editor.sections UI contribution");', + " if (error) return error;", + " }", + ' if ((contributes.ui?.["providers.editor.fields"]?.length ?? 0) > 0) {', + ' const error = requireCapability("provider.extensionValues", "providers.editor.fields UI contribution");', + " if (error) return error;", + " }", + " if (uiHasButtonCommand(contributes.ui)) {", + ' const error = requireCapability("commands.execute", "UI command field");', + " if (error) return error;", + " }", + " return null;", + "}", + "function uiHasButtonCommand(ui: unknown) { return String(ui).includes('button'); }", + ].join("\n") + ); + writeFileSync( + join(root, "packages/create-aio-plugin/src/scaffold.ts"), + [ + 'main: "dist/extension.js"', + 'runtime: { kind: "extensionHost", language: "typescript" }', + 'hostCompatibility: { app: ">=0.60.0 <1.0.0", pluginApi: "^1.0.0" }', + 'capabilities: ["gateway.hooks"]', + "contributes gatewayHooks:", + "api.gateway.registerHook", + 'capabilities = ["commands.execute"]', + ].join("\n") + ); + writeFileSync( + join(root, "src-tauri/src/gateway/plugins/contract.rs"), + [ + [ + "gateway.request.afterBodyRead", + "gateway.request.beforeSend", + "gateway.response.chunk", + "gateway.response.after", + "gateway.error", + "log.beforePersist", + "gateway.response.headers", + ].join(" "), + [ + "request.meta.read", + "request.header.read", + "request.header.readSensitive", + "request.header.write", + "request.body.read", + "request.body.write", + "response.header.read", + "response.header.write", + "response.body.read", + "response.body.write", + "stream.inspect", + "stream.modify", + "log.redact", + "network.fetch", + ].join(" "), + ].join("\n") + ); + writeFileSync( + join(root, "src-tauri/src/domain/plugins.rs"), + [ + "extensionHost wasm process native privacyFilter", + "crate::gateway::plugins::contract::is_active_hook", + "crate::gateway::plugins::contract::is_reserved_hook", + "crate::gateway::plugins::contract::hook_contract", + [ + "request.meta.read", + "request.header.read", + "request.header.readSensitive", + "request.header.write", + "request.body.read", + "request.body.write", + "response.header.read", + "response.header.write", + "response.body.read", + "response.body.write", + "stream.inspect", + "stream.modify", + "log.redact", + "network.fetch", + ].join(" "), + "providers.editor.sections UI contribution requires provider.extensionValues", + "providers.editor.fields UI contribution requires provider.extensionValues", + "UI command field requires commands.execute", + "pub fn is_active_gateway_hook(hook: &str) -> bool {", + ' hook == "gateway.request.afterBodyRead" || hook == "gateway.request.beforeSend"', + "}", + 'pub fn is_reserved_gateway_hook(hook: &str) -> bool { hook == "gateway.response.headers" }', + "fn permission_risk(permission: &str) { request.body.read; request.body.write; network.fetch; }", + "PLUGIN_RESERVED_HOOK", + ].join("\n") + ); + writeFileSync( + join(root, "src-tauri/src/gateway/plugins/pipeline.rs"), + "Duration::from_millis(150) FailurePolicy::FailOpen" + ); + writeFileSync( + join(root, "docs/plugin-manifest-v1.md"), + [ + "gateway.request.afterBodyRead gateway.request.beforeSend gateway.response.headers", + "request.body.read request.body.write network.fetch", + ].join("\n") + ); + writeFileSync( + join(root, "docs/plugins/reference/hooks.md"), + "gateway.request.afterBodyRead gateway.request.beforeSend gateway.response.headers" + ); + writeFileSync( + join(root, "docs/plugins/reference/permissions.md"), + "request.body.read request.body.write network.fetch" + ); + writeFileSync( + join(root, "docs/plugins/reference/manifest.md"), + "extensionHost wasm process native privacyFilter" + ); + writeFileSync(join(root, "docs/plugins/runtime/wasm.md"), "wasm PLUGIN_RUNTIME_DISABLED"); + writePassingManifestDocs(root); + writePassingDevtools(root); +} + +const extensionHostDependencyBaselineRoot = makeRoot("extension-host-dependency-baseline"); +writeJson(extensionHostDependencyBaselineRoot, "docs/plugins/plugin-api-v1-contract.json", { + apiVersion: "1.0.0", + defaultHookTimeoutMs: 150, + defaultFailurePolicy: "fail-open", + activeHooks: [ + "gateway.request.afterBodyRead", + "gateway.request.beforeSend", + "gateway.response.chunk", + ], + reservedHooks: ["gateway.response.headers"], + activeMutationFields: ["requestBody", "streamChunk"], + configSchemaTypes: ["object"], + activePermissions: [ + "request.meta.read", + "request.header.read", + "request.header.readSensitive", + "request.body.read", + "request.body.write", + "stream.inspect", + "stream.modify", + ], + reservedPermissions: ["network.fetch"], + capabilityDependencies: { + commands: ["commands.execute"], + providers: ["provider.extensionValues"], + "ui.providers.editor.sections": ["provider.extensionValues"], + "ui.providers.editor.fields": ["provider.extensionValues"], + "ui.buttonCommandFields": ["commands.execute"], + gatewayHooks: ["gateway.hooks"], + protocolBridges: ["protocol.bridge"], + }, + hookMatrix: { + "gateway.request.afterBodyRead": { + phase: "after request body read and before upstream provider send", + kind: "request", + status: "active", + defaultFailurePolicy: "fail-open", + timeoutMs: 150, + reservedHeaderPolicy: "block-gateway-owned", + readPermissions: [ + "request.meta.read", + "request.header.read", + "request.header.readSensitive", + "request.body.read", + ], + writePermissions: ["request.body.write"], + permissionDependencies: { "request.body.write": ["request.body.read"] }, + mutationFields: ["requestBody"], + contextFields: ["traceId", "request.body"], + }, + "gateway.request.beforeSend": { + phase: "after provider resolution and before upstream provider send", + kind: "request", + status: "active", + defaultFailurePolicy: "fail-open", + timeoutMs: 150, + reservedHeaderPolicy: "block-gateway-owned", + readPermissions: ["request.body.read"], + writePermissions: ["request.body.write"], + permissionDependencies: {}, + mutationFields: ["requestBody"], + contextFields: ["traceId", "request.body"], + }, + "gateway.response.chunk": { + phase: "for each bounded streaming response chunk", + kind: "stream", + status: "active", + defaultFailurePolicy: "fail-open", + timeoutMs: 150, + reservedHeaderPolicy: "block-gateway-owned", + readPermissions: ["stream.inspect"], + writePermissions: ["stream.modify"], + permissionDependencies: { "stream.modify": ["stream.inspect"] }, + mutationFields: ["streamChunk"], + contextFields: ["traceId", "stream.chunk"], + }, + }, + communityRuntimes: ["extensionHost"], + unsupportedLegacyRuntimes: ["wasm", "process", "native"], +}); +writePassingScaffold(extensionHostDependencyBaselineRoot); + +const extensionHostDependencyBaselineResult = runCheck(extensionHostDependencyBaselineRoot); +if (extensionHostDependencyBaselineResult.status !== 0) { + throw new Error( + `expected Extension Host dependency baseline to pass, got status ${extensionHostDependencyBaselineResult.status}\n${extensionHostDependencyBaselineResult.stderr}` + ); +} + +const reservedHostMediatedLabelDriftRoot = makeRoot("reserved-host-mediated-label-drift"); +writeJson(reservedHostMediatedLabelDriftRoot, "docs/plugins/plugin-api-v1-contract.json", { + apiVersion: "1.0.0", + defaultHookTimeoutMs: 150, + defaultFailurePolicy: "fail-open", + activeHooks: [ + "gateway.request.afterBodyRead", + "gateway.request.beforeSend", + "gateway.response.chunk", + ], + reservedHooks: ["gateway.response.headers"], + activeMutationFields: ["requestBody", "streamChunk"], + configSchemaTypes: ["object"], + capabilities: ["network.fetch"], + activePermissions: [ + "request.meta.read", + "request.header.read", + "request.header.readSensitive", + "request.body.read", + "request.body.write", + "stream.inspect", + "stream.modify", + "network.fetch", + ], + reservedPermissions: ["network.fetch"], + capabilityDependencies: { + commands: ["commands.execute"], + providers: ["provider.extensionValues"], + "ui.providers.editor.sections": ["provider.extensionValues"], + "ui.providers.editor.fields": ["provider.extensionValues"], + "ui.buttonCommandFields": ["commands.execute"], + gatewayHooks: ["gateway.hooks"], + protocolBridges: ["protocol.bridge"], + }, + hookMatrix: JSON.parse( + readFileSync( + join(extensionHostDependencyBaselineRoot, "docs/plugins/plugin-api-v1-contract.json"), + "utf8" + ) + ).hookMatrix, + communityRuntimes: ["extensionHost"], + unsupportedLegacyRuntimes: ["wasm", "process", "native"], +}); +writePassingScaffold(reservedHostMediatedLabelDriftRoot); + +const reservedHostMediatedLabelDriftResult = runCheck(reservedHostMediatedLabelDriftRoot); +if ( + reservedHostMediatedLabelDriftResult.status === 0 || + !reservedHostMediatedLabelDriftResult.stderr.includes( + "capabilities must not include reserved host-mediated label network.fetch" + ) || + !reservedHostMediatedLabelDriftResult.stderr.includes( + "activePermissions must not include reserved host-mediated label network.fetch" + ) +) { + throw new Error( + `expected reserved host-mediated label drift failure, got status ${reservedHostMediatedLabelDriftResult.status}\n${reservedHostMediatedLabelDriftResult.stderr}` + ); +} + +const singlePermissionModelRoot = makeRoot("single-permission-model"); +writeJson(singlePermissionModelRoot, "docs/plugins/plugin-api-v1-contract.json", { + apiVersion: "1.0.0", + defaultHookTimeoutMs: 150, + defaultFailurePolicy: "fail-open", + activeHooks: [ + "gateway.request.afterBodyRead", + "gateway.request.beforeSend", + "gateway.response.chunk", + ], + reservedHooks: ["gateway.response.headers"], + activeMutationFields: ["requestBody", "streamChunk"], + configSchemaTypes: ["object"], + activePermissions: [ + "request.meta.read", + "request.header.read", + "request.header.readSensitive", + "request.body.read", + "request.body.write", + "stream.inspect", + "stream.modify", + ], + reservedPermissions: ["network.fetch"], + capabilityDependencies: { + commands: ["commands.execute"], + providers: ["provider.extensionValues"], + "ui.providers.editor.sections": ["provider.extensionValues"], + "ui.providers.editor.fields": ["provider.extensionValues"], + "ui.buttonCommandFields": ["commands.execute"], + gatewayHooks: ["gateway.hooks"], + protocolBridges: ["protocol.bridge"], + }, + hookMatrix: JSON.parse( + readFileSync( + join(extensionHostDependencyBaselineRoot, "docs/plugins/plugin-api-v1-contract.json"), + "utf8" + ) + ).hookMatrix, + communityRuntimes: ["extensionHost"], + unsupportedLegacyRuntimes: ["wasm", "process", "native"], +}); +writePassingScaffold(singlePermissionModelRoot); +writeFileSync( + join(singlePermissionModelRoot, "src-tauri/src/domain/plugins.rs"), + [ + "extensionHost wasm process native privacyFilter", + "crate::gateway::plugins::contract::is_active_hook", + "crate::gateway::plugins::contract::is_reserved_hook", + "crate::gateway::plugins::contract::hook_contract", + [ + "request.meta.read", + "request.header.read", + "request.header.readSensitive", + "request.header.write", + "request.body.read", + "request.body.write", + "response.header.read", + "response.header.write", + "response.body.read", + "response.body.write", + "stream.inspect", + "stream.modify", + "log.redact", + "network.fetch", + ].join(" "), + "providers.editor.sections UI contribution requires provider.extensionValues", + "providers.editor.fields UI contribution requires provider.extensionValues", + "UI command field requires commands.execute", + "pub fn is_active_gateway_hook(hook: &str) -> bool {", + ' hook == "gateway.request.afterBodyRead" || hook == "gateway.request.beforeSend"', + "}", + 'pub fn is_reserved_gateway_hook(hook: &str) -> bool { hook == "gateway.response.headers" }', + "fn permission_risk(permission: &str) { request.body.read; request.body.write; network.fetch; }", + "PLUGIN_RESERVED_HOOK", + ].join("\n") +); + +const singlePermissionModelResult = runCheck(singlePermissionModelRoot); +if (singlePermissionModelResult.status !== 0) { + throw new Error( + `expected Extension Host single permission model without reserved permission manifest validation to pass, got status ${singlePermissionModelResult.status}\n${singlePermissionModelResult.stderr}` + ); +} + +const protocolBridgeBoundaryDriftRoot = makeRoot("protocol-bridge-boundary-drift"); +writeJson(protocolBridgeBoundaryDriftRoot, "docs/plugins/plugin-api-v1-contract.json", { + apiVersion: "1.0.0", + defaultHookTimeoutMs: 150, + defaultFailurePolicy: "fail-open", + activeHooks: [ + "gateway.request.afterBodyRead", + "gateway.request.beforeSend", + "gateway.response.chunk", + ], + reservedHooks: ["gateway.response.headers"], + activeMutationFields: ["requestBody", "streamChunk"], + configSchemaTypes: ["object"], + activePermissions: [ + "request.meta.read", + "request.header.read", + "request.header.readSensitive", + "request.body.read", + "request.body.write", + "stream.inspect", + "stream.modify", + ], + reservedPermissions: ["network.fetch"], + capabilityDependencies: { + commands: ["commands.execute"], + providers: ["provider.extensionValues"], + "ui.providers.editor.sections": ["provider.extensionValues"], + "ui.providers.editor.fields": ["provider.extensionValues"], + "ui.buttonCommandFields": ["commands.execute"], + gatewayHooks: ["gateway.hooks"], + protocolBridges: ["protocol.bridge"], + }, + protocolBridgeContribution: { + status: "active-execution", + executionBoundary: "protocol bridge execution is fully active", + }, + hookMatrix: JSON.parse( + readFileSync( + join(extensionHostDependencyBaselineRoot, "docs/plugins/plugin-api-v1-contract.json"), + "utf8" + ) + ).hookMatrix, + communityRuntimes: ["extensionHost"], + unsupportedLegacyRuntimes: ["wasm", "process", "native"], +}); +writePassingScaffold(protocolBridgeBoundaryDriftRoot); + +const protocolBridgeBoundaryDriftResult = runCheck(protocolBridgeBoundaryDriftRoot); +if ( + protocolBridgeBoundaryDriftResult.status === 0 || + !protocolBridgeBoundaryDriftResult.stderr.includes( + "protocolBridgeContribution.status must be mvp-skeleton" + ) || + !protocolBridgeBoundaryDriftResult.stderr.includes( + "protocolBridgeContribution.executionBoundary must describe future host integration" + ) +) { + throw new Error( + `expected protocol bridge boundary drift failure, got status ${protocolBridgeBoundaryDriftResult.status}\n${protocolBridgeBoundaryDriftResult.stderr}` + ); +} + +const capabilityDependencyDriftRoot = makeRoot("capability-dependency-drift"); +writeJson(capabilityDependencyDriftRoot, "docs/plugins/plugin-api-v1-contract.json", { + apiVersion: "1.0.0", + defaultHookTimeoutMs: 150, + defaultFailurePolicy: "fail-open", + activeHooks: [ + "gateway.request.afterBodyRead", + "gateway.request.beforeSend", + "gateway.response.chunk", + ], + reservedHooks: ["gateway.response.headers"], + activeMutationFields: ["requestBody", "streamChunk"], + configSchemaTypes: ["object"], + activePermissions: [ + "request.meta.read", + "request.header.read", + "request.header.readSensitive", + "request.body.read", + "request.body.write", + "stream.inspect", + "stream.modify", + ], + reservedPermissions: ["network.fetch"], + capabilityDependencies: { + commands: ["commands.execute"], + providers: ["provider.extensionValues"], + "ui.providers.editor.sections": ["provider.extensionValues"], + "ui.providers.card.badges": ["provider.extensionValues"], + "ui.providers.card.actions": ["provider.extensionValues"], + gatewayHooks: ["gateway.hooks"], + protocolBridges: ["protocol.bridge"], + }, + hookMatrix: + extensionHostDependencyBaselineResult.status === 0 + ? JSON.parse( + readFileSync( + join(extensionHostDependencyBaselineRoot, "docs/plugins/plugin-api-v1-contract.json"), + "utf8" + ) + ).hookMatrix + : {}, + communityRuntimes: ["extensionHost"], + unsupportedLegacyRuntimes: ["wasm", "process", "native"], +}); +writePassingScaffold(capabilityDependencyDriftRoot); + +const capabilityDependencyDriftResult = runCheck(capabilityDependencyDriftRoot); +if ( + capabilityDependencyDriftResult.status === 0 || + !capabilityDependencyDriftResult.stderr.includes( + "capabilityDependencies.ui.providers.editor.fields" + ) || + !capabilityDependencyDriftResult.stderr.includes( + "capabilityDependencies.ui.buttonCommandFields" + ) || + !capabilityDependencyDriftResult.stderr.includes("ui.providers.card.badges") || + !capabilityDependencyDriftResult.stderr.includes("ui.providers.card.actions") +) { + throw new Error( + `expected capability dependency drift failure, got status ${capabilityDependencyDriftResult.status}\n${capabilityDependencyDriftResult.stderr}` + ); +} + +const reservedHookRoot = makeRoot("reserved-hook"); +writeJson(reservedHookRoot, "docs/plugins/plugin-api-v1-contract.json", { + apiVersion: "1.0.0", + defaultHookTimeoutMs: 150, + defaultFailurePolicy: "fail-open", + activeHooks: ["gateway.request.afterBodyRead"], + reservedHooks: ["gateway.response.headers"], + activeMutationFields: ["requestBody"], + configSchemaTypes: ["object"], + activePermissions: ["request.body.read"], + reservedPermissions: ["network.fetch"], + communityRuntimes: ["extensionHost"], + unsupportedLegacyRuntimes: ["wasm", "process", "native"], +}); +writeFileSync( + join(reservedHookRoot, "packages/plugin-sdk/src/index.ts"), + "gateway.request.afterBodyRead request.body.read extensionHost" +); +writeFileSync( + join(reservedHookRoot, "packages/create-aio-plugin/src/scaffold.ts"), + "extensionHost gateway.request.afterBodyRead request.body.read" +); +writeFileSync( + join(reservedHookRoot, "src-tauri/src/domain/plugins.rs"), + "gateway.request.afterBodyRead request.body.read extensionHost" +); +writeFileSync( + join(reservedHookRoot, "docs/plugin-manifest-v1.md"), + "gateway.request.afterBodyRead request.body.read" +); +writeFileSync( + join(reservedHookRoot, "docs/plugins/reference/hooks.md"), + "gateway.request.afterBodyRead" +); +writeFileSync(join(reservedHookRoot, "docs/plugins/reference/permissions.md"), "request.body.read"); +writeFileSync( + join(reservedHookRoot, "docs/plugins/reference/manifest.md"), + "extensionHost wasm process native privacyFilter" +); +writeFileSync( + join(reservedHookRoot, "docs/plugins/runtime/wasm.md"), + "wasm PLUGIN_RUNTIME_DISABLED" +); + +const reservedHookResult = runCheck(reservedHookRoot); +if ( + reservedHookResult.status === 0 || + !reservedHookResult.stderr.includes("gateway.response.headers") +) { + throw new Error( + `expected structural contract failure, got status ${reservedHookResult.status}\n${reservedHookResult.stderr}` + ); +} + +const missingHookMetadataRoot = makeRoot("missing-hook-metadata"); +writeJson(missingHookMetadataRoot, "docs/plugins/plugin-api-v1-contract.json", { + apiVersion: "1.0.0", + defaultHookTimeoutMs: 150, + defaultFailurePolicy: "fail-open", + activeHooks: ["gateway.request.afterBodyRead"], + reservedHooks: ["gateway.response.headers"], + activeMutationFields: ["requestBody"], + configSchemaTypes: ["object"], + activePermissions: ["request.body.read"], + reservedPermissions: ["network.fetch"], + hookMatrix: { + "gateway.request.afterBodyRead": { + phase: "after request body read and before upstream provider send", + readPermissions: ["request.body.read"], + writePermissions: [], + contextFields: ["traceId"], + timeoutMs: 150, + }, + }, + communityRuntimes: ["extensionHost"], + unsupportedLegacyRuntimes: ["wasm", "process", "native"], +}); +writePassingScaffold(missingHookMetadataRoot); + +const missingHookMetadataResult = runCheck(missingHookMetadataRoot); +if ( + missingHookMetadataResult.status === 0 || + !missingHookMetadataResult.stderr.includes("hookMatrix.gateway.request.afterBodyRead.kind") || + !missingHookMetadataResult.stderr.includes("hookMatrix.gateway.request.afterBodyRead.status") || + !missingHookMetadataResult.stderr.includes( + "hookMatrix.gateway.request.afterBodyRead.permissionDependencies" + ) || + !missingHookMetadataResult.stderr.includes( + "hookMatrix.gateway.request.afterBodyRead.mutationFields" + ) +) { + throw new Error( + `expected hookMatrix metadata failure, got status ${missingHookMetadataResult.status}\n${missingHookMetadataResult.stderr}` + ); +} + +const inconsistentHookMetadataRoot = makeRoot("inconsistent-hook-metadata"); +writeJson(inconsistentHookMetadataRoot, "docs/plugins/plugin-api-v1-contract.json", { + apiVersion: "1.0.0", + defaultHookTimeoutMs: 150, + defaultFailurePolicy: "fail-open", + activeHooks: ["gateway.request.afterBodyRead"], + reservedHooks: ["gateway.response.headers"], + activeMutationFields: ["requestBody"], + configSchemaTypes: ["object"], + activePermissions: ["request.body.read"], + reservedPermissions: ["network.fetch"], + hookMatrix: { + "gateway.request.afterBodyRead": { + phase: "after request body read and before upstream provider send", + kind: "request", + status: "reserved", + defaultFailurePolicy: "fail-closed", + timeoutMs: 150, + reservedHeaderPolicy: "allow-all", + readPermissions: ["request.body.read"], + writePermissions: [], + mutationFields: ["requestBody"], + contextFields: ["traceId"], + }, + }, + communityRuntimes: ["extensionHost"], + unsupportedLegacyRuntimes: ["wasm", "process", "native"], +}); +writePassingScaffold(inconsistentHookMetadataRoot); + +const inconsistentHookMetadataResult = runCheck(inconsistentHookMetadataRoot); +if ( + inconsistentHookMetadataResult.status === 0 || + !inconsistentHookMetadataResult.stderr.includes( + "hookMatrix.gateway.request.afterBodyRead.status must be active" + ) || + !inconsistentHookMetadataResult.stderr.includes( + "hookMatrix.gateway.request.afterBodyRead.defaultFailurePolicy must equal defaultFailurePolicy" + ) || + !inconsistentHookMetadataResult.stderr.includes( + "hookMatrix.gateway.request.afterBodyRead.reservedHeaderPolicy must be one of block-gateway-owned" ) +) { + throw new Error( + `expected hookMatrix consistency failure, got status ${inconsistentHookMetadataResult.status}\n${inconsistentHookMetadataResult.stderr}` + ); +} + +const duplicateHookMetadataRoot = makeRoot("duplicate-hook-metadata"); +writeJson(duplicateHookMetadataRoot, "docs/plugins/plugin-api-v1-contract.json", { + apiVersion: "1.0.0", + defaultHookTimeoutMs: 150, + defaultFailurePolicy: "fail-open", + activeHooks: ["gateway.request.afterBodyRead"], + reservedHooks: ["gateway.response.headers"], + activeMutationFields: ["requestBody"], + configSchemaTypes: ["object"], + activePermissions: ["request.body.read"], + reservedPermissions: ["network.fetch"], + hookMatrix: { + "gateway.request.afterBodyRead": { + phase: "after request body read and before upstream provider send", + kind: "request", + status: "active", + defaultFailurePolicy: "fail-open", + timeoutMs: 150, + reservedHeaderPolicy: "block-gateway-owned", + readPermissions: ["request.body.read", "request.body.read"], + writePermissions: [], + permissionDependencies: {}, + mutationFields: ["requestBody"], + contextFields: ["traceId"], + }, + }, + communityRuntimes: ["extensionHost"], + unsupportedLegacyRuntimes: ["wasm", "process", "native"], +}); +writePassingScaffold(duplicateHookMetadataRoot); + +const duplicateHookMetadataResult = runCheck(duplicateHookMetadataRoot); +if ( + duplicateHookMetadataResult.status === 0 || + !duplicateHookMetadataResult.stderr.includes( + "hookMatrix.gateway.request.afterBodyRead.readPermissions contains duplicate request.body.read" + ) +) { + throw new Error( + `expected hookMatrix duplicate metadata failure, got status ${duplicateHookMetadataResult.status}\n${duplicateHookMetadataResult.stderr}` + ); +} + +const missingDevtoolsMetadataRoot = makeRoot("missing-devtools-metadata"); +writeJson(missingDevtoolsMetadataRoot, "docs/plugins/plugin-api-v1-contract.json", { + apiVersion: "1.0.0", + defaultHookTimeoutMs: 150, + defaultFailurePolicy: "fail-open", + activeHooks: ["gateway.request.afterBodyRead"], + reservedHooks: ["gateway.response.headers"], + activeMutationFields: ["requestBody"], + configSchemaTypes: ["object"], + activePermissions: ["request.body.read", "request.body.write"], + reservedPermissions: ["network.fetch"], + hookMatrix: { + "gateway.request.afterBodyRead": { + phase: "after request body read and before upstream provider send", + kind: "request", + status: "active", + defaultFailurePolicy: "fail-open", + timeoutMs: 150, + reservedHeaderPolicy: "block-gateway-owned", + readPermissions: ["request.body.read"], + writePermissions: ["request.body.write"], + permissionDependencies: { "request.body.write": ["request.body.read"] }, + mutationFields: ["requestBody"], + contextFields: ["traceId"], + }, + }, + communityRuntimes: ["extensionHost"], + unsupportedLegacyRuntimes: ["wasm", "process", "native"], +}); +writePassingScaffold(missingDevtoolsMetadataRoot); +writeFileSync( + join(missingDevtoolsMetadataRoot, "packages/create-aio-plugin/src/devtools.ts"), + "validatePluginFilesStrict doctorPluginFiles" +); + +const missingDevtoolsMetadataResult = runCheck(missingDevtoolsMetadataRoot); +if ( + missingDevtoolsMetadataResult.status === 0 || + !missingDevtoolsMetadataResult.stderr.includes("packages/create-aio-plugin/src/devtools.ts") || + !missingDevtoolsMetadataResult.stderr.includes("PLUGIN_INVALID_MAIN") +) { + throw new Error( + `expected devtools metadata failure, got status ${missingDevtoolsMetadataResult.status}\n${missingDevtoolsMetadataResult.stderr}` + ); +} + +const partialDevtoolsMetadataRoot = makeRoot("partial-devtools-metadata"); +writeJson(partialDevtoolsMetadataRoot, "docs/plugins/plugin-api-v1-contract.json", { + apiVersion: "1.0.0", + defaultHookTimeoutMs: 150, + defaultFailurePolicy: "fail-open", + activeHooks: ["gateway.request.afterBodyRead"], + reservedHooks: ["gateway.response.headers"], + activeMutationFields: ["requestBody", "headers"], + configSchemaTypes: ["object"], + activePermissions: ["request.meta.read", "request.body.read"], + reservedPermissions: ["network.fetch"], + hookMatrix: { + "gateway.request.afterBodyRead": { + phase: "after request body read and before upstream provider send", + kind: "request", + status: "active", + defaultFailurePolicy: "fail-open", + timeoutMs: 150, + reservedHeaderPolicy: "block-gateway-owned", + readPermissions: ["request.meta.read", "request.body.read"], + writePermissions: [], + permissionDependencies: {}, + mutationFields: ["requestBody", "headers"], + contextFields: ["traceId", "request.body"], + }, + }, + communityRuntimes: ["extensionHost"], + unsupportedLegacyRuntimes: ["wasm", "process", "native"], +}); +writePassingScaffold(partialDevtoolsMetadataRoot); +writeFileSync( + join(partialDevtoolsMetadataRoot, "src-tauri/src/gateway/plugins/contract.rs"), + [ + "gateway.request.afterBodyRead gateway.response.headers", + "request.meta.read request.body.read network.fetch", + ].join("\n") ); writeFileSync( - join(root, "packages/plugin-sdk/src/index.ts"), - "gateway.request.afterBodyRead request.body.read declarativeRules" + join(partialDevtoolsMetadataRoot, "src-tauri/src/domain/plugins.rs"), + [ + "extensionHost wasm process native privacyFilter", + "crate::gateway::plugins::contract::is_active_hook", + "crate::gateway::plugins::contract::is_reserved_hook", + "crate::gateway::plugins::contract::hook_contract", + "pub fn is_active_gateway_hook(hook: &str) -> bool {", + ' hook == "gateway.request.afterBodyRead"', + "}", + 'pub fn is_reserved_gateway_hook(hook: &str) -> bool { hook == "gateway.response.headers" }', + "fn permission_risk(permission: &str) { request.meta.read; request.body.read; network.fetch; }", + "PLUGIN_RESERVED_HOOK", + ].join("\n") ); writeFileSync( - join(root, "packages/create-aio-plugin/src/scaffold.ts"), - "declarativeRules gateway.request.afterBodyRead request.body.read" + join(partialDevtoolsMetadataRoot, "docs/plugin-manifest-v1.md"), + "gateway.request.afterBodyRead gateway.response.headers request.meta.read request.body.read network.fetch" ); writeFileSync( - join(root, "src-tauri/src/domain/plugins.rs"), - "gateway.request.afterBodyRead request.body.read declarativeRules" + join(partialDevtoolsMetadataRoot, "docs/plugins/reference/permissions.md"), + "request.meta.read request.body.read network.fetch" ); -writeFileSync(join(root, "docs/plugin-manifest-v1.md"), "gateway.request.afterBodyRead request.body.read"); -writeFileSync(join(root, "docs/plugins/reference/hooks.md"), "gateway.request.afterBodyRead"); -writeFileSync(join(root, "docs/plugins/reference/permissions.md"), "request.body.read"); -writeFileSync(join(root, "docs/plugins/reference/manifest.md"), "declarativeRules wasm native privacyFilter"); -writeFileSync(join(root, "docs/plugins/runtime/wasm.md"), "wasm PLUGIN_RUNTIME_DISABLED"); +writeFileSync( + join(partialDevtoolsMetadataRoot, "packages/create-aio-plugin/src/devtools.ts"), + [ + "doctorPluginFiles validatePluginFilesStrict", + "dist/extension.js gateway.hooks contributes.gatewayHooks", + "PLUGIN_UNSUPPORTED_LEGACY_RUNTIME PLUGIN_REPLAY_UNSUPPORTED", + ].join("\n") +); + +const partialDevtoolsMetadataResult = runCheck(partialDevtoolsMetadataRoot); +if ( + partialDevtoolsMetadataResult.status === 0 || + !partialDevtoolsMetadataResult.stderr.includes( + "packages/create-aio-plugin/src/devtools.ts is missing Extension Host developer tool package shape packPluginBytes" + ) || + !partialDevtoolsMetadataResult.stderr.includes( + "packages/create-aio-plugin/src/devtools.ts is missing Extension Host developer tool package shape PLUGIN_INVALID_MAIN" + ) +) { + throw new Error( + `expected full devtools metadata failure, got status ${partialDevtoolsMetadataResult.status}\n${partialDevtoolsMetadataResult.stderr}` + ); +} -const result = spawnSync("node", ["scripts/check-plugin-api-contract.mjs"], { - cwd: process.cwd(), - env: { ...process.env, AIO_PLUGIN_CONTRACT_TEST_ROOT: root }, - encoding: "utf8", +const globalPermissionDependencyRoot = makeRoot("global-permission-dependency"); +writeJson(globalPermissionDependencyRoot, "docs/plugins/plugin-api-v1-contract.json", { + apiVersion: "1.0.0", + defaultHookTimeoutMs: 150, + defaultFailurePolicy: "fail-open", + activeHooks: ["gateway.request.afterBodyRead", "gateway.request.beforeSend"], + reservedHooks: ["gateway.response.headers"], + activeMutationFields: ["requestBody"], + configSchemaTypes: ["object"], + activePermissions: ["request.body.read", "request.body.write"], + reservedPermissions: ["network.fetch"], + hookMatrix: { + "gateway.request.afterBodyRead": { + phase: "after request body read and before upstream provider send", + kind: "request", + status: "active", + defaultFailurePolicy: "fail-open", + timeoutMs: 150, + reservedHeaderPolicy: "block-gateway-owned", + readPermissions: ["request.body.read"], + writePermissions: ["request.body.write"], + permissionDependencies: { + "request.body.write": ["request.body.read"], + }, + mutationFields: ["requestBody"], + contextFields: ["traceId", "request.body"], + }, + "gateway.request.beforeSend": { + phase: "after provider resolution and before upstream provider send", + kind: "request", + status: "active", + defaultFailurePolicy: "fail-open", + timeoutMs: 150, + reservedHeaderPolicy: "block-gateway-owned", + readPermissions: ["request.body.read"], + writePermissions: ["request.body.write"], + permissionDependencies: {}, + mutationFields: ["requestBody"], + contextFields: ["traceId", "request.body"], + }, + }, + communityRuntimes: ["extensionHost"], + unsupportedLegacyRuntimes: ["wasm", "process", "native"], }); +writePassingScaffold(globalPermissionDependencyRoot); +writeFileSync( + join(globalPermissionDependencyRoot, "packages/plugin-sdk/src/index.ts"), + [ + "export type PluginPermission = 'request.body.read' | 'request.body.write' | 'network.fetch';", + "export type ActiveGatewayHookName = 'gateway.request.afterBodyRead' | 'gateway.request.beforeSend';", + "export type ReservedGatewayHookName = 'gateway.response.headers';", + "export type GatewayHookName = ActiveGatewayHookName | ReservedGatewayHookName;", + "const runtimeTokens = 'extensionHost wasm';", + "const activeMutationField = 'requestBody';", + "function validateManifest(manifest: { permissions: PluginPermission[] }) {", + " return validatePermissionSet(manifest.permissions);", + "}", + "function validatePermissionSet(permissions: PluginPermission[]) {", + " const set = new Set(permissions);", + " if (set.has('request.body.write') && !set.has('request.body.read')) {", + " return 'request.body.write requires request.body.read';", + " }", + " return null;", + "}", + ].join("\n") +); -if (result.status === 0 || !result.stderr.includes("gateway.response.headers")) { +const globalPermissionDependencyResult = runCheck(globalPermissionDependencyRoot); +if ( + globalPermissionDependencyResult.status === 0 || + !globalPermissionDependencyResult.stderr.includes( + "packages/plugin-sdk/src/index.ts is missing Extension Host SDK contract export type ExtensionRuntime" + ) || + !globalPermissionDependencyResult.stderr.includes( + "packages/plugin-sdk/src/index.ts is missing validateCapabilityDependencies body" + ) +) { throw new Error( - `expected structural contract failure, got status ${result.status}\n${result.stderr}` + `expected capability dependency failure, got status ${globalPermissionDependencyResult.status}\n${globalPermissionDependencyResult.stderr}` ); } diff --git a/scripts/check-plugin-system-completion.mjs b/scripts/check-plugin-system-completion.mjs index a0d49569..c37f5215 100644 --- a/scripts/check-plugin-system-completion.mjs +++ b/scripts/check-plugin-system-completion.mjs @@ -46,11 +46,6 @@ requireScript( "check:plugin-api-contract", "node scripts/check-plugin-api-contract.mjs" ); -requireScript( - rootPackage, - "plugin-wasm-sdk:test", - "cargo test --manifest-path packages/plugin-wasm-sdk/Cargo.toml && cargo test --manifest-path packages/plugin-wasm-sdk/examples/redactor/Cargo.toml" -); requireScript(rootPackage, "test:e2e", "vitest run src/e2e"); const workspace = readText("pnpm-workspace.yaml"); @@ -58,45 +53,17 @@ if (!workspace.includes("packages/*")) { failures.push("pnpm-workspace.yaml: packages/* workspace is required"); } -const sdkCargo = readText("packages/plugin-wasm-sdk/Cargo.toml"); -for (const phrase of [ - 'name = "aio-plugin-wasm-sdk"', - "serde", - "serde_json", - "crate-type", -]) { - if (!sdkCargo.includes(phrase)) { - failures.push(`packages/plugin-wasm-sdk/Cargo.toml: missing "${phrase}"`); - } -} - -requireFile("packages/plugin-wasm-sdk/src/lib.rs"); -requireFile("packages/plugin-wasm-sdk/examples/redactor/Cargo.toml"); -requireFile("packages/plugin-wasm-sdk/examples/redactor/src/lib.rs"); -requireFile("packages/plugin-wasm-sdk/tests/sdk_contract.rs"); - -const sdkLib = readText("packages/plugin-wasm-sdk/src/lib.rs"); -for (const phrase of [ - "HookRequest", - "HookResult", - "PluginManifest", - "aio_plugin_entrypoint", - "serde", -]) { - if (!sdkLib.includes(phrase)) { - failures.push(`packages/plugin-wasm-sdk/src/lib.rs: missing "${phrase}"`); - } -} +requireFile("packages/plugin-sdk/src/index.ts"); +requireFile("packages/create-aio-plugin/src/scaffold.ts"); +requireFile("packages/create-aio-plugin/src/devtools.ts"); const ci = readText(".github/workflows/ci.yml"); for (const phrase of [ "pnpm check:plugin-api-contract", "pnpm check:plugin-system-docs", "pnpm check:generated-bindings", - "pnpm plugin-sdk:typecheck", - "cargo test --manifest-path packages/plugin-wasm-sdk/Cargo.toml", - "cargo test --manifest-path packages/plugin-wasm-sdk/examples/redactor/Cargo.toml", - "pnpm create-aio-plugin:test", + "pnpm --filter @aio-coding-hub/plugin-sdk typecheck", + "pnpm --filter create-aio-plugin test", "pnpm test:e2e", ]) { if (!ci.includes(phrase)) { @@ -105,27 +72,40 @@ for (const phrase of [ } const docs = [ + "docs/plugin-manifest-v1.md", "docs/plugins/reference/sdk.md", "docs/plugins/developer-guide.md", - "docs/plugins/runtime/wasm.md", + "docs/plugins/runtime/README.md", ]; for (const doc of docs) { const text = readText(doc); - if (!text.includes("plugin-wasm-sdk")) { - failures.push(`${doc}: must reference plugin-wasm-sdk`); + if (!text.includes("Extension Host")) { + failures.push(`${doc}: must reference Extension Host`); } } for (const [doc, phrases] of Object.entries({ + "docs/plugin-manifest-v1.md": [ + 'runtime.kind = "extensionHost"', + "`main` points at bundled JavaScript output", + "`contributes.gatewayHooks`", + '`capabilities: ["gateway.hooks"]`', + "`api.gateway.registerHook`", + ], "docs/plugins/developer-guide.md": [ - "`declarativeRules` 是默认社区运行时", - "WASM 执行受宿主策略控制", - "`plugin.wasm`", + "Extension Host 是唯一 community runtime", + '`runtime.kind = "extensionHost"`', + "`main` 指向打包后的 JavaScript 输出", + "`contributes.gatewayHooks`", + "`api.gateway.registerHook`", + "PLUGIN_REPLAY_UNSUPPORTED", ], - "docs/plugins/runtime/wasm.md": [ - "`declarativeRules` 是默认社区运行时", - "WASM 只用于宿主策略启用后", - "`plugin.wasm` artifacts 会由 `create-aio-plugin pack` 作为 binary files 打包", + "docs/plugins/plugin-api-v1-contract.json": [ + '"communityRuntimes": [', + '"extensionHost"', + '"unsupportedLegacyRuntimes"', + '"gatewayHooks"', + '"protocolBridges"', ], })) { const text = readText(doc); @@ -136,6 +116,23 @@ for (const [doc, phrases] of Object.entries({ } } +for (const [doc, phrases] of Object.entries({ + "docs/plugins/developer-guide.md": [ + "WASM 执行受宿主策略控制", + "`plugin.wasm`", + "pnpm --filter create-aio-plugin cli replay", + ], + "docs/plugin-manifest-v1.md": ['"kind": "wasm"'], + "docs/plugins/plugin-api-v1-contract.json": ['"policyGatedRuntimes"'], +})) { + const text = readText(doc); + for (const phrase of phrases) { + if (text.includes(phrase)) { + failures.push(`${doc}: forbidden "${phrase}"`); + } + } +} + if (failures.length > 0) { console.error("Plugin system completion contract failed:"); for (const failure of failures) { diff --git a/scripts/check-plugin-system-docs.mjs b/scripts/check-plugin-system-docs.mjs index d158303c..c83df35e 100644 --- a/scripts/check-plugin-system-docs.mjs +++ b/scripts/check-plugin-system-docs.mjs @@ -1,9 +1,12 @@ +import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = dirname(scriptDir); +const legacyCreateAioPluginExecCommand = + "pnpm --filter create-aio-plugin exec " + "create-aio-plugin"; const requiredDocs = [ { @@ -19,6 +22,16 @@ const requiredDocs = [ "gateway.response.chunk (active SSE chunk inspect/modify/block)", "log.beforePersist (active request log redaction before enqueue)", "WASM", + "public manifests use `capabilities`", + "Extension Host public manifest 不支持 top-level `permissions`", + "capability changes", + ], + forbiddenPhrases: [ + "granted permissions", + "body-read permissions", + "write permissions", + "add permissions", + "permission changes", ], }, { @@ -28,6 +41,16 @@ const requiredDocs = [ "SemVer", "apiVersion", "hostCompatibility", + "main", + 'runtime.kind = "extensionHost"', + "contributes.gatewayHooks", + 'capabilities: ["gateway.hooks"]', + "api.gateway.registerHook", + "commands -> commands.execute", + "providers / provider UI -> provider.extensionValues", + "gatewayHooks -> gateway.hooks", + "protocolBridges -> protocol.bridge", + "Protocol bridge MVP skeleton", "gateway.response.chunk", "Active hooks in plugin API v1", "Reserved hooks for future host integration", @@ -36,9 +59,10 @@ const requiredDocs = [ "official.privacy-filter", "acme.prompt-helper", "quarantined", - "高危权限需要二次授权", - "插件升级新增权限必须重新授权", + "Extension Host public manifest 不支持 top-level `permissions`", + "High-risk 和 critical labels", ], + forbiddenPhrases: ['"kind": "wasm"'], }, { path: "docs/plugins/README.md", @@ -58,58 +82,78 @@ const requiredDocs = [ phrases: [ "插件开发总指南", "plugin.json", - "declarativeRules", + "Extension Host", + "doctor -> validate --strict -> pack -> publish-check -> install/update -> export replay fixture -> fix -> reinstall", + "plugin_export_replay_fixture", + "publish-check", + "dist/extension.js", + 'runtime.kind = "extensionHost"', + "contributes.gatewayHooks", + "capabilities", + "api.gateway.registerHook", "gateway.request.beforeSend", "request.normalizedMessages", "configSchema", "x-aio-ui", - "pnpm create-aio-plugin validate", - "pnpm create-aio-plugin replay", - "pnpm create-aio-plugin pack", + "pnpm --filter create-aio-plugin cli validate", + "pnpm --filter create-aio-plugin cli pack", + "PLUGIN_REPLAY_UNSUPPORTED", ".aio-plugin", "official.privacy-filter", + "精选插件", + "高级来源", + "example:prompt-helper", + "example:redactor", + "example:response-guard", + "示例是开发模板,不是默认可安装市场包", + ], + forbiddenPhrases: [ + "WASM 适合需要确定性代码逻辑的插件", + "pnpm plugin-wasm-sdk:test", + "最小声明式规则插件", + "pnpm --filter create-aio-plugin cli replay", + legacyCreateAioPluginExecCommand, ], }, { path: "docs/plugins/runtime/wasm.md", phrases: [ - "WASM ABI v1", + "unsupported pre-release legacy runtime", + "not part of the public Plugin API v1 community runtime surface", + "community plugins must migrate to Extension Host", + 'runtime.kind = "extensionHost"', + ], + forbiddenPhrases: [ "WASM packages are installable only when host policy enables execution", - "PLUGIN_RUNTIME_DISABLED", - "WASM enablement is rejected while host policy disables execution", - "guest entrypoint", - "memory/time/filesystem/network 限制", - "no WASI filesystem imports", - "fuel-based termination", - "host only passes permission-trimmed JSON", + "WASM 只用于宿主策略启用后", + "插件作者应使用", ], }, { path: "docs/plugins/runtime/process-poc.md", phrases: [ + "unsupported pre-release legacy runtime", + "not part of the public Plugin API v1 community runtime surface", + "Extension Host", "JSON-RPC over stdio", "disabled by default", - "start timeout", - "hook timeout", - "crash isolation", - "idle recycle", - "no marketplace enablement by default", ], + forbiddenPhrases: ["服务于未来无法放进 WASM ABI"], }, { path: "docs/plugins/developer-guide.md", phrases: [ "create-aio-plugin", - "pnpm create-aio-plugin", - "pnpm create-aio-plugin validate", - "pnpm create-aio-plugin replay", - "pnpm create-aio-plugin pack", + "pnpm --filter create-aio-plugin cli", + "pnpm --filter create-aio-plugin cli validate", + "pnpm --filter create-aio-plugin cli pack", + "pnpm --filter create-aio-plugin cli publish-check", "从 Plugins 页面本地安装", "Claude 和 Codex request shapes", "@aio-coding-hub/plugin-sdk", "plugin.json", - "最小声明式规则插件", - "声明式规则", + "最小 Extension Host 插件", + "Extension Host", ], }, { @@ -119,42 +163,66 @@ const requiredDocs = [ "PluginManifest", "validateManifest", "permissionRisk", + "Extension Host", + 'runtime: { kind: "extensionHost"', + "api.gateway.registerHook", "SDK 边界", ], - }, - { - path: "docs/plugins/reference/declarative-rules.md", - phrases: [ - "declarativeRules", - "规则文件结构", - "request.body", - "log.message", - "appendMessage", - "运行时限制", - ], + forbiddenPhrases: ["aio-plugin-wasm-sdk"], }, { path: "docs/plugins/examples/privacy-filter.md", phrases: [ "official.privacy-filter", "packyme/privacy-filter", + "Extension Host", + "privacy.redact", + "api.privacy.redactRequestBody", "已移除的内置示例", ], + forbiddenPhrases: ["native:privacyFilter", "host-owned built-in"], + }, + { + path: "docs/plugins/examples/README.md", + phrases: [ + "example:prompt-helper", + "example:redactor", + "example:response-guard", + "fixtures/claude-request.json", + "fixtures/response-warn.json", + "不是默认可安装市场包", + "checksum", + "signature", + "托管", + "市场索引流程", + ], }, { path: "docs/plugins/architecture/audit.md", phrases: [ "official.privacy-filter", - "declarativeRules", - "WASM", - "native", + "Extension Host", + "gatewayHooks", + "protocolBridges", + "unsupported pre-release legacy runtime", "信任边界", "性能与稳定性建议", + "0.62 does not add public provider plugin APIs", ], + caseInsensitivePhrases: ["provider adapter facades remain internal"], }, { path: "docs/plugins/reference/manifest.md", - phrases: ["apiVersion", "hostCompatibility", "declarativeRules", "wasm"], + phrases: [ + "apiVersion", + "hostCompatibility", + 'runtime.kind = "extensionHost"', + "main", + "contributes.gatewayHooks", + "capabilities", + "Protocol bridge MVP skeleton", + ], + forbiddenPhrases: ['{ "kind": "wasm"'], }, { path: "docs/plugins/reference/hooks.md", @@ -162,12 +230,14 @@ const requiredDocs = [ "gateway.request.afterBodyRead", "gateway.response.chunk", "log.beforePersist", - "默认 vNext hook timeout: 150 ms", + "plugin_hook_execution_reports", + "plugin_export_replay_fixture", + "默认 vNext hook timeout: 5000 ms", ], }, { path: "docs/plugins/reference/permissions.md", - phrases: ["request.body.read", "secret.read", "critical", "重新授权"], + phrases: ["request.body.read", "secret.read", "critical", "新增 capability 需要用户重新确认"], }, { path: "docs/plugins/reference/config-schema.md", @@ -185,8 +255,9 @@ const requiredDocs = [ phrases: [ "fail-closed", "quarantined", - "no arbitrary JavaScript", - "默认 vNext hook timeout: 150 ms", + "Extension Host", + "不在 Rust 主进程或 Tauri WebView 执行第三方插件代码", + "默认 vNext hook timeout: 5000 ms", ], }, { @@ -195,16 +266,116 @@ const requiredDocs = [ }, { path: "docs/plugins/reference/publishing.md", - phrases: [".aio-plugin", "sha256", "Ed25519", "rollback"], + phrases: [ + ".aio-plugin", + "sha256", + "Ed25519", + "rollback", + "publish-check", + "market index URL", + "trusted public key", + "revoked / incompatible install blocks", + "plugin_export_replay_fixture", + "默认市场视图", + "自定义 market index 属于高级来源", + "示例模板可以运行 publish-check", + "不代表示例已经被上传、签名、加入默认 market index", + ], + }, + { + path: "docs/plugins/runtime/README.md", + phrases: [ + "Host Runtime Lifecycle", + "plugin_hook_execution_reports", + "host-owned lifecycle", + "Dispose", + ], }, { path: "docs/plugins/reference/compatibility.md", - phrases: ["SemVer", "pluginApi", "platforms", "WASM ABI"], + phrases: [ + "SemVer", + "pluginApi", + "platforms", + "Plugin API v1 remains externally compatible in 0.62", + "0.62 does not add public provider plugin APIs", + "Extension Host is the only community runtime", + "unsupported pre-release legacy runtime", + ], + forbiddenPhrases: ['{ "kind": "wasm"'], }, ]; const failures = []; +const localReplayBoundaryFiles = [ + "docs/plugins/README.md", + "docs/plugins/developer-guide.md", + "docs/plugins/reference/sdk.md", + "docs/plugins/reference/compatibility.md", + "docs/plugins/architecture/audit.md", + "docs/plugins/examples/README.md", + "docs/plugins/examples/privacy-filter.md", + "docs/plugins/reference/publishing.md", + "packages/create-aio-plugin/src/scaffold.ts", + "packages/create-aio-plugin/src/scaffold.test.ts", + "packages/create-aio-plugin/src/devtools.ts", +]; + +const replaySuccessPatterns = [ + /pnpm --filter create-aio-plugin cli replay/, + /\bcreate-aio-plugin\s+replay\b/, + /\breplay --explain\b/, + /validate[\s\S]{0,80}replay[\s\S]{0,80}pack/, +]; + +const supersededHistoricalDocsFallback = [ + "docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-1-plugin-developer-loop.md", + "docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md", + "docs/superpowers/plans/2026-06-25-aio-coding-hub-plugin-observability-replay-publishing.md", + "docs/superpowers/plans/2026-06-26-aio-coding-hub-plugin-example-developer-loop-phase-1.md", + "docs/superpowers/specs/2026-06-21-aio-coding-hub-0-62-plugin-platform-kernel-design.md", + "docs/superpowers/specs/2026-06-22-aio-coding-hub-0-62-1-plugin-developer-loop-design.md", + "docs/superpowers/specs/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel-design.md", + "docs/superpowers/specs/2026-06-25-aio-coding-hub-plugin-observability-replay-publishing-design.md", + "docs/superpowers/specs/2026-06-26-aio-coding-hub-plugin-example-developer-loop-phase-1-design.md", + "docs/superpowers/specs/2026-06-27-aio-coding-hub-plugin-runtime-lifecycle-registry-design.md", +]; + +function lineExplainsReplayUnsupported(line) { + return ( + line.includes("PLUGIN_REPLAY_UNSUPPORTED") || + line.includes("unsupported for Extension Host") || + line.includes("当前不执行 Extension Host gateway hooks") || + line.includes("不在本地执行 Extension Host gateway hooks") || + line.includes("not local `create-aio-plugin replay` execution") || + line.includes("not.toContain") + ); +} + +function trackedSuperpowersMarkdownDocs() { + const result = spawnSync( + "git", + ["ls-files", "docs/superpowers/plans", "docs/superpowers/specs"], + { + cwd: repoRoot, + encoding: "utf8", + } + ); + if (result.status !== 0) { + return supersededHistoricalDocsFallback; + } + return result.stdout.split(/\r?\n/).filter((path) => path.endsWith(".md")); +} + +function hasReplaySuccessPath(text) { + return replaySuccessPatterns.some((pattern) => pattern.test(text)); +} + +function hasSupersededHistoricalSuccessPath(text) { + return hasReplaySuccessPath(text); +} + for (const doc of requiredDocs) { const fullPath = join(repoRoot, doc.path); if (!existsSync(fullPath)) { @@ -218,6 +389,50 @@ for (const doc of requiredDocs) { failures.push(`${doc.path}: missing required phrase "${phrase}"`); } } + + const normalizedText = text.toLowerCase(); + for (const phrase of doc.caseInsensitivePhrases ?? []) { + if (!normalizedText.includes(phrase.toLowerCase())) { + failures.push(`${doc.path}: missing required phrase "${phrase}"`); + } + } + + for (const phrase of doc.forbiddenPhrases ?? []) { + if (text.includes(phrase)) { + failures.push(`${doc.path}: forbidden phrase "${phrase}"`); + } + } +} + +for (const path of localReplayBoundaryFiles) { + const fullPath = join(repoRoot, path); + if (!existsSync(fullPath)) { + failures.push(`${path}: missing local replay boundary file`); + continue; + } + const lines = readFileSync(fullPath, "utf8").split(/\r?\n/); + lines.forEach((line, index) => { + if (lineExplainsReplayUnsupported(line)) return; + if (replaySuccessPatterns.some((pattern) => pattern.test(line))) { + failures.push( + `${path}:${index + 1}: local create-aio-plugin replay must not be documented as a successful Extension Host hook path` + ); + } + }); +} + +for (const path of trackedSuperpowersMarkdownDocs()) { + const fullPath = join(repoRoot, path); + if (!existsSync(fullPath)) { + failures.push(`${path}: missing superseded historical document`); + continue; + } + const text = readFileSync(fullPath, "utf8"); + if (!hasSupersededHistoricalSuccessPath(text)) continue; + const head = text.split(/\r?\n/).slice(0, 16).join("\n"); + if (!head.includes("Status: Superseded.") || !head.includes("MUST NOT be executed")) { + failures.push(`${path}: historical local replay public runtime plan must be marked superseded`); + } } if (failures.length > 0) { diff --git a/scripts/fetch-checked-file.mjs b/scripts/fetch-checked-file.mjs new file mode 100644 index 00000000..7bf16492 --- /dev/null +++ b/scripts/fetch-checked-file.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { open, rm } from "node:fs/promises"; + +const args = parseArgs(process.argv.slice(2)); +const url = requireArg(args, "url"); +const expectedSha256 = requireArg(args, "sha256").toLowerCase(); +const output = requireArg(args, "output"); + +if (!/^[a-f0-9]{64}$/.test(expectedSha256)) { + throw new Error("Expected --sha256 to be a lowercase or uppercase SHA-256 hex digest."); +} + +await fetchCheckedFile(url, expectedSha256, output); + +function parseArgs(argv) { + const parsed = new Map(); + for (let index = 0; index < argv.length; index += 1) { + const name = argv[index]; + if (!name.startsWith("--")) { + throw new Error(`Unexpected argument: ${name}`); + } + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`Missing value for ${name}`); + } + parsed.set(name.slice(2), value); + index += 1; + } + return parsed; +} + +function requireArg(args, name) { + const value = args.get(name); + if (!value) { + throw new Error(`Missing required argument --${name}`); + } + return value; +} + +async function fetchCheckedFile(rawUrl, expectedDigest, outputPath) { + const url = new URL(rawUrl); + if (url.protocol !== "https:") { + throw new Error(`Refusing non-HTTPS URL: ${rawUrl}`); + } + + const response = await fetch(url, { + redirect: "follow", + headers: { + "User-Agent": "aio-coding-hub-release", + }, + }); + if (!response.ok) { + throw new Error(`Fetch failed with HTTP ${response.status}: ${rawUrl}`); + } + if (!response.body) { + throw new Error(`Fetch returned an empty body: ${rawUrl}`); + } + + const hash = createHash("sha256"); + const file = await open(outputPath, "w", 0o600); + let ok = false; + try { + for await (const chunk of response.body) { + const buffer = Buffer.from(chunk); + hash.update(buffer); + await file.write(buffer); + } + + const actualDigest = hash.digest("hex"); + if (actualDigest !== expectedDigest) { + throw new Error( + `SHA-256 mismatch for ${rawUrl}: expected ${expectedDigest}, got ${actualDigest}` + ); + } + ok = true; + } finally { + await file.close(); + if (!ok) { + await rm(outputPath, { force: true }); + } + } +} diff --git a/scripts/run-checks.mjs b/scripts/run-checks.mjs new file mode 100644 index 00000000..dbab91cb --- /dev/null +++ b/scripts/run-checks.mjs @@ -0,0 +1,131 @@ +/** + * Single source of truth for aggregate check stages. + * + * Usage: + * node scripts/run-checks.mjs + * node scripts/run-checks.mjs --list + * + * Adding a check: define its command in CHECKS, then add its id to the + * stages that should run it. Hooks (.githooks/*) and package.json aggregate + * scripts all resolve stages through this file, so there is exactly one + * list to edit. + */ +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const CHECKS = { + "format-check": "pnpm format:check", + lint: "pnpm lint", + typecheck: "pnpm typecheck", + "no-instant-now-sub": "pnpm check:no-instant-now-sub", + "release-pr-changelog": "pnpm check:release-pr-changelog", + "spec-links": "pnpm check:spec-links", + "support-matrix": "pnpm check:support-matrix", + "homebrew-cask": "pnpm check:homebrew-cask", + "gateway-error-codes": "pnpm check:gateway-error-codes", + "plugin-system-docs": "pnpm check:plugin-system-docs", + "plugin-api-contract": "pnpm check:plugin-api-contract", + "plugin-sdk-typecheck": "pnpm --filter @aio-coding-hub/plugin-sdk typecheck", + "plugin-sdk-test": "pnpm --filter @aio-coding-hub/plugin-sdk test", + "create-aio-plugin-test": "pnpm --filter create-aio-plugin test", + "unit-coverage-shards": "pnpm test:unit:coverage:shards", + "generated-bindings": "pnpm check:generated-bindings", + "tauri-fmt": "pnpm tauri:fmt", + "tauri-check": "pnpm tauri:check", + "tauri-test": "pnpm tauri:test", + "tauri-lib-test": "cd src-tauri && cargo test --lib", + "tauri-clippy": "pnpm tauri:clippy", +}; + +const PRECOMMIT_SRC = ["lint", "typecheck", "no-instant-now-sub"]; +const PRECOMMIT_TAURI = ["tauri-check"]; +const PREPUSH_STATIC = [ + "lint", + "typecheck", + "support-matrix", + "homebrew-cask", + "gateway-error-codes", + "plugin-system-docs", + "plugin-api-contract", + "plugin-sdk-typecheck", + "tauri-fmt", +]; + +const STAGES = { + // pre-commit hook picks the sub-stage based on which files are staged. + "precommit-src": PRECOMMIT_SRC, + "precommit-tauri": PRECOMMIT_TAURI, + precommit: [...PRECOMMIT_SRC, ...PRECOMMIT_TAURI], + "precommit-full": [ + "format-check", + ...PRECOMMIT_SRC, + "release-pr-changelog", + "spec-links", + "support-matrix", + "homebrew-cask", + "gateway-error-codes", + "tauri-fmt", + "tauri-check", + "generated-bindings", + "tauri-clippy", + ], + prepush: [ + ...PREPUSH_STATIC, + "unit-coverage-shards", + "plugin-sdk-test", + "create-aio-plugin-test", + "generated-bindings", + "tauri-test", + "tauri-clippy", + ], + "plugin-hardening": [ + "plugin-api-contract", + "plugin-sdk-test", + "plugin-sdk-typecheck", + "tauri-lib-test", + ], +}; + +function listStages() { + for (const [stage, ids] of Object.entries(STAGES)) { + console.log(`${stage}:`); + for (const id of ids) { + console.log(` ${id}: ${CHECKS[id]}`); + } + } +} + +function main() { + const arg = process.argv[2]; + if (arg === "--list") { + listStages(); + return; + } + + const ids = STAGES[arg]; + if (!ids) { + console.error(`[checks] unknown stage: ${arg ?? ""}`); + console.error(`[checks] available stages: ${Object.keys(STAGES).join(", ")}`); + process.exit(1); + } + + for (const [index, id] of ids.entries()) { + const command = CHECKS[id]; + console.log(`[checks] (${index + 1}/${ids.length}) ${id}: ${command}`); + const result = spawnSync(command, { + cwd: repoRoot, + stdio: "inherit", + shell: true, + }); + if (result.status !== 0) { + console.error(`[checks] ${id} failed (exit ${result.status ?? "signal"})`); + process.exit(result.status ?? 1); + } + } + console.log(`[checks] stage "${arg}" passed (${ids.length} checks)`); +} + +main(); diff --git a/scripts/run-coverage-shards.mjs b/scripts/run-coverage-shards.mjs new file mode 100644 index 00000000..0228647f --- /dev/null +++ b/scripts/run-coverage-shards.mjs @@ -0,0 +1,37 @@ +/** + * Run the unit test suite as sequential vitest shards with blob reports, + * then merge the reports so the coverage thresholds gate the combined run. + * Per-shard thresholds are disabled because they only make sense globally. + */ +import { spawnSync } from "node:child_process"; +import { rmSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const SHARD_COUNT = 4; +const NO_THRESHOLDS = [ + "--coverage.thresholds.statements=0", + "--coverage.thresholds.branches=0", + "--coverage.thresholds.functions=0", + "--coverage.thresholds.lines=0", +].join(" "); + +function run(command) { + const result = spawnSync(command, { cwd: repoRoot, stdio: "inherit", shell: true }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +rmSync(resolve(repoRoot, ".vitest-reports"), { recursive: true, force: true }); + +for (let shard = 1; shard <= SHARD_COUNT; shard += 1) { + console.log(`[coverage-shards] shard ${shard}/${SHARD_COUNT}`); + run( + `pnpm exec vitest run --reporter=blob --coverage ${NO_THRESHOLDS} --shard=${shard}/${SHARD_COUNT}` + ); +} + +console.log("[coverage-shards] merging reports and applying coverage thresholds"); +run("pnpm exec vitest run --merge-reports --coverage"); diff --git a/scripts/support-matrix.homebrew-cask.selftest.mjs b/scripts/support-matrix.homebrew-cask.selftest.mjs new file mode 100644 index 00000000..af15b6f8 --- /dev/null +++ b/scripts/support-matrix.homebrew-cask.selftest.mjs @@ -0,0 +1,107 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const command = ["scripts/support-matrix.mjs", "homebrew-cask"]; + +function runSupportMatrix(args) { + return spawnSync("node", [...command, ...args], { + cwd: process.cwd(), + encoding: "utf8", + }); +} + +function assertIncludes(value, expected) { + if (!value.includes(expected)) { + throw new Error(`Expected output to include:\n${expected}\n\nActual output:\n${value}`); + } +} + +function assertEqual(actual, expected, label) { + if (actual !== expected) { + throw new Error(`${label}\nExpected: ${expected}\nActual: ${actual}`); + } +} + +function testPrintsCaskForCurrentRelease() { + const result = runSupportMatrix([ + "--tag", + "aio-coding-hub-v0.60.4", + "--repo", + "dyndynjyxa/aio-coding-hub", + "--macos-arm-sha256", + "6b126f39ec625e97d182301fafcbfff81ce6f332e297880aef2b0eab0a3c0c4a", + "--macos-intel-sha256", + "18f376bc6266e8cef4fb3978240ba0247c56b703370f6a95269443c2adbbbcc6", + ]); + + assertEqual(result.status, 0, "homebrew-cask command should succeed"); + assertIncludes(result.stdout, 'cask "aio-coding-hub" do'); + assertIncludes(result.stdout, 'version "0.60.4"'); + assertIncludes(result.stdout, 'arch arm: "arm", intel: "intel"'); + assertIncludes( + result.stdout, + 'sha256 arm: "6b126f39ec625e97d182301fafcbfff81ce6f332e297880aef2b0eab0a3c0c4a",' + ); + assertIncludes( + result.stdout, + ' intel: "18f376bc6266e8cef4fb3978240ba0247c56b703370f6a95269443c2adbbbcc6"' + ); + assertIncludes( + result.stdout, + 'url "https://github.com/dyndynjyxa/aio-coding-hub/releases/download/aio-coding-hub-v#{version}/aio-coding-hub-macos-#{arch}.zip"' + ); + assertIncludes(result.stdout, 'app "AIO Coding Hub.app"'); + assertIncludes(result.stdout, "auto_updates true"); + assertIncludes(result.stdout, "depends_on :macos"); +} + +function testWritesCaskToOutputPath() { + const root = mkdtempSync(join(tmpdir(), "aio-homebrew-cask-")); + const outputPath = join(root, "Casks/aio-coding-hub.rb"); + + try { + const result = runSupportMatrix([ + "--tag", + "aio-coding-hub-v0.60.4", + "--repo", + "dyndynjyxa/aio-coding-hub", + "--macos-arm-sha256", + "6b126f39ec625e97d182301fafcbfff81ce6f332e297880aef2b0eab0a3c0c4a", + "--macos-intel-sha256", + "18f376bc6266e8cef4fb3978240ba0247c56b703370f6a95269443c2adbbbcc6", + "--output", + outputPath, + ]); + + assertEqual(result.status, 0, "homebrew-cask command should write an output file"); + assertIncludes(readFileSync(outputPath, "utf8"), 'cask "aio-coding-hub" do'); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function testRequiresMacosHashes() { + const result = runSupportMatrix([ + "--tag", + "aio-coding-hub-v0.60.4", + "--repo", + "dyndynjyxa/aio-coding-hub", + "--macos-arm-sha256", + "6b126f39ec625e97d182301fafcbfff81ce6f332e297880aef2b0eab0a3c0c4a", + ]); + + assertEqual(result.status, 1, "homebrew-cask command should fail without both hashes"); + assertIncludes(result.stderr, "Missing required argument: --macos-intel-sha256"); +} + +for (const testCase of [ + testPrintsCaskForCurrentRelease, + testWritesCaskToOutputPath, + testRequiresMacosHashes, +]) { + testCase(); +} + +console.log("[support-matrix] Homebrew Cask self-test passed."); diff --git a/scripts/support-matrix.mjs b/scripts/support-matrix.mjs index 264df0d4..d8e0ccdb 100644 --- a/scripts/support-matrix.mjs +++ b/scripts/support-matrix.mjs @@ -194,6 +194,15 @@ const WORKFLOW_PATHS = Object.freeze({ releasePrSyncCargoLock: join(repoRoot, ".github/workflows/release-pr-sync-cargo-lock.yml"), }); +const HOMEBREW_CASK = Object.freeze({ + token: "aio-coding-hub", + appName: "AIO Coding Hub.app", + name: "AIO Coding Hub", + desc: "Local AI CLI unified gateway", + homepage: "https://github.com/dyndynjyxa/aio-coding-hub", + bundleIdentifier: "io.aio.codinghub", +}); + function getAllBuildTargets() { return [ ...OFFICIAL_RELEASE_TARGETS.map((item) => ({ @@ -390,6 +399,58 @@ function buildLatestJson({ tag, repo, pubDate, stableAssetsDir, releaseBody, fal }; } +function normalizeSha256(value, label) { + const normalized = value.replace(/^sha256:/, "").toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(normalized)) { + throw new Error(`Invalid SHA-256 for ${label}: ${value}`); + } + return normalized; +} + +function buildVersionedTagTemplate(tag, version) { + if (!tag.includes(version)) { + throw new Error(`Release tag must contain normalized version ${version}: ${tag}`); + } + return tag.replace(version, "#{version}"); +} + +function buildHomebrewCask({ tag, repo, macosArmSha256, macosIntelSha256 }) { + const version = normalizeReleaseVersion(tag, repo); + const tagTemplate = buildVersionedTagTemplate(tag, version); + const armSha256 = normalizeSha256(macosArmSha256, "macOS Apple Silicon zip"); + const intelSha256 = normalizeSha256(macosIntelSha256, "macOS Intel zip"); + + return [ + "# This file is generated from dyndynjyxa/aio-coding-hub.", + "# Update it by running `node scripts/support-matrix.mjs homebrew-cask` in the source repo.", + `cask "${HOMEBREW_CASK.token}" do`, + ' arch arm: "arm", intel: "intel"', + "", + ` version "${version}"`, + ` sha256 arm: "${armSha256}",`, + ` intel: "${intelSha256}"`, + "", + ` url "https://github.com/${repo}/releases/download/${tagTemplate}/aio-coding-hub-macos-#{arch}.zip"`, + ` name "${HOMEBREW_CASK.name}"`, + ` desc "${HOMEBREW_CASK.desc}"`, + ` homepage "${HOMEBREW_CASK.homepage}"`, + "", + " auto_updates true", + " depends_on :macos", + "", + ` app "${HOMEBREW_CASK.appName}"`, + "", + " zap trash: [", + ` "~/Library/Application Support/${HOMEBREW_CASK.bundleIdentifier}",`, + ` "~/Library/Caches/${HOMEBREW_CASK.bundleIdentifier}",`, + ` "~/Library/Preferences/${HOMEBREW_CASK.bundleIdentifier}.plist",`, + ` "~/Library/Saved Application State/${HOMEBREW_CASK.bundleIdentifier}.savedState",`, + " ]", + "end", + "", + ].join("\n"); +} + function extractMarkedBlock(content, markerName) { const { start, end } = README_MARKERS[markerName]; const startIndex = content.indexOf(start); @@ -507,6 +568,11 @@ function checkWorkflowContracts() { "ci desktop matrix usage" ); assertWorkflowContains(ciWorkflow, "run: pnpm check:support-matrix", "ci support matrix check"); + assertWorkflowContains( + ciWorkflow, + "run: node scripts/support-matrix.homebrew-cask.selftest.mjs", + "ci Homebrew Cask generator check" + ); assertWorkflowContains(ciWorkflow, "run: pnpm audit:deps", "ci fail-close dependency audit"); assertWorkflowContains( @@ -534,6 +600,12 @@ function checkWorkflowContracts() { "node scripts/support-matrix.mjs generate-latest-json \\", "latest.json generation delegation" ); + assertWorkflowContains( + releaseWorkflow, + "node scripts/support-matrix.mjs homebrew-cask \\", + "Homebrew Cask generation delegation" + ); + assertWorkflowContains(releaseWorkflow, "HOMEBREW_TAP_TOKEN", "optional Homebrew tap sync token"); } function runSupportMatrixCheck() { @@ -733,6 +805,30 @@ function writeLatestJsonFile(args) { logger.info("[support-matrix] latest.json 生成完成:%s", outputPath); } +function writeHomebrewCaskFile(args) { + const tag = requireArg(args, "tag"); + const repo = requireArg(args, "repo"); + const macosArmSha256 = requireArg(args, "macos-arm-sha256"); + const macosIntelSha256 = requireArg(args, "macos-intel-sha256"); + const outputPath = args.get("output") ?? ""; + + const cask = buildHomebrewCask({ + tag, + repo, + macosArmSha256, + macosIntelSha256, + }); + + if (outputPath.length === 0) { + process.stdout.write(cask); + return; + } + + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, cask, "utf8"); + logger.info("[support-matrix] Homebrew Cask 生成完成:%s", outputPath); +} + function printBuildMatrix() { process.stdout.write(JSON.stringify(buildWorkflowMatrix())); } @@ -752,7 +848,7 @@ function printReadmeBlock(args) { function printUsageAndExit() { logger.error( - "Usage: node scripts/support-matrix.mjs [--key value]" + "Usage: node scripts/support-matrix.mjs [--key value]" ); process.exit(1); } @@ -781,6 +877,9 @@ function main() { case "generate-latest-json": writeLatestJsonFile(args); return; + case "homebrew-cask": + writeHomebrewCaskFile(args); + return; case "readme-block": printReadmeBlock(args); return; diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7c2bcad9..d540eaa4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,15 +8,6 @@ version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" @@ -46,7 +37,7 @@ dependencies = [ [[package]] name = "aio-coding-hub" -version = "0.60.2" +version = "0.60.11" dependencies = [ "axum", "base64 0.22.1", @@ -64,6 +55,7 @@ dependencies = [ "regex", "reqwest", "rodio", + "rquickjs", "rusqlite", "serde", "serde_json", @@ -91,8 +83,6 @@ dependencies = [ "tracing-appender", "tracing-log", "tracing-subscriber", - "wasmtime", - "wat", "windows-sys 0.59.0", "zip", ] @@ -151,9 +141,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arbitrary" @@ -511,9 +501,6 @@ name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -dependencies = [ - "allocator-api2", -] [[package]] name = "bytemuck" @@ -643,7 +630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" dependencies = [ "smallvec", - "target-lexicon 0.12.16", + "target-lexicon", ] [[package]] @@ -679,15 +666,6 @@ dependencies = [ "error-code", ] -[[package]] -name = "cobs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" -dependencies = [ - "thiserror 2.0.18", -] - [[package]] name = "combine" version = "4.6.7" @@ -828,144 +806,6 @@ dependencies = [ "libc", ] -[[package]] -name = "cranelift-assembler-x64" -version = "0.123.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de2be1bdbf929c2a2242cbbe15d6583c56f1cc723c6c8452d0179362de28c9d5" -dependencies = [ - "cranelift-assembler-x64-meta", -] - -[[package]] -name = "cranelift-assembler-x64-meta" -version = "0.123.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a0336914de11298290783a95a9a7154b894da601659eb5f8f8bc62d1bea98f8" -dependencies = [ - "cranelift-srcgen", -] - -[[package]] -name = "cranelift-bforest" -version = "0.123.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb972cba51a52c1b2a329fec993b911e4d1f9cfab3795811a319b6746c28e014" -dependencies = [ - "cranelift-entity", -] - -[[package]] -name = "cranelift-bitset" -version = "0.123.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "642c920666bfed9aebca39d8c6e7cb76f09314cc7a4074b1db5edcccdde771b9" -dependencies = [ - "serde", - "serde_derive", -] - -[[package]] -name = "cranelift-codegen" -version = "0.123.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e1231caaeee3d2363d9b2dba9d6c1f7ff835b8ede6612fba98120af73df44bd" -dependencies = [ - "bumpalo", - "cranelift-assembler-x64", - "cranelift-bforest", - "cranelift-bitset", - "cranelift-codegen-meta", - "cranelift-codegen-shared", - "cranelift-control", - "cranelift-entity", - "cranelift-isle", - "gimli", - "hashbrown 0.15.4", - "log", - "pulley-interpreter", - "regalloc2", - "rustc-hash", - "serde", - "smallvec", - "target-lexicon 0.13.5", - "wasmtime-internal-math", -] - -[[package]] -name = "cranelift-codegen-meta" -version = "0.123.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb83e89be8b413e4f7a4215a02d5c5f3e6f04b1060f5db293dd1007b2871dcf5" -dependencies = [ - "cranelift-assembler-x64-meta", - "cranelift-codegen-shared", - "cranelift-srcgen", - "heck 0.5.0", - "pulley-interpreter", -] - -[[package]] -name = "cranelift-codegen-shared" -version = "0.123.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d14f8068a98f0a85ffa63dc5fe73cb486a955adbe7311465d13cde54c656d5f" - -[[package]] -name = "cranelift-control" -version = "0.123.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c070aee9312b9736028e99b58d45e1099683386082af38529d5e2ce8c76648f3" -dependencies = [ - "arbitrary", -] - -[[package]] -name = "cranelift-entity" -version = "0.123.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2d619bb3d14251e96dc9b6a846d6955d78048a168cc3876eb2b789b855c1c22" -dependencies = [ - "cranelift-bitset", - "serde", - "serde_derive", -] - -[[package]] -name = "cranelift-frontend" -version = "0.123.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2350fcff24d78be5e4201e1eeb4b306e474b9f21e452722b21ffc4f773e8d49a" -dependencies = [ - "cranelift-codegen", - "log", - "smallvec", - "target-lexicon 0.13.5", -] - -[[package]] -name = "cranelift-isle" -version = "0.123.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bdc2b14d7491c53c2989b967b4c07511374733abbc01a895fb01ea31e97bfc8" - -[[package]] -name = "cranelift-native" -version = "0.123.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e98dbe1326d0001a17b3b0675e3adafcfbd0e7f25f1f845a2f1bb9ce3029f359" -dependencies = [ - "cranelift-codegen", - "libc", - "target-lexicon 0.13.5", -] - -[[package]] -name = "cranelift-srcgen" -version = "0.123.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36d7af563cd300c8a1e4e64387929b40e32867112143f0a0e1ce90f977ce4a41" - [[package]] name = "crc32fast" version = "1.5.0" @@ -1324,12 +1164,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "either" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" - [[package]] name = "embed-resource" version = "3.0.6" @@ -1350,18 +1184,6 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" -[[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 = "encoding_rs" version = "0.8.35" @@ -1562,6 +1384,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.5.0" @@ -1859,17 +1687,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" -dependencies = [ - "fallible-iterator", - "indexmap 2.14.0", - "stable_deref_trait", -] - [[package]] name = "gio" version = "0.18.4" @@ -2050,8 +1867,7 @@ version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" dependencies = [ - "foldhash", - "serde", + "foldhash 0.1.5", ] [[package]] @@ -2059,6 +1875,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -2458,15 +2279,6 @@ dependencies = [ "once_cell", ] -[[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" @@ -2591,12 +2403,6 @@ 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 = "libappindicator" version = "0.9.0" @@ -2637,12 +2443,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - [[package]] name = "libredox" version = "0.1.12" @@ -2775,15 +2575,6 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "memfd" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" -dependencies = [ - "rustix", -] - [[package]] name = "memoffset" version = "0.9.1" @@ -3270,18 +3061,6 @@ dependencies = [ "objc2-security", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "crc32fast", - "hashbrown 0.15.4", - "indexmap 2.14.0", - "memchr", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -3604,13 +3383,13 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "plist" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml 0.38.4", + "quick-xml 0.39.2", "serde", "time", ] @@ -3655,18 +3434,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[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.5" @@ -3765,29 +3532,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "pulley-interpreter" -version = "36.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "329f575a931601f71fbcb3b31d32d16273da5ba7f532fc10be2e432e710b02de" -dependencies = [ - "cranelift-bitset", - "log", - "pulley-macros", - "wasmtime-internal-math", -] - -[[package]] -name = "pulley-macros" -version = "36.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bccae89ed67a40989e780105fab43e6c71a077b9fc8ae4c805ff5f73d2a79c8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "pxfm" version = "0.1.27" @@ -3812,15 +3556,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.39.2" @@ -3852,9 +3587,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "bytes", "getrandom 0.3.4", @@ -4098,20 +3833,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "regalloc2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5216b1837de2149f8bc8e6d5f88a9326b63b8c836ed58ce4a0a29ec736a59734" -dependencies = [ - "allocator-api2", - "bumpalo", - "hashbrown 0.15.4", - "log", - "rustc-hash", - "smallvec", -] - [[package]] name = "regex" version = "1.12.2" @@ -4141,6 +3862,15 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "relative-path" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca40a312222d8ba74837cb474edef44b37f561da5f773981007a10bbaa992b0" +dependencies = [ + "serde", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -4233,6 +3963,35 @@ dependencies = [ "symphonia", ] +[[package]] +name = "rquickjs" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0688f8b0192998cca685adefdfad3483da295fa40a0ec406b4c14ecd729e858" +dependencies = [ + "rquickjs-core", +] + +[[package]] +name = "rquickjs-core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fee8c5383f0cfda3b980a80ca4520e726e09b593c59562f579daa51b6c20411" +dependencies = [ + "hashbrown 0.17.1", + "relative-path", + "rquickjs-sys", +] + +[[package]] +name = "rquickjs-sys" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "698077537c286a169de8693b216672bcef148bf2e2e112ebf50758c68e9afa09" +dependencies = [ + "cc", +] + [[package]] name = "rusqlite" version = "0.31.0" @@ -4724,9 +4483,6 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -dependencies = [ - "serde", -] [[package]] name = "socket2" @@ -5088,12 +4844,6 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" -[[package]] -name = "target-lexicon" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" - [[package]] name = "tauri" version = "2.9.5" @@ -5564,15 +5314,6 @@ dependencies = [ "utf-8", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -6073,12 +5814,6 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - [[package]] name = "untrusted" version = "0.9.0" @@ -6274,26 +6009,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.236.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "724fccfd4f3c24b7e589d333fc0429c68042897a7e8a5f8694f31792471841e7" -dependencies = [ - "leb128fmt", - "wasmparser 0.236.1", -] - -[[package]] -name = "wasm-encoder" -version = "0.251.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a879a421bd17c528b74721b2abf4c62e8f1d1889c2ba8c3c50d02deaf2ce395" -dependencies = [ - "leb128fmt", - "wasmparser 0.251.0", -] - [[package]] name = "wasm-streams" version = "0.4.2" @@ -6307,240 +6022,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.236.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9b1e81f3eb254cf7404a82cee6926a4a3ccc5aad80cc3d43608a070c67aa1d7" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.4", - "indexmap 2.14.0", - "semver", - "serde", -] - -[[package]] -name = "wasmparser" -version = "0.251.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "437970b35b1a85cfde9c74b2398352d8d653f3bd8e3a3db0c063ea8f5b4b36ff" -dependencies = [ - "bitflags 2.11.1", - "indexmap 2.14.0", - "semver", -] - -[[package]] -name = "wasmprinter" -version = "0.236.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2df225df06a6df15b46e3f73ca066ff92c2e023670969f7d50ce7d5e695abbb1" -dependencies = [ - "anyhow", - "termcolor", - "wasmparser 0.236.1", -] - -[[package]] -name = "wasmtime" -version = "36.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507d213104e83a7519d91af444a8b19c04281f2eef162d448ee7a894ac1c827d" -dependencies = [ - "addr2line", - "anyhow", - "bitflags 2.11.1", - "bumpalo", - "cc", - "cfg-if", - "hashbrown 0.15.4", - "indexmap 2.14.0", - "libc", - "log", - "mach2", - "memfd", - "object", - "once_cell", - "postcard", - "pulley-interpreter", - "rustix", - "serde", - "serde_derive", - "smallvec", - "target-lexicon 0.13.5", - "wasmparser 0.236.1", - "wasmtime-environ", - "wasmtime-internal-asm-macros", - "wasmtime-internal-cranelift", - "wasmtime-internal-fiber", - "wasmtime-internal-jit-debug", - "wasmtime-internal-jit-icache-coherence", - "wasmtime-internal-math", - "wasmtime-internal-slab", - "wasmtime-internal-unwinder", - "wasmtime-internal-versioned-export-macros", - "windows-sys 0.60.2", -] - -[[package]] -name = "wasmtime-environ" -version = "36.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9784b325c3b85562ac6d7f81c8348c42af1f137d98dd4fc6631860e4e68bb655" -dependencies = [ - "anyhow", - "cranelift-bitset", - "cranelift-entity", - "gimli", - "indexmap 2.14.0", - "log", - "object", - "postcard", - "serde", - "serde_derive", - "smallvec", - "target-lexicon 0.13.5", - "wasm-encoder 0.236.1", - "wasmparser 0.236.1", - "wasmprinter", -] - -[[package]] -name = "wasmtime-internal-asm-macros" -version = "36.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcaa9336cd5ba934ba734dfdfe35f5245c3c74b4e34f9af9e114fad892d81b3d" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "wasmtime-internal-cranelift" -version = "36.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7d938ae501275f44e7e5532ae4bb720542b429357014d33842e128c46fb9b54" -dependencies = [ - "anyhow", - "cfg-if", - "cranelift-codegen", - "cranelift-control", - "cranelift-entity", - "cranelift-frontend", - "cranelift-native", - "gimli", - "itertools", - "log", - "object", - "pulley-interpreter", - "smallvec", - "target-lexicon 0.13.5", - "thiserror 2.0.18", - "wasmparser 0.236.1", - "wasmtime-environ", - "wasmtime-internal-math", - "wasmtime-internal-versioned-export-macros", -] - -[[package]] -name = "wasmtime-internal-fiber" -version = "36.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1443b0914ff848ee7920e0f232368168e2819b739c54f3c352f0559b6164343" -dependencies = [ - "anyhow", - "cc", - "cfg-if", - "libc", - "rustix", - "wasmtime-internal-asm-macros", - "wasmtime-internal-versioned-export-macros", - "windows-sys 0.60.2", -] - -[[package]] -name = "wasmtime-internal-jit-debug" -version = "36.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "861d6f2a1652e95ca10b02552934b3bd460d7416b285fe10d7ca8c0a2b90dc3e" -dependencies = [ - "cc", - "wasmtime-internal-versioned-export-macros", -] - -[[package]] -name = "wasmtime-internal-jit-icache-coherence" -version = "36.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1caeb3140c46319fecf09d93dc38a373eb535fd478e401a9fb2ac2da30fe5f6" -dependencies = [ - "anyhow", - "cfg-if", - "libc", - "windows-sys 0.60.2", -] - -[[package]] -name = "wasmtime-internal-math" -version = "36.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c631615929951a4076aae64da7d6cad88668d292f19672606392c24ae9c5a00" -dependencies = [ - "libm", -] - -[[package]] -name = "wasmtime-internal-slab" -version = "36.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b28104d57b5bdb5d8facb3a8418463ec6c2cb40bb4adf9833b727ebf6a254eb" - -[[package]] -name = "wasmtime-internal-unwinder" -version = "36.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd89f2db7377869aeaf66b71f56def8df54b9482e4f4e5533ccec2505f5c691" -dependencies = [ - "anyhow", - "cfg-if", - "cranelift-codegen", - "log", - "object", -] - -[[package]] -name = "wasmtime-internal-versioned-export-macros" -version = "36.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9cdb9c2e3965ee15629d067203cb800e9822664d04335dadc6fe1788d4fc335" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "wast" -version = "251.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc7467dda0a96142eb2c980329dfb62480b1e1d3622fdeb1a44e2bca6ceed74" -dependencies = [ - "bumpalo", - "leb128fmt", - "memchr", - "unicode-width", - "wasm-encoder 0.251.0", -] - -[[package]] -name = "wat" -version = "1.251.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81b1086c9e85b95bd6a229a928bc6c6d0662e42af0250c88d067b418831ea4d4" -dependencies = [ - "wast", -] - [[package]] name = "wayland-backend" version = "0.3.15" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0d82cab4..2aca686b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aio-coding-hub" -version = "0.60.2" +version = "0.60.11" description = "AIO Coding Hub" authors = ["dyndynjyxa"] edition = "2021" @@ -46,7 +46,6 @@ hostname = "0.4" futures-core = "0.3" flate2 = "1.1.5" zip = { version = "4.6", default-features = false, features = ["deflate"] } -wasmtime = { version = "36.0.2", default-features = false, features = ["cranelift", "runtime", "std"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } chrono = { version = "0.4", default-features = false, features = ["std"] } @@ -59,6 +58,7 @@ specta-typescript = "0.0.9" tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] } tauri-plugin-dialog = "2" rodio = { version = "0.21.1", default-features = false, features = ["playback", "mp3"] } +rquickjs = { version = "0.12.0", default-features = false, features = ["std"] } [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] tauri-plugin-updater = "2" @@ -75,7 +75,6 @@ junction = "1" tempfile = "3" tauri = { version = "2", features = ["test"] } tower = { version = "0.5", features = ["util"] } -wat = "1.251" [patch.crates-io] # Patch `mac-notification-sys` so notifications can still present when app is foreground on macOS. diff --git a/src-tauri/resources/plugins/official/privacy-filter/dist/extension.js b/src-tauri/resources/plugins/official/privacy-filter/dist/extension.js new file mode 100644 index 00000000..4626a814 --- /dev/null +++ b/src-tauri/resources/plugins/official/privacy-filter/dist/extension.js @@ -0,0 +1,68 @@ +function arrayOption(value) { + return Array.isArray(value) + ? value.filter(function filterString(item) { + return typeof item === "string"; + }) + : undefined; +} + +function privacyOptions(config) { + return { + sensitiveTypes: arrayOption(config && config.sensitiveTypes), + redactionScopes: arrayOption(config && config.redactionScopes), + }; +} + +function handleRequestHook(api, payload) { + const config = payload && payload.config ? payload.config : {}; + if (config.redactBeforeUpstream !== true) { + return { action: "pass" }; + } + const body = + payload && payload.context && payload.context.request + ? payload.context.request.body + : undefined; + if (typeof body !== "string" || body.length === 0) { + return { action: "pass" }; + } + const result = api.privacy.redactRequestBody(body, privacyOptions(config)); + return result && result.hit + ? { action: "replace", requestBody: result.redacted } + : { action: "pass" }; +} + +function handleLogHook(api, payload) { + const config = payload && payload.config ? payload.config : {}; + if (config.redactLogs !== true) { + return { action: "pass" }; + } + const message = + payload && payload.context && payload.context.log + ? payload.context.log.message + : undefined; + if (typeof message !== "string" || message.length === 0) { + return { action: "pass" }; + } + const result = api.privacy.redactText(message, privacyOptions(config)); + return result && result.hit + ? { action: "replace", logMessage: result.redacted } + : { action: "pass" }; +} + +module.exports.activate = function activate(api) { + api.gateway.registerHook( + "gateway.request.afterBodyRead", + function onAfterBodyRead(payload) { + return handleRequestHook(api, payload); + } + ); + api.gateway.registerHook( + "gateway.request.beforeSend", + function onBeforeSend(payload) { + return handleRequestHook(api, payload); + } + ); + api.gateway.registerHook("log.beforePersist", function onBeforePersist(payload) { + return handleLogHook(api, payload); + }); +}; diff --git a/src-tauri/resources/plugins/official/privacy-filter/plugin.json b/src-tauri/resources/plugins/official/privacy-filter/plugin.json index 41bd901e..1fc249c8 100644 --- a/src-tauri/resources/plugins/official/privacy-filter/plugin.json +++ b/src-tauri/resources/plugins/official/privacy-filter/plugin.json @@ -5,7 +5,7 @@ "apiVersion": "1.0.0", "configVersion": 3, "category": "privacy", - "description": "Official native privacy filter aligned with packyme/privacy-filter for pre-upstream prompt and log redaction.", + "description": "Official Extension Host privacy filter aligned with packyme/privacy-filter for pre-upstream prompt and log redaction.", "homepage": "https://github.com/packyme/privacy-filter", "repository": { "type": "git", @@ -13,29 +13,40 @@ }, "license": "MIT", "runtime": { - "kind": "native", - "engine": "privacyFilter" + "kind": "extensionHost", + "language": "typescript" }, - "hooks": [ - { - "name": "gateway.request.afterBodyRead", - "priority": 5, - "failurePolicy": "fail-closed" - }, - { - "name": "gateway.request.beforeSend", - "priority": 5, - "failurePolicy": "fail-closed" - }, - { - "name": "log.beforePersist", - "priority": 1, - "failurePolicy": "fail-closed" - } + "main": "dist/extension.js", + "activationEvents": [ + "onGatewayHook:gateway.request.afterBodyRead", + "onGatewayHook:gateway.request.beforeSend", + "onGatewayHook:log.beforePersist" ], - "permissions": ["request.body.read", "request.body.write", "log.redact"], + "capabilities": ["gateway.hooks", "privacy.redact"], + "contributes": { + "gatewayHooks": [ + { + "name": "gateway.request.afterBodyRead", + "priority": 5, + "failurePolicy": "fail-closed", + "timeoutMs": 5000 + }, + { + "name": "gateway.request.beforeSend", + "priority": 5, + "failurePolicy": "fail-closed", + "timeoutMs": 5000 + }, + { + "name": "log.beforePersist", + "priority": 1, + "failurePolicy": "fail-closed", + "timeoutMs": 5000 + } + ] + }, "hostCompatibility": { - "app": ">=0.56.0 <1.0.0", + "app": ">=0.60.0 <1.0.0", "pluginApi": "^1.0.0", "platforms": ["macos", "windows", "linux"] }, @@ -166,12 +177,7 @@ "type": "array", "title": "请求处理范围", "description": "选择发送给模型前需要脱敏的协议文本字段。工具定义、工具调用参数、元数据、推理块和文件/图片引用不会被处理。", - "default": [ - "system_instructions", - "user_prompts", - "tool_results", - "legacy_prompt" - ], + "default": ["system_instructions", "user_prompts", "tool_results", "legacy_prompt"], "items": { "type": "string", "enum": [ diff --git a/src-tauri/src/app/cleanup.rs b/src-tauri/src/app/cleanup.rs index f93e2ea2..249c84e5 100644 --- a/src-tauri/src/app/cleanup.rs +++ b/src-tauri/src/app/cleanup.rs @@ -21,6 +21,7 @@ const CLEANUP_STATE_DONE: u8 = 2; const CLEANUP_WAIT_TIMEOUT: Duration = Duration::from_secs(15); const CLI_PROXY_RESTORE_TIMEOUT: Duration = Duration::from_secs(3); +const EXTENSION_HOST_DISPOSE_TIMEOUT: Duration = Duration::from_secs(5); const GATEWAY_SERVER_STOP_TIMEOUT: Duration = Duration::from_secs(3); const GATEWAY_BACKGROUND_DRAIN_TIMEOUT: Duration = Duration::from_secs(1); const GATEWAY_OAUTH_STOP_TIMEOUT: Duration = Duration::from_secs(1); @@ -59,6 +60,7 @@ pub(crate) async fn cleanup_before_exit(app: &tauri::AppHandle) { Ordering::Acquire, ) { Ok(_) => { + dispose_extension_hosts_best_effort(app).await; stop_gateway_best_effort(app).await; restore_cli_proxy_keep_state_best_effort( app, @@ -96,6 +98,22 @@ pub(crate) async fn cleanup_before_exit(app: &tauri::AppHandle) { } } +async fn dispose_extension_hosts_best_effort(app: &tauri::AppHandle) { + let Some(state) = + app.try_state::() + else { + return; + }; + + match tokio::time::timeout(EXTENSION_HOST_DISPOSE_TIMEOUT, state.dispose_all()).await { + Ok(()) => tracing::info!("extension host instances disposed during exit cleanup"), + Err(_) => tracing::warn!( + "exit cleanup: extension host disposal timed out ({}s)", + EXTENSION_HOST_DISPOSE_TIMEOUT.as_secs() + ), + } +} + async fn wait_for_cleanup_done(notify: &Notify) { if CLEANUP_STATE.load(Ordering::Acquire) == CLEANUP_STATE_DONE { return; diff --git a/src-tauri/src/app/gateway_runtime_access.rs b/src-tauri/src/app/gateway_runtime_access.rs index 4a0958cd..d0726967 100644 --- a/src-tauri/src/app/gateway_runtime_access.rs +++ b/src-tauri/src/app/gateway_runtime_access.rs @@ -31,6 +31,17 @@ pub(crate) fn app_gateway_active_sessions( }) } +pub(crate) fn app_gateway_active_requests_snapshot( + app: &tauri::AppHandle, +) -> Vec { + super::gateway_state::try_with_app_running_gateway(app, |running| { + running + .map(|runtime| runtime.active_requests_snapshot()) + .unwrap_or_default() + }) + .unwrap_or_default() +} + pub(crate) fn app_gateway_circuit_status( app: &tauri::AppHandle, db: &db::Db, @@ -40,3 +51,15 @@ pub(crate) fn app_gateway_circuit_status( gateway::control_service::GatewayControlService::circuit_status(running, app, db, cli_key) }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn active_requests_snapshot_returns_empty_without_running_gateway() { + let app = tauri::test::mock_app(); + + assert!(app_gateway_active_requests_snapshot(app.handle()).is_empty()); + } +} diff --git a/src-tauri/src/app/heartbeat_watchdog.rs b/src-tauri/src/app/heartbeat_watchdog.rs index 596aadbd..3de4e8d8 100644 --- a/src-tauri/src/app/heartbeat_watchdog.rs +++ b/src-tauri/src/app/heartbeat_watchdog.rs @@ -4,18 +4,35 @@ //! Contract: //! - Backend emits `app:heartbeat` every 15s. //! - Frontend listens to `app:heartbeat` and invokes `app_heartbeat_pong` (fire-and-forget). -//! - If backend sees no pong for 30s and the main window is visible (and not minimized), -//! it triggers recovery with exponential backoff + circuit breaker. +//! - If backend sees no pong for 30s (60s while the window is hidden/minimized, to +//! tolerate OS throttling), it triggers recovery with exponential backoff. //! -//! Recovery escalation: -//! 1. Page-level reload (for normal white screen). -//! 2. If error is unrecoverable (e.g. HRESULT 0x8007139F): mark webview broken, -//! attempt to destroy + rebuild the main window. -//! 3. If rebuild fails or rebuild attempts exhausted: fallback to full app restart -//! with restart-storm protection via a marker file. +//! Recovery escalation (visibility tunes behavior, it never blocks detection): +//! 1. Page-level reload — runs even while the window is hidden in the tray, so a +//! WebView that dies in the background is repaired before the user opens it. +//! 2. If error is unrecoverable (e.g. HRESULT 0x8007139F) or reloads are exhausted: +//! mark webview broken, destroy + rebuild the main window from the tauri.conf.json +//! window config, preserving its previous visibility (a hidden window is rebuilt +//! hidden, without stealing focus). At most `REBUILD_MAX_ATTEMPTS` consecutive +//! unconfirmed rebuilds per broken episode (a pong resets the budget). +//! 3. If rebuild fails or the rebuild budget is exhausted: full app restart with +//! restart-storm protection via a marker file. Restart is deferred while the +//! window is hidden so ongoing gateway traffic is not killed behind the user's +//! back; a missing window is always restartable (there is nothing left to show). +//! +//! Every "wait for the frontend to confirm recovery" state carries a deadline +//! (`recovery_confirm_deadline`): if no pong arrives in time the attempt counts as +//! failed and escalation continues, so recovery can never deadlock waiting forever. +//! Deferring never holds the confirm slot — only an actually-issued rebuild does. +//! `on_main_window_shown` runs an immediate check when the window is shown or +//! focused, so a user opening the window never stares at a white screen until the +//! next tick; freshly shown windows get one heartbeat round-trip of grace before +//! silence accrued under OS throttling is judged by the strict visible threshold. +//! Checks are serialized by `check_in_progress` — the tick loop and show/focus +//! hooks can never run two recovery passes concurrently. use serde::Serialize; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Mutex; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tauri::{Emitter, Manager}; @@ -29,16 +46,29 @@ const MAIN_WINDOW_LABEL: &str = "main"; pub(crate) const HEARTBEAT_EVENT_NAME: &str = "app:heartbeat"; const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15); const PONG_TIMEOUT: Duration = Duration::from_secs(30); +/// Hidden/minimized windows may have their timers throttled by the OS, so give +/// them a longer grace period before treating pong silence as a dead WebView. +const PONG_TIMEOUT_HIDDEN: Duration = Duration::from_secs(60); +/// Until the very first pong of this process arrives, a hidden window (e.g. +/// `start_minimized` on a slow cold start) gets an even longer allowance so a +/// still-loading frontend is not reloaded mid-boot. +const STARTUP_PONG_TIMEOUT_HIDDEN: Duration = Duration::from_secs(180); +/// After the window becomes visible/focused, silence accrued while hidden is +/// still judged by the lenient hidden threshold for this long — the frontend +/// needs at least one heartbeat round-trip to prove itself after unthrottling. +const RECENTLY_SHOWN_GRACE: Duration = Duration::from_secs(20); +/// How long an escalated recovery (window rebuild) may wait for a confirming +/// pong before the attempt is considered failed and escalation continues. +const RECOVERY_CONFIRM_TIMEOUT: Duration = Duration::from_secs(90); const RECOVERY_BACKOFF_BASE: Duration = Duration::from_secs(30); const RECOVERY_BACKOFF_MAX: Duration = Duration::from_secs(5 * 60); const RECOVERY_CIRCUIT_THRESHOLD: u32 = 5; -/// Maximum number of window rebuild attempts within `REBUILD_COOLDOWN` before -/// escalating to a full app restart. +/// Maximum number of consecutive unconfirmed window rebuild attempts before +/// escalating to a full app restart. Reset by a pong. const REBUILD_MAX_ATTEMPTS: u32 = 3; -const REBUILD_COOLDOWN: Duration = Duration::from_secs(120); /// If a restart marker file is younger than this duration at startup, we consider /// the app to be in a restart storm and refuse to auto-recover. @@ -69,17 +99,22 @@ struct WatchdogSnapshot { #[derive(Debug)] struct WatchdogInner { last_pong_unix_ms: u64, + /// `true` once any pong has been received in this process — before that, + /// silence may just be a slow frontend boot, not a dead WebView. + has_received_pong: bool, recovery_streak: u32, next_recovery_allowed_unix_ms: u64, circuit_open_until_unix_ms: u64, last_timeout_logged_unix_ms: u64, + /// Throttles the recurring "restart deferred while hidden" warning. + last_deferred_restart_logged_unix_ms: u64, + /// Timestamp (unix ms) of the last show/focus of the main window. + last_shown_unix_ms: u64, /// Whether the WebView has been classified as unrecoverably broken /// (e.g. HRESULT 0x8007139F). webview_broken: bool, - /// Number of window rebuild attempts within the current cooldown window. + /// Number of consecutive unconfirmed rebuild attempts. Reset by a pong. rebuild_count: u32, - /// Timestamp (unix ms) of the first rebuild attempt in the current window. - first_rebuild_unix_ms: u64, } impl Default for WatchdogInner { @@ -87,13 +122,15 @@ impl Default for WatchdogInner { let now = now_unix_millis(); Self { last_pong_unix_ms: now, + has_received_pong: false, recovery_streak: 0, next_recovery_allowed_unix_ms: 0, circuit_open_until_unix_ms: 0, last_timeout_logged_unix_ms: 0, + last_deferred_restart_logged_unix_ms: 0, + last_shown_unix_ms: 0, webview_broken: false, rebuild_count: 0, - first_rebuild_unix_ms: 0, } } } @@ -103,8 +140,17 @@ pub(crate) struct HeartbeatWatchdogState { /// `false` when the WebView is confirmed unresponsive (reload failed). /// Checked by event emitters to skip sending to a dead WebView. webview_alive: AtomicBool, - /// Prevents concurrent recovery attempts (rebuild / restart). - recovery_in_flight: AtomicBool, + /// Unix ms until which an escalated recovery (rebuild) is awaiting a + /// confirming pong. `0` means no recovery is awaiting confirmation. + /// Once the deadline passes without a pong the attempt counts as failed + /// and escalation continues — this can never deadlock recovery. + recovery_confirm_deadline_unix_ms: AtomicU64, + /// Set when a restart storm is detected: auto-recovery stays off for the + /// rest of the session so the storm dialog is shown exactly once. + auto_recovery_disabled: AtomicBool, + /// Serializes `check_and_recover_if_needed` — the tick loop and the + /// show/focus hooks must never run two recovery passes concurrently. + check_in_progress: AtomicBool, } impl Default for HeartbeatWatchdogState { @@ -112,11 +158,22 @@ impl Default for HeartbeatWatchdogState { Self { inner: Mutex::new(WatchdogInner::default()), webview_alive: AtomicBool::new(true), - recovery_in_flight: AtomicBool::new(false), + recovery_confirm_deadline_unix_ms: AtomicU64::new(0), + auto_recovery_disabled: AtomicBool::new(false), + check_in_progress: AtomicBool::new(false), } } } +/// Clears `check_in_progress` on every exit path of a recovery check. +struct CheckInProgressGuard<'a>(&'a AtomicBool); + +impl Drop for CheckInProgressGuard<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + impl HeartbeatWatchdogState { /// Returns `true` when the WebView is believed to be responsive. /// Event emitters should skip `app.emit()` when this returns `false`. @@ -132,7 +189,8 @@ impl HeartbeatWatchdogState { let now = now_unix_millis(); // A pong proves the WebView is alive. self.set_webview_alive(true); - self.recovery_in_flight.store(false, Ordering::Relaxed); + self.recovery_confirm_deadline_unix_ms + .store(0, Ordering::Release); let mut inner = self .inner @@ -140,12 +198,12 @@ impl HeartbeatWatchdogState { .unwrap_or_else(|poisoned| poisoned.into_inner()); inner.last_pong_unix_ms = now; + inner.has_received_pong = true; inner.recovery_streak = 0; inner.next_recovery_allowed_unix_ms = 0; inner.circuit_open_until_unix_ms = 0; inner.webview_broken = false; inner.rebuild_count = 0; - inner.first_rebuild_unix_ms = 0; } fn snapshot(&self) -> WatchdogSnapshot { @@ -177,25 +235,105 @@ impl HeartbeatWatchdogState { inner.webview_broken = true; } - /// Returns `true` if we can still attempt a window rebuild, `false` if - /// max attempts within cooldown have been exhausted. + /// Returns `true` if we can still attempt a window rebuild, `false` once + /// `REBUILD_MAX_ATTEMPTS` consecutive rebuilds went unconfirmed. Only a + /// pong (proof of a live WebView) resets the budget — a wall-clock window + /// would silently re-arm an endless rebuild loop. fn try_bump_rebuild_count(&self) -> bool { - let now = now_unix_millis(); let mut inner = self .inner .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); + if inner.rebuild_count >= REBUILD_MAX_ATTEMPTS { + return false; + } + inner.rebuild_count += 1; + true + } + + fn note_window_shown(&self, now_unix_ms: u64) { + let mut inner = self + .inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + inner.last_shown_unix_ms = now_unix_ms; + } + + fn recently_shown(&self, now_unix_ms: u64) -> bool { + let inner = self + .inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + now_unix_ms.saturating_sub(inner.last_shown_unix_ms) + < RECENTLY_SHOWN_GRACE.as_millis() as u64 + } + + fn has_received_pong(&self) -> bool { + let inner = self + .inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + inner.has_received_pong + } + + /// Rate-limits the recurring "restart deferred while hidden" warning. + fn should_log_deferred_restart(&self, now_unix_ms: u64) -> bool { + let mut inner = self + .inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if now_unix_ms.saturating_sub(inner.last_deferred_restart_logged_unix_ms) <= 60_000 { + return false; + } + inner.last_deferred_restart_logged_unix_ms = now_unix_ms; + true + } + + fn recovery_confirm_deadline_unix_ms(&self) -> u64 { + self.recovery_confirm_deadline_unix_ms + .load(Ordering::Acquire) + } - let cooldown_ms = REBUILD_COOLDOWN.as_millis() as u64; - if now.saturating_sub(inner.first_rebuild_unix_ms) > cooldown_ms { - // Reset the window. - inner.rebuild_count = 1; - inner.first_rebuild_unix_ms = now; - return true; + fn clear_recovery_confirm_deadline(&self) { + self.recovery_confirm_deadline_unix_ms + .store(0, Ordering::Release); + } + + /// Claim the escalated-recovery slot until `deadline_unix_ms`. Fails when a + /// previous attempt is still awaiting confirmation (deadline not yet passed). + fn try_claim_recovery_confirm(&self, now_unix_ms: u64, deadline_unix_ms: u64) -> bool { + let current = self + .recovery_confirm_deadline_unix_ms + .load(Ordering::Acquire); + if current != 0 && now_unix_ms < current { + return false; } + self.recovery_confirm_deadline_unix_ms + .compare_exchange( + current, + deadline_unix_ms, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } - inner.rebuild_count = inner.rebuild_count.saturating_add(1); - inner.rebuild_count <= REBUILD_MAX_ATTEMPTS + fn is_auto_recovery_disabled(&self) -> bool { + self.auto_recovery_disabled.load(Ordering::Relaxed) + } + + fn disable_auto_recovery(&self) { + self.auto_recovery_disabled.store(true, Ordering::Relaxed); + } + + /// Forget any scheduled backoff so the next check may act immediately. + /// The recovery streak is preserved: repeated failures still escalate. + fn clear_recovery_backoff(&self) { + let mut inner = self + .inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + inner.next_recovery_allowed_unix_ms = 0; } fn set_last_timeout_logged_unix_ms(&self, ts_unix_ms: u64) { @@ -291,12 +429,75 @@ pub(crate) fn install(app: &tauri::AppHandle) { }); } +/// Called whenever the main window is shown or focused (tray click, dock icon, +/// second instance, startup settings, OS-level unminimize/restore). If the +/// WebView already looks dead, run a recovery check right away — the user +/// should never stare at a white screen waiting for the next heartbeat tick or +/// a stale backoff window. +pub(crate) fn on_main_window_shown(app: &tauri::AppHandle) { + let Some(state) = app.try_state::() else { + return; + }; + + let now = now_unix_millis(); + state.note_window_shown(now); + + // The silence judged here accrued while the window was hidden/throttled, + // so use the lenient hidden allowance (and the startup allowance before + // the first pong) — a healthy-but-throttled frontend must get a chance to + // answer the next heartbeat instead of being reloaded on show. + let stale_after = if state.has_received_pong() { + PONG_TIMEOUT_HIDDEN + } else { + STARTUP_PONG_TIMEOUT_HIDDEN + }; + let since_last_pong_ms = now.saturating_sub(state.snapshot().last_pong_unix_ms); + if since_last_pong_ms <= stale_after.as_millis() as u64 { + return; + } + + state.clear_recovery_backoff(); + tracing::warn!( + since_last_pong_ms, + "main window shown with a stale heartbeat, running immediate recovery check" + ); + + let app = app.clone(); + tauri::async_runtime::spawn(async move { + check_and_recover_if_needed(&app).await; + }); +} + fn heartbeat_interval() -> tokio::time::Interval { let mut interval = tokio::time::interval(HEARTBEAT_INTERVAL); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); interval } +/// Returns how long pong silence is tolerated for the current window state. +/// +/// - Visible, settled window: strict 30s — a user is (potentially) looking at +/// a white screen, recover fast. +/// - Freshly shown/focused window: silence accrued under OS throttling while +/// hidden must not be judged strictly; give one heartbeat round-trip. +/// - Hidden window: lenient 60s (OS timer throttling), and until the very +/// first pong of the process an even longer startup allowance so a slow +/// `start_minimized` boot is not reloaded mid-load. +fn pong_timeout_for( + window_visible: bool, + recently_shown: bool, + has_received_pong: bool, +) -> Duration { + if window_visible && !recently_shown { + return PONG_TIMEOUT; + } + if has_received_pong { + PONG_TIMEOUT_HIDDEN + } else { + STARTUP_PONG_TIMEOUT_HIDDEN + } +} + async fn check_and_recover_if_needed(app: &tauri::AppHandle) { if app_is_terminating(app) { return; @@ -304,48 +505,95 @@ async fn check_and_recover_if_needed(app: &tauri::AppHandle) { let now = now_unix_millis(); let state = app.state::(); - let snapshot = state.snapshot(); + // Serialize recovery passes: the tick loop and the show/focus hooks may + // call in concurrently; a second pass would double reloads and streaks. + if state + .check_in_progress + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed) + .is_err() + { + return; + } + let _check_guard = CheckInProgressGuard(&state.check_in_progress); + + let snapshot = state.snapshot(); let since_last_pong_ms = now.saturating_sub(snapshot.last_pong_unix_ms); + + // Cheap early-return on the global minimum threshold BEFORE any window + // query — window getters are blocking round-trips to the main thread and + // this path runs every 15s for the app's whole lifetime. if since_last_pong_ms <= PONG_TIMEOUT.as_millis() as u64 { return; } + // Visibility tunes the detection threshold and rebuild/restart behavior; + // it never blocks recovery — a WebView that dies while the window is + // hidden in the tray must be repaired before the user opens it again. + // Errors while querying window state default to "treat as visible" so a + // misreporting platform can never silently disable recovery. It is + // sampled ONCE here and threaded through the whole pass so reload, + // rebuild, and restart decisions can never disagree mid-recovery. + let window = app.get_webview_window(MAIN_WINDOW_LABEL); + let window_exists = window.is_some(); + let window_visible = window + .as_ref() + .map(|w| w.is_visible().unwrap_or(true) && !w.is_minimized().unwrap_or(false)) + .unwrap_or(false); + + let pong_timeout = pong_timeout_for( + window_visible, + state.recently_shown(now), + state.has_received_pong(), + ); + if since_last_pong_ms <= pong_timeout.as_millis() as u64 { + return; + } + if now.saturating_sub(snapshot.last_timeout_logged_unix_ms) > 60_000 { state.set_last_timeout_logged_unix_ms(now); tracing::warn!( since_last_pong_ms, + window_visible, "frontend heartbeat timeout detected (possible blank screen / freeze)" ); } - // If recovery is already in flight (e.g. rebuild/restart), don't pile on. - if state.recovery_in_flight.load(Ordering::Relaxed) { - tracing::debug!("recovery already in flight, skipping"); + if state.is_auto_recovery_disabled() { + // Restart storm was detected earlier in this session; the dialog has + // already told the user to restart manually. return; } + // An escalated recovery is awaiting pong confirmation. Wait until its + // deadline; once it passes, the attempt counts as failed and we continue. + let confirm_deadline = state.recovery_confirm_deadline_unix_ms(); + if confirm_deadline != 0 { + if now < confirm_deadline { + return; + } + state.clear_recovery_confirm_deadline(); + tracing::warn!( + confirm_deadline, + "recovery attempt was not confirmed by a pong within the deadline, continuing escalation" + ); + } + // If the WebView has been classified as broken (unrecoverable), skip page-level // recovery and go straight to the rebuild/restart path. if state.is_webview_broken() { - attempt_escalated_recovery(app).await; + attempt_escalated_recovery(app, window_visible, window_exists).await; return; } - let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else { - tracing::debug!("heartbeat watchdog: main window not found"); + let Some(window) = window else { + tracing::warn!("heartbeat watchdog: main window not found, escalating to rebuild"); // Window gone — treat as broken and try to rebuild. state.mark_webview_broken(); - attempt_escalated_recovery(app).await; + attempt_escalated_recovery(app, window_visible, window_exists).await; return; }; - let is_visible = window.is_visible().unwrap_or(false); - let is_minimized = window.is_minimized().unwrap_or(true); - if !is_visible || is_minimized { - return; - } - match recovery_gate(now, snapshot) { RecoveryGate::Allowed => {} RecoveryGate::CircuitOpen { open_until_unix_ms } => { @@ -380,7 +628,7 @@ async fn check_and_recover_if_needed(app: &tauri::AppHandle) { ); state.mark_webview_broken(); state.set_webview_alive(false); - attempt_escalated_recovery(app).await; + attempt_escalated_recovery(app, window_visible, window_exists).await; return; } @@ -405,7 +653,7 @@ async fn check_and_recover_if_needed(app: &tauri::AppHandle) { ); state.mark_webview_broken(); state.set_webview_alive(false); - attempt_escalated_recovery(app).await; + attempt_escalated_recovery(app, window_visible, window_exists).await; } else { // WebView is confirmed unresponsive — gate all event emissions. state.set_webview_alive(false); @@ -422,8 +670,16 @@ async fn check_and_recover_if_needed(app: &tauri::AppHandle) { } /// Escalated recovery: try to rebuild the main window first; if that fails or -/// attempts are exhausted, fall back to a full app restart. -async fn attempt_escalated_recovery(app: &tauri::AppHandle) { +/// the rebuild budget is exhausted, fall back to a full app restart. +/// +/// `window_visible`/`window_exists` are the values sampled once by the calling +/// check — re-querying here would let the reload/rebuild/restart decisions of +/// one recovery pass disagree with each other. +async fn attempt_escalated_recovery( + app: &tauri::AppHandle, + window_visible: bool, + window_exists: bool, +) { if app_is_terminating(app) { tracing::debug!("explicit exit/restart in progress, skipping escalated recovery"); return; @@ -431,25 +687,36 @@ async fn attempt_escalated_recovery(app: &tauri::AppHandle) { let state = app.state::(); - // Prevent concurrent recovery. - if state - .recovery_in_flight - .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed) - .is_err() - { - tracing::debug!("escalated recovery already in flight"); + // Claim the recovery slot until the confirm deadline. A previous attempt + // that is still within its deadline blocks new attempts; an expired one + // does not — recovery can always make progress. + let now = now_unix_millis(); + let deadline = now.saturating_add(RECOVERY_CONFIRM_TIMEOUT.as_millis() as u64); + if !state.try_claim_recovery_confirm(now, deadline) { + tracing::debug!("escalated recovery already awaiting confirmation"); + return; + } + + // A pong may have arrived between the caller's staleness snapshot and this + // claim; never destroy a WebView that has just proven itself alive. + if now.saturating_sub(state.snapshot().last_pong_unix_ms) <= PONG_TIMEOUT.as_millis() as u64 { + state.clear_recovery_confirm_deadline(); + tracing::info!("pong arrived during escalation, aborting recovery"); return; } // Check rebuild budget. + let mut rebuild_exhausted = false; if state.try_bump_rebuild_count() { - tracing::warn!("attempting main window rebuild"); - match rebuild_main_window(app) { + tracing::warn!(window_visible, "attempting main window rebuild"); + match rebuild_main_window(app, window_visible) { Ok(()) => { tracing::info!( - "main window rebuilt successfully, waiting for frontend pong to confirm recovery" + confirm_timeout_s = RECOVERY_CONFIRM_TIMEOUT.as_secs(), + "main window rebuilt, waiting for frontend pong to confirm recovery" ); - // Keep recovery_in_flight=true until a pong arrives (record_pong clears it). + // The claimed confirm deadline stays set: a pong clears it, + // otherwise the next check after the deadline escalates again. return; } Err(err) => { @@ -458,10 +725,32 @@ async fn attempt_escalated_recovery(app: &tauri::AppHandle) { } } } else { + rebuild_exhausted = true; + } + + // A full restart tears down the gateway and kills in-flight upstream + // requests. Never do that behind the user's back: while a window exists + // but is hidden, wait for it to be shown (`on_main_window_shown` runs an + // immediate check then). A MISSING window is always restartable — it can + // never be "shown", so deferring would leave a permanent headless zombie. + if window_exists && !window_visible { + // Nothing is awaiting pong confirmation — release the slot so the + // show-triggered check can escalate immediately instead of stalling + // on a deadline that guards no action. + state.clear_recovery_confirm_deadline(); + if state.should_log_deferred_restart(now) { + tracing::warn!( + rebuild_exhausted, + "webview broken while window is hidden; deferring app restart until the window is shown" + ); + } + return; + } + + if rebuild_exhausted { tracing::warn!( max = REBUILD_MAX_ATTEMPTS, - cooldown_s = REBUILD_COOLDOWN.as_secs(), - "window rebuild attempts exhausted within cooldown, escalating to app restart" + "consecutive window rebuilds went unconfirmed, escalating to app restart" ); } @@ -469,11 +758,14 @@ async fn attempt_escalated_recovery(app: &tauri::AppHandle) { escalate_to_app_restart(app).await; } -/// Destroy the current main window and recreate it with the same configuration. -fn rebuild_main_window(app: &tauri::AppHandle) -> Result<(), AppError> { +/// Destroy the current main window and recreate it from the tauri.conf.json +/// window config (so titleBarStyle/hiddenTitle/etc. survive recovery), with +/// only visibility overridden: a window hidden in the tray is rebuilt hidden, +/// so background recovery never pops a window or steals focus. +fn rebuild_main_window(app: &tauri::AppHandle, show: bool) -> Result<(), AppError> { // Destroy old window if it still exists. if let Some(old_window) = app.get_webview_window(MAIN_WINDOW_LABEL) { - tracing::info!("destroying old main window"); + tracing::info!(show, "destroying old main window"); if let Err(err) = old_window.destroy() { tracing::warn!(error = %err, "failed to destroy old main window, continuing with rebuild"); } @@ -482,24 +774,48 @@ fn rebuild_main_window(app: &tauri::AppHandle) -> Result<(), AppError> { // Small delay to allow the old window resources to be released. std::thread::sleep(Duration::from_millis(100)); - // Rebuild with the same settings as tauri.conf.json. - let url = tauri::WebviewUrl::App("index.html".into()); - let new_window = tauri::webview::WebviewWindowBuilder::new(app, MAIN_WINDOW_LABEL, url) - .title("AIO Coding Hub") - .inner_size(1500.0, 900.0) - .build() - .map_err(|e| { - AppError::new( - "WINDOW_REBUILD_FAILED", - format!("failed to build window: {e}"), - ) - })?; + let map_build_err = |e: tauri::Error| { + AppError::new( + "WINDOW_REBUILD_FAILED", + format!("failed to build window: {e}"), + ) + }; + + let window_config = app + .config() + .app + .windows + .iter() + .find(|w| w.label == MAIN_WINDOW_LABEL) + .cloned(); + + let new_window = match window_config { + Some(mut config) => { + config.visible = show; + tauri::webview::WebviewWindowBuilder::from_config(app, &config) + .map_err(map_build_err)? + .build() + .map_err(map_build_err)? + } + None => { + // No config entry for the main window (should not happen) — + // fall back to a minimal window rather than giving up. + let url = tauri::WebviewUrl::App("index.html".into()); + tauri::webview::WebviewWindowBuilder::new(app, MAIN_WINDOW_LABEL, url) + .title("AIO Coding Hub") + .inner_size(1500.0, 900.0) + .visible(show) + .build() + .map_err(map_build_err)? + } + }; crate::app::window_chrome::apply_main_window_chrome(&new_window); - // Make the window visible and focused. - let _ = new_window.show(); - let _ = new_window.unminimize(); - let _ = new_window.set_focus(); + if show { + let _ = new_window.show(); + let _ = new_window.unminimize(); + let _ = new_window.set_focus(); + } Ok(()) } @@ -518,6 +834,8 @@ async fn escalate_to_app_restart(app: &tauri::AppHandle) { The user will need to restart the app manually.", RESTART_STORM_WINDOW.as_secs() ); + app.state::() + .disable_auto_recovery(); show_restart_storm_dialog(app); return; } @@ -826,21 +1144,117 @@ mod tests { for _ in 0..REBUILD_MAX_ATTEMPTS { assert!(state.try_bump_rebuild_count()); } - // Next one should fail (budget exhausted). + // Budget stays exhausted no matter how much time passes — only a pong + // (proof of a live WebView) re-arms it, never the wall clock. + assert!(!state.try_bump_rebuild_count()); assert!(!state.try_bump_rebuild_count()); + + state.record_pong(); + assert!(state.try_bump_rebuild_count()); } #[test] - fn recovery_in_flight_prevents_concurrent_recovery() { + fn recovery_confirm_deadline_prevents_concurrent_recovery_until_expiry() { let state = HeartbeatWatchdogState::default(); - assert!(!state.recovery_in_flight.load(Ordering::Relaxed)); + let now = 10_000u64; + let deadline = now + RECOVERY_CONFIRM_TIMEOUT.as_millis() as u64; + + // First claim succeeds. + assert!(state.try_claim_recovery_confirm(now, deadline)); + assert_eq!(state.recovery_confirm_deadline_unix_ms(), deadline); + + // A concurrent claim before the deadline is rejected. + assert!(!state.try_claim_recovery_confirm(now + 1_000, deadline + 1_000)); + + // Once the deadline passes, the slot can be reclaimed — the previous + // unconfirmed attempt can never deadlock recovery. + let later = deadline + 1; + assert!(state.try_claim_recovery_confirm(later, later + 90_000)); + assert_eq!(state.recovery_confirm_deadline_unix_ms(), later + 90_000); + } - state.recovery_in_flight.store(true, Ordering::Relaxed); - assert!(state.recovery_in_flight.load(Ordering::Relaxed)); + #[test] + fn pong_clears_recovery_confirm_deadline() { + let state = HeartbeatWatchdogState::default(); + assert!(state.try_claim_recovery_confirm(1_000, 91_000)); - // Pong clears it. state.record_pong(); - assert!(!state.recovery_in_flight.load(Ordering::Relaxed)); + assert_eq!(state.recovery_confirm_deadline_unix_ms(), 0); + } + + #[test] + fn pong_timeout_matrix_covers_visibility_grace_and_startup() { + // Settled visible window: strict threshold. + assert_eq!(pong_timeout_for(true, false, true), PONG_TIMEOUT); + // Hidden window: lenient threshold once the frontend has ponged. + assert_eq!(pong_timeout_for(false, false, true), PONG_TIMEOUT_HIDDEN); + // Freshly shown window: silence accrued while hidden gets the lenient + // threshold for one heartbeat round-trip. + assert_eq!(pong_timeout_for(true, true, true), PONG_TIMEOUT_HIDDEN); + // Before the first pong (slow start_minimized boot): startup allowance. + assert_eq!( + pong_timeout_for(false, false, false), + STARTUP_PONG_TIMEOUT_HIDDEN + ); + assert_eq!( + pong_timeout_for(true, true, false), + STARTUP_PONG_TIMEOUT_HIDDEN + ); + // Visible settled window recovers fast even before the first pong + // (startup white screens must still be repaired quickly). + assert_eq!(pong_timeout_for(true, false, false), PONG_TIMEOUT); + assert!(PONG_TIMEOUT_HIDDEN > PONG_TIMEOUT); + assert!(STARTUP_PONG_TIMEOUT_HIDDEN > PONG_TIMEOUT_HIDDEN); + } + + #[test] + fn recently_shown_grace_window() { + let state = HeartbeatWatchdogState::default(); + assert!(!state.recently_shown(now_unix_millis())); + + let now = 100_000u64; + state.note_window_shown(now); + assert!(state.recently_shown(now + RECENTLY_SHOWN_GRACE.as_millis() as u64 - 1)); + assert!(!state.recently_shown(now + RECENTLY_SHOWN_GRACE.as_millis() as u64)); + } + + #[test] + fn first_pong_flips_has_received_pong() { + let state = HeartbeatWatchdogState::default(); + assert!(!state.has_received_pong()); + + state.record_pong(); + assert!(state.has_received_pong()); + } + + #[test] + fn deferred_restart_log_is_throttled() { + let state = HeartbeatWatchdogState::default(); + assert!(state.should_log_deferred_restart(100_000)); + assert!(!state.should_log_deferred_restart(100_000 + 60_000)); + assert!(state.should_log_deferred_restart(100_000 + 60_001)); + } + + #[test] + fn clear_recovery_backoff_resets_schedule_but_keeps_streak() { + let state = HeartbeatWatchdogState::default(); + let streak = state.bump_recovery_streak(); + state.schedule_next_recovery(streak, 1_000); + assert!(state.snapshot().next_recovery_allowed_unix_ms > 0); + + state.clear_recovery_backoff(); + assert_eq!(state.snapshot().next_recovery_allowed_unix_ms, 0); + // Streak is preserved so repeated failures still escalate. + assert_eq!(state.bump_recovery_streak(), streak + 1); + } + + #[test] + fn auto_recovery_disabled_lifecycle() { + let state = HeartbeatWatchdogState::default(); + assert!(!state.is_auto_recovery_disabled()); + + state.disable_auto_recovery(); + assert!(state.is_auto_recovery_disabled()); } #[test] diff --git a/src-tauri/src/app/plugin_registry.rs b/src-tauri/src/app/plugin_registry.rs index 42561e4b..c9aa62ed 100644 --- a/src-tauri/src/app/plugin_registry.rs +++ b/src-tauri/src/app/plugin_registry.rs @@ -11,6 +11,7 @@ pub(crate) fn create_builder() -> tauri::Builder { .manage(resident::ResidentState::default()) .manage(StartupState::default()) .manage(crate::app::heartbeat_watchdog::HeartbeatWatchdogState::default()) + .manage(crate::app::plugins::extension_host_registry::ExtensionHostRuntimeState::default()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_clipboard_manager::init()) diff --git a/src-tauri/src/app/plugin_service.rs b/src-tauri/src/app/plugin_service.rs index 82ef8cd1..afc575a0 100644 --- a/src-tauri/src/app/plugin_service.rs +++ b/src-tauri/src/app/plugin_service.rs @@ -1,19 +1,315 @@ +use crate::app::plugins::contribution_registry::ActiveContributionSnapshot; +use crate::app::plugins::extension_host_registry::ExtensionHostInstanceRegistry; use crate::domain::plugins::{ - permission_risk, validate_manifest, PluginDetail, PluginInstallSource, PluginManifest, - PluginPermissionRisk, PluginRuntime, PluginStatus, + manifest_effective_permissions, permission_risk, validate_manifest, + validate_manifest_for_official_plugin, PluginCommandImpact, PluginCompatibilitySummary, + PluginContributionChange, PluginContributionImpact, PluginContributionImpactItem, PluginDetail, + PluginHookLifecycleSummary, PluginInstallPreview, PluginInstallSource, PluginLifecycleChange, + PluginLifecycleNotice, PluginManifest, PluginPermissionLifecycleChange, + PluginPermissionLifecycleSummary, PluginPermissionRisk, PluginRuntime, + PluginRuntimeLifecycleSummary, PluginStatus, PluginTrustSummary, PluginUiSlotImpact, + PluginUpdateDiff, +}; +use crate::infra::plugins::runtime_reports::{ + record_extension_execution_report, RecordPluginExtensionExecutionReportInput, }; use crate::infra::plugins::{package, repository, signing}; use crate::shared::error::{AppError, AppResult}; +use crate::shared::time::now_unix_millis; +use rusqlite::OptionalExtension; +use std::cmp::Ordering; +use std::collections::{btree_map::Entry, BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering}; const OFFICIAL_PRIVACY_FILTER_ID: &str = "official.privacy-filter"; +const UNSUPPORTED_LEGACY_RUNTIME_ERROR: &str = + "Unsupported pre-release plugin runtime; reinstall an Extension Host version"; +static PLUGIN_WORK_PATH_COUNTER: AtomicU64 = AtomicU64::new(0); pub(crate) fn list_plugins(db: &crate::db::Db) -> AppResult> { - repository::list_plugins(db) + let mut out = Vec::new(); + for summary in repository::list_plugins(db)? { + out.push(normalize_unsupported_legacy_plugin_summary_for_list( + db, summary, + )?); + } + Ok(out) } pub(crate) fn get_plugin_detail(db: &crate::db::Db, plugin_id: &str) -> AppResult { - detail_with_config_defaults_for_db(db, repository::get_plugin(db, plugin_id)?) + Ok(normalize_unsupported_legacy_plugin_detail( + detail_with_config_defaults_for_db(db, repository::get_plugin(db, plugin_id)?)?, + )) +} + +pub(crate) fn active_plugin_contributions( + db: &crate::db::Db, +) -> AppResult { + let mut details = Vec::new(); + for summary in list_plugins(db)? { + details.push(normalize_unsupported_legacy_plugin_detail( + detail_with_config_defaults_for_db( + db, + repository::get_plugin(db, &summary.plugin_id)?, + )?, + )); + } + let duplicate_plugin_ids = duplicate_enabled_command_plugin_ids(&details); + let active_details = details + .into_iter() + .filter(|detail| !duplicate_plugin_ids.contains(&detail.summary.plugin_id)) + .collect::>(); + ActiveContributionSnapshot::from_plugin_details(&active_details) +} + +pub(crate) async fn execute_plugin_command( + db: &crate::db::Db, + registry: &ExtensionHostInstanceRegistry, + command: &str, + args: serde_json::Value, +) -> AppResult { + let command = normalize_plugin_command(command)?; + let detail = find_unique_enabled_command_owner(db, &command)?.ok_or_else(|| { + AppError::new( + "PLUGIN_COMMAND_NOT_FOUND", + format!("plugin command is not declared: {command}"), + ) + })?; + if detail.summary.status != PluginStatus::Enabled { + return Err(AppError::new( + "PLUGIN_COMMAND_PLUGIN_DISABLED", + format!( + "plugin {} is not enabled for command {command}", + detail.summary.plugin_id + ), + )); + } + let plugin_id = detail.summary.plugin_id.clone(); + let trace_id = args + .get("traceId") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + let started_at_ms = now_unix_millis(); + let result = registry + .execute_command(detail.clone(), &command, args.clone()) + .await; + let duration_ms = now_unix_millis().saturating_sub(started_at_ms); + + match result { + Ok(output) => { + if let Err(report_error) = record_command_execution_report( + db, + &plugin_id, + &command, + trace_id, + started_at_ms, + duration_ms, + "completed", + None, + None, + &args, + Some(output.cold_start), + Some(&output.value), + ) { + tracing::warn!( + plugin_id, + command, + error = %report_error, + "failed to record successful plugin command execution report" + ); + } + Ok(output.value) + } + Err(error) => { + if let Err(report_error) = record_command_execution_report( + db, + &plugin_id, + &command, + trace_id, + started_at_ms, + duration_ms, + "failed", + Some("runtime"), + Some(error.code()), + &args, + None, + None, + ) { + tracing::warn!( + plugin_id, + command, + error = %report_error, + "failed to record plugin command execution report" + ); + } + Err(error) + } + } +} + +fn normalize_plugin_command(command: &str) -> AppResult { + let command = command.trim(); + if command.is_empty() { + return Err(AppError::new( + "SEC_INVALID_INPUT", + "plugin command is required", + )); + } + Ok(command.to_string()) +} + +fn find_unique_enabled_command_owner( + db: &crate::db::Db, + command: &str, +) -> AppResult> { + let mut owners = Vec::new(); + let mut disabled_owner = None; + for summary in list_plugins(db)? { + let detail = + normalize_unsupported_legacy_plugin_detail(detail_with_config_defaults_for_db( + db, + repository::get_plugin(db, &summary.plugin_id)?, + )?); + let declared = detail + .manifest + .contributes + .as_ref() + .is_some_and(|contributes| { + contributes + .commands + .iter() + .any(|contribution| contribution.command == command) + }); + if declared { + if detail.summary.status == PluginStatus::Enabled { + owners.push(detail); + } else if disabled_owner.is_none() { + disabled_owner = Some(detail); + } + } + } + match owners.len() { + 0 => Ok(disabled_owner), + 1 => Ok(owners.into_iter().next()), + _ => { + let plugin_ids = owners + .iter() + .map(|detail| detail.summary.plugin_id.as_str()) + .collect::>() + .join(", "); + Err(AppError::new( + "PLUGIN_DUPLICATE_COMMAND", + format!("command {command} is declared by multiple enabled plugins: {plugin_ids}"), + )) + } + } +} + +fn duplicate_enabled_command_plugin_ids(details: &[PluginDetail]) -> BTreeSet { + let mut command_owners = BTreeMap::>::new(); + for detail in details { + if detail.summary.status != PluginStatus::Enabled { + continue; + } + let Some(contributes) = detail.manifest.contributes.as_ref() else { + continue; + }; + for command in &contributes.commands { + command_owners + .entry(command.command.clone()) + .or_default() + .push(detail.summary.plugin_id.clone()); + } + } + + command_owners + .into_values() + .filter(|owners| owners.len() > 1) + .flatten() + .collect() +} + +fn ensure_no_duplicate_enabled_commands(details: &[PluginDetail]) -> AppResult<()> { + let mut command_owners = BTreeMap::>::new(); + for detail in details { + if detail.summary.status != PluginStatus::Enabled { + continue; + } + let Some(contributes) = detail.manifest.contributes.as_ref() else { + continue; + }; + for command in &contributes.commands { + let owners = command_owners.entry(command.command.clone()).or_default(); + owners.push(detail.summary.plugin_id.clone()); + if owners.len() > 1 { + return Err(AppError::new( + "PLUGIN_DUPLICATE_COMMAND", + format!( + "command {} is declared by multiple enabled plugins: {}", + command.command, + owners.join(", ") + ), + )); + } + } + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn record_command_execution_report( + db: &crate::db::Db, + plugin_id: &str, + command: &str, + trace_id: Option, + started_at_ms: i64, + duration_ms: i64, + status: &str, + failure_kind: Option<&str>, + error_code: Option<&str>, + args: &serde_json::Value, + cold_start: Option, + output: Option<&serde_json::Value>, +) -> AppResult<()> { + record_extension_execution_report( + db, + RecordPluginExtensionExecutionReportInput { + plugin_id: plugin_id.to_string(), + contribution_type: "command".to_string(), + contribution_id: command.to_string(), + command_or_hook: Some(command.to_string()), + trace_id, + status: status.to_string(), + started_at_ms, + duration_ms, + failure_kind: failure_kind.map(str::to_string), + error_code: error_code.map(str::to_string), + input_budget: command_input_budget(args, cold_start), + output_budget: output + .map(json_budget) + .unwrap_or_else(|| serde_json::json!({})), + mutation_summary: serde_json::json!({ "changed": false }), + replayable: false, + }, + ) + .map(|_| ()) +} + +fn command_input_budget(args: &serde_json::Value, cold_start: Option) -> serde_json::Value { + let mut budget = json_budget(args); + if let Some(cold_start) = cold_start { + if let Some(object) = budget.as_object_mut() { + object.insert("coldStart".to_string(), serde_json::Value::Bool(cold_start)); + } + } + budget +} + +fn json_budget(value: &serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "bytes": serde_json::to_vec(value).map(|bytes| bytes.len()).unwrap_or(0) + }) } pub(crate) fn enabled_plugins_for_gateway(db: &crate::db::Db) -> AppResult> { @@ -33,14 +329,31 @@ pub(crate) fn enabled_plugins_for_gateway(db: &crate::db::Db) -> AppResult AppResult> { let mut out = Vec::new(); - for summary in repository::list_plugins(db)? { + for summary in list_plugins(db)? { if summary.status != PluginStatus::Enabled { continue; } - out.push(detail_with_config_defaults_for_db( - db, - repository::get_plugin(db, &summary.plugin_id)?, - )?); + let detail = + normalize_unsupported_legacy_plugin_detail(detail_with_config_defaults_for_db( + db, + repository::get_plugin(db, &summary.plugin_id)?, + )?); + if is_unsupported_legacy_runtime_detail(&detail) { + continue; + } + if let Err(err) = validate_manifest_for_source( + &detail.manifest, + detail.install_source, + env!("CARGO_PKG_VERSION"), + ) { + tracing::warn!( + plugin_id = %summary.plugin_id, + error = ?err, + "skipping enabled plugin with invalid manifest" + ); + continue; + } + out.push(detail); } Ok(out) } @@ -50,6 +363,48 @@ fn is_missing_plugin_table_error(err: &AppError) -> bool { message.contains("no such table: plugins") || message.contains("no such table: plugin_") } +fn is_unsupported_legacy_runtime_summary(runtime: &str) -> bool { + matches!(runtime, "wasm" | "process" | "native") || runtime.starts_with("native:") +} + +fn is_unsupported_legacy_runtime_detail(detail: &PluginDetail) -> bool { + is_unsupported_legacy_runtime_summary(&detail.summary.runtime) + || matches!(detail.manifest.runtime, PluginRuntime::ExtensionHost { .. }) + && detail.manifest.main.as_deref() == Some("legacy/unsupported.js") +} + +fn normalize_unsupported_legacy_plugin_summary_for_list( + _db: &crate::db::Db, + summary: crate::plugins::PluginSummary, +) -> AppResult { + Ok(normalize_unsupported_legacy_plugin_summary(summary)) +} + +fn normalize_unsupported_legacy_plugin_summary( + mut summary: crate::plugins::PluginSummary, +) -> crate::plugins::PluginSummary { + if is_unsupported_legacy_runtime_summary(&summary.runtime) { + summary = mark_unsupported_legacy_plugin_summary(summary); + } + summary +} + +fn normalize_unsupported_legacy_plugin_detail(mut detail: PluginDetail) -> PluginDetail { + if is_unsupported_legacy_runtime_detail(&detail) { + detail.summary = mark_unsupported_legacy_plugin_summary(detail.summary); + } + detail +} + +fn mark_unsupported_legacy_plugin_summary( + mut summary: crate::plugins::PluginSummary, +) -> crate::plugins::PluginSummary { + summary.status = PluginStatus::Disabled; + summary.update_available = false; + summary.last_error = Some(UNSUPPORTED_LEGACY_RUNTIME_ERROR.to_string()); + summary +} + pub(crate) fn install_official_plugin( db: &crate::db::Db, plugin_id: &str, @@ -81,8 +436,7 @@ pub(crate) fn install_official_plugin( &fixture.default_config, &[], )?; - let detail = - repository::save_plugin_permissions(db, plugin_id, &fixture.manifest.permissions, &[])?; + let detail = repository::save_plugin_permissions(db, plugin_id, &[], &[])?; append_audit( db, Some(plugin_id.to_string()), @@ -101,10 +455,9 @@ pub(crate) fn install_plugin_manifest( installed_dir: Option, host_version: &str, ) -> AppResult { - validate_manifest(&manifest, host_version)?; + validate_manifest_for_source(&manifest, install_source, host_version)?; validate_reserved_official_source(&manifest, install_source)?; let plugin_id = manifest.id.clone(); - let requested_permissions = manifest.permissions.clone(); let detail = repository::insert_plugin( db, repository::InsertPluginInput { @@ -117,7 +470,7 @@ pub(crate) fn install_plugin_manifest( let detail = if install_source == PluginInstallSource::Official { detail } else { - repository::save_plugin_permissions(db, &plugin_id, &[], &requested_permissions)? + repository::save_plugin_permissions(db, &plugin_id, &[], &[])? }; append_audit( db, @@ -147,14 +500,31 @@ pub(crate) fn install_plugin_from_local_package( ) } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub(crate) struct LocalPackageInstallPolicy { pub(crate) expected_plugin_id: Option, pub(crate) expected_checksum: Option, pub(crate) signature: Option, pub(crate) public_key: Option, - pub(crate) allow_unsigned: bool, pub(crate) developer_mode: bool, + pub(crate) install_source: PluginInstallSource, + pub(crate) remote_source_url: Option, + pub(crate) market_source_url: Option, +} + +impl Default for LocalPackageInstallPolicy { + fn default() -> Self { + Self { + expected_plugin_id: None, + expected_checksum: None, + signature: None, + public_key: None, + developer_mode: false, + install_source: PluginInstallSource::Local, + remote_source_url: None, + market_source_url: None, + } + } } #[derive(Debug, Clone)] @@ -164,6 +534,7 @@ pub(crate) struct RemotePackageInstallPolicy { pub(crate) expected_checksum: String, pub(crate) signature: Option, pub(crate) public_key: Option, + pub(crate) market_source_url: Option, } #[derive(Debug, Clone, Copy)] @@ -171,48 +542,1030 @@ struct PackageTrust { signature_verified: bool, } -pub(crate) fn install_plugin_from_local_package_with_policy( +fn lifecycle_notice( + severity: &str, + code: &str, + message: impl Into, +) -> PluginLifecycleNotice { + PluginLifecycleNotice { + severity: severity.to_string(), + code: code.to_string(), + message: message.into(), + } +} + +fn cleanup_staging_dir(staging_root: &Path, staging_dir: &Path) { + let _ = std::fs::remove_dir_all(staging_dir); + let _ = std::fs::remove_dir(staging_root); +} + +fn safe_work_path_label(value: &str, fallback: &str) -> String { + let mut out = String::new(); + for ch in value.trim().chars() { + if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') { + out.push(ch); + } else { + out.push('-'); + } + } + let out = out.trim_matches(['.', '-']); + if out.is_empty() { + fallback.to_string() + } else { + out.to_string() + } +} + +fn unique_work_path_segment(prefix: &str) -> String { + let counter = PLUGIN_WORK_PATH_COUNTER.fetch_add(1, AtomicOrdering::Relaxed); + format!( + "{}-{}-{}-{}", + safe_work_path_label(prefix, "plugin"), + now_unix_millis(), + std::process::id(), + counter + ) +} + +fn unique_staging_dir(staging_root: &Path, prefix: &str) -> PathBuf { + staging_root.join(unique_work_path_segment(prefix)) +} + +fn unique_cache_package_path(cache_dir: &Path, plugin_id: &str, version: &str) -> PathBuf { + let prefix = format!( + "{}-{}", + safe_work_path_label(plugin_id, "plugin"), + safe_work_path_label(version, "version") + ); + cache_dir.join(format!("{}.aio-plugin", unique_work_path_segment(&prefix))) +} + +fn unique_remote_package_path(cache_dir: &Path, plugin_id: &str) -> PathBuf { + let prefix = format!("remote-{}", safe_work_path_label(plugin_id, "plugin")); + cache_dir.join(format!("{}.aio-plugin", unique_work_path_segment(&prefix))) +} + +fn app_error_message(error: &AppError) -> String { + let rendered = error.to_string(); + rendered + .split_once(':') + .map_or(rendered.clone(), |(_, message)| message.trim().to_string()) +} + +fn compare_version_direction(from: &str, to: &str) -> String { + match (parse_semver_precedence(from), parse_semver_precedence(to)) { + (Some(left), Some(right)) => match compare_semver_precedence(&left, &right) { + Ordering::Less => "upgrade".to_string(), + Ordering::Greater => "downgrade".to_string(), + Ordering::Equal => "same".to_string(), + }, + _ => "unknown".to_string(), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SemverPrecedence { + core: (u64, u64, u64), + prerelease: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum SemverPrereleaseIdentifier { + Numeric(u64), + Text(String), +} + +fn parse_semver_precedence(version: &str) -> Option { + let version = version.trim(); + let version = version.split_once('+').map_or(version, |(left, _)| left); + let (core, prerelease) = version + .split_once('-') + .map_or((version, None), |(core, prerelease)| { + (core, Some(prerelease)) + }); + let mut core_parts = core.split('.'); + let major = parse_semver_core_number(core_parts.next()?)?; + let minor = parse_semver_core_number(core_parts.next()?)?; + let patch = parse_semver_core_number(core_parts.next()?)?; + if core_parts.next().is_some() { + return None; + } + let prerelease = match prerelease { + Some(raw) => parse_semver_prerelease(raw)?, + None => Vec::new(), + }; + Some(SemverPrecedence { + core: (major, minor, patch), + prerelease, + }) +} + +fn parse_semver_core_number(value: &str) -> Option { + if value.is_empty() || (value.len() > 1 && value.starts_with('0')) { + return None; + } + value.parse::().ok() +} + +fn parse_semver_prerelease(raw: &str) -> Option> { + raw.split('.') + .map(|identifier| { + if identifier.is_empty() + || !identifier + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + { + return None; + } + if identifier.bytes().all(|byte| byte.is_ascii_digit()) { + if identifier.len() > 1 && identifier.starts_with('0') { + return None; + } + return identifier + .parse::() + .ok() + .map(SemverPrereleaseIdentifier::Numeric); + } + Some(SemverPrereleaseIdentifier::Text(identifier.to_string())) + }) + .collect() +} + +fn compare_semver_precedence(left: &SemverPrecedence, right: &SemverPrecedence) -> Ordering { + let core_order = left.core.cmp(&right.core); + if core_order != Ordering::Equal { + return core_order; + } + match (left.prerelease.is_empty(), right.prerelease.is_empty()) { + (true, true) => Ordering::Equal, + (true, false) => Ordering::Greater, + (false, true) => Ordering::Less, + (false, false) => compare_prerelease_identifiers(&left.prerelease, &right.prerelease), + } +} + +fn compare_prerelease_identifiers( + left: &[SemverPrereleaseIdentifier], + right: &[SemverPrereleaseIdentifier], +) -> Ordering { + for (left_identifier, right_identifier) in left.iter().zip(right.iter()) { + let order = match (left_identifier, right_identifier) { + ( + SemverPrereleaseIdentifier::Numeric(left_number), + SemverPrereleaseIdentifier::Numeric(right_number), + ) => left_number.cmp(right_number), + (SemverPrereleaseIdentifier::Numeric(_), SemverPrereleaseIdentifier::Text(_)) => { + Ordering::Less + } + (SemverPrereleaseIdentifier::Text(_), SemverPrereleaseIdentifier::Numeric(_)) => { + Ordering::Greater + } + ( + SemverPrereleaseIdentifier::Text(left_text), + SemverPrereleaseIdentifier::Text(right_text), + ) => left_text.cmp(right_text), + }; + if order != Ordering::Equal { + return order; + } + } + left.len().cmp(&right.len()) +} + +fn runtime_lifecycle_summary( + manifest: &PluginManifest, + _source: PluginInstallSource, +) -> PluginRuntimeLifecycleSummary { + match &manifest.runtime { + PluginRuntime::ExtensionHost { .. } => PluginRuntimeLifecycleSummary { + kind: "extensionHost".to_string(), + label: "Extension Host".to_string(), + supported: true, + blocking_reasons: Vec::new(), + }, + } +} + +fn hook_lifecycle_summaries(manifest: &PluginManifest) -> Vec { + declared_gateway_hooks(manifest) + .into_iter() + .map(|hook| PluginHookLifecycleSummary { + name: hook.name.clone(), + priority: hook.priority, + failure_policy: hook.failure_policy.clone(), + timeout_ms: hook.timeout_ms, + }) + .collect() +} + +fn declared_gateway_hooks(manifest: &PluginManifest) -> Vec<&crate::domain::plugins::PluginHook> { + let mut hooks = manifest.hooks.iter().collect::>(); + if let Some(contributes) = manifest.contributes.as_ref() { + hooks.extend(contributes.gateway_hooks.iter()); + } + hooks +} + +fn permission_lifecycle_summaries( + permissions: &[String], + granted: &[String], + pending: &[String], +) -> Vec { + permissions + .iter() + .map(|permission| PluginPermissionLifecycleSummary { + permission: permission.clone(), + risk: permission_risk(permission).unwrap_or(PluginPermissionRisk::Low), + granted: granted.contains(permission), + pending: pending.contains(permission), + }) + .collect() +} + +fn contribution_impact(manifest: &PluginManifest) -> PluginContributionImpact { + let Some(contributes) = manifest.contributes.as_ref() else { + return PluginContributionImpact { + providers: Vec::new(), + protocols: Vec::new(), + protocol_bridges: Vec::new(), + ui_slots: Vec::new(), + commands: Vec::new(), + gateway: Vec::new(), + capabilities: manifest.capabilities.clone(), + }; + }; + + let providers = contributes + .providers + .iter() + .map(|provider| PluginContributionImpactItem { + id: provider.provider_type.clone(), + label: Some(provider.display_name.clone()), + }) + .collect(); + let protocols = contributes + .protocols + .iter() + .map(|protocol| PluginContributionImpactItem { + id: protocol.protocol_id.clone(), + label: Some(format!("{:?}", protocol.direction)), + }) + .collect(); + let protocol_bridges = contributes + .protocol_bridges + .iter() + .map(|bridge| PluginContributionImpactItem { + id: bridge.bridge_type.clone(), + label: Some(format!( + "{} -> {}", + bridge.inbound_protocol, bridge.outbound_protocol + )), + }) + .collect(); + let ui_slots = contributes + .ui + .iter() + .flat_map(|(slot_id, contributions)| { + contributions + .iter() + .map(move |contribution| PluginUiSlotImpact { + slot_id: slot_id.clone(), + contribution_id: contribution.id.clone(), + title: contribution.title.clone(), + }) + }) + .collect(); + let commands = contributes + .commands + .iter() + .map(|command| PluginCommandImpact { + command: command.command.clone(), + title: command.title.clone(), + category: command.category.clone(), + }) + .collect(); + let gateway_hooks = contributes + .gateway_hooks + .iter() + .map(|hook| PluginContributionImpactItem { + id: hook.name.clone(), + label: Some(format!( + "priority={}, failurePolicy={}", + hook.priority, + hook.failure_policy.as_deref().unwrap_or("-") + )), + }); + PluginContributionImpact { + providers, + protocols, + protocol_bridges, + ui_slots, + commands, + gateway: gateway_hooks.collect(), + capabilities: manifest.capabilities.clone(), + } +} + +fn compatibility_summary( + manifest: &PluginManifest, + host_version: &str, +) -> PluginCompatibilitySummary { + match validate_manifest(manifest, host_version) { + Ok(()) => PluginCompatibilitySummary { + compatible: true, + host_version: host_version.to_string(), + app_range: manifest.host_compatibility.app.clone(), + plugin_api_range: manifest.host_compatibility.plugin_api.clone(), + platforms: manifest.host_compatibility.platforms.clone(), + blocking_reasons: Vec::new(), + }, + Err(error) => PluginCompatibilitySummary { + compatible: false, + host_version: host_version.to_string(), + app_range: manifest.host_compatibility.app.clone(), + plugin_api_range: manifest.host_compatibility.plugin_api.clone(), + platforms: manifest.host_compatibility.platforms.clone(), + blocking_reasons: vec![lifecycle_notice("error", &error.code, error.message)], + }, + } +} + +fn trust_summary( + extracted: &package::ExtractedPluginPackage, + policy: &LocalPackageInstallPolicy, + trust: PackageTrust, +) -> PluginTrustSummary { + let checksum_verified = policy.expected_checksum.as_deref().is_some_and(|expected| { + expected + .trim() + .eq_ignore_ascii_case(extracted.checksum.as_str()) + }); + PluginTrustSummary { + checksum: extracted.checksum.clone(), + expected_checksum: policy.expected_checksum.clone(), + checksum_verified, + signature_verified: trust.signature_verified, + unsigned: !trust.signature_verified, + developer_mode: policy.developer_mode, + } +} + +pub(crate) fn preview_plugin_from_local_package_with_policy( db: &crate::db::Db, package_path: &Path, cache_dir: &Path, - installed_root: &Path, host_version: &str, policy: LocalPackageInstallPolicy, -) -> AppResult { +) -> AppResult { std::fs::create_dir_all(cache_dir).map_err(|e| { format!( "failed to create plugin cache dir {}: {e}", cache_dir.display() ) })?; - std::fs::create_dir_all(installed_root).map_err(|e| { - format!( - "failed to create plugin installed dir {}: {e}", - installed_root.display() - ) - })?; - let staging_root = cache_dir.join("staging"); - let staging_dir = - staging_root.join(format!("local-{}", crate::shared::time::now_unix_seconds())); - let extracted = match package::extract_plugin_package( + let staging_dir = unique_staging_dir(&staging_root, "preview"); + let extracted = match package::extract_plugin_package_for_inspection( package_path, &staging_dir, package::PluginPackageLimits::default(), ) { Ok(extracted) => extracted, Err(error) => { - let _ = std::fs::remove_dir_all(&staging_dir); - let _ = std::fs::remove_dir(&staging_root); + cleanup_staging_dir(&staging_root, &staging_dir); return Err(error); } }; - let trust = match validate_local_package_install(&extracted, host_version, &policy) { + let result = build_install_preview( + db, + &extracted, + host_version, + PluginInstallSource::Local, + &policy, + ); + cleanup_staging_dir(&staging_root, &staging_dir); + result +} + +fn build_install_preview( + db: &crate::db::Db, + extracted: &package::ExtractedPluginPackage, + host_version: &str, + source: PluginInstallSource, + policy: &LocalPackageInstallPolicy, +) -> AppResult { + let manifest = &extracted.manifest; + let compatibility = compatibility_summary(manifest, host_version); + let mut blocking_reasons = compatibility.blocking_reasons.clone(); + let mut warnings = Vec::new(); + let runtime = runtime_lifecycle_summary(manifest, source); + blocking_reasons.extend( + runtime + .blocking_reasons + .iter() + .filter(|notice| notice.severity == "error") + .cloned(), + ); + warnings.extend( + runtime + .blocking_reasons + .iter() + .filter(|notice| notice.severity != "error") + .cloned(), + ); + + let trust = match verify_local_package(extracted, policy) { Ok(trust) => trust, Err(error) => { - let _ = std::fs::remove_dir_all(&staging_dir); - let _ = std::fs::remove_dir(&staging_root); - return Err(error); + blocking_reasons.push(lifecycle_notice( + "error", + error.code(), + app_error_message(&error), + )); + PackageTrust { + signature_verified: false, + } + } + }; + if let Err(error) = enforce_unsigned_install_policy(manifest, policy, trust) { + blocking_reasons.push(lifecycle_notice( + "error", + error.code(), + app_error_message(&error), + )); + } + if let Err(error) = validate_reserved_official_source(manifest, source) { + blocking_reasons.push(lifecycle_notice( + "error", + error.code(), + app_error_message(&error), + )); + } + + let existing = repository::get_plugin(db, &manifest.id).ok(); + let existing_status = existing.as_ref().map(|detail| detail.summary.status); + let existing_version = existing + .as_ref() + .and_then(|detail| detail.summary.current_version.clone()); + let effective_permissions = manifest_effective_permissions(manifest); + + Ok(PluginInstallPreview { + plugin_id: manifest.id.clone(), + name: manifest.name.clone(), + version: manifest.version.clone(), + source, + description: manifest.description.clone(), + author: manifest.author.clone(), + homepage: manifest.homepage.clone(), + repository: manifest.repository.clone(), + license: manifest.license.clone(), + category: manifest.category.clone(), + runtime, + hooks: hook_lifecycle_summaries(manifest), + permissions: permission_lifecycle_summaries( + &effective_permissions, + &effective_permissions, + &[], + ), + contribution_impact: contribution_impact(manifest), + compatibility, + trust: trust_summary(extracted, policy, trust), + existing_status, + existing_version, + blocking_reasons, + warnings, + }) +} + +pub(crate) fn preview_plugin_update_from_local_package( + db: &crate::db::Db, + package_path: &Path, + cache_dir: &Path, + host_version: &str, + policy: LocalPackageInstallPolicy, +) -> AppResult { + std::fs::create_dir_all(cache_dir).map_err(|e| { + format!( + "failed to create plugin cache dir {}: {e}", + cache_dir.display() + ) + })?; + let staging_root = cache_dir.join("staging"); + let staging_dir = unique_staging_dir(&staging_root, "update-preview"); + let extracted = match package::extract_plugin_package_for_inspection( + package_path, + &staging_dir, + package::PluginPackageLimits::default(), + ) { + Ok(extracted) => extracted, + Err(error) => { + cleanup_staging_dir(&staging_root, &staging_dir); + return Err(error); + } + }; + let result = build_update_diff(db, &extracted, host_version, &policy); + cleanup_staging_dir(&staging_root, &staging_dir); + result +} + +fn build_update_diff( + db: &crate::db::Db, + extracted: &package::ExtractedPluginPackage, + host_version: &str, + policy: &LocalPackageInstallPolicy, +) -> AppResult { + let manifest = &extracted.manifest; + let current = repository::get_plugin(db, &manifest.id)?; + let compatibility = compatibility_summary(manifest, host_version); + let mut blocking_reasons = compatibility.blocking_reasons.clone(); + let mut warnings = Vec::new(); + + let trust = match verify_local_package(extracted, policy) { + Ok(trust) => trust, + Err(error) => { + blocking_reasons.push(lifecycle_notice( + "error", + error.code(), + app_error_message(&error), + )); + PackageTrust { + signature_verified: false, + } + } + }; + if let Err(error) = enforce_unsigned_install_policy(manifest, policy, trust) { + blocking_reasons.push(lifecycle_notice( + "error", + error.code(), + app_error_message(&error), + )); + } + if let Err(error) = validate_reserved_official_source(manifest, PluginInstallSource::Local) { + blocking_reasons.push(lifecycle_notice( + "error", + error.code(), + app_error_message(&error), + )); + } + + let current_runtime = runtime_lifecycle_summary(¤t.manifest, current.install_source); + let next_runtime = runtime_lifecycle_summary(manifest, PluginInstallSource::Local); + blocking_reasons.extend( + next_runtime + .blocking_reasons + .iter() + .filter(|notice| notice.severity == "error") + .cloned(), + ); + warnings.extend( + next_runtime + .blocking_reasons + .iter() + .filter(|notice| notice.severity != "error") + .cloned(), + ); + let runtime_change = (current_runtime.kind != next_runtime.kind + || current_runtime.label != next_runtime.label + || current_runtime.supported != next_runtime.supported) + .then(|| PluginLifecycleChange { + name: "runtime".to_string(), + change: "changed".to_string(), + before: Some(current_runtime.label), + after: Some(next_runtime.label), + }); + + let from_version = current + .summary + .current_version + .clone() + .unwrap_or_else(|| current.manifest.version.clone()); + let version_direction = compare_version_direction(&from_version, &manifest.version); + if version_direction == "downgrade" { + warnings.push(lifecycle_notice( + "warn", + "PLUGIN_UPDATE_DOWNGRADE", + "selected package version is lower than the installed version", + )); + } + + Ok(PluginUpdateDiff { + plugin_id: manifest.id.clone(), + from_version: from_version.clone(), + to_version: manifest.version.clone(), + version_direction, + runtime_change, + hook_changes: diff_hooks(¤t.manifest, manifest), + permission_changes: diff_permissions(¤t, manifest), + contribution_changes: diff_contributions(¤t.manifest, manifest), + config_version_change: config_version_change(¤t.manifest, manifest), + compatibility, + trust: trust_summary(extracted, policy, trust), + rollback_available: rollback_available(db, &manifest.id, &from_version), + blocking_reasons, + warnings, + }) +} + +fn rollback_available(db: &crate::db::Db, plugin_id: &str, version: &str) -> bool { + rollback_candidate_installed_dir(db, plugin_id, version) + .is_some_and(|installed_dir| repository::plugin_installed_dir_available(&installed_dir)) +} + +fn rollback_candidate_installed_dir( + db: &crate::db::Db, + plugin_id: &str, + version: &str, +) -> Option { + let conn = db.open_connection().ok()?; + conn.query_row( + r#" +SELECT installed_dir +FROM plugin_versions +WHERE plugin_id = ?1 AND version = ?2 +"#, + rusqlite::params![plugin_id, version], + |row| row.get(0), + ) + .optional() + .ok()? + .flatten() +} + +fn diff_hooks(before: &PluginManifest, after: &PluginManifest) -> Vec { + let mut changes = Vec::new(); + let before_hooks = declared_gateway_hooks(before); + let after_hooks = declared_gateway_hooks(after); + for hook in &before_hooks { + match after_hooks.iter().find(|next| next.name == hook.name) { + Some(next) + if next.priority != hook.priority || next.failure_policy != hook.failure_policy => + { + changes.push(PluginLifecycleChange { + name: hook.name.clone(), + change: "changed".to_string(), + before: Some(format!( + "priority={}, failurePolicy={}", + hook.priority, + hook.failure_policy.as_deref().unwrap_or("-") + )), + after: Some(format!( + "priority={}, failurePolicy={}", + next.priority, + next.failure_policy.as_deref().unwrap_or("-") + )), + }); + } + Some(_) => {} + None => changes.push(PluginLifecycleChange { + name: hook.name.clone(), + change: "removed".to_string(), + before: Some("declared".to_string()), + after: None, + }), + } + } + for hook in &after_hooks { + if before_hooks.iter().all(|prev| prev.name != hook.name) { + changes.push(PluginLifecycleChange { + name: hook.name.clone(), + change: "added".to_string(), + before: None, + after: Some(format!( + "priority={}, failurePolicy={}", + hook.priority, + hook.failure_policy.as_deref().unwrap_or("-") + )), + }); + } + } + changes +} + +fn diff_permissions( + current: &PluginDetail, + next: &PluginManifest, +) -> Vec { + let current_permissions = manifest_effective_permissions(¤t.manifest); + let next_permissions = manifest_effective_permissions(next); + let mut all = current_permissions.clone(); + for permission in &next_permissions { + if !all.contains(permission) { + all.push(permission.clone()); + } + } + all.sort(); + + all.into_iter() + .map(|permission| { + let was_available = current_permissions.contains(&permission); + let is_available = next_permissions.contains(&permission); + let change = match (was_available, is_available) { + (true, true) => "unchanged", + (false, true) => "added", + (true, false) => "removed", + (false, false) => "not_available", + }; + let risk = permission_risk(&permission).unwrap_or(PluginPermissionRisk::Low); + PluginPermissionLifecycleChange { + permission, + risk, + change: change.to_string(), + } + }) + .filter(|change| matches!(change.change.as_str(), "added" | "removed")) + .collect() +} + +fn diff_contributions( + before: &PluginManifest, + after: &PluginManifest, +) -> Vec { + let before = contribution_signatures(before); + let after = contribution_signatures(after); + let names: BTreeSet = before.keys().chain(after.keys()).cloned().collect(); + + names + .into_iter() + .filter_map(|name| match (before.get(&name), after.get(&name)) { + (Some(previous), Some(next)) if previous != next => Some(PluginContributionChange { + kind: next.kind.clone(), + name: next.name.clone(), + label: next.label.clone(), + change: "changed".to_string(), + before: Some(previous.summary.clone()), + after: Some(next.summary.clone()), + }), + (Some(previous), None) => Some(PluginContributionChange { + kind: previous.kind.clone(), + name: previous.name.clone(), + label: previous.label.clone(), + change: "removed".to_string(), + before: Some(previous.summary.clone()), + after: None, + }), + (None, Some(next)) => Some(PluginContributionChange { + kind: next.kind.clone(), + name: next.name.clone(), + label: next.label.clone(), + change: "added".to_string(), + before: None, + after: Some(next.summary.clone()), + }), + _ => None, + }) + .collect() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ContributionSignature { + kind: String, + name: String, + label: Option, + summary: String, + fingerprint: String, +} + +fn contribution_signatures(manifest: &PluginManifest) -> BTreeMap { + let mut out = BTreeMap::new(); + let Some(contributes) = manifest.contributes.as_ref() else { + for capability in &manifest.capabilities { + insert_contribution_signature( + &mut out, + format!("capability:{capability}"), + ContributionSignature { + kind: "capability".to_string(), + name: capability.clone(), + label: None, + summary: "declared".to_string(), + fingerprint: format!("capability:{capability}"), + }, + ); + } + return out; + }; + + for provider in &contributes.providers { + let label = Some(provider.display_name.clone()); + insert_contribution_signature( + &mut out, + format!("provider:{}", provider.provider_type), + ContributionSignature { + kind: "provider".to_string(), + name: provider.provider_type.clone(), + label, + summary: short_contribution_summary(format!( + "{} ({})", + provider.display_name, provider.provider_type + )), + fingerprint: contribution_fingerprint("provider", provider), + }, + ); + } + for protocol in &contributes.protocols { + let direction = format!("{:?}", protocol.direction); + insert_contribution_signature( + &mut out, + format!("protocol:{}", protocol.protocol_id), + ContributionSignature { + kind: "protocol".to_string(), + name: protocol.protocol_id.clone(), + label: Some(direction.clone()), + summary: short_contribution_summary(format!( + "{} ({})", + direction, protocol.protocol_id + )), + fingerprint: contribution_fingerprint("protocol", protocol), + }, + ); + } + for bridge in &contributes.protocol_bridges { + let route = format!( + "{} -> {}", + bridge.inbound_protocol, bridge.outbound_protocol + ); + insert_contribution_signature( + &mut out, + format!("protocolBridge:{}", bridge.bridge_type), + ContributionSignature { + kind: "protocolBridge".to_string(), + name: bridge.bridge_type.clone(), + label: Some(route.clone()), + summary: short_contribution_summary(format!("{} ({})", route, bridge.bridge_type)), + fingerprint: contribution_fingerprint("protocolBridge", bridge), + }, + ); + } + for command in &contributes.commands { + insert_contribution_signature( + &mut out, + format!("command:{}", command.command), + ContributionSignature { + kind: "command".to_string(), + name: command.command.clone(), + label: Some(command.title.clone()), + summary: short_contribution_summary(format!( + "{} ({})", + command.title, command.command + )), + fingerprint: contribution_fingerprint("command", command), + }, + ); + } + for hook in &contributes.gateway_hooks { + insert_contribution_signature( + &mut out, + format!("gatewayHook:{}", hook.name), + ContributionSignature { + kind: "gatewayHook".to_string(), + name: hook.name.clone(), + label: None, + summary: short_contribution_summary(format!( + "priority={}, failurePolicy={}", + hook.priority, + hook.failure_policy.as_deref().unwrap_or("-") + )), + fingerprint: contribution_fingerprint("gatewayHook", hook), + }, + ); + } + for (slot_id, contributions) in &contributes.ui { + for contribution in contributions { + let label = contribution + .title + .clone() + .filter(|title| !title.trim().is_empty()) + .unwrap_or_else(|| contribution.id.clone()); + insert_contribution_signature( + &mut out, + format!("ui:{slot_id}:{}", contribution.id), + ContributionSignature { + kind: "ui".to_string(), + name: format!("{slot_id}/{}", contribution.id), + label: Some(label.clone()), + summary: short_contribution_summary(format!("{label} ({slot_id})")), + fingerprint: contribution_fingerprint("ui", contribution), + }, + ); + } + } + + for capability in &manifest.capabilities { + insert_contribution_signature( + &mut out, + format!("capability:{capability}"), + ContributionSignature { + kind: "capability".to_string(), + name: capability.clone(), + label: None, + summary: "declared".to_string(), + fingerprint: format!("capability:{capability}"), + }, + ); + } + + out +} + +fn insert_contribution_signature( + out: &mut BTreeMap, + key: String, + signature: ContributionSignature, +) { + if let Entry::Vacant(entry) = out.entry(key.clone()) { + entry.insert(signature); + return; + } + + let mut index = 2; + loop { + let candidate = format!("{key}#{index}"); + if let Entry::Vacant(entry) = out.entry(candidate) { + entry.insert(signature); + return; + } + index += 1; + } +} + +fn contribution_fingerprint(kind: &str, value: &T) -> String { + serde_json::to_string(value) + .map(|json| format!("{kind}:{json}")) + .unwrap_or_else(|_| kind.to_string()) +} + +fn short_contribution_summary(value: impl AsRef) -> String { + let trimmed = value.as_ref().trim(); + const MAX_CHARS: usize = 80; + if trimmed.chars().count() <= MAX_CHARS { + return trimmed.to_string(); + } + + let mut out = trimmed.chars().take(MAX_CHARS - 3).collect::(); + out.push_str("..."); + out +} + +fn reconcile_permissions_for_manifest( + _current: &PluginDetail, + _manifest: &PluginManifest, +) -> (Vec, Vec) { + (Vec::new(), Vec::new()) +} + +fn config_for_manifest_version( + current: &PluginDetail, + manifest: &PluginManifest, +) -> serde_json::Value { + config_with_schema_defaults(manifest.config_schema.as_ref(), current.config.clone()) +} + +fn config_version_change(before: &PluginManifest, after: &PluginManifest) -> Option { + let before_version = before.config_version.unwrap_or(1); + let after_version = after.config_version.unwrap_or(1); + (before_version != after_version).then(|| format!("{before_version} -> {after_version}")) +} + +pub(crate) fn install_plugin_from_local_package_with_policy( + db: &crate::db::Db, + package_path: &Path, + cache_dir: &Path, + installed_root: &Path, + host_version: &str, + policy: LocalPackageInstallPolicy, +) -> AppResult { + std::fs::create_dir_all(cache_dir).map_err(|e| { + format!( + "failed to create plugin cache dir {}: {e}", + cache_dir.display() + ) + })?; + std::fs::create_dir_all(installed_root).map_err(|e| { + format!( + "failed to create plugin installed dir {}: {e}", + installed_root.display() + ) + })?; + + let staging_root = cache_dir.join("staging"); + let staging_dir = unique_staging_dir(&staging_root, "local"); + let extracted = match package::extract_plugin_package_for_inspection( + package_path, + &staging_dir, + package::PluginPackageLimits::default(), + ) { + Ok(extracted) => extracted, + Err(error) => { + let _ = std::fs::remove_dir_all(&staging_dir); + let _ = std::fs::remove_dir(&staging_root); + return Err(error); + } + }; + let trust = match validate_local_package_install(&extracted, host_version, &policy) { + Ok(trust) => trust, + Err(error) => { + let _ = std::fs::remove_dir_all(&staging_dir); + let _ = std::fs::remove_dir(&staging_root); + return Err(error); } }; @@ -221,12 +1574,7 @@ pub(crate) fn install_plugin_from_local_package_with_policy( let installed_dir = installed_root .join(crate::app_paths::plugin_id_path_segment(&plugin_id)?) .join(crate::app_paths::plugin_id_path_segment(&version)?); - let cache_package_path = cache_dir.join(format!( - "{}-{}-{}.aio-plugin", - plugin_id, - version, - crate::shared::time::now_unix_seconds() - )); + let cache_package_path = unique_cache_package_path(cache_dir, &plugin_id, &version); let result = (|| -> AppResult { std::fs::copy(package_path, &cache_package_path).map_err(|e| { @@ -238,32 +1586,39 @@ pub(crate) fn install_plugin_from_local_package_with_policy( })?; replace_dir(&extracted.root_dir, &installed_dir)?; - let requested_permissions = extracted.manifest.permissions.clone(); - repository::insert_plugin( - db, - repository::InsertPluginInput { - manifest: extracted.manifest.clone(), - install_source: PluginInstallSource::Local, - status: PluginStatus::Disabled, - installed_dir: Some(installed_dir.to_string_lossy().to_string()), - }, - )?; - let detail = - repository::save_plugin_permissions(db, &plugin_id, &[], &requested_permissions)?; - append_audit( - db, - Some(plugin_id.clone()), - "plugin.installed", - "medium", - "Local plugin package installed", - serde_json::json!({ - "source": "local", - "packageChecksum": extracted.checksum, - "cachedPackage": cache_package_path.to_string_lossy(), - "unsigned": !trust.signature_verified, - "developerMode": policy.developer_mode, - }), - )?; + let detail = repository::with_plugin_transaction(db, |tx| { + repository::insert_plugin_with_tx( + tx, + repository::InsertPluginInput { + manifest: extracted.manifest.clone(), + install_source: policy.install_source, + status: PluginStatus::Disabled, + installed_dir: Some(installed_dir.to_string_lossy().to_string()), + }, + )?; + let detail = repository::save_plugin_permissions_with_tx(tx, &plugin_id, &[], &[])?; + append_audit_with_tx( + tx, + Some(plugin_id.clone()), + install_audit_event_type(policy.install_source), + "medium", + install_audit_message(policy.install_source), + install_audit_details( + policy.install_source, + policy.remote_source_url.as_deref(), + policy.market_source_url.as_deref(), + serde_json::json!({ + "source": "local", + "packageChecksum": extracted.checksum, + "cachedPackage": cache_package_path.to_string_lossy(), + "unsigned": !trust.signature_verified, + "signatureVerified": trust.signature_verified, + "developerMode": policy.developer_mode, + }), + ), + )?; + Ok(detail) + })?; tracing::info!( plugin_id, version, @@ -312,11 +1667,10 @@ pub(crate) fn install_plugin_from_remote_package_bytes( cache_dir.display() ) })?; - let package_path = cache_dir.join(format!( - "remote-{}-{}.aio-plugin", + let package_path = unique_remote_package_path( + cache_dir, crate::app_paths::plugin_id_path_segment(&policy.expected_plugin_id)?, - crate::shared::time::now_unix_seconds() - )); + ); std::fs::write(&package_path, &package_bytes).map_err(|e| { format!( "failed to write remote plugin package cache {}: {e}", @@ -324,7 +1678,13 @@ pub(crate) fn install_plugin_from_remote_package_bytes( ) })?; + let install_source = policy.install_source; + let expected_plugin_id = policy.expected_plugin_id.clone(); + let expected_checksum = policy.expected_checksum.clone(); let signature = policy.signature.clone(); + let market_source_url = (install_source == PluginInstallSource::Market) + .then(|| policy.market_source_url.clone()) + .flatten(); let public_key = remote_package_trusted_public_key(db, source_url, &policy)?; let result = install_plugin_from_local_package_with_policy( db, @@ -333,102 +1693,192 @@ pub(crate) fn install_plugin_from_remote_package_bytes( installed_root, host_version, LocalPackageInstallPolicy { - expected_plugin_id: Some(policy.expected_plugin_id), - expected_checksum: Some(policy.expected_checksum), + expected_plugin_id: Some(expected_plugin_id), + expected_checksum: Some(expected_checksum), signature, public_key, - allow_unsigned: false, developer_mode: false, + install_source, + remote_source_url: Some(source_url.to_string()), + market_source_url, }, ) - .and_then(|detail| { - let plugin_id = detail.summary.plugin_id.clone(); - let detail = repository::insert_plugin( - db, - repository::InsertPluginInput { - manifest: detail.manifest.clone(), - install_source: policy.install_source, - status: PluginStatus::Disabled, - installed_dir: detail.installed_dir.clone(), - }, - )?; - append_audit( - db, - Some(plugin_id.clone()), - "plugin.remote.installed", - "medium", - "Remote plugin package installed", - serde_json::json!({ - "sourceUrl": source_url, - "source": policy.install_source.as_str(), - }), - )?; - let next = repository::get_plugin(db, &plugin_id).unwrap_or(detail); + .inspect(|detail| { tracing::info!( - plugin_id, - source = policy.install_source.as_str(), + plugin_id = %detail.summary.plugin_id, + source = install_source.as_str(), "remote plugin package installed" ); - Ok(next) }); let _ = std::fs::remove_file(&package_path); result } -fn remote_package_trusted_public_key( +pub(crate) fn preview_plugin_update_from_remote_package_bytes( db: &crate::db::Db, + package_bytes: Vec, source_url: &str, - policy: &RemotePackageInstallPolicy, -) -> AppResult> { - match policy.install_source { - PluginInstallSource::Market => { - if policy.signature.is_none() { - return Ok(None); - } - repository::trusted_market_public_key_for_url(db, source_url)? - .ok_or_else(|| { - AppError::new( - "PLUGIN_MARKET_TRUSTED_PUBLIC_KEY_REQUIRED", - "signed market plugin installation requires a trusted market source public key", - ) - }) - .map(Some) - } - PluginInstallSource::GithubRelease => Ok(policy.public_key.clone()), - _ => Err(AppError::new( - "PLUGIN_REMOTE_INSTALL_SOURCE_INVALID", - "remote plugin install source must be market or GitHub release", - )), - } + cache_dir: &Path, + host_version: &str, + policy: RemotePackageInstallPolicy, +) -> AppResult { + let package_path = + write_remote_package_bytes(cache_dir, &policy.expected_plugin_id, &package_bytes)?; + let local_policy = local_policy_for_remote_package(db, source_url, &policy)?; + let result = preview_plugin_update_from_local_package( + db, + &package_path, + cache_dir, + host_version, + local_policy, + ); + let _ = std::fs::remove_file(&package_path); + result } -pub(crate) fn update_plugin_from_local_package( +pub(crate) fn update_plugin_from_remote_package_bytes( db: &crate::db::Db, - package_path: &Path, + package_bytes: Vec, + source_url: &str, cache_dir: &Path, installed_root: &Path, host_version: &str, - policy: LocalPackageInstallPolicy, + policy: RemotePackageInstallPolicy, ) -> AppResult { - std::fs::create_dir_all(cache_dir).map_err(|e| { - format!( - "failed to create plugin cache dir {}: {e}", - cache_dir.display() - ) - })?; - std::fs::create_dir_all(installed_root).map_err(|e| { - format!( - "failed to create plugin installed dir {}: {e}", - installed_root.display() - ) - })?; + let install_source = policy.install_source; + let package_path = + write_remote_package_bytes(cache_dir, &policy.expected_plugin_id, &package_bytes)?; + let local_policy = local_policy_for_remote_package(db, source_url, &policy)?; + let result = update_plugin_from_local_package( + db, + &package_path, + cache_dir, + installed_root, + host_version, + local_policy, + ) + .inspect(|detail| { + tracing::info!( + plugin_id = %detail.summary.plugin_id, + source = install_source.as_str(), + "remote plugin package updated" + ); + }); + let _ = std::fs::remove_file(&package_path); + result +} + +fn write_remote_package_bytes( + cache_dir: &Path, + expected_plugin_id: &str, + package_bytes: &[u8], +) -> AppResult { + std::fs::create_dir_all(cache_dir).map_err(|e| { + format!( + "failed to create plugin cache dir {}: {e}", + cache_dir.display() + ) + })?; + let package_path = unique_remote_package_path( + cache_dir, + crate::app_paths::plugin_id_path_segment(expected_plugin_id)?, + ); + std::fs::write(&package_path, package_bytes).map_err(|e| { + format!( + "failed to write remote plugin package cache {}: {e}", + package_path.display() + ) + })?; + Ok(package_path) +} + +fn local_policy_for_remote_package( + db: &crate::db::Db, + source_url: &str, + policy: &RemotePackageInstallPolicy, +) -> AppResult { + if !matches!( + policy.install_source, + PluginInstallSource::Market | PluginInstallSource::GithubRelease + ) { + return Err(AppError::new( + "PLUGIN_REMOTE_INSTALL_SOURCE_INVALID", + "remote plugin install source must be market or GitHub release", + )); + } + if policy.expected_checksum.trim().is_empty() { + return Err(AppError::new( + "PLUGIN_REMOTE_CHECKSUM_REQUIRED", + "remote plugin installation requires a package checksum", + )); + } + let public_key = remote_package_trusted_public_key(db, source_url, policy)?; + Ok(LocalPackageInstallPolicy { + expected_plugin_id: Some(policy.expected_plugin_id.clone()), + expected_checksum: Some(policy.expected_checksum.clone()), + signature: policy.signature.clone(), + public_key, + developer_mode: false, + install_source: policy.install_source, + remote_source_url: Some(source_url.to_string()), + market_source_url: (policy.install_source == PluginInstallSource::Market) + .then(|| policy.market_source_url.clone()) + .flatten(), + }) +} + +fn remote_package_trusted_public_key( + db: &crate::db::Db, + source_url: &str, + policy: &RemotePackageInstallPolicy, +) -> AppResult> { + match policy.install_source { + PluginInstallSource::Market => { + if policy.signature.is_none() { + return Ok(None); + } + let trust_source_url = policy.market_source_url.as_deref().unwrap_or(source_url); + repository::trusted_market_public_key_for_url(db, trust_source_url)? + .ok_or_else(|| { + AppError::new( + "PLUGIN_MARKET_TRUSTED_PUBLIC_KEY_REQUIRED", + "signed market plugin installation requires a trusted market source public key", + ) + }) + .map(Some) + } + PluginInstallSource::GithubRelease => Ok(policy.public_key.clone()), + _ => Err(AppError::new( + "PLUGIN_REMOTE_INSTALL_SOURCE_INVALID", + "remote plugin install source must be market or GitHub release", + )), + } +} + +pub(crate) fn update_plugin_from_local_package( + db: &crate::db::Db, + package_path: &Path, + cache_dir: &Path, + installed_root: &Path, + host_version: &str, + policy: LocalPackageInstallPolicy, +) -> AppResult { + std::fs::create_dir_all(cache_dir).map_err(|e| { + format!( + "failed to create plugin cache dir {}: {e}", + cache_dir.display() + ) + })?; + std::fs::create_dir_all(installed_root).map_err(|e| { + format!( + "failed to create plugin installed dir {}: {e}", + installed_root.display() + ) + })?; let staging_root = cache_dir.join("staging"); - let staging_dir = staging_root.join(format!( - "update-{}", - crate::shared::time::now_unix_seconds() - )); - let extracted = match package::extract_plugin_package( + let staging_dir = unique_staging_dir(&staging_root, "update"); + let extracted = match package::extract_plugin_package_for_inspection( package_path, &staging_dir, package::PluginPackageLimits::default(), @@ -459,46 +1909,40 @@ pub(crate) fn update_plugin_from_local_package( let result = (|| -> AppResult { replace_dir(&extracted.root_dir, &installed_dir)?; - let granted: Vec = current - .granted_permissions - .iter() - .filter(|permission| extracted.manifest.permissions.contains(*permission)) - .cloned() - .collect(); - let pending: Vec = extracted - .manifest - .permissions - .iter() - .filter(|permission| !granted.contains(permission)) - .cloned() - .collect(); - repository::update_plugin_manifest( - db, - extracted.manifest.clone(), - Some(installed_dir.to_string_lossy().to_string()), - )?; - repository::save_plugin_config( - db, - &plugin_id, - extracted.manifest.config_version.unwrap_or(1), - ¤t.config, - &[], - )?; - let detail = repository::save_plugin_permissions(db, &plugin_id, &granted, &pending)?; - append_audit( - db, - Some(plugin_id.clone()), - "plugin.updated", - "high", - "Plugin updated from local package", - serde_json::json!({ - "fromVersion": current.summary.current_version, - "toVersion": extracted.manifest.version, - "pendingPermissions": pending, - "unsigned": !trust.signature_verified, - "developerMode": policy.developer_mode, - }), - )?; + let (granted, pending) = reconcile_permissions_for_manifest(¤t, &extracted.manifest); + let next_config = config_for_manifest_version(¤t, &extracted.manifest); + let detail = repository::with_plugin_transaction(db, |tx| { + repository::update_plugin_manifest_with_tx( + tx, + extracted.manifest.clone(), + Some(installed_dir.to_string_lossy().to_string()), + )?; + repository::save_plugin_config_with_tx( + tx, + &plugin_id, + extracted.manifest.config_version.unwrap_or(1), + &next_config, + &[], + )?; + let detail = + repository::save_plugin_permissions_with_tx(tx, &plugin_id, &granted, &pending)?; + append_audit_with_tx( + tx, + Some(plugin_id.clone()), + "plugin.updated", + "high", + "Plugin updated from local package", + serde_json::json!({ + "fromVersion": current.summary.current_version, + "toVersion": extracted.manifest.version, + "pendingPermissions": pending, + "unsigned": !trust.signature_verified, + "signatureVerified": trust.signature_verified, + "developerMode": policy.developer_mode, + }), + )?; + Ok(detail) + })?; tracing::info!( plugin_id, version = extracted.manifest.version, @@ -521,15 +1965,42 @@ pub(crate) fn rollback_plugin_to_version( version: &str, ) -> AppResult { let (manifest, installed_dir) = repository::get_plugin_version(db, plugin_id, version)?; - let detail = repository::update_plugin_manifest(db, manifest, installed_dir)?; - append_audit( - db, - Some(plugin_id.to_string()), - "plugin.rollback", - "high", - "Plugin rolled back", - serde_json::json!({ "version": version }), - )?; + let installed_dir_value = installed_dir.as_deref().ok_or_else(|| { + AppError::new( + "PLUGIN_ROLLBACK_UNAVAILABLE", + format!("plugin version {version} has no install directory"), + ) + })?; + if !repository::plugin_installed_dir_available(installed_dir_value) { + return Err(AppError::new( + "PLUGIN_ROLLBACK_UNAVAILABLE", + format!("plugin version {version} install directory is unavailable"), + )); + } + let current = repository::get_plugin(db, plugin_id)?; + let (granted, pending) = reconcile_permissions_for_manifest(¤t, &manifest); + let next_config = config_for_manifest_version(¤t, &manifest); + let config_version = manifest.config_version.unwrap_or(1); + let detail = repository::with_plugin_transaction(db, |tx| { + repository::update_plugin_manifest_with_tx(tx, manifest, installed_dir)?; + repository::save_plugin_config_with_tx(tx, plugin_id, config_version, &next_config, &[])?; + let detail = + repository::save_plugin_permissions_with_tx(tx, plugin_id, &granted, &pending)?; + append_audit_with_tx( + tx, + Some(plugin_id.to_string()), + "plugin.rollback", + "high", + "Plugin rolled back", + serde_json::json!({ + "version": version, + "grantedPermissions": granted, + "pendingPermissions": pending, + "configVersion": config_version, + }), + )?; + Ok(detail) + })?; tracing::warn!(plugin_id, version, "plugin rolled back to previous version"); Ok(detail) } @@ -554,26 +2025,10 @@ pub(crate) fn quarantine_revoked_plugin( } fn enforce_unsigned_install_policy( - manifest: &PluginManifest, - policy: &LocalPackageInstallPolicy, - trust: PackageTrust, + _manifest: &PluginManifest, + _policy: &LocalPackageInstallPolicy, + _trust: PackageTrust, ) -> AppResult<()> { - if trust.signature_verified { - return Ok(()); - } - if !policy.allow_unsigned || !policy.developer_mode { - for permission in &manifest.permissions { - if matches!( - permission_risk(permission), - Some(PluginPermissionRisk::High | PluginPermissionRisk::Critical) - ) { - return Err(AppError::new( - "PLUGIN_UNSIGNED_HIGH_RISK_PERMISSION", - format!("unsigned plugin cannot request high-risk permission: {permission}"), - )); - } - } - } Ok(()) } @@ -619,13 +2074,58 @@ fn validate_local_package_install( host_version: &str, policy: &LocalPackageInstallPolicy, ) -> AppResult { + validate_reserved_official_source(&extracted.manifest, policy.install_source)?; validate_manifest(&extracted.manifest, host_version)?; - validate_reserved_official_source(&extracted.manifest, PluginInstallSource::Local)?; let trust = verify_local_package(extracted, policy)?; enforce_unsigned_install_policy(&extracted.manifest, policy, trust)?; Ok(trust) } +fn install_audit_event_type(source: PluginInstallSource) -> &'static str { + match source { + PluginInstallSource::Market | PluginInstallSource::GithubRelease => { + "plugin.remote.installed" + } + _ => "plugin.installed", + } +} + +fn install_audit_message(source: PluginInstallSource) -> &'static str { + match source { + PluginInstallSource::Market | PluginInstallSource::GithubRelease => { + "Remote plugin package installed" + } + _ => "Local plugin package installed", + } +} + +fn install_audit_details( + source: PluginInstallSource, + source_url: Option<&str>, + market_source_url: Option<&str>, + mut details: serde_json::Value, +) -> serde_json::Value { + if let serde_json::Value::Object(object) = &mut details { + object.insert( + "source".to_string(), + serde_json::Value::String(source.as_str().to_string()), + ); + if let Some(source_url) = source_url { + object.insert( + "sourceUrl".to_string(), + serde_json::Value::String(source_url.to_string()), + ); + } + if let Some(market_source_url) = market_source_url { + object.insert( + "marketSourceUrl".to_string(), + serde_json::Value::String(market_source_url.to_string()), + ); + } + } + details +} + fn validate_reserved_official_source( manifest: &PluginManifest, install_source: PluginInstallSource, @@ -657,10 +2157,25 @@ pub(crate) fn enable_plugin( ), )); } - validate_manifest(&detail.manifest, host_version)?; + validate_manifest_for_source(&detail.manifest, detail.install_source, host_version)?; ensure_runtime_enabled(&detail.manifest)?; - ensure_required_permissions_granted(&detail)?; validate_config_against_schema(detail.manifest.config_schema.as_ref(), &detail.config)?; + let mut candidate = detail.clone(); + candidate.summary.status = PluginStatus::Enabled; + let mut active_details = Vec::new(); + for summary in list_plugins(db)? { + if summary.plugin_id == plugin_id { + continue; + } + active_details.push(normalize_unsupported_legacy_plugin_detail( + detail_with_config_defaults_for_db( + db, + repository::get_plugin(db, &summary.plugin_id)?, + )?, + )); + } + active_details.push(candidate); + ensure_no_duplicate_enabled_commands(&active_details)?; let next = repository::update_plugin_status(db, plugin_id, PluginStatus::Enabled, None)?; append_audit( db, @@ -726,99 +2241,42 @@ pub(crate) fn save_plugin_config( pub(crate) fn grant_plugin_permissions( db: &crate::db::Db, plugin_id: &str, - permissions: Vec, + _permissions: Vec, ) -> AppResult { - let detail = repository::get_plugin(db, plugin_id)?; - let mut granted = detail.granted_permissions; - for permission in permissions { - if !detail.manifest.permissions.contains(&permission) { - return Err(AppError::new( - "PLUGIN_PERMISSION_NOT_REQUESTED", - format!("plugin did not request permission: {permission}"), - )); - } - if !granted.contains(&permission) { - granted.push(permission); - } - } - granted.sort(); - let next = repository::save_plugin_permissions(db, plugin_id, &granted, &[])?; - append_audit( - db, - Some(plugin_id.to_string()), - "plugin.permissions.granted", - "high", - "Plugin permissions granted", - serde_json::json!({ "permissions": granted }), - )?; - Ok(next) + let _ = repository::get_plugin(db, plugin_id)?; + Err(AppError::new( + "PLUGIN_PERMISSION_MODEL_REMOVED", + "Extension Host access is derived from capabilities and contributions; manual permission grants are not supported", + )) } pub(crate) fn revoke_plugin_permission( db: &crate::db::Db, plugin_id: &str, - permission: &str, + _permission: &str, ) -> AppResult { - let detail = repository::get_plugin(db, plugin_id)?; - let granted: Vec = detail - .granted_permissions - .into_iter() - .filter(|item| item != permission) - .collect(); - let next = repository::save_plugin_permissions(db, plugin_id, &granted, &[])?; - append_audit( - db, - Some(plugin_id.to_string()), - "plugin.permissions.revoked", - "medium", - "Plugin permission revoked", - serde_json::json!({ "permission": permission }), - )?; - repository::get_plugin(db, plugin_id).or(Ok(next)) + let _ = repository::get_plugin(db, plugin_id)?; + Err(AppError::new( + "PLUGIN_PERMISSION_MODEL_REMOVED", + "Extension Host access is derived from capabilities and contributions; manual permission revocation is not supported", + )) } -fn ensure_required_permissions_granted(detail: &PluginDetail) -> AppResult<()> { - let missing: Vec<&str> = detail - .manifest - .permissions - .iter() - .map(String::as_str) - .filter(|permission| { - !detail - .granted_permissions - .iter() - .any(|item| item == permission) - }) - .collect(); - if missing.is_empty() { - Ok(()) - } else { - Err(AppError::new( - "PLUGIN_PERMISSION_REQUIRED", - format!( - "missing required plugin permissions: {}", - missing.join(", ") - ), - )) +fn ensure_runtime_enabled(manifest: &PluginManifest) -> AppResult<()> { + match &manifest.runtime { + PluginRuntime::ExtensionHost { .. } => Ok(()), } } -fn ensure_runtime_enabled(manifest: &PluginManifest) -> AppResult<()> { - match &manifest.runtime { - PluginRuntime::DeclarativeRules { .. } => Ok(()), - PluginRuntime::Native { engine } - if manifest.id == "official.privacy-filter" && engine == "privacyFilter" => - { - Ok(()) - } - PluginRuntime::Wasm { .. } => Err(AppError::new( - "PLUGIN_RUNTIME_DISABLED", - "wasm runtime execution is disabled by host policy", - )), - PluginRuntime::Native { .. } => Err(AppError::new( - "PLUGIN_UNSUPPORTED_RUNTIME", - "native runtime is reserved for official plugins", - )), +fn validate_manifest_for_source( + manifest: &PluginManifest, + install_source: PluginInstallSource, + host_version: &str, +) -> Result<(), crate::domain::plugins::PluginValidationError> { + if install_source == PluginInstallSource::Official { + validate_manifest_for_official_plugin(manifest, host_version) + } else { + validate_manifest(manifest, host_version) } } @@ -983,6 +2441,28 @@ fn append_audit( Ok(()) } +fn append_audit_with_tx( + conn: &rusqlite::Transaction<'_>, + plugin_id: Option, + event_type: &str, + risk_level: &str, + message: &str, + details: serde_json::Value, +) -> AppResult<()> { + repository::append_audit_log_with_tx( + conn, + repository::AppendPluginAuditLogInput { + plugin_id, + trace_id: None, + event_type: event_type.to_string(), + risk_level: risk_level.to_string(), + message: message.to_string(), + details, + }, + )?; + Ok(()) +} + fn replace_dir(src: &Path, dst: &Path) -> AppResult<()> { let Some(parent) = dst.parent() else { return Err(AppError::new( @@ -1160,7 +2640,7 @@ mod tests { use crate::gateway::plugins::context::{GatewayPluginHookName, GatewayRequestHookInput}; use crate::gateway::plugins::pipeline::{GatewayPluginPipeline, GatewayPluginPipelineConfig}; use std::io::Write; - use std::path::Path; + use std::path::{Path, PathBuf}; use std::sync::Arc; fn manifest() -> PluginManifest { @@ -1170,17 +2650,18 @@ mod tests { "version": "1.0.0", "apiVersion": "1.0.0", "runtime": { - "kind": "declarativeRules", - "rules": ["rules/main.json"] + "kind": "extensionHost", + "language": "typescript" }, - "hooks": [ - { + "main": "dist/extension.js", + "contributes": { + "gatewayHooks": [{ "name": "gateway.request.afterBodyRead", "priority": 100, "failurePolicy": "fail-open" - } - ], - "permissions": ["request.body.read", "request.body.write"], + }] + }, + "capabilities": ["gateway.hooks"], "hostCompatibility": { "app": ">=0.56.0 <1.0.0", "pluginApi": "^1.0.0", @@ -1200,71 +2681,399 @@ mod tests { .unwrap() } - fn wasm_manifest(plugin_id: &str) -> PluginManifest { + fn extension_manifest(plugin_id: &str, command: &str) -> PluginManifest { serde_json::from_value(serde_json::json!({ "id": plugin_id, - "name": "WASM Policy Plugin", + "name": "Acme Debug", "version": "1.0.0", "apiVersion": "1.0.0", - "runtime": { - "kind": "wasm", - "abiVersion": "1.0.0", - "memoryLimitBytes": 16777216 + "runtime": { "kind": "extensionHost", "language": "typescript" }, + "main": "dist/extension.js", + "activationEvents": [format!("onCommand:{command}")], + "contributes": { + "commands": [ + { + "command": command, + "title": "Export Trace", + "category": "Debug" + } + ] }, - "entry": "plugin.wasm", - "hooks": [ - { - "name": "gateway.request.afterBodyRead", - "priority": 100, - "failurePolicy": "fail-open" - } - ], - "permissions": ["request.body.read"], + "capabilities": ["commands.execute", "storage.plugin"], "hostCompatibility": { "app": ">=0.56.0 <1.0.0", "pluginApi": "^1.0.0", "platforms": ["macos", "windows", "linux"] } })) - .unwrap() + .expect("extension manifest") } - #[test] - fn service_requires_permissions_before_enable_and_preserves_config_on_disable() { - let dir = tempfile::tempdir().unwrap(); - let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + fn install_enabled_extension_with_command( + db: &crate::db::Db, + root: &Path, + plugin_id: &str, + command: &str, + ) { + std::fs::create_dir_all(root.join("dist")).expect("create dist"); + let manifest = extension_manifest(plugin_id, command); + std::fs::write( + root.join("plugin.json"), + serde_json::to_vec_pretty(&manifest).expect("manifest json"), + ) + .expect("write manifest"); + std::fs::write( + root.join("dist/extension.js"), + format!( + r#" + module.exports.activate = function(api) {{ + api.commands.registerCommand("{command}", function(args) {{ + api.storage.set("lastTraceId", args.traceId); + return {{ + ok: true, + traceId: args.traceId, + storedTraceId: api.storage.get("lastTraceId") + }}; + }}); + }}; + "# + ), + ) + .expect("write extension"); install_plugin_manifest( - &db, - manifest(), + db, + manifest, PluginInstallSource::Local, - None, + Some(root.to_string_lossy().to_string()), env!("CARGO_PKG_VERSION"), ) - .unwrap(); - - let err = - enable_plugin(&db, "community.prompt-helper", env!("CARGO_PKG_VERSION")).unwrap_err(); - assert!(err.to_string().starts_with("PLUGIN_PERMISSION_REQUIRED:")); + .expect("install extension"); + repository::update_plugin_status(db, plugin_id, PluginStatus::Enabled, None) + .expect("enable extension"); + } - save_plugin_config( - &db, - "community.prompt-helper", - serde_json::json!({"mode": "append_instruction"}), + fn install_enabled_counting_extension_with_command( + db: &crate::db::Db, + root: &Path, + plugin_id: &str, + command: &str, + ) { + std::fs::create_dir_all(root.join("dist")).expect("create dist"); + let manifest = extension_manifest(plugin_id, command); + std::fs::write( + root.join("plugin.json"), + serde_json::to_vec_pretty(&manifest).expect("manifest json"), + ) + .expect("write manifest"); + std::fs::write( + root.join("dist/extension.js"), + format!( + r#" + let executionCount = 0; + let startRecorded = false; + let currentStartCount = 0; + + module.exports.activate = function(api) {{ + api.commands.registerCommand("{command}", function(args) {{ + if (!startRecorded) {{ + const startCount = (api.storage.get("startCount") || 0) + 1; + api.storage.set("startCount", startCount); + currentStartCount = startCount; + startRecorded = true; + }} + executionCount += 1; + return {{ + ok: true, + traceId: args.traceId, + startCount: currentStartCount, + executionCount: executionCount + }}; + }}); + }}; + "# + ), + ) + .expect("write extension"); + + install_plugin_manifest( + db, + manifest, + PluginInstallSource::Local, + Some(root.to_string_lossy().to_string()), + env!("CARGO_PKG_VERSION"), + ) + .expect("install extension"); + repository::update_plugin_status(db, plugin_id, PluginStatus::Enabled, None) + .expect("enable extension"); + } + + #[tokio::test] + async fn plugin_command_execution_records_extension_report() { + let dir = tempfile::tempdir().expect("tempdir"); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).expect("db"); + let plugin_root = dir.path().join("acme.debug"); + install_enabled_extension_with_command( + &db, + &plugin_root, + "acme.debug", + "acme.debug.exportTrace", + ); + let registry = ExtensionHostInstanceRegistry::new(db.clone()); + + let value = execute_plugin_command( + &db, + ®istry, + "acme.debug.exportTrace", + serde_json::json!({ "traceId": "trace-1" }), + ) + .await + .expect("execute command"); + + assert_eq!( + value, + serde_json::json!({ "ok": true, "traceId": "trace-1", "storedTraceId": "trace-1" }) + ); + let detail = repository::get_plugin(&db, "acme.debug").expect("plugin"); + assert_eq!(detail.config["storage"]["lastTraceId"], "trace-1"); + let reports = crate::infra::plugins::runtime_reports::list_extension_execution_reports( + &db, + Some("acme.debug"), + Some("command"), + Some("acme.debug.exportTrace"), + None, + 20, + ) + .expect("list reports"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].contribution_type, "command"); + assert_eq!(reports[0].contribution_id, "acme.debug.exportTrace"); + assert_eq!(reports[0].input_budget["coldStart"], true); + } + + #[tokio::test] + async fn plugin_command_execution_reuses_registry_instance_between_calls() { + let dir = tempfile::tempdir().expect("tempdir"); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).expect("db"); + let plugin_root = dir.path().join("acme.counter"); + install_enabled_counting_extension_with_command( + &db, + &plugin_root, + "acme.counter", + "acme.counter.run", + ); + let registry = ExtensionHostInstanceRegistry::new(db.clone()); + + let first = execute_plugin_command( + &db, + ®istry, + "acme.counter.run", + serde_json::json!({ "traceId": "trace-1" }), + ) + .await + .expect("first command"); + let second = execute_plugin_command( + &db, + ®istry, + "acme.counter.run", + serde_json::json!({ "traceId": "trace-2" }), + ) + .await + .expect("second command"); + + assert_eq!(first["startCount"], 1); + assert_eq!(first["executionCount"], 1); + assert_eq!( + second["startCount"], 1, + "same command should reuse one registry-started extension host" + ); + assert_eq!(second["executionCount"], 2); + let reports = crate::infra::plugins::runtime_reports::list_extension_execution_reports( + &db, + Some("acme.counter"), + Some("command"), + Some("acme.counter.run"), + None, + 20, + ) + .expect("list reports"); + assert_eq!(reports.len(), 2); + assert_eq!(reports[0].input_budget["coldStart"], false); + assert_eq!(reports[1].input_budget["coldStart"], true); + } + + #[tokio::test] + async fn plugin_command_execution_reports_disabled_declared_owner() { + let dir = tempfile::tempdir().expect("tempdir"); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).expect("db"); + let manifest = extension_manifest("acme.disabled", "acme.disabled.run"); + install_plugin_manifest( + &db, + manifest, + PluginInstallSource::Local, + Some( + dir.path() + .join("acme.disabled") + .to_string_lossy() + .to_string(), + ), + env!("CARGO_PKG_VERSION"), + ) + .expect("install disabled extension"); + let registry = ExtensionHostInstanceRegistry::new(db.clone()); + + let err = execute_plugin_command( + &db, + ®istry, + "acme.disabled.run", + serde_json::json!({ "traceId": "trace-disabled" }), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), "PLUGIN_COMMAND_PLUGIN_DISABLED"); + } + + #[test] + fn active_plugin_contributions_isolates_duplicate_command_plugins() { + let dir = tempfile::tempdir().expect("tempdir"); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).expect("db"); + install_enabled_extension_with_command( + &db, + &dir.path().join("acme.one"), + "acme.one", + "acme.shared.run", + ); + install_enabled_extension_with_command( + &db, + &dir.path().join("acme.two"), + "acme.two", + "acme.shared.run", + ); + install_enabled_extension_with_command( + &db, + &dir.path().join("acme.three"), + "acme.three", + "acme.three.run", + ); + + let snapshot = active_plugin_contributions(&db).expect("snapshot"); + + assert_eq!(snapshot.commands.len(), 1); + assert_eq!(snapshot.commands[0].plugin_id, "acme.three"); + assert_eq!(snapshot.commands[0].command, "acme.three.run"); + } + + #[test] + fn enable_plugin_rejects_duplicate_command_with_enabled_plugin() { + let dir = tempfile::tempdir().expect("tempdir"); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).expect("db"); + install_enabled_extension_with_command( + &db, + &dir.path().join("acme.enabled"), + "acme.enabled", + "acme.shared.run", + ); + install_plugin_manifest( + &db, + extension_manifest("acme.disabled", "acme.shared.run"), + PluginInstallSource::Local, + Some( + dir.path() + .join("acme.disabled") + .to_string_lossy() + .to_string(), + ), + env!("CARGO_PKG_VERSION"), + ) + .expect("install disabled extension"); + + let err = enable_plugin(&db, "acme.disabled", env!("CARGO_PKG_VERSION")).unwrap_err(); + + assert_eq!(err.code(), "PLUGIN_DUPLICATE_COMMAND"); + assert!(err.to_string().contains("acme.shared.run")); + } + + #[tokio::test] + async fn plugin_command_execution_rejects_duplicate_enabled_command_owners() { + let dir = tempfile::tempdir().expect("tempdir"); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).expect("db"); + install_enabled_extension_with_command( + &db, + &dir.path().join("acme.one"), + "acme.one", + "acme.shared.run", + ); + install_enabled_extension_with_command( + &db, + &dir.path().join("acme.two"), + "acme.two", + "acme.shared.run", + ); + let registry = ExtensionHostInstanceRegistry::new(db.clone()); + + let err = execute_plugin_command( + &db, + ®istry, + "acme.shared.run", + serde_json::json!({ "traceId": "trace-duplicate" }), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), "PLUGIN_DUPLICATE_COMMAND"); + assert!(err.to_string().contains("acme.one")); + assert!(err.to_string().contains("acme.two")); + } + + #[test] + fn plugin_package_work_paths_are_unique_for_same_prefix() { + let root = Path::new("/tmp/aio-plugin-cache"); + + let first_staging = unique_staging_dir(root, "local"); + let second_staging = unique_staging_dir(root, "local"); + let first_cache = unique_cache_package_path(root, "local.safe", "1.0.0"); + let second_cache = unique_cache_package_path(root, "local.safe", "1.0.0"); + + assert_ne!(first_staging, second_staging); + assert_ne!(first_cache, second_cache); + assert!(first_staging + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("local-"))); + assert!(first_cache + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.starts_with("local.safe-1.0.0-") && name.ends_with(".aio-plugin") + })); + } + + #[test] + fn service_enables_extension_host_without_permission_grants_and_preserves_config_on_disable() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + + install_plugin_manifest( + &db, + manifest(), + PluginInstallSource::Local, + None, + env!("CARGO_PKG_VERSION"), ) .unwrap(); - grant_plugin_permissions( + + save_plugin_config( &db, "community.prompt-helper", - vec![ - "request.body.read".to_string(), - "request.body.write".to_string(), - ], + serde_json::json!({"mode": "append_instruction"}), ) .unwrap(); let enabled = enable_plugin(&db, "community.prompt-helper", env!("CARGO_PKG_VERSION")).unwrap(); assert_eq!(enabled.summary.status, PluginStatus::Enabled); + assert!(enabled.granted_permissions.is_empty()); + assert!(enabled.pending_permissions.is_empty()); let disabled = disable_plugin(&db, "community.prompt-helper").unwrap(); assert_eq!(disabled.summary.status, PluginStatus::Disabled); @@ -1272,7 +3081,7 @@ mod tests { } #[test] - fn local_plugin_install_records_manifest_permissions_as_pending() { + fn local_plugin_install_records_no_pending_permissions_for_extension_host() { let dir = tempfile::tempdir().unwrap(); let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); @@ -1286,9 +3095,10 @@ mod tests { .unwrap(); assert_eq!(installed.granted_permissions, Vec::::new()); + assert!(installed.pending_permissions.is_empty()); assert_eq!( - installed.pending_permissions, - vec!["request.body.read", "request.body.write"] + installed.manifest.capabilities, + vec!["gateway.hooks".to_string()] ); } @@ -1311,15 +3121,6 @@ mod tests { serde_json::json!({"mode": "append_instruction"}), ) .unwrap(); - grant_plugin_permissions( - &db, - "community.prompt-helper", - vec![ - "request.body.read".to_string(), - "request.body.write".to_string(), - ], - ) - .unwrap(); quarantine_revoked_plugin(&db, "community.prompt-helper", "revoked").unwrap(); let err = @@ -1349,15 +3150,6 @@ mod tests { serde_json::json!({"mode": "append_instruction"}), ) .unwrap(); - grant_plugin_permissions( - &db, - "community.prompt-helper", - vec![ - "request.body.read".to_string(), - "request.body.write".to_string(), - ], - ) - .unwrap(); uninstall_plugin(&db, "community.prompt-helper").unwrap(); let err = @@ -1368,31 +3160,6 @@ mod tests { assert_eq!(detail.summary.status, PluginStatus::Uninstalled); } - #[test] - fn enable_plugin_rejects_wasm_when_host_policy_disables_execution() { - let dir = tempfile::tempdir().expect("db dir"); - let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).expect("db"); - install_plugin_manifest( - &db, - wasm_manifest("acme.wasm-policy"), - PluginInstallSource::Local, - Some(dir.path().to_string_lossy().to_string()), - env!("CARGO_PKG_VERSION"), - ) - .expect("install"); - grant_plugin_permissions( - &db, - "acme.wasm-policy", - vec!["request.body.read".to_string()], - ) - .expect("grant"); - - let err = enable_plugin(&db, "acme.wasm-policy", env!("CARGO_PKG_VERSION")) - .expect_err("wasm should not enable without policy"); - - assert_eq!(err.code(), "PLUGIN_RUNTIME_DISABLED"); - } - #[test] fn uninstall_keeps_audit_logs() { let dir = tempfile::tempdir().unwrap(); @@ -1414,36 +3181,34 @@ mod tests { } #[test] - fn revoke_plugin_permission_removes_grant_and_records_audit() { + fn manual_permission_mutations_are_rejected_for_extension_host_plugins() { let dir = tempfile::tempdir().unwrap(); - let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("permission-model.db")).unwrap(); + let installed_root = dir.path().join("installed"); + let official_root = crate::app::plugins::official::official_resource_root_for_tests(); - install_plugin_manifest( + let installed = install_official_plugin( &db, - manifest(), - PluginInstallSource::Local, - None, + "official.privacy-filter", + &official_root, env!("CARGO_PKG_VERSION"), + &installed_root, ) .unwrap(); - grant_plugin_permissions( + assert!(installed.granted_permissions.is_empty()); + assert!(installed.pending_permissions.is_empty()); + + let grant_err = grant_plugin_permissions( &db, - "community.prompt-helper", - vec![ - "request.body.read".to_string(), - "request.body.write".to_string(), - ], + "official.privacy-filter", + vec!["log.redact".to_string()], ) - .unwrap(); - - let detail = - revoke_plugin_permission(&db, "community.prompt-helper", "request.body.write").unwrap(); + .unwrap_err(); + assert_eq!(grant_err.code(), "PLUGIN_PERMISSION_MODEL_REMOVED"); - assert_eq!(detail.granted_permissions, vec!["request.body.read"]); - assert!(detail - .audit_logs - .iter() - .any(|log| log.event_type == "plugin.permissions.revoked")); + let revoke_err = + revoke_plugin_permission(&db, "official.privacy-filter", "log.redact").unwrap_err(); + assert_eq!(revoke_err.code(), "PLUGIN_PERMISSION_MODEL_REMOVED"); } #[test] @@ -1470,9 +3235,9 @@ mod tests { let installed_dir = std::path::Path::new(installed.installed_dir.as_deref().unwrap()); assert!(installed_dir.join("plugin.json").exists()); assert!(installed_dir.join("rules/gitleaks.toml").exists()); - assert!(installed - .granted_permissions - .contains(&"log.redact".to_string())); + assert!(installed.granted_permissions.is_empty()); + assert!(installed.pending_permissions.is_empty()); + assert!(installed.manifest.permissions.is_empty()); assert_eq!(installed.config["redactBeforeUpstream"], true); assert_eq!(installed.config["redactLogs"], true); @@ -1525,6 +3290,167 @@ DROP TABLE plugins; ); } + #[test] + fn enabled_plugins_for_gateway_skips_manifest_that_no_longer_validates() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("invalid-enabled-plugin.db")).unwrap(); + + install_plugin_manifest( + &db, + manifest(), + PluginInstallSource::Local, + None, + env!("CARGO_PKG_VERSION"), + ) + .unwrap(); + save_plugin_config( + &db, + "community.prompt-helper", + serde_json::json!({"mode": "append_instruction"}), + ) + .unwrap(); + enable_plugin(&db, "community.prompt-helper", env!("CARGO_PKG_VERSION")).unwrap(); + let mut invalid_manifest = manifest(); + invalid_manifest.api_version = "2.0.0".to_string(); + let invalid_manifest_json = serde_json::to_string(&invalid_manifest).unwrap(); + db.open_connection() + .unwrap() + .execute( + "UPDATE plugins SET manifest_json = ?1 WHERE plugin_id = ?2", + rusqlite::params![invalid_manifest_json, "community.prompt-helper"], + ) + .unwrap(); + + let active = enabled_plugins_for_gateway(&db).unwrap(); + + assert!(active.is_empty()); + } + + #[test] + fn legacy_runtime_db_row_is_disabled_for_ui_and_gateway() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("legacy-runtime-plugin.db")).unwrap(); + let manifest = legacy_wasm_package_manifest("local.legacy-db", "1.0.0"); + let manifest_json = manifest.to_string(); + let now = crate::shared::time::now_unix_seconds(); + db.open_connection() + .unwrap() + .execute( + r#" +INSERT INTO plugins ( + plugin_id, + name, + current_version, + install_source, + status, + manifest_json, + config_json, + granted_permissions_json, + installed_dir, + created_at, + updated_at +) VALUES (?1, ?2, ?3, ?4, ?5, ?6, '{}', '[]', NULL, ?7, ?7) +"#, + rusqlite::params![ + "local.legacy-db", + "Legacy Rules Plugin", + "1.0.0", + "local", + "enabled", + manifest_json, + now + ], + ) + .unwrap(); + + let listed = list_plugins(&db).unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].status, PluginStatus::Disabled); + assert_eq!(listed[0].runtime, "wasm"); + assert!(listed[0] + .last_error + .as_deref() + .is_some_and(|message| message.contains("Unsupported pre-release plugin runtime"))); + + let detail = get_plugin_detail(&db, "local.legacy-db").unwrap(); + assert_eq!(detail.summary.status, PluginStatus::Disabled); + assert_eq!(detail.summary.runtime, "wasm"); + assert!(detail + .summary + .last_error + .as_deref() + .is_some_and(|message| message.contains("Unsupported pre-release plugin runtime"))); + + let active = enabled_plugins_for_gateway(&db).unwrap(); + assert!(active.is_empty()); + } + + #[test] + fn local_native_privacy_filter_db_row_is_disabled_for_ui_and_gateway() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("local-native-plugin.db")).unwrap(); + let mut manifest = serde_json::to_value(manifest()).unwrap(); + manifest["id"] = serde_json::json!("acme.native-privacy-filter"); + manifest["name"] = serde_json::json!("Native Privacy Filter"); + manifest["runtime"] = serde_json::json!({ + "kind": "native", + "engine": "hostPrivateRedactor" + }); + let manifest_json = serde_json::to_string(&manifest).unwrap(); + let now = crate::shared::time::now_unix_seconds(); + db.open_connection() + .unwrap() + .execute( + r#" +INSERT INTO plugins ( + plugin_id, + name, + current_version, + install_source, + status, + manifest_json, + config_json, + granted_permissions_json, + installed_dir, + created_at, + updated_at +) VALUES (?1, ?2, ?3, ?4, ?5, ?6, '{}', '[]', NULL, ?7, ?7) +"#, + rusqlite::params![ + "acme.native-privacy-filter", + "Native Privacy Filter", + "1.0.0", + "local", + "enabled", + manifest_json, + now + ], + ) + .unwrap(); + + let listed = list_plugins(&db).unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].runtime, "native:hostPrivateRedactor"); + assert_eq!(listed[0].status, PluginStatus::Disabled); + assert!(listed[0] + .last_error + .as_deref() + .is_some_and(|message| message.contains("Unsupported pre-release plugin runtime"))); + + let detail = get_plugin_detail(&db, "acme.native-privacy-filter").unwrap(); + assert_eq!(detail.install_source, PluginInstallSource::Local); + assert_eq!(detail.summary.runtime, "native:hostPrivateRedactor"); + assert_eq!(detail.summary.status, PluginStatus::Disabled); + assert!(detail + .summary + .last_error + .as_deref() + .is_some_and(|message| message.contains("Unsupported pre-release plugin runtime"))); + + let active = enabled_plugins_for_gateway(&db).unwrap(); + assert!(active.is_empty()); + } + #[test] fn official_privacy_filter_install_uses_upstream_redaction_defaults() { let dir = tempfile::tempdir().unwrap(); @@ -1543,13 +3469,22 @@ DROP TABLE plugins; assert_eq!(installed.install_source, PluginInstallSource::Official); assert_eq!(installed.summary.status, PluginStatus::Disabled); - assert_eq!(installed.summary.runtime, "native:privacyFilter"); + assert_eq!(installed.summary.runtime, "extensionHost"); assert_eq!( installed.manifest.runtime, - crate::plugins::PluginRuntime::Native { - engine: "privacyFilter".to_string() + crate::plugins::PluginRuntime::ExtensionHost { + language: "typescript".to_string() } ); + assert_eq!( + installed.manifest.main.as_deref(), + Some("dist/extension.js") + ); + assert!(installed + .manifest + .capabilities + .iter() + .any(|capability| capability == "privacy.redact")); assert!(installed .installed_dir .as_deref() @@ -1561,12 +3496,11 @@ DROP TABLE plugins; assert_eq!(installed.config["redactBeforeUpstream"], true); assert_eq!(installed.config["redactLogs"], true); assert_eq!(installed.config["profile"], "balanced"); - assert!(installed - .granted_permissions - .contains(&"request.body.write".to_string())); - assert!(installed - .granted_permissions - .contains(&"log.redact".to_string())); + assert!(installed.granted_permissions.is_empty()); + assert!(installed.pending_permissions.is_empty()); + let effective_permissions = manifest_effective_permissions(&installed.manifest); + assert!(effective_permissions.contains(&"request.body.write".to_string())); + assert!(effective_permissions.contains(&"log.redact".to_string())); } #[test] @@ -1618,7 +3552,9 @@ DROP TABLE plugins; let pipeline = GatewayPluginPipeline::for_tests( active, Arc::new( - crate::app::plugins::runtime_executor::RuntimeGatewayPluginExecutor::default(), + crate::app::plugins::runtime_executor::RuntimeGatewayPluginExecutor::with_db( + db.clone(), + ), ), GatewayPluginPipelineConfig::default(), ); @@ -1711,7 +3647,9 @@ DROP TABLE plugins; let pipeline = GatewayPluginPipeline::for_tests( active, Arc::new( - crate::app::plugins::runtime_executor::RuntimeGatewayPluginExecutor::default(), + crate::app::plugins::runtime_executor::RuntimeGatewayPluginExecutor::with_db( + db.clone(), + ), ), GatewayPluginPipelineConfig::default(), ); @@ -1761,217 +3699,1045 @@ DROP TABLE plugins; let installed_root = dir.path().join("installed"); let official_root = crate::app::plugins::official::official_resource_root_for_tests(); - install_official_plugin( + install_official_plugin( + &db, + "official.privacy-filter", + &official_root, + env!("CARGO_PKG_VERSION"), + &installed_root, + ) + .unwrap(); + repository::save_plugin_config( + &db, + "official.privacy-filter", + 3, + &serde_json::json!({ + "redactBeforeUpstream": true, + "redactLogs": true, + "profile": "balanced", + "sensitiveTypes": ["email"], + "redactionScopes": ["user_prompts"] + }), + &[], + ) + .unwrap(); + enable_plugin(&db, "official.privacy-filter", env!("CARGO_PKG_VERSION")).unwrap(); + + let active = enabled_plugins_for_gateway(&db).unwrap(); + + assert_eq!(active.len(), 1); + assert!(active[0] + .config + .get("sensitiveTypes") + .and_then(serde_json::Value::as_array) + .is_some_and(|items| !items.iter().any(|item| item == "cn_phone"))); + assert_eq!( + active[0].config["redactionScopes"], + serde_json::json!(["user_prompts"]) + ); + } + + #[test] + fn enabled_official_privacy_filter_preserves_existing_redaction_scopes() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests( + &dir.path() + .join("official-privacy-filter-redaction-scopes.db"), + ) + .unwrap(); + let installed_root = dir.path().join("installed"); + let official_root = crate::app::plugins::official::official_resource_root_for_tests(); + + install_official_plugin( + &db, + "official.privacy-filter", + &official_root, + env!("CARGO_PKG_VERSION"), + &installed_root, + ) + .unwrap(); + repository::save_plugin_config( + &db, + "official.privacy-filter", + 2, + &serde_json::json!({ + "redactBeforeUpstream": true, + "redactLogs": true, + "profile": "balanced", + "sensitiveTypes": ["email"], + "redactionScopes": ["user_prompts"] + }), + &[], + ) + .unwrap(); + enable_plugin(&db, "official.privacy-filter", env!("CARGO_PKG_VERSION")).unwrap(); + + let active = enabled_plugins_for_gateway(&db).unwrap(); + + assert_eq!(active.len(), 1); + assert_eq!( + active[0].config["redactionScopes"], + serde_json::json!(["user_prompts"]) + ); + } + + #[test] + fn enabled_official_privacy_filter_merges_packaged_manifest_hooks() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("official-privacy-filter-hooks.db")) + .unwrap(); + let installed_root = dir.path().join("installed"); + let official_root = crate::app::plugins::official::official_resource_root_for_tests(); + + install_official_plugin( + &db, + "official.privacy-filter", + &official_root, + env!("CARGO_PKG_VERSION"), + &installed_root, + ) + .unwrap(); + let mut legacy = repository::get_plugin(&db, "official.privacy-filter") + .unwrap() + .manifest; + legacy + .contributes + .as_mut() + .expect("official manifest contributes") + .gateway_hooks + .retain(|hook| hook.name != "gateway.request.beforeSend"); + repository::update_plugin_manifest( + &db, + legacy, + Some(installed_root.to_string_lossy().to_string()), + ) + .unwrap(); + enable_plugin(&db, "official.privacy-filter", env!("CARGO_PKG_VERSION")).unwrap(); + + let active = enabled_plugins_for_gateway(&db).unwrap(); + + assert_eq!(active.len(), 1); + assert!(active[0] + .manifest + .contributes + .as_ref() + .expect("official manifest contributes") + .gateway_hooks + .iter() + .any(|hook| hook.name == "gateway.request.beforeSend")); + } + + #[test] + fn official_privacy_filter_detail_and_enable_return_packaged_manifest_hooks() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("official-privacy-filter-detail.db")) + .unwrap(); + let installed_root = dir.path().join("installed"); + let official_root = crate::app::plugins::official::official_resource_root_for_tests(); + + install_official_plugin( + &db, + "official.privacy-filter", + &official_root, + env!("CARGO_PKG_VERSION"), + &installed_root, + ) + .unwrap(); + let mut legacy = repository::get_plugin(&db, "official.privacy-filter") + .unwrap() + .manifest; + legacy + .contributes + .as_mut() + .expect("official manifest contributes") + .gateway_hooks + .retain(|hook| hook.name != "gateway.request.beforeSend"); + repository::update_plugin_manifest( + &db, + legacy, + Some(installed_root.to_string_lossy().to_string()), + ) + .unwrap(); + + let enabled = + enable_plugin(&db, "official.privacy-filter", env!("CARGO_PKG_VERSION")).unwrap(); + let detail = get_plugin_detail(&db, "official.privacy-filter").unwrap(); + + for item in [&enabled, &detail] { + assert!(item + .manifest + .contributes + .as_ref() + .expect("official manifest contributes") + .gateway_hooks + .iter() + .any(|hook| hook.name == "gateway.request.beforeSend")); + assert_eq!(item.config["redactBeforeUpstream"], true); + assert!(item + .config + .get("sensitiveTypes") + .and_then(serde_json::Value::as_array) + .is_some_and(|items| items.iter().any(|item| item == "cn_phone"))); + } + } + + fn local_package_manifest(plugin_id: &str, version: &str) -> serde_json::Value { + serde_json::json!({ + "id": plugin_id, + "name": "Local Package Plugin", + "version": version, + "apiVersion": "1.0.0", + "runtime": { + "kind": "extensionHost", + "language": "typescript" + }, + "main": "dist/extension.js", + "contributes": { + "gatewayHooks": [{ + "name": "gateway.request.afterBodyRead", + "priority": 10, + "failurePolicy": "fail-open" + }] + }, + "capabilities": ["gateway.hooks"], + "hostCompatibility": { + "app": ">=0.56.0 <1.0.0", + "pluginApi": "^1.0.0", + "platforms": ["macos", "windows", "linux"] + } + }) + } + + struct PluginTestContext { + _dir: tempfile::TempDir, + db: crate::db::Db, + package_dir: PathBuf, + cache_dir: PathBuf, + } + + fn plugin_test_context() -> PluginTestContext { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + let package_dir = dir.path().join("packages"); + let cache_dir = dir.path().join("plugins/cache"); + std::fs::create_dir_all(&package_dir).unwrap(); + PluginTestContext { + _dir: dir, + db, + package_dir, + cache_dir, + } + } + + fn extension_package_manifest( + plugin_id: &str, + version: &str, + contributes: serde_json::Value, + ) -> serde_json::Value { + serde_json::json!({ + "id": plugin_id, + "name": "Extension Package Plugin", + "version": version, + "apiVersion": "1.0.0", + "main": "dist/extension.js", + "runtime": { "kind": "extensionHost", "language": "typescript" }, + "activationEvents": ["onStartup"], + "contributes": contributes, + "capabilities": ["provider.extensionValues", "commands.execute"], + "hostCompatibility": { + "app": ">=0.56.0 <1.0.0", + "pluginApi": "^1.0.0", + "platforms": ["macos", "windows", "linux"] + } + }) + } + + fn write_extension_package( + ctx: &PluginTestContext, + plugin_id: &str, + contributes: serde_json::Value, + ) -> PathBuf { + let package_path = ctx.package_dir.join(format!("{plugin_id}.aio-plugin")); + write_local_package( + &package_path, + extension_package_manifest(plugin_id, "1.0.0", contributes), + ); + package_path + } + + fn write_extension_package_with_slots( + ctx: &PluginTestContext, + plugin_id: &str, + slots: Vec<&str>, + ) -> PathBuf { + let ui = slots + .into_iter() + .map(|slot| { + ( + slot.to_string(), + serde_json::json!([{ + "id": format!("{slot}.panel"), + "title": format!("{slot} panel"), + "schema": { "type": "section", "fields": [] } + }]), + ) + }) + .collect::>(); + write_extension_package( + ctx, + plugin_id, + serde_json::json!({ + "ui": ui + }), + ) + } + + fn install_extension_manifest(db: &crate::db::Db, plugin_id: &str, slots: Vec<&str>) { + let ui = slots + .into_iter() + .map(|slot| { + ( + slot.to_string(), + serde_json::json!([{ + "id": format!("{slot}.panel"), + "title": format!("{slot} panel"), + "schema": { "type": "section", "fields": [] } + }]), + ) + }) + .collect::>(); + let manifest: PluginManifest = serde_json::from_value(extension_package_manifest( + plugin_id, + "1.0.0", + serde_json::json!({ "ui": ui }), + )) + .unwrap(); + + install_plugin_manifest(db, manifest, PluginInstallSource::Local, None, "0.62.0").unwrap(); + } + + fn write_local_package(path: &Path, manifest: serde_json::Value) { + let file = std::fs::File::create(path).expect("create package"); + let mut zip = zip::ZipWriter::new(file); + let opts = zip::write::FileOptions::<()>::default(); + zip.start_file("plugin.json", opts).expect("manifest entry"); + zip.write_all(manifest.to_string().as_bytes()) + .expect("manifest bytes"); + zip.start_file("rules/main.json", opts) + .expect("rules entry"); + zip.write_all(br#"{"rules":[]}"#).expect("rules bytes"); + if manifest + .get("runtime") + .and_then(|runtime| runtime.get("kind")) + .and_then(serde_json::Value::as_str) + == Some("extensionHost") + { + zip.start_file("dist/extension.js", opts) + .expect("extension entry"); + zip.write_all(b"export default {};") + .expect("extension bytes"); + } + zip.finish().expect("finish package"); + } + + fn legacy_wasm_package_manifest(plugin_id: &str, version: &str) -> serde_json::Value { + let mut manifest = local_package_manifest(plugin_id, version); + manifest["runtime"] = serde_json::json!({ + "kind": "wasm", + "abiVersion": "1.0.0" + }); + let object = manifest.as_object_mut().expect("manifest object"); + object.remove("main"); + object.remove("activationEvents"); + object.remove("contributes"); + manifest["hooks"] = serde_json::json!([{ + "name": "gateway.request.afterBodyRead", + "priority": 10, + "failurePolicy": "fail-open" + }]); + manifest["permissions"] = serde_json::json!(["request.body.read"]); + manifest["capabilities"] = serde_json::json!([]); + manifest + } + + fn invalid_checksum() -> String { + "sha256:0000000000000000000000000000000000000000000000000000000000000000".to_string() + } + + #[test] + fn plugin_local_install_preview_rejects_legacy_wasm_runtime() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + let package_path = dir.path().join("legacy-wasm.aio-plugin"); + write_local_package( + &package_path, + legacy_wasm_package_manifest("local.legacy-rules", "1.0.0"), + ); + + let err = preview_plugin_from_local_package_with_policy( + &db, + &package_path, + &dir.path().join("plugins/cache"), + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap_err(); + + assert_eq!(err.code(), "PLUGIN_UNSUPPORTED_RUNTIME"); + assert!(repository::get_plugin(&db, "local.legacy-rules").is_err()); + } + + #[test] + fn plugin_local_install_preview_reports_identity_risk_and_trust_without_db_mutation() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + let package_path = dir.path().join("preview-safe.aio-plugin"); + write_local_package( + &package_path, + local_package_manifest("local.preview-safe", "1.0.0"), + ); + + let preview = preview_plugin_from_local_package_with_policy( + &db, + &package_path, + &dir.path().join("plugins/cache"), + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap(); + + assert_eq!(preview.plugin_id, "local.preview-safe"); + assert_eq!(preview.name, "Local Package Plugin"); + assert_eq!(preview.version, "1.0.0"); + assert_eq!(preview.source, PluginInstallSource::Local); + assert_eq!(preview.runtime.kind, "extensionHost"); + assert!(preview.runtime.supported); + assert!(preview.compatibility.compatible); + assert!(preview.trust.unsigned); + assert!(!preview.trust.signature_verified); + assert!(preview + .permissions + .iter() + .any(|permission| permission.permission == "request.body.read")); + assert!(preview + .permissions + .iter() + .any(|permission| permission.permission == "request.body.write")); + assert!(preview + .permissions + .iter() + .all(|permission| permission.granted && !permission.pending)); + assert_eq!(preview.hooks[0].name, "gateway.request.afterBodyRead"); + assert_eq!( + preview.contribution_impact.capabilities, + vec!["gateway.hooks".to_string()] + ); + assert!(preview.blocking_reasons.is_empty()); + assert!(repository::get_plugin(&db, "local.preview-safe").is_err()); + } + + #[test] + fn plugin_local_install_preview_reports_incompatible_manifest_without_installing() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + let package_path = dir.path().join("preview-incompatible.aio-plugin"); + let mut manifest = local_package_manifest("local.preview-incompatible", "1.0.0"); + manifest["hostCompatibility"] = serde_json::json!({ + "app": ">=999.0.0 <1000.0.0", + "pluginApi": "^1.0.0", + "platforms": ["macos", "windows", "linux"] + }); + write_local_package(&package_path, manifest); + + let preview = preview_plugin_from_local_package_with_policy( + &db, + &package_path, + &dir.path().join("plugins/cache"), + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap(); + + assert_eq!(preview.plugin_id, "local.preview-incompatible"); + assert!(!preview.compatibility.compatible); + assert!(preview + .blocking_reasons + .iter() + .any(|notice| notice.code == "PLUGIN_INCOMPATIBLE_HOST")); + assert!(repository::get_plugin(&db, "local.preview-incompatible").is_err()); + } + + #[test] + fn plugin_local_install_preview_does_not_apply_legacy_permission_block_to_extension_host() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + let package_path = dir.path().join("preview-extension-host.aio-plugin"); + write_local_package( + &package_path, + local_package_manifest("local.preview-extension-host", "1.0.0"), + ); + + let preview = preview_plugin_from_local_package_with_policy( + &db, + &package_path, + &dir.path().join("plugins/cache"), + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy::default(), + ) + .unwrap(); + + assert!(preview + .blocking_reasons + .iter() + .all(|notice| notice.code != "PLUGIN_UNSIGNED_HIGH_RISK_PERMISSION")); + assert!(preview + .permissions + .iter() + .any(|permission| permission.permission == "request.body.write")); + assert!(preview + .permissions + .iter() + .all(|permission| !permission.pending)); + assert!(repository::get_plugin(&db, "local.preview-extension-host").is_err()); + } + + #[test] + fn install_preview_describes_extension_contribution_impact() { + let ctx = plugin_test_context(); + let package = write_extension_package( + &ctx, + "acme.openrouter", + serde_json::json!({ + "providers": [{ + "providerType": "openrouter", + "displayName": "OpenRouter", + "targetCliKeys": ["claude"], + "extensionNamespace": "openrouter" + }], + "ui": { + "providers.editor.sections": [{ + "id": "openrouter-routing", + "title": "OpenRouter 路由", + "order": 10, + "schema": { "type": "section", "fields": [] } + }] + }, + "commands": [{ "command": "acme.openrouter.refreshModels", "title": "刷新模型" }] + }), + ); + + let preview = preview_plugin_from_local_package_with_policy( + &ctx.db, + &package, + &ctx.cache_dir, + "0.62.0", + LocalPackageInstallPolicy { + developer_mode: true, + ..Default::default() + }, + ) + .unwrap(); + + assert!(preview + .contribution_impact + .providers + .iter() + .any(|p| p.id == "openrouter")); + assert!(preview + .contribution_impact + .ui_slots + .iter() + .any(|s| s.slot_id == "providers.editor.sections")); + assert!(preview + .contribution_impact + .commands + .iter() + .any(|c| c.command == "acme.openrouter.refreshModels")); + } + + #[test] + fn contribution_impact_update_diff_reports_removed_and_added_contributions() { + let ctx = plugin_test_context(); + install_extension_manifest(&ctx.db, "acme.debug", vec!["logs.detail.tabs"]); + let package = + write_extension_package_with_slots(&ctx, "acme.debug", vec!["settings.sections"]); + + let diff = preview_plugin_update_from_local_package( + &ctx.db, + &package, + &ctx.cache_dir, + "0.62.0", + LocalPackageInstallPolicy { + developer_mode: true, + ..Default::default() + }, + ) + .unwrap(); + + assert!(diff + .contribution_changes + .iter() + .any(|c| c.name == "logs.detail.tabs/logs.detail.tabs.panel" && c.change == "removed")); + assert!(diff.contribution_changes.iter().any(|c| { + c.name == "settings.sections/settings.sections.panel" && c.change == "added" + })); + } + + #[test] + fn contribution_impact_update_diff_keeps_same_id_contribution_types_distinct() { + let ctx = plugin_test_context(); + let current_manifest: PluginManifest = serde_json::from_value(extension_package_manifest( + "acme.collision", + "1.0.0", + serde_json::json!({ + "providers": [{ + "providerType": "shared", + "displayName": "Shared Provider", + "targetCliKeys": ["codex"], + "extensionNamespace": "shared" + }], + "commands": [{ "command": "shared", "title": "Shared Command" }] + }), + )) + .unwrap(); + install_plugin_manifest( + &ctx.db, + current_manifest, + PluginInstallSource::Local, + None, + "0.62.0", + ) + .unwrap(); + let package = write_extension_package( + &ctx, + "acme.collision", + serde_json::json!({ + "providers": [{ + "providerType": "shared", + "displayName": "Shared Provider Updated", + "targetCliKeys": ["codex"], + "extensionNamespace": "shared" + }], + "commands": [{ "command": "shared", "title": "Shared Command Updated" }] + }), + ); + + let diff = preview_plugin_update_from_local_package( + &ctx.db, + &package, + &ctx.cache_dir, + "0.62.0", + LocalPackageInstallPolicy { + developer_mode: true, + ..Default::default() + }, + ) + .unwrap(); + + assert!(diff + .contribution_changes + .iter() + .any(|c| { c.kind == "provider" && c.name == "shared" && c.change == "changed" })); + assert!(diff + .contribution_changes + .iter() + .any(|c| c.kind == "command" && c.name == "shared" && c.change == "changed")); + } + + #[test] + fn contribution_impact_update_diff_reports_ui_contribution_item_replacement() { + let ctx = plugin_test_context(); + let current_manifest: PluginManifest = serde_json::from_value(extension_package_manifest( + "acme.ui-items", + "1.0.0", + serde_json::json!({ + "ui": { + "settings.sections": [ + { + "id": "removed-panel", + "title": "Removed Panel", + "schema": { "type": "section", "fields": [] } + }, + { + "id": "kept-panel", + "title": "Kept Panel", + "schema": { "type": "section", "fields": [] } + } + ] + } + }), + )) + .unwrap(); + install_plugin_manifest( + &ctx.db, + current_manifest, + PluginInstallSource::Local, + None, + "0.62.0", + ) + .unwrap(); + let package = write_extension_package( + &ctx, + "acme.ui-items", + serde_json::json!({ + "ui": { + "settings.sections": [ + { + "id": "kept-panel", + "title": "Kept Panel", + "schema": { "type": "section", "fields": [] } + }, + { + "id": "added-panel", + "title": "Added Panel", + "schema": { "type": "section", "fields": [] } + } + ] + } + }), + ); + + let diff = preview_plugin_update_from_local_package( + &ctx.db, + &package, + &ctx.cache_dir, + "0.62.0", + LocalPackageInstallPolicy { + developer_mode: true, + ..Default::default() + }, + ) + .unwrap(); + + assert!(diff.contribution_changes.iter().any(|c| { + c.kind == "ui" && c.name == "settings.sections/removed-panel" && c.change == "removed" + })); + assert!(diff.contribution_changes.iter().any(|c| { + c.kind == "ui" && c.name == "settings.sections/added-panel" && c.change == "added" + })); + assert!(!diff + .contribution_changes + .iter() + .any(|c| c.name == "settings.sections" && c.change == "changed")); + } + + #[test] + fn contribution_impact_update_diff_uses_short_user_facing_summaries() { + let ctx = plugin_test_context(); + let current_manifest: PluginManifest = serde_json::from_value(extension_package_manifest( + "acme.summary", + "1.0.0", + serde_json::json!({ + "ui": { + "settings.sections": [{ + "id": "summary-panel", + "title": "Summary Panel", + "schema": { + "type": "section", + "fields": [ + { "type": "textarea", "key": "long", "label": "Long schema field" } + ] + } + }] + } + }), + )) + .unwrap(); + install_plugin_manifest( + &ctx.db, + current_manifest, + PluginInstallSource::Local, + None, + "0.62.0", + ) + .unwrap(); + let package = write_extension_package( + &ctx, + "acme.summary", + serde_json::json!({ + "ui": { + "settings.sections": [{ + "id": "summary-panel", + "title": "Summary Panel Updated", + "schema": { + "type": "section", + "fields": [ + { "type": "textarea", "key": "long", "label": "Long schema field" }, + { "type": "info", "key": "extra", "label": "Extra schema field", "value": "schema internals" } + ] + } + }] + } + }), + ); + + let diff = preview_plugin_update_from_local_package( + &ctx.db, + &package, + &ctx.cache_dir, + "0.62.0", + LocalPackageInstallPolicy { + developer_mode: true, + ..Default::default() + }, + ) + .unwrap(); + + let change = diff + .contribution_changes + .iter() + .find(|c| c.kind == "ui" && c.name == "settings.sections/summary-panel") + .expect("ui contribution change"); + assert_eq!(change.label.as_deref(), Some("Summary Panel Updated")); + let rendered = serde_json::to_string(change).unwrap(); + assert!(!rendered.contains("\"schema\"")); + assert!(!rendered.contains("fields")); + assert!(change + .before + .as_ref() + .is_some_and(|before| before.len() <= 80)); + assert!(change.after.as_ref().is_some_and(|after| after.len() <= 80)); + } + + #[test] + fn plugin_local_update_preview_reports_gateway_hook_and_config_changes() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + let cache_dir = dir.path().join("plugins/cache"); + let installed_dir = dir.path().join("plugins/installed"); + let v1_package = dir.path().join("diff-v1.aio-plugin"); + write_local_package(&v1_package, local_package_manifest("local.diff", "1.0.0")); + install_plugin_from_local_package_with_policy( + &db, + &v1_package, + &cache_dir, + &installed_dir, + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap(); + + let v2_package = dir.path().join("diff-v2.aio-plugin"); + let mut v2_manifest = local_package_manifest("local.diff", "1.1.0"); + v2_manifest["configVersion"] = serde_json::json!(2); + v2_manifest["contributes"]["gatewayHooks"] = serde_json::json!([ + { + "name": "gateway.request.afterBodyRead", + "priority": 10, + "failurePolicy": "fail-open" + }, + { + "name": "gateway.request.beforeSend", + "priority": 20, + "failurePolicy": "fail-open" + } + ]); + write_local_package(&v2_package, v2_manifest); + + let diff = preview_plugin_update_from_local_package( &db, - "official.privacy-filter", - &official_root, + &v2_package, + &cache_dir, env!("CARGO_PKG_VERSION"), - &installed_root, - ) - .unwrap(); - repository::save_plugin_config( - &db, - "official.privacy-filter", - 3, - &serde_json::json!({ - "redactBeforeUpstream": true, - "redactLogs": true, - "profile": "balanced", - "sensitiveTypes": ["email"], - "redactionScopes": ["user_prompts"] - }), - &[], + LocalPackageInstallPolicy { + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, ) .unwrap(); - enable_plugin(&db, "official.privacy-filter", env!("CARGO_PKG_VERSION")).unwrap(); - - let active = enabled_plugins_for_gateway(&db).unwrap(); - assert_eq!(active.len(), 1); - assert!(active[0] - .config - .get("sensitiveTypes") - .and_then(serde_json::Value::as_array) - .is_some_and(|items| !items.iter().any(|item| item == "cn_phone"))); - assert_eq!( - active[0].config["redactionScopes"], - serde_json::json!(["user_prompts"]) - ); + assert_eq!(diff.plugin_id, "local.diff"); + assert_eq!(diff.from_version, "1.0.0"); + assert_eq!(diff.to_version, "1.1.0"); + assert_eq!(diff.version_direction, "upgrade"); + assert_eq!(diff.config_version_change.as_deref(), Some("1 -> 2")); + assert!(diff.rollback_available); + assert!(diff + .hook_changes + .iter() + .any(|change| change.name == "gateway.request.beforeSend" && change.change == "added")); + assert!(diff.permission_changes.is_empty()); + assert!(diff.contribution_changes.iter().any(|change| { + change.kind == "gatewayHook" + && change.name == "gateway.request.beforeSend" + && change.change == "added" + })); + assert!(diff.blocking_reasons.is_empty()); } #[test] - fn enabled_official_privacy_filter_preserves_existing_redaction_scopes() { + fn plugin_local_update_preview_reports_prerelease_version_direction() { let dir = tempfile::tempdir().unwrap(); - let db = crate::db::init_for_tests( - &dir.path() - .join("official-privacy-filter-redaction-scopes.db"), - ) - .unwrap(); - let installed_root = dir.path().join("installed"); - let official_root = crate::app::plugins::official::official_resource_root_for_tests(); - - install_official_plugin( + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + let cache_dir = dir.path().join("plugins/cache"); + let installed_dir = dir.path().join("plugins/installed"); + let rc_package = dir.path().join("prerelease-rc.aio-plugin"); + write_local_package( + &rc_package, + local_package_manifest("local.prerelease", "1.0.0-rc.1"), + ); + install_plugin_from_local_package_with_policy( &db, - "official.privacy-filter", - &official_root, + &rc_package, + &cache_dir, + &installed_dir, env!("CARGO_PKG_VERSION"), - &installed_root, + LocalPackageInstallPolicy { + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, ) .unwrap(); - repository::save_plugin_config( + + let release_package = dir.path().join("prerelease-release.aio-plugin"); + write_local_package( + &release_package, + local_package_manifest("local.prerelease", "1.0.0"), + ); + let release_diff = preview_plugin_update_from_local_package( &db, - "official.privacy-filter", - 2, - &serde_json::json!({ - "redactBeforeUpstream": true, - "redactLogs": true, - "profile": "balanced", - "sensitiveTypes": ["email"], - "redactionScopes": ["user_prompts"] - }), - &[], + &release_package, + &cache_dir, + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, ) .unwrap(); - enable_plugin(&db, "official.privacy-filter", env!("CARGO_PKG_VERSION")).unwrap(); - - let active = enabled_plugins_for_gateway(&db).unwrap(); - - assert_eq!(active.len(), 1); - assert_eq!( - active[0].config["redactionScopes"], - serde_json::json!(["user_prompts"]) - ); - } - #[test] - fn enabled_official_privacy_filter_merges_packaged_manifest_hooks() { - let dir = tempfile::tempdir().unwrap(); - let db = crate::db::init_for_tests(&dir.path().join("official-privacy-filter-hooks.db")) - .unwrap(); - let installed_root = dir.path().join("installed"); - let official_root = crate::app::plugins::official::official_resource_root_for_tests(); + assert_eq!(release_diff.from_version, "1.0.0-rc.1"); + assert_eq!(release_diff.to_version, "1.0.0"); + assert_eq!(release_diff.version_direction, "upgrade"); + assert!(release_diff + .warnings + .iter() + .all(|notice| notice.code != "PLUGIN_UPDATE_DOWNGRADE")); - install_official_plugin( + update_plugin_from_local_package( &db, - "official.privacy-filter", - &official_root, + &release_package, + &cache_dir, + &installed_dir, env!("CARGO_PKG_VERSION"), - &installed_root, + LocalPackageInstallPolicy { + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, ) .unwrap(); - let mut legacy = repository::get_plugin(&db, "official.privacy-filter") - .unwrap() - .manifest; - legacy - .hooks - .retain(|hook| hook.name != "gateway.request.beforeSend"); - repository::update_plugin_manifest( + + let rc_diff = preview_plugin_update_from_local_package( &db, - legacy, - Some(installed_root.to_string_lossy().to_string()), + &rc_package, + &cache_dir, + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, ) .unwrap(); - enable_plugin(&db, "official.privacy-filter", env!("CARGO_PKG_VERSION")).unwrap(); - - let active = enabled_plugins_for_gateway(&db).unwrap(); - assert_eq!(active.len(), 1); - assert!(active[0] - .manifest - .hooks + assert_eq!(rc_diff.from_version, "1.0.0"); + assert_eq!(rc_diff.to_version, "1.0.0-rc.1"); + assert_eq!(rc_diff.version_direction, "downgrade"); + assert!(rc_diff + .warnings .iter() - .any(|hook| hook.name == "gateway.request.beforeSend")); + .any(|notice| notice.code == "PLUGIN_UPDATE_DOWNGRADE")); } #[test] - fn official_privacy_filter_detail_and_enable_return_packaged_manifest_hooks() { + fn plugin_local_update_preview_reports_rollback_unavailable_when_current_install_dir_is_missing( + ) { let dir = tempfile::tempdir().unwrap(); - let db = crate::db::init_for_tests(&dir.path().join("official-privacy-filter-detail.db")) - .unwrap(); - let installed_root = dir.path().join("installed"); - let official_root = crate::app::plugins::official::official_resource_root_for_tests(); - - install_official_plugin( + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + let cache_dir = dir.path().join("plugins/cache"); + let installed_dir = dir.path().join("plugins/installed"); + let v1_package = dir.path().join("rollback-v1.aio-plugin"); + let v2_package = dir.path().join("rollback-v2.aio-plugin"); + let v3_package = dir.path().join("rollback-v3.aio-plugin"); + write_local_package( + &v1_package, + local_package_manifest("local.rollback-preview", "1.0.0"), + ); + write_local_package( + &v2_package, + local_package_manifest("local.rollback-preview", "1.1.0"), + ); + write_local_package( + &v3_package, + local_package_manifest("local.rollback-preview", "1.2.0"), + ); + install_plugin_from_local_package_with_policy( &db, - "official.privacy-filter", - &official_root, + &v1_package, + &cache_dir, + &installed_dir, env!("CARGO_PKG_VERSION"), - &installed_root, + LocalPackageInstallPolicy { + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, ) .unwrap(); - let mut legacy = repository::get_plugin(&db, "official.privacy-filter") - .unwrap() - .manifest; - legacy - .hooks - .retain(|hook| hook.name != "gateway.request.beforeSend"); - repository::update_plugin_manifest( + update_plugin_from_local_package( &db, - legacy, - Some(installed_root.to_string_lossy().to_string()), + &v2_package, + &cache_dir, + &installed_dir, + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, ) .unwrap(); - let enabled = - enable_plugin(&db, "official.privacy-filter", env!("CARGO_PKG_VERSION")).unwrap(); - let detail = get_plugin_detail(&db, "official.privacy-filter").unwrap(); + let diff_with_current_dir = preview_plugin_update_from_local_package( + &db, + &v3_package, + &cache_dir, + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap(); - for item in [&enabled, &detail] { - assert!(item - .manifest - .hooks - .iter() - .any(|hook| hook.name == "gateway.request.beforeSend")); - assert_eq!(item.config["redactBeforeUpstream"], true); - assert!(item - .config - .get("sensitiveTypes") - .and_then(serde_json::Value::as_array) - .is_some_and(|items| items.iter().any(|item| item == "cn_phone"))); - } - } + assert!(diff_with_current_dir.rollback_available); - fn local_package_manifest(plugin_id: &str, version: &str) -> serde_json::Value { - serde_json::json!({ - "id": plugin_id, - "name": "Local Package Plugin", - "version": version, - "apiVersion": "1.0.0", - "runtime": { - "kind": "declarativeRules", - "rules": ["rules/main.json"] - }, - "hooks": [ - { - "name": "gateway.request.afterBodyRead", - "priority": 10, - "failurePolicy": "fail-open" - } - ], - "permissions": ["request.meta.read"], - "hostCompatibility": { - "app": ">=0.56.0 <1.0.0", - "pluginApi": "^1.0.0", - "platforms": ["macos", "windows", "linux"] - } - }) - } + let v2_installed_dir = installed_dir.join("local.rollback-preview").join("1.1.0"); + assert!(v2_installed_dir.is_dir()); + std::fs::remove_dir_all(&v2_installed_dir).unwrap(); - fn write_local_package(path: &Path, manifest: serde_json::Value) { - let file = std::fs::File::create(path).expect("create package"); - let mut zip = zip::ZipWriter::new(file); - let opts = zip::write::FileOptions::<()>::default(); - zip.start_file("plugin.json", opts).expect("manifest entry"); - zip.write_all(manifest.to_string().as_bytes()) - .expect("manifest bytes"); - zip.start_file("rules/main.json", opts) - .expect("rules entry"); - zip.write_all(br#"{"rules":[]}"#).expect("rules bytes"); - zip.finish().expect("finish package"); - } + let diff_without_current_dir = preview_plugin_update_from_local_package( + &db, + &v3_package, + &cache_dir, + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap(); - fn invalid_checksum() -> String { - "sha256:0000000000000000000000000000000000000000000000000000000000000000".to_string() + assert!(!diff_without_current_dir.rollback_available); } fn signed_package_policy( @@ -1999,7 +4765,6 @@ DROP TABLE plugins; LocalPackageInstallPolicy { signature: Some(signature_b64), public_key: Some(public_key_b64), - allow_unsigned: false, developer_mode: false, ..LocalPackageInstallPolicy::default() }, @@ -2055,6 +4820,32 @@ DROP TABLE plugins; .any(|log| log.event_type == "plugin.installed")); } + #[test] + fn plugin_local_install_rejects_legacy_wasm_runtime() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + let package_path = dir.path().join("legacy-wasm-install.aio-plugin"); + write_local_package( + &package_path, + legacy_wasm_package_manifest("local.legacy-install", "1.0.0"), + ); + let cache_dir = dir.path().join("plugins/cache"); + let installed_dir = dir.path().join("plugins/installed"); + + let err = install_plugin_from_local_package( + &db, + &package_path, + &cache_dir, + &installed_dir, + env!("CARGO_PKG_VERSION"), + ) + .unwrap_err(); + + assert_eq!(err.code(), "PLUGIN_UNSUPPORTED_RUNTIME"); + assert!(repository::get_plugin(&db, "local.legacy-install").is_err()); + assert!(!installed_dir.join("local.legacy-install").exists()); + } + #[test] fn plugin_local_install_rolls_back_invalid_package_without_db_row_or_install_dir() { let dir = tempfile::tempdir().unwrap(); @@ -2083,14 +4874,41 @@ DROP TABLE plugins; } #[test] - fn plugin_local_install_rejects_reserved_official_privacy_filter_native_package() { + fn plugin_local_install_rejects_reserved_official_privacy_filter_package() { let dir = tempfile::tempdir().unwrap(); let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); let package_path = dir.path().join("fake-official-privacy-filter.aio-plugin"); + let manifest = local_package_manifest("official.privacy-filter", "1.0.0"); + write_local_package(&package_path, manifest); + + let err = install_plugin_from_local_package_with_policy( + &db, + &package_path, + &dir.path().join("plugins/cache"), + &dir.path().join("plugins/installed"), + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap_err(); + + assert!(err.to_string().starts_with("PLUGIN_RESERVED_OFFICIAL_ID:")); + assert!(repository::get_plugin(&db, "official.privacy-filter").is_err()); + } + + #[test] + fn plugin_local_install_preview_rejects_fake_official_native_runtime() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + let package_path = dir + .path() + .join("fake-official-privacy-filter-preview.aio-plugin"); let mut manifest = local_package_manifest("official.privacy-filter", "1.0.0"); manifest["runtime"] = serde_json::json!({ "kind": "native", - "engine": "privacyFilter" + "engine": "hostPrivateRedactor" }); manifest["hooks"] = serde_json::json!([ { @@ -2108,21 +4926,19 @@ DROP TABLE plugins; serde_json::json!(["request.body.read", "request.body.write", "log.redact"]); write_local_package(&package_path, manifest); - let err = install_plugin_from_local_package_with_policy( + let err = preview_plugin_from_local_package_with_policy( &db, &package_path, &dir.path().join("plugins/cache"), - &dir.path().join("plugins/installed"), env!("CARGO_PKG_VERSION"), LocalPackageInstallPolicy { - allow_unsigned: true, developer_mode: true, ..LocalPackageInstallPolicy::default() }, ) .unwrap_err(); - assert!(err.to_string().starts_with("PLUGIN_RESERVED_OFFICIAL_ID:")); + assert_eq!(err.code(), "PLUGIN_UNSUPPORTED_RUNTIME"); assert!(repository::get_plugin(&db, "official.privacy-filter").is_err()); } @@ -2146,7 +4962,6 @@ DROP TABLE plugins; env!("CARGO_PKG_VERSION"), LocalPackageInstallPolicy { expected_checksum: Some(invalid_checksum()), - allow_unsigned: true, developer_mode: true, ..LocalPackageInstallPolicy::default() }, @@ -2190,13 +5005,14 @@ DROP TABLE plugins; } #[test] - fn plugin_signature_verification_allows_high_risk_signed_local_install() { + fn plugin_signature_verification_records_signed_local_install() { let dir = tempfile::tempdir().unwrap(); let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); - let package_path = dir.path().join("signed-risky.aio-plugin"); - let mut manifest = local_package_manifest("local.signed-risky", "1.0.0"); - manifest["permissions"] = serde_json::json!(["request.body.read"]); - write_local_package(&package_path, manifest); + let package_path = dir.path().join("signed-extension.aio-plugin"); + write_local_package( + &package_path, + local_package_manifest("local.signed-extension", "1.0.0"), + ); let (expected_checksum, mut policy) = signed_package_policy(&package_path, 8); policy.expected_checksum = Some(expected_checksum); @@ -2210,7 +5026,7 @@ DROP TABLE plugins; ) .unwrap(); - assert_eq!(detail.summary.plugin_id, "local.signed-risky"); + assert_eq!(detail.summary.plugin_id, "local.signed-extension"); let install_audit = detail .audit_logs .iter() @@ -2220,13 +5036,14 @@ DROP TABLE plugins; } #[test] - fn plugin_local_package_install_records_manifest_permissions_as_pending() { + fn plugin_local_package_install_records_no_pending_permissions_for_extension_host() { let dir = tempfile::tempdir().unwrap(); let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); - let package_path = dir.path().join("signed-pending-permissions.aio-plugin"); - let mut manifest = local_package_manifest("local.pending-permissions", "1.0.0"); - manifest["permissions"] = serde_json::json!(["request.body.read", "request.body.write"]); - write_local_package(&package_path, manifest); + let package_path = dir.path().join("signed-extension-host.aio-plugin"); + write_local_package( + &package_path, + local_package_manifest("local.extension-host", "1.0.0"), + ); let (expected_checksum, mut policy) = signed_package_policy(&package_path, 9); policy.expected_checksum = Some(expected_checksum); @@ -2241,9 +5058,10 @@ DROP TABLE plugins; .unwrap(); assert_eq!(detail.granted_permissions, Vec::::new()); + assert!(detail.pending_permissions.is_empty()); assert_eq!( - detail.pending_permissions, - vec!["request.body.read", "request.body.write"] + detail.manifest.capabilities, + vec!["gateway.hooks".to_string()] ); } @@ -2263,7 +5081,6 @@ DROP TABLE plugins; &dir.path().join("plugins/installed"), env!("CARGO_PKG_VERSION"), LocalPackageInstallPolicy { - allow_unsigned: true, developer_mode: true, ..LocalPackageInstallPolicy::default() }, @@ -2314,6 +5131,7 @@ DROP TABLE plugins; expected_checksum: checksum, signature: None, public_key: None, + market_source_url: None, }, ) .unwrap(); @@ -2327,6 +5145,47 @@ DROP TABLE plugins; .exists()); } + #[test] + fn github_release_plugin_install_ignores_market_source_url_in_audit() { + use sha2::Digest; + + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + let package_path = dir.path().join("github-release-market-url.aio-plugin"); + write_local_package( + &package_path, + local_package_manifest("github.market-url", "1.0.0"), + ); + let package_bytes = std::fs::read(&package_path).unwrap(); + let checksum = format!("sha256:{:x}", sha2::Sha256::digest(&package_bytes)); + + let detail = install_plugin_from_remote_package_bytes( + &db, + package_bytes, + "https://github.com/acme/release/releases/download/v1/plugin.aio-plugin", + &dir.path().join("plugins/cache"), + &dir.path().join("plugins/installed"), + env!("CARGO_PKG_VERSION"), + RemotePackageInstallPolicy { + install_source: PluginInstallSource::GithubRelease, + expected_plugin_id: "github.market-url".to_string(), + expected_checksum: checksum, + signature: None, + public_key: None, + market_source_url: Some("https://plugins.example.test/index.json".to_string()), + }, + ) + .unwrap(); + + let install_audit = detail + .audit_logs + .iter() + .find(|log| log.event_type == "plugin.remote.installed") + .unwrap(); + assert_eq!(install_audit.details["source"], "github_release"); + assert!(install_audit.details.get("marketSourceUrl").is_none()); + } + #[test] fn github_release_plugin_install_rejects_checksum_mismatch_without_installing() { let dir = tempfile::tempdir().unwrap(); @@ -2349,6 +5208,7 @@ DROP TABLE plugins; expected_checksum: invalid_checksum(), signature: None, public_key: None, + market_source_url: None, }, ) .unwrap_err(); @@ -2363,10 +5223,11 @@ DROP TABLE plugins; fn plugin_remote_install_uses_trusted_market_source_public_key() { let dir = tempfile::tempdir().unwrap(); let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); - let package_path = dir.path().join("market-signed-risky.aio-plugin"); - let mut manifest = local_package_manifest("market.signed-risky", "1.0.0"); - manifest["permissions"] = serde_json::json!(["request.body.read"]); - write_local_package(&package_path, manifest); + let package_path = dir.path().join("market-signed-extension.aio-plugin"); + write_local_package( + &package_path, + local_package_manifest("market.signed-extension", "1.0.0"), + ); let package_bytes = std::fs::read(&package_path).unwrap(); let (expected_checksum, trusted_policy) = signed_package_policy(&package_path, 9); let trusted_public_key = trusted_policy.public_key.clone().unwrap(); @@ -2404,23 +5265,101 @@ INSERT INTO plugin_market_sources( env!("CARGO_PKG_VERSION"), RemotePackageInstallPolicy { install_source: PluginInstallSource::Market, - expected_plugin_id: "market.signed-risky".to_string(), + expected_plugin_id: "market.signed-extension".to_string(), expected_checksum, signature: trusted_policy.signature, public_key: Some(caller_public_key), + market_source_url: None, }, ) .unwrap(); - assert_eq!(detail.summary.plugin_id, "market.signed-risky"); + assert_eq!(detail.summary.plugin_id, "market.signed-extension"); + assert_eq!(detail.install_source, PluginInstallSource::Market); assert_eq!(detail.granted_permissions, Vec::::new()); - assert_eq!(detail.pending_permissions, vec!["request.body.read"]); + assert!(detail.pending_permissions.is_empty()); let install_audit = detail .audit_logs .iter() - .find(|log| log.event_type == "plugin.installed") + .find(|log| log.event_type == "plugin.remote.installed") .unwrap(); + assert_eq!(install_audit.details["source"], "market"); + assert_eq!( + install_audit.details["sourceUrl"], + "https://plugins.example.test/download/market-signed-risky.aio-plugin" + ); assert_eq!(install_audit.details["unsigned"], false); + assert_eq!(install_audit.details["signatureVerified"], true); + } + + #[test] + fn plugin_remote_install_uses_market_source_url_when_download_host_differs() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + let package_path = dir.path().join("market-cdn-signed.aio-plugin"); + write_local_package( + &package_path, + local_package_manifest("market.cdn-signed", "1.0.0"), + ); + let package_bytes = std::fs::read(&package_path).unwrap(); + let (expected_checksum, trusted_policy) = signed_package_policy(&package_path, 19); + let trusted_public_key = trusted_policy.public_key.clone().unwrap(); + + let conn = db.open_connection().unwrap(); + conn.execute( + r#" +INSERT INTO plugin_market_sources( + name, + index_url, + enabled, + trusted_public_key, + created_at, + updated_at +) VALUES (?1, ?2, 1, ?3, 1, 1) +"#, + rusqlite::params![ + "Community CDN", + "https://plugins.example.test/index.json", + trusted_public_key + ], + ) + .unwrap(); + drop(conn); + + let detail = install_plugin_from_remote_package_bytes( + &db, + package_bytes, + "https://cdn.example.test/download/market-cdn-signed.aio-plugin", + &dir.path().join("plugins/cache"), + &dir.path().join("plugins/installed"), + env!("CARGO_PKG_VERSION"), + RemotePackageInstallPolicy { + install_source: PluginInstallSource::Market, + expected_plugin_id: "market.cdn-signed".to_string(), + expected_checksum, + signature: trusted_policy.signature, + public_key: None, + market_source_url: Some("https://plugins.example.test/index.json".to_string()), + }, + ) + .unwrap(); + + assert_eq!(detail.summary.plugin_id, "market.cdn-signed"); + assert_eq!(detail.install_source, PluginInstallSource::Market); + let install_audit = detail + .audit_logs + .iter() + .find(|log| log.event_type == "plugin.remote.installed") + .unwrap(); + assert_eq!( + install_audit.details["sourceUrl"], + "https://cdn.example.test/download/market-cdn-signed.aio-plugin" + ); + assert_eq!( + install_audit.details["marketSourceUrl"], + "https://plugins.example.test/index.json" + ); + assert_eq!(install_audit.details["signatureVerified"], true); } #[test] @@ -2448,6 +5387,7 @@ INSERT INTO plugin_market_sources( expected_checksum, signature: policy.signature, public_key: policy.public_key, + market_source_url: None, }, ) .unwrap_err(); @@ -2459,32 +5399,34 @@ INSERT INTO plugin_market_sources( } #[test] - fn plugin_unsigned_offline_install_rejects_high_risk_permissions_by_default() { + fn plugin_unsigned_offline_install_allows_extension_host_without_legacy_permissions() { let dir = tempfile::tempdir().unwrap(); let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); - let package_path = dir.path().join("unsigned-risky.aio-plugin"); - let mut manifest = local_package_manifest("local.risky", "1.0.0"); - manifest["permissions"] = serde_json::json!(["request.header.readSensitive"]); - write_local_package(&package_path, manifest); + let package_path = dir.path().join("unsigned-extension.aio-plugin"); + write_local_package( + &package_path, + local_package_manifest("local.unsigned-extension", "1.0.0"), + ); - let err = install_plugin_from_local_package_with_policy( + let detail = install_plugin_from_local_package_with_policy( &db, &package_path, &dir.path().join("plugins/cache"), &dir.path().join("plugins/installed"), env!("CARGO_PKG_VERSION"), LocalPackageInstallPolicy { - allow_unsigned: false, developer_mode: false, ..LocalPackageInstallPolicy::default() }, ) - .unwrap_err(); + .unwrap(); - assert!(err - .to_string() - .starts_with("PLUGIN_UNSIGNED_HIGH_RISK_PERMISSION:")); - assert!(repository::get_plugin(&db, "local.risky").is_err()); + assert_eq!(detail.summary.plugin_id, "local.unsigned-extension"); + assert!(detail.pending_permissions.is_empty()); + assert!(detail + .audit_logs + .iter() + .any(|log| log.details["unsigned"] == true)); } #[test] @@ -2504,7 +5446,6 @@ INSERT INTO plugin_market_sources( &dir.path().join("plugins/installed"), env!("CARGO_PKG_VERSION"), LocalPackageInstallPolicy { - allow_unsigned: true, developer_mode: true, ..LocalPackageInstallPolicy::default() }, @@ -2519,7 +5460,7 @@ INSERT INTO plugin_market_sources( } #[test] - fn plugin_update_rollback_marks_new_permissions_pending_and_keeps_existing_config() { + fn plugin_update_keeps_existing_config_without_legacy_permission_state() { let dir = tempfile::tempdir().unwrap(); let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); let cache_dir = dir.path().join("plugins/cache"); @@ -2536,25 +5477,18 @@ INSERT INTO plugin_market_sources( &installed_dir, env!("CARGO_PKG_VERSION"), LocalPackageInstallPolicy { - allow_unsigned: true, developer_mode: true, ..LocalPackageInstallPolicy::default() }, ) .unwrap(); save_plugin_config(&db, "local.updatable", serde_json::json!({"enabled": true})).unwrap(); - grant_plugin_permissions( - &db, - "local.updatable", - vec!["request.meta.read".to_string()], - ) - .unwrap(); let v2_package = dir.path().join("plugin-v2.aio-plugin"); - let mut v2_manifest = local_package_manifest("local.updatable", "1.1.0"); - v2_manifest["permissions"] = - serde_json::json!(["request.meta.read", "request.header.read"]); - write_local_package(&v2_package, v2_manifest); + write_local_package( + &v2_package, + local_package_manifest("local.updatable", "1.1.0"), + ); let updated = update_plugin_from_local_package( &db, @@ -2563,7 +5497,6 @@ INSERT INTO plugin_market_sources( &installed_dir, env!("CARGO_PKG_VERSION"), LocalPackageInstallPolicy { - allow_unsigned: true, developer_mode: true, ..LocalPackageInstallPolicy::default() }, @@ -2572,8 +5505,8 @@ INSERT INTO plugin_market_sources( assert_eq!(updated.summary.current_version.as_deref(), Some("1.1.0")); assert_eq!(updated.config["enabled"], true); - assert_eq!(updated.granted_permissions, vec!["request.meta.read"]); - assert_eq!(updated.pending_permissions, vec!["request.header.read"]); + assert!(updated.granted_permissions.is_empty()); + assert!(updated.pending_permissions.is_empty()); } #[test] @@ -2594,7 +5527,6 @@ INSERT INTO plugin_market_sources( &installed_dir, env!("CARGO_PKG_VERSION"), LocalPackageInstallPolicy { - allow_unsigned: true, developer_mode: true, ..LocalPackageInstallPolicy::default() }, @@ -2614,7 +5546,6 @@ INSERT INTO plugin_market_sources( &installed_dir, env!("CARGO_PKG_VERSION"), LocalPackageInstallPolicy { - allow_unsigned: true, developer_mode: true, ..LocalPackageInstallPolicy::default() }, @@ -2650,7 +5581,6 @@ INSERT INTO plugin_market_sources( &installed_dir, env!("CARGO_PKG_VERSION"), LocalPackageInstallPolicy { - allow_unsigned: true, developer_mode: true, ..LocalPackageInstallPolicy::default() }, @@ -2673,7 +5603,6 @@ INSERT INTO plugin_market_sources( LocalPackageInstallPolicy { signature: Some(signature_for_empty_payload), public_key: Some("11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=".to_string()), - allow_unsigned: false, developer_mode: false, ..LocalPackageInstallPolicy::default() }, @@ -2708,7 +5637,6 @@ INSERT INTO plugin_market_sources( &installed_dir, env!("CARGO_PKG_VERSION"), LocalPackageInstallPolicy { - allow_unsigned: true, developer_mode: true, ..LocalPackageInstallPolicy::default() }, @@ -2721,7 +5649,6 @@ INSERT INTO plugin_market_sources( &installed_dir, env!("CARGO_PKG_VERSION"), LocalPackageInstallPolicy { - allow_unsigned: true, developer_mode: true, ..LocalPackageInstallPolicy::default() }, @@ -2739,4 +5666,122 @@ INSERT INTO plugin_market_sources( .as_deref() .is_some_and(|path| path.ends_with("plugins/installed/local.manual/1.0.0"))); } + + #[test] + fn plugin_update_rollback_reconciles_config_version_without_legacy_permission_state() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + let cache_dir = dir.path().join("plugins/cache"); + let installed_dir = dir.path().join("plugins/installed"); + let v1_package = dir.path().join("plugin-v1.aio-plugin"); + let v2_package = dir.path().join("plugin-v2.aio-plugin"); + + let mut v1_manifest = local_package_manifest("local.rollback-state", "1.0.0"); + v1_manifest["configVersion"] = serde_json::json!(1); + write_local_package(&v1_package, v1_manifest); + + let mut v2_manifest = local_package_manifest("local.rollback-state", "1.1.0"); + v2_manifest["configVersion"] = serde_json::json!(2); + write_local_package(&v2_package, v2_manifest); + + install_plugin_from_local_package_with_policy( + &db, + &v1_package, + &cache_dir, + &installed_dir, + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap(); + save_plugin_config( + &db, + "local.rollback-state", + serde_json::json!({"enabled": true, "extra": "kept"}), + ) + .unwrap(); + + let updated = update_plugin_from_local_package( + &db, + &v2_package, + &cache_dir, + &installed_dir, + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap(); + assert!(updated.pending_permissions.is_empty()); + + let rolled_back = rollback_plugin_to_version(&db, "local.rollback-state", "1.0.0").unwrap(); + + assert_eq!( + rolled_back.summary.current_version.as_deref(), + Some("1.0.0") + ); + assert!(rolled_back.granted_permissions.is_empty()); + assert!(rolled_back.pending_permissions.is_empty()); + assert_eq!(rolled_back.config["enabled"], true); + assert_eq!( + repository::plugin_config_version(&db, "local.rollback-state").unwrap(), + Some(1) + ); + assert!(enable_plugin(&db, "local.rollback-state", env!("CARGO_PKG_VERSION")).is_ok()); + } + + #[test] + fn plugin_update_rollback_rejects_missing_historical_install_dir() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + let cache_dir = dir.path().join("plugins/cache"); + let installed_dir = dir.path().join("plugins/installed"); + let v1_package = dir.path().join("plugin-v1.aio-plugin"); + let v2_package = dir.path().join("plugin-v2.aio-plugin"); + write_local_package( + &v1_package, + local_package_manifest("local.missing-rollback", "1.0.0"), + ); + write_local_package( + &v2_package, + local_package_manifest("local.missing-rollback", "1.1.0"), + ); + install_plugin_from_local_package_with_policy( + &db, + &v1_package, + &cache_dir, + &installed_dir, + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap(); + update_plugin_from_local_package( + &db, + &v2_package, + &cache_dir, + &installed_dir, + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap(); + + let v1_installed_dir = installed_dir.join("local.missing-rollback").join("1.0.0"); + assert!(v1_installed_dir.is_dir()); + std::fs::remove_dir_all(&v1_installed_dir).unwrap(); + + let err = rollback_plugin_to_version(&db, "local.missing-rollback", "1.0.0").unwrap_err(); + + assert!(err.to_string().starts_with("PLUGIN_ROLLBACK_UNAVAILABLE:")); + let current = get_plugin_detail(&db, "local.missing-rollback").unwrap(); + assert_eq!(current.summary.current_version.as_deref(), Some("1.1.0")); + } } diff --git a/src-tauri/src/app/plugins/access_policy.rs b/src-tauri/src/app/plugins/access_policy.rs new file mode 100644 index 00000000..995543c1 --- /dev/null +++ b/src-tauri/src/app/plugins/access_policy.rs @@ -0,0 +1,130 @@ +//! Usage: Derives runtime access for Extension Host plugin contributions. + +use crate::domain::plugins::{gateway_hook_effective_permissions, PluginDetail}; +use crate::gateway::plugins::context::GatewayPluginHookName; + +pub(crate) fn effective_hook_permissions( + plugin: &PluginDetail, + hook_name: GatewayPluginHookName, +) -> Vec { + gateway_hook_effective_permissions(&plugin.manifest, hook_name.as_str()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::plugin_contributions::PluginContributes; + use crate::domain::plugins::{ + PluginDetail, PluginHook, PluginHostCompatibility, PluginInstallSource, PluginManifest, + PluginPermissionRisk, PluginRuntime, PluginStatus, PluginSummary, + }; + use std::collections::BTreeMap; + + fn extension_host_plugin(hooks: Vec, capabilities: Vec<&str>) -> PluginDetail { + PluginDetail { + summary: PluginSummary { + id: 1, + plugin_id: "community.prompt-helper".to_string(), + name: "Prompt Helper".to_string(), + current_version: Some("1.0.0".to_string()), + status: PluginStatus::Enabled, + runtime: "extensionHost".to_string(), + permission_risk: PluginPermissionRisk::Low, + update_available: false, + last_error: None, + created_at: 1, + updated_at: 1, + }, + manifest: PluginManifest { + id: "community.prompt-helper".to_string(), + name: "Prompt Helper".to_string(), + version: "1.0.0".to_string(), + api_version: "1.0.0".to_string(), + runtime: PluginRuntime::ExtensionHost { + language: "typescript".to_string(), + }, + hooks: vec![], + permissions: vec![], + main: Some("dist/extension.js".to_string()), + activation_events: vec![], + contributes: Some(PluginContributes { + providers: vec![], + protocols: vec![], + protocol_bridges: vec![], + commands: vec![], + gateway_hooks: hooks, + ui: BTreeMap::new(), + }), + capabilities: capabilities.into_iter().map(str::to_string).collect(), + host_compatibility: PluginHostCompatibility { + app: ">=0.60.0 <1.0.0".to_string(), + plugin_api: "^1.0.0".to_string(), + platforms: vec![], + }, + entry: None, + config_schema: None, + config_version: None, + description: None, + author: None, + homepage: None, + repository: None, + license: None, + checksum: None, + signature: None, + category: None, + }, + install_source: PluginInstallSource::Local, + installed_dir: None, + config: serde_json::json!({}), + granted_permissions: vec![], + pending_permissions: vec![], + audit_logs: vec![], + runtime_failures: vec![], + rollback_versions: vec![], + } + } + + #[test] + fn extension_host_hook_access_is_derived_from_contribution_descriptor() { + let plugin = extension_host_plugin( + vec![PluginHook { + name: "gateway.request.afterBodyRead".to_string(), + priority: 10, + failure_policy: Some("fail-open".to_string()), + timeout_ms: None, + }], + vec!["gateway.hooks"], + ); + + let permissions = + effective_hook_permissions(&plugin, GatewayPluginHookName::RequestAfterBodyRead); + + assert!(permissions.contains(&"request.body.read".to_string())); + assert!(permissions.contains(&"request.body.write".to_string())); + } + + #[test] + fn extension_host_hook_access_requires_matching_capability_and_contribution() { + let missing_capability = extension_host_plugin( + vec![PluginHook { + name: "gateway.request.afterBodyRead".to_string(), + priority: 10, + failure_policy: Some("fail-open".to_string()), + timeout_ms: None, + }], + vec![], + ); + assert!(effective_hook_permissions( + &missing_capability, + GatewayPluginHookName::RequestAfterBodyRead + ) + .is_empty()); + + let missing_contribution = extension_host_plugin(vec![], vec!["gateway.hooks"]); + assert!(effective_hook_permissions( + &missing_contribution, + GatewayPluginHookName::RequestAfterBodyRead + ) + .is_empty()); + } +} diff --git a/src-tauri/src/app/plugins/contribution_registry.rs b/src-tauri/src/app/plugins/contribution_registry.rs new file mode 100644 index 00000000..46d12d34 --- /dev/null +++ b/src-tauri/src/app/plugins/contribution_registry.rs @@ -0,0 +1,644 @@ +use std::collections::BTreeMap; + +use serde::Serialize; + +use crate::domain::plugin_contributions::is_known_ui_slot; +use crate::plugins::PluginStatus; +use crate::shared::error::AppError; +use crate::shared::error::AppResult; + +use super::extension_protocol_bridge::ExtensionProtocolBridgeRegistry; + +#[derive(Debug, Clone, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ActiveUiContribution { + pub plugin_id: String, + pub contribution_id: String, + pub provider_extension_namespace: Option, + pub slot_id: String, + pub title: Option, + pub order: i32, + pub schema: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ActiveProviderContribution { + pub plugin_id: String, + pub provider_type: String, + pub display_name: String, + pub target_cli_keys: Vec, + pub extension_namespace: String, +} + +#[derive(Debug, Clone, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ActiveProtocolContribution { + pub plugin_id: String, + pub protocol_id: String, + pub direction: crate::domain::plugin_contributions::ProtocolDirection, +} + +#[derive(Debug, Clone, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ActiveProtocolBridgeContribution { + pub plugin_id: String, + pub contribution_id: String, + pub bridge_type: String, + pub inbound_protocol: String, + pub outbound_protocol: String, + pub supports_streaming: Option, +} + +#[derive(Debug, Clone, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ActiveCommandContribution { + pub plugin_id: String, + pub command: String, + pub title: String, + pub category: Option, +} + +#[derive(Debug, Clone, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ActiveGatewayHookContribution { + pub plugin_id: String, + pub name: String, + pub priority: i32, + pub failure_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, +} + +#[derive(Debug, Clone, Default, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ActiveContributionSnapshot { + pub ui: Vec, + pub providers: Vec, + pub protocols: Vec, + pub protocol_bridges: Vec, + pub commands: Vec, + pub gateway_hooks: Vec, +} + +impl ActiveContributionSnapshot { + pub fn from_plugin_details(plugins: &[crate::plugins::PluginDetail]) -> AppResult { + let mut snapshot = Self::default(); + let mut command_owners = BTreeMap::::new(); + + for plugin in plugins { + if plugin.summary.status != PluginStatus::Enabled { + continue; + } + + let plugin_id = plugin.manifest.id.as_str(); + let Some(contributes) = plugin.manifest.contributes.as_ref() else { + continue; + }; + let provider_extension_namespace_for_ui = match contributes.providers.as_slice() { + [provider] => Some(provider.extension_namespace.clone()), + _ => None, + }; + + for provider in &contributes.providers { + ensure_contribution_id(&provider.provider_type, "provider")?; + ensure_namespaced(plugin_id, &provider.provider_type, "provider")?; + snapshot.providers.push(ActiveProviderContribution { + plugin_id: plugin_id.to_string(), + provider_type: provider.provider_type.clone(), + display_name: provider.display_name.clone(), + target_cli_keys: provider.target_cli_keys.clone(), + extension_namespace: provider.extension_namespace.clone(), + }); + } + + for protocol in &contributes.protocols { + ensure_contribution_id(&protocol.protocol_id, "protocol")?; + snapshot.protocols.push(ActiveProtocolContribution { + plugin_id: plugin_id.to_string(), + protocol_id: protocol.protocol_id.clone(), + direction: protocol.direction.clone(), + }); + } + + for bridge in &contributes.protocol_bridges { + ensure_protocol_bridge_type(plugin_id, &bridge.bridge_type)?; + snapshot + .protocol_bridges + .push(ActiveProtocolBridgeContribution { + plugin_id: plugin_id.to_string(), + contribution_id: ExtensionProtocolBridgeRegistry::contribution_id( + plugin_id, + &bridge.bridge_type, + ), + bridge_type: bridge.bridge_type.clone(), + inbound_protocol: bridge.inbound_protocol.clone(), + outbound_protocol: bridge.outbound_protocol.clone(), + supports_streaming: bridge.supports_streaming, + }); + } + + for command in &contributes.commands { + ensure_contribution_id(&command.command, "command")?; + if let Some(owner) = + command_owners.insert(command.command.clone(), plugin_id.to_string()) + { + return Err(AppError::new( + "PLUGIN_DUPLICATE_COMMAND", + format!( + "command {} is declared by both {} and {}", + command.command, owner, plugin_id + ), + )); + } + snapshot.commands.push(ActiveCommandContribution { + plugin_id: plugin_id.to_string(), + command: command.command.clone(), + title: command.title.clone(), + category: command.category.clone(), + }); + } + + for hook in &contributes.gateway_hooks { + ensure_contribution_id(&hook.name, "gateway hook")?; + snapshot.gateway_hooks.push(ActiveGatewayHookContribution { + plugin_id: plugin_id.to_string(), + name: hook.name.clone(), + priority: hook.priority, + failure_policy: hook.failure_policy.clone(), + timeout_ms: hook.timeout_ms, + }); + } + + for (slot_id, contributions) in &contributes.ui { + if !is_known_ui_slot(slot_id) { + return Err(AppError::new( + "PLUGIN_UNKNOWN_UI_SLOT", + format!("unknown UI contribution slot: {slot_id}"), + )); + } + + for contribution in contributions { + ensure_contribution_id(&contribution.id, "UI")?; + snapshot.ui.push(ActiveUiContribution { + plugin_id: plugin_id.to_string(), + contribution_id: contribution.id.clone(), + provider_extension_namespace: provider_extension_namespace_for_ui.clone(), + slot_id: slot_id.clone(), + title: contribution.title.clone(), + order: contribution.order.unwrap_or(0), + schema: serde_json::to_value(&contribution.schema).map_err(|error| { + AppError::new( + "PLUGIN_INVALID_UI_CONTRIBUTION", + format!("failed to serialize UI contribution schema: {error}"), + ) + })?, + }); + } + } + } + + snapshot.ui.sort_by(|left, right| { + left.order + .cmp(&right.order) + .then_with(|| left.plugin_id.cmp(&right.plugin_id)) + .then_with(|| left.contribution_id.cmp(&right.contribution_id)) + }); + + Ok(snapshot) + } + + #[cfg(test)] + pub fn ui_for_slot(&self, slot_id: &str) -> Vec<&ActiveUiContribution> { + self.ui + .iter() + .filter(|item| item.slot_id == slot_id) + .collect() + } +} + +fn ensure_contribution_id(value: &str, contribution_kind: &str) -> AppResult<()> { + if value.trim().is_empty() { + return Err(AppError::new( + "PLUGIN_INVALID_CONTRIBUTION_ID", + format!("{contribution_kind} contribution id must be non-empty"), + )); + } + Ok(()) +} + +fn ensure_namespaced(plugin_id: &str, value: &str, contribution_kind: &str) -> AppResult<()> { + if is_plugin_namespaced(plugin_id, value) { + return Ok(()); + } + + Err(AppError::new( + "PLUGIN_CONTRIBUTION_NAMESPACE_MISMATCH", + format!( + "{contribution_kind} contribution id {value} must be namespaced by plugin {plugin_id}" + ), + )) +} + +fn ensure_protocol_bridge_type(plugin_id: &str, bridge_type: &str) -> AppResult<()> { + ensure_contribution_id(bridge_type, "protocol bridge")?; + if !is_valid_protocol_bridge_type(bridge_type) { + return Err(AppError::new( + "PLUGIN_INVALID_PROTOCOL_BRIDGE_CONTRIBUTION", + "protocol bridge bridgeType must use lower-case id segments", + )); + } + if is_protocol_bridge_namespaced(plugin_id, bridge_type) { + return Ok(()); + } + + Err(AppError::new( + "PLUGIN_CONTRIBUTION_NAMESPACE_MISMATCH", + format!( + "protocol bridge contribution id {bridge_type} must be namespaced by plugin {plugin_id}" + ), + )) +} + +fn is_valid_protocol_bridge_type(value: &str) -> bool { + value.split(['.', '/', ':']).all(|segment| { + let mut chars = segment.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first.is_ascii_lowercase() || first.is_ascii_digit()) + && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') + }) +} + +fn is_protocol_bridge_namespaced(plugin_id: &str, value: &str) -> bool { + if value == plugin_id { + return true; + } + value + .strip_prefix(plugin_id) + .is_some_and(|suffix| matches!(suffix.as_bytes().first(), Some(b'.' | b'/' | b':'))) +} + +fn is_plugin_namespaced(plugin_id: &str, value: &str) -> bool { + if value == plugin_id { + return true; + } + value + .strip_prefix(plugin_id) + .is_some_and(|suffix| suffix.starts_with('.') || suffix.starts_with('/')) +} + +#[cfg(test)] +mod contribution_registry_tests { + use std::collections::BTreeMap; + + use crate::domain::plugin_contributions::{ + CommandContribution, HostRenderedSchema, PluginContributes, ProtocolBridgeContribution, + ProviderContribution, TargetCliKey, UiContribution, + }; + use crate::plugins::{ + PluginAuditLog, PluginDetail, PluginHostCompatibility, PluginInstallSource, PluginManifest, + PluginPermissionRisk, PluginRuntime, PluginRuntimeFailure, PluginStatus, PluginSummary, + }; + + use super::ActiveContributionSnapshot; + + #[test] + fn contribution_registry_filters_enabled_plugins_and_orders_ui_slots() { + let enabled = plugin_detail_with_ui( + "acme.settings", + crate::plugins::PluginStatus::Enabled, + "settings.sections", + "settings-a", + 20, + ); + let disabled = plugin_detail_with_ui( + "acme.disabled", + crate::plugins::PluginStatus::Disabled, + "settings.sections", + "settings-hidden", + 10, + ); + let earlier = plugin_detail_with_ui( + "acme.settings-earlier", + crate::plugins::PluginStatus::Enabled, + "settings.sections", + "settings-b", + 5, + ); + + let snapshot = + ActiveContributionSnapshot::from_plugin_details(&[enabled, disabled, earlier]) + .expect("snapshot"); + + let ids: Vec<_> = snapshot + .ui_for_slot("settings.sections") + .iter() + .map(|item| item.contribution_id.as_str()) + .collect(); + assert_eq!(ids, vec!["settings-b", "settings-a"]); + } + + #[test] + fn contribution_registry_rejects_unknown_slots() { + let plugin = plugin_detail_with_raw_ui_slot("acme.bad", "settings.unknown"); + let err = ActiveContributionSnapshot::from_plugin_details(&[plugin]).unwrap_err(); + assert_eq!(err.code(), "PLUGIN_UNKNOWN_UI_SLOT"); + } + + #[test] + fn contribution_registry_rejects_duplicate_command_ids() { + let first = plugin_detail_with_command("acme.one", "acme.command.open"); + let second = plugin_detail_with_command("acme.two", "acme.command.open"); + + let err = ActiveContributionSnapshot::from_plugin_details(&[first, second]).unwrap_err(); + + assert_eq!(err.code(), "PLUGIN_DUPLICATE_COMMAND"); + } + + #[test] + fn contribution_registry_rejects_provider_namespace_mismatch() { + let plugin = plugin_detail_with_provider("acme.provider", "other.provider"); + + let err = ActiveContributionSnapshot::from_plugin_details(&[plugin]).unwrap_err(); + + assert_eq!(err.code(), "PLUGIN_CONTRIBUTION_NAMESPACE_MISMATCH"); + } + + #[test] + fn contribution_registry_rejects_protocol_bridge_namespace_mismatch() { + let plugin = plugin_detail_with_protocol_bridge("acme.bridge", "other.bridge"); + + let err = ActiveContributionSnapshot::from_plugin_details(&[plugin]).unwrap_err(); + + assert_eq!(err.code(), "PLUGIN_CONTRIBUTION_NAMESPACE_MISMATCH"); + } + + #[test] + fn contribution_registry_rejects_invalid_protocol_bridge_type() { + let plugin = plugin_detail_with_protocol_bridge("acme.bridge", "acme.bridge.OpenAI"); + + let err = ActiveContributionSnapshot::from_plugin_details(&[plugin]).unwrap_err(); + + assert_eq!(err.code(), "PLUGIN_INVALID_PROTOCOL_BRIDGE_CONTRIBUTION"); + } + + #[test] + fn contribution_registry_indexes_namespaced_protocol_bridge() { + let plugin = plugin_detail_with_protocol_bridge("acme.bridge", "acme.bridge.openai-gemini"); + + let snapshot = + ActiveContributionSnapshot::from_plugin_details(&[plugin]).expect("snapshot"); + + assert_eq!(snapshot.protocol_bridges.len(), 1); + let bridge = &snapshot.protocol_bridges[0]; + assert_eq!(bridge.plugin_id, "acme.bridge"); + assert_eq!(bridge.contribution_id, "acme.bridge.openai-gemini"); + assert_eq!(bridge.bridge_type, "acme.bridge.openai-gemini"); + assert_eq!(bridge.inbound_protocol, "claude"); + assert_eq!(bridge.outbound_protocol, "codex"); + assert_eq!(bridge.supports_streaming, Some(true)); + } + + #[test] + fn contribution_registry_rejects_empty_contribution_id() { + let plugin = plugin_detail_with_raw_ui_slot_and_id("acme.empty", "settings.sections", " "); + + let err = ActiveContributionSnapshot::from_plugin_details(&[plugin]).unwrap_err(); + + assert_eq!(err.code(), "PLUGIN_INVALID_CONTRIBUTION_ID"); + } + + #[test] + fn contribution_registry_sets_provider_extension_namespace_for_single_provider_ui() { + let plugin = plugin_detail_with_provider_and_ui( + "acme.provider", + vec![provider_contribution("acme.provider.codex", "shared")], + "providers.editor.sections", + ui_contribution("routing-panel", 0), + ); + + let snapshot = + ActiveContributionSnapshot::from_plugin_details(&[plugin]).expect("snapshot"); + + let ui = snapshot.ui_for_slot("providers.editor.sections"); + assert_eq!( + ui[0].provider_extension_namespace.as_deref(), + Some("shared") + ); + } + + #[test] + fn contribution_registry_omits_provider_extension_namespace_without_exactly_one_provider() { + let no_provider = + plugin_detail_with_raw_ui_slot("acme.no-provider", "providers.editor.sections"); + let two_providers = plugin_detail_with_provider_and_ui( + "acme.multi-provider", + vec![ + provider_contribution("acme.multi-provider.codex", "codex"), + provider_contribution("acme.multi-provider.gemini", "gemini"), + ], + "providers.editor.sections", + ui_contribution("routing-panel", 0), + ); + + let snapshot = + ActiveContributionSnapshot::from_plugin_details(&[no_provider, two_providers]) + .expect("snapshot"); + + let namespaces: Vec<_> = snapshot + .ui_for_slot("providers.editor.sections") + .iter() + .map(|item| item.provider_extension_namespace.as_deref()) + .collect(); + assert_eq!(namespaces, vec![None, None]); + } + + fn plugin_detail_with_ui( + plugin_id: &str, + status: PluginStatus, + slot_id: &str, + contribution_id: &str, + order: i32, + ) -> PluginDetail { + plugin_detail_with_ui_contribution( + plugin_id, + status, + slot_id, + ui_contribution(contribution_id, order), + ) + } + + fn plugin_detail_with_raw_ui_slot(plugin_id: &str, slot_id: &str) -> PluginDetail { + plugin_detail_with_raw_ui_slot_and_id(plugin_id, slot_id, "settings-a") + } + + fn plugin_detail_with_raw_ui_slot_and_id( + plugin_id: &str, + slot_id: &str, + contribution_id: &str, + ) -> PluginDetail { + plugin_detail_with_ui_contribution( + plugin_id, + PluginStatus::Enabled, + slot_id, + ui_contribution(contribution_id, 0), + ) + } + + fn plugin_detail_with_command(plugin_id: &str, command: &str) -> PluginDetail { + let mut detail = plugin_detail(plugin_id, PluginStatus::Enabled); + detail.manifest.contributes = Some(PluginContributes { + commands: vec![CommandContribution { + command: command.to_string(), + title: "Open".to_string(), + category: None, + }], + ..empty_contributes() + }); + detail + } + + fn plugin_detail_with_provider(plugin_id: &str, provider_type: &str) -> PluginDetail { + let mut detail = plugin_detail(plugin_id, PluginStatus::Enabled); + detail.manifest.contributes = Some(PluginContributes { + providers: vec![provider_contribution(provider_type, plugin_id)], + ..empty_contributes() + }); + detail + } + + fn plugin_detail_with_provider_and_ui( + plugin_id: &str, + providers: Vec, + slot_id: &str, + contribution: UiContribution, + ) -> PluginDetail { + let mut detail = plugin_detail(plugin_id, PluginStatus::Enabled); + detail.manifest.contributes = Some(PluginContributes { + providers, + ui: BTreeMap::from([(slot_id.to_string(), vec![contribution])]), + ..empty_contributes() + }); + detail + } + + fn provider_contribution( + provider_type: &str, + extension_namespace: &str, + ) -> ProviderContribution { + ProviderContribution { + provider_type: provider_type.to_string(), + display_name: "Provider".to_string(), + target_cli_keys: vec![TargetCliKey::Codex], + extension_namespace: extension_namespace.to_string(), + } + } + + fn plugin_detail_with_protocol_bridge(plugin_id: &str, bridge_type: &str) -> PluginDetail { + let mut detail = plugin_detail(plugin_id, PluginStatus::Enabled); + detail.manifest.contributes = Some(PluginContributes { + protocol_bridges: vec![ProtocolBridgeContribution { + bridge_type: bridge_type.to_string(), + inbound_protocol: "claude".to_string(), + outbound_protocol: "codex".to_string(), + supports_streaming: Some(true), + }], + ..empty_contributes() + }); + detail + } + + fn plugin_detail_with_ui_contribution( + plugin_id: &str, + status: PluginStatus, + slot_id: &str, + contribution: UiContribution, + ) -> PluginDetail { + let mut detail = plugin_detail(plugin_id, status); + detail.manifest.contributes = Some(PluginContributes { + ui: BTreeMap::from([(slot_id.to_string(), vec![contribution])]), + ..empty_contributes() + }); + detail + } + + fn ui_contribution(contribution_id: &str, order: i32) -> UiContribution { + UiContribution { + id: contribution_id.to_string(), + title: Some("Settings".to_string()), + order: Some(order), + schema: HostRenderedSchema::Panel { fields: Vec::new() }, + when: None, + } + } + + fn empty_contributes() -> PluginContributes { + PluginContributes { + providers: Vec::new(), + protocols: Vec::new(), + protocol_bridges: Vec::new(), + commands: Vec::new(), + gateway_hooks: Vec::new(), + ui: BTreeMap::new(), + } + } + + fn plugin_detail(plugin_id: &str, status: PluginStatus) -> PluginDetail { + PluginDetail { + summary: PluginSummary { + id: 1, + plugin_id: plugin_id.to_string(), + name: "Plugin".to_string(), + current_version: Some("1.0.0".to_string()), + status, + runtime: "extensionHost".to_string(), + permission_risk: PluginPermissionRisk::Low, + update_available: false, + last_error: None, + created_at: 10, + updated_at: 20, + }, + manifest: PluginManifest { + id: plugin_id.to_string(), + name: "Plugin".to_string(), + version: "1.0.0".to_string(), + api_version: "1.0.0".to_string(), + runtime: PluginRuntime::ExtensionHost { + language: "javascript".to_string(), + }, + hooks: Vec::new(), + permissions: Vec::new(), + main: None, + activation_events: Vec::new(), + contributes: None, + capabilities: Vec::new(), + host_compatibility: PluginHostCompatibility { + app: ">=0.56.0 <1.0.0".to_string(), + plugin_api: "^1.0.0".to_string(), + platforms: Vec::new(), + }, + entry: None, + config_schema: None, + config_version: None, + description: None, + author: None, + homepage: None, + repository: None, + license: None, + checksum: None, + signature: None, + category: None, + }, + install_source: PluginInstallSource::Local, + installed_dir: None, + config: serde_json::json!({}), + granted_permissions: Vec::new(), + pending_permissions: Vec::new(), + audit_logs: Vec::::new(), + runtime_failures: Vec::::new(), + rollback_versions: Vec::new(), + } + } +} diff --git a/src-tauri/src/app/plugins/extension_host.rs b/src-tauri/src/app/plugins/extension_host.rs new file mode 100644 index 00000000..22d2cb45 --- /dev/null +++ b/src-tauri/src/app/plugins/extension_host.rs @@ -0,0 +1,1277 @@ +//! Usage: Parent-side extension host worker lifecycle and command dispatch. + +use super::extension_host_process::{ + ExtensionHostChildProcess, ExtensionHostMethodHandler, ExtensionHostProcessConfig, +}; +use super::extension_host_worker::{ + default_extension_host_max_line_bytes, ExtensionHostWorkerConfig, +}; +use super::privacy_redaction_service::PrivacyRedactionService; +use crate::db; +use crate::infra::plugins::{repository, runtime_reports}; +use crate::plugins::PluginManifest; +use crate::shared::error::{AppError, AppResult}; +use rand::RngCore; +use serde_json::{json, Value}; +use sha2::Digest; +use std::collections::BTreeSet; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +const DEFAULT_EXTENSION_HOST_START_TIMEOUT: Duration = Duration::from_secs(5); +pub(crate) const DEFAULT_EXTENSION_HOST_CALL_TIMEOUT: Duration = Duration::from_secs(30); +const DEFAULT_EXTENSION_HOST_IDLE_RECYCLE: Duration = Duration::from_secs(30); +const PLUGIN_STORAGE_MAX_BYTES: usize = 64 * 1024; + +#[derive(Debug)] +pub(crate) struct ExtensionHostInstance { + manifest: PluginManifest, + runtime: ExtensionHostChildProcess, + startup_timeout: Duration, + _config_file: ExtensionHostConfigFile, +} + +impl ExtensionHostInstance { + #[allow(dead_code)] + pub(crate) async fn start(manifest: PluginManifest, plugin_root: PathBuf) -> AppResult { + Self::start_with_timeout(manifest, plugin_root, DEFAULT_EXTENSION_HOST_CALL_TIMEOUT).await + } + + #[cfg(test)] + pub(crate) async fn start_with_host_api( + manifest: PluginManifest, + plugin_root: PathBuf, + db: db::Db, + ) -> AppResult { + Self::start_with_host_api_and_privacy_redaction( + manifest, + plugin_root, + db, + Arc::new(PrivacyRedactionService::default()), + ) + .await + } + + #[allow(dead_code)] + pub(crate) async fn start_with_host_api_and_privacy_redaction( + manifest: PluginManifest, + plugin_root: PathBuf, + db: db::Db, + privacy_redaction: Arc, + ) -> AppResult { + Self::start_with_host_api_and_privacy_redaction_with_timeout( + manifest, + plugin_root, + db, + privacy_redaction, + DEFAULT_EXTENSION_HOST_CALL_TIMEOUT, + ) + .await + } + + pub(crate) async fn start_with_host_api_and_privacy_redaction_with_timeout( + manifest: PluginManifest, + plugin_root: PathBuf, + db: db::Db, + privacy_redaction: Arc, + call_timeout: Duration, + ) -> AppResult { + let handler_plugin_root = plugin_root.clone(); + Self::start_with_timeout_and_host_handler( + manifest.clone(), + plugin_root, + call_timeout, + Some(Arc::new(ExtensionHostApiHandler { + db, + plugin_id: manifest.id, + plugin_root: handler_plugin_root, + capabilities: manifest.capabilities.into_iter().collect(), + privacy_redaction, + })), + ) + .await + } + + #[allow(dead_code)] + pub(crate) async fn start_with_timeout( + manifest: PluginManifest, + plugin_root: PathBuf, + call_timeout: Duration, + ) -> AppResult { + Self::start_with_timeout_and_host_handler(manifest, plugin_root, call_timeout, None).await + } + + async fn start_with_timeout_and_host_handler( + manifest: PluginManifest, + plugin_root: PathBuf, + call_timeout: Duration, + host_handler: Option>, + ) -> AppResult { + let current_exe = std::env::current_exe().map_err(|err| { + AppError::new( + "PLUGIN_EXTENSION_HOST_EXE_UNAVAILABLE", + format!("failed to resolve current executable: {err}"), + ) + })?; + Self::start_with_program( + manifest, + plugin_root, + current_exe, + call_timeout, + host_handler, + ) + .await + } + + async fn start_with_program( + manifest: PluginManifest, + plugin_root: PathBuf, + program: PathBuf, + call_timeout: Duration, + host_handler: Option>, + ) -> AppResult { + let contribution_hash = contribution_hash(&manifest); + let startup_timeout = DEFAULT_EXTENSION_HOST_START_TIMEOUT; + let config_file = write_worker_config( + &plugin_root, + startup_timeout, + call_timeout, + contribution_hash.clone(), + )?; + #[cfg(not(test))] + let args = vec![ + "--extension-host-worker".to_string(), + "--extension-host-config".to_string(), + config_file.path().display().to_string(), + ]; + #[cfg(test)] + let args = vec![ + "--exact".to_string(), + "app::plugins::extension_host_worker::extension_host_worker_process_entry_for_tests" + .to_string(), + "--nocapture".to_string(), + "--".to_string(), + "--extension-host-config".to_string(), + config_file.path().display().to_string(), + ]; + let max_line_bytes = default_extension_host_max_line_bytes(); + let runtime = ExtensionHostChildProcess::start(ExtensionHostProcessConfig { + program: program.display().to_string(), + args, + start_timeout: DEFAULT_EXTENSION_HOST_START_TIMEOUT, + hook_timeout: call_timeout, + idle_recycle: DEFAULT_EXTENSION_HOST_IDLE_RECYCLE, + max_line_bytes, + ready_method: "extension.ready".to_string(), + allow_startup_noise: cfg!(test), + host_handler, + }) + .await + .map_err(map_extension_host_process_error)?; + + let mut host = Self { + manifest, + runtime, + startup_timeout, + _config_file: config_file, + }; + host.handshake().await?; + Ok(host) + } + + async fn handshake(&mut self) -> AppResult<()> { + self.runtime + .call_method_with_timeout( + "extension.handshake", + json!({ + "pluginId": self.manifest.id, + "version": self.manifest.version, + "apiVersion": self.manifest.api_version, + "contributionHash": contribution_hash(&self.manifest), + }), + self.startup_timeout, + ) + .await + .map(|_| ()) + .map_err(map_extension_host_process_error) + } + + #[allow(dead_code)] + pub(crate) async fn activate(&mut self) -> AppResult<()> { + self.runtime + .call_method_with_timeout("extension.activate", Value::Null, self.startup_timeout) + .await + .map(|_| ()) + .map_err(map_extension_host_process_error) + } + + #[allow(dead_code)] + pub(crate) async fn execute_command(&mut self, command: &str, args: Value) -> AppResult { + if !self + .manifest + .capabilities + .iter() + .any(|capability| capability == "commands.execute") + { + return Err(AppError::new( + "PLUGIN_EXTENSION_HOST_FORBIDDEN", + "extension host API requires commands.execute", + )); + } + self.activate().await?; + self.runtime + .call_method( + "commands.execute", + json!({ + "command": command, + "args": args, + }), + ) + .await + .map_err(map_extension_host_process_error) + } + + pub(crate) async fn execute_gateway_hook( + &mut self, + hook: &str, + context: Value, + ) -> AppResult { + if !self + .manifest + .capabilities + .iter() + .any(|capability| capability == "gateway.hooks") + { + return Err(AppError::new( + "PLUGIN_EXTENSION_HOST_FORBIDDEN", + "extension host API requires gateway.hooks", + )); + } + self.activate().await?; + self.runtime + .call_method( + "gatewayHooks.execute", + json!({ + "hook": hook, + "context": context, + }), + ) + .await + .map_err(map_extension_host_process_error) + } + + #[cfg(test)] + async fn execute_command_rpc_for_tests( + &mut self, + command: &str, + args: Value, + ) -> AppResult { + self.runtime + .call_method( + "commands.execute", + json!({ + "command": command, + "args": args, + }), + ) + .await + .map_err(map_extension_host_process_error) + } + + #[allow(dead_code)] + pub(crate) fn is_running(&mut self) -> bool { + self.runtime.is_running() + } + + #[allow(dead_code)] + pub(crate) async fn dispose(&mut self) { + let _ = self + .runtime + .call_method_with_timeout("extension.deactivate", Value::Null, self.startup_timeout) + .await; + self.runtime.shutdown().await; + } + + #[cfg(test)] + async fn start_for_tests(plugin_root: &Path) -> AppResult { + let manifest = read_manifest(plugin_root)?; + Self::start_for_tests_with_manifest( + manifest, + plugin_root, + DEFAULT_EXTENSION_HOST_CALL_TIMEOUT, + ) + .await + } + + #[cfg(test)] + async fn start_for_tests_with_timeout( + plugin_root: &Path, + call_timeout: Duration, + ) -> AppResult { + let manifest = read_manifest(plugin_root)?; + Self::start_for_tests_with_manifest(manifest, plugin_root, call_timeout).await + } + + #[cfg(test)] + async fn start_for_tests_with_manifest( + manifest: PluginManifest, + plugin_root: &Path, + call_timeout: Duration, + ) -> AppResult { + let program = std::env::current_exe().map_err(|err| { + AppError::new( + "PLUGIN_EXTENSION_HOST_EXE_UNAVAILABLE", + format!("failed to resolve current test executable: {err}"), + ) + })?; + Self::start_with_program( + manifest, + plugin_root.to_path_buf(), + program, + call_timeout, + None, + ) + .await + } +} + +#[allow(dead_code)] +pub(crate) type ExtensionHost = ExtensionHostInstance; + +struct ExtensionHostApiHandler { + db: db::Db, + plugin_id: String, + plugin_root: PathBuf, + capabilities: BTreeSet, + privacy_redaction: Arc, +} + +impl ExtensionHostMethodHandler for ExtensionHostApiHandler { + fn handle_host_method(&self, method: &str, params: Value) -> AppResult { + match method { + "storage.get" => self.storage_get(params), + "storage.set" => self.storage_set(params), + "diagnostics.getRuntimeReports" => self.diagnostics_get_runtime_reports(params), + "privacy.redactText" => self.privacy_redact_text(params), + "privacy.redactRequestBody" => self.privacy_redact_request_body(params), + other => Err(AppError::new( + "PLUGIN_EXTENSION_HOST_METHOD_NOT_FOUND", + format!("unsupported extension host API method: {other}"), + )), + } + } +} + +impl ExtensionHostApiHandler { + fn require_capability(&self, capability: &str) -> AppResult<()> { + if self.capabilities.contains(capability) { + return Ok(()); + } + Err(AppError::new( + "PLUGIN_EXTENSION_HOST_FORBIDDEN", + format!("extension host API requires {capability}"), + )) + } + + fn storage_get(&self, params: Value) -> AppResult { + self.require_capability("storage.plugin")?; + let plugin_id = self.host_api_plugin_id(¶ms)?; + let key = required_string(¶ms, "key")?; + let detail = repository::get_plugin(&self.db, plugin_id)?; + Ok(detail + .config + .get("storage") + .and_then(Value::as_object) + .and_then(|storage| storage.get(key)) + .cloned() + .unwrap_or(Value::Null)) + } + + fn storage_set(&self, params: Value) -> AppResult { + self.require_capability("storage.plugin")?; + let plugin_id = self.host_api_plugin_id(¶ms)?.to_string(); + let key = required_string(¶ms, "key")?.to_string(); + let value = params.get("value").cloned().unwrap_or(Value::Null); + let detail = repository::get_plugin(&self.db, &plugin_id)?; + let mut config = detail.config; + if !config.is_object() { + config = json!({}); + } + let object = config.as_object_mut().ok_or_else(|| { + AppError::new( + "PLUGIN_STORAGE_INVALID", + "plugin config storage root must be an object", + ) + })?; + let storage_value = object + .entry("storage".to_string()) + .or_insert_with(|| json!({})); + if !storage_value.is_object() { + *storage_value = json!({}); + } + storage_value + .as_object_mut() + .expect("storage object") + .insert(key, value); + let storage_bytes = serde_json::to_vec(storage_value).map_err(|err| { + AppError::new( + "PLUGIN_STORAGE_INVALID", + format!("failed to encode plugin storage: {err}"), + ) + })?; + if storage_bytes.len() > PLUGIN_STORAGE_MAX_BYTES { + return Err(AppError::new( + "PLUGIN_STORAGE_LIMIT_EXCEEDED", + "plugin storage exceeded 64 KiB", + )); + } + let config_version = detail.manifest.config_version.unwrap_or(1); + repository::save_plugin_config(&self.db, &plugin_id, config_version, &config, &[])?; + Ok(json!({ "ok": true })) + } + + fn diagnostics_get_runtime_reports(&self, params: Value) -> AppResult { + self.require_capability("diagnostics.read")?; + let plugin_id = self.host_api_plugin_id(¶ms)?; + let limit = params + .get("limit") + .and_then(Value::as_u64) + .unwrap_or(20) + .clamp(1, 100) as usize; + let reports = runtime_reports::list_extension_execution_reports( + &self.db, + Some(plugin_id), + None, + None, + None, + limit, + )?; + serde_json::to_value(reports).map_err(|err| { + AppError::new( + "PLUGIN_DIAGNOSTICS_ENCODE_FAILED", + format!("failed to encode runtime reports: {err}"), + ) + }) + } + + fn privacy_redact_text(&self, params: Value) -> AppResult { + self.require_capability("privacy.redact")?; + let plugin_id = self.host_api_plugin_id(¶ms)?; + let text = required_string(¶ms, "text")?; + let options = params.get("options").cloned().unwrap_or_else(|| json!({})); + let plugin = self.plugin_detail_with_root(plugin_id)?; + let output = self + .privacy_redaction + .redact_text(&plugin, text, &options) + .map_err(|err| { + AppError::new( + "PLUGIN_PRIVACY_REDACTION_FAILED", + format!("privacy redaction failed: {err}"), + ) + })?; + serde_json::to_value(output).map_err(|err| { + AppError::new( + "PLUGIN_PRIVACY_REDACTION_ENCODE_FAILED", + format!("failed to encode privacy redaction result: {err}"), + ) + }) + } + + fn privacy_redact_request_body(&self, params: Value) -> AppResult { + self.require_capability("privacy.redact")?; + let plugin_id = self.host_api_plugin_id(¶ms)?; + let body = required_string(¶ms, "body")?; + let options = params.get("options").cloned().unwrap_or_else(|| json!({})); + let plugin = self.plugin_detail_with_root(plugin_id)?; + let output = self + .privacy_redaction + .redact_request_body(&plugin, body, &options) + .map_err(|err| { + AppError::new( + "PLUGIN_PRIVACY_REDACTION_FAILED", + format!("privacy request body redaction failed: {err}"), + ) + })?; + serde_json::to_value(output).map_err(|err| { + AppError::new( + "PLUGIN_PRIVACY_REDACTION_ENCODE_FAILED", + format!("failed to encode privacy redaction result: {err}"), + ) + }) + } + + fn plugin_detail_with_root(&self, plugin_id: &str) -> AppResult { + let mut detail = repository::get_plugin(&self.db, plugin_id)?; + detail.installed_dir = Some(self.plugin_root.to_string_lossy().to_string()); + Ok(detail) + } + + fn host_api_plugin_id<'a>(&self, params: &'a Value) -> AppResult<&'a str> { + let plugin_id = required_string(params, "pluginId")?; + if plugin_id != self.plugin_id { + return Err(AppError::new( + "PLUGIN_EXTENSION_HOST_FORBIDDEN", + "extension host API pluginId did not match owning plugin", + )); + } + Ok(plugin_id) + } +} + +fn required_string<'a>(params: &'a Value, key: &str) -> AppResult<&'a str> { + params + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + AppError::new( + "PLUGIN_EXTENSION_HOST_INVALID_REQUEST", + format!("extension host API requires {key}"), + ) + }) +} + +#[derive(Debug)] +struct ExtensionHostConfigFile { + path: PathBuf, +} + +impl ExtensionHostConfigFile { + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for ExtensionHostConfigFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +fn write_worker_config( + plugin_root: &Path, + startup_timeout: Duration, + call_timeout: Duration, + contribution_hash: String, +) -> AppResult { + let config = ExtensionHostWorkerConfig { + plugin_root: plugin_root.to_path_buf(), + contribution_hash: Some(contribution_hash), + max_line_bytes: default_extension_host_max_line_bytes(), + startup_js_timeout_ms: startup_timeout.as_millis().try_into().unwrap_or(u64::MAX), + js_timeout_ms: call_timeout.as_millis().try_into().unwrap_or(u64::MAX), + }; + let mut nonce = [0_u8; 8]; + rand::thread_rng().fill_bytes(&mut nonce); + let path = std::env::temp_dir().join(format!( + "aio-extension-host-{}-{:016x}.json", + std::process::id(), + u64::from_le_bytes(nonce) + )); + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&path) + .map_err(|err| { + AppError::new( + "PLUGIN_EXTENSION_HOST_CONFIG_CREATE_FAILED", + format!("failed to create extension host config file: {err}"), + ) + })?; + let bytes = serde_json::to_vec(&config).map_err(|err| { + AppError::new( + "PLUGIN_EXTENSION_HOST_CONFIG_ENCODE_FAILED", + format!("failed to encode extension host config: {err}"), + ) + })?; + file.write_all(&bytes).map_err(|err| { + AppError::new( + "PLUGIN_EXTENSION_HOST_CONFIG_WRITE_FAILED", + format!("failed to write extension host config: {err}"), + ) + })?; + file.flush().map_err(|err| { + AppError::new( + "PLUGIN_EXTENSION_HOST_CONFIG_WRITE_FAILED", + format!("failed to flush extension host config: {err}"), + ) + })?; + Ok(ExtensionHostConfigFile { path }) +} + +fn contribution_hash(manifest: &PluginManifest) -> String { + let bytes = serde_json::to_vec(&json!({ + "runtime": manifest.runtime, + "main": manifest.main, + "activationEvents": manifest.activation_events, + "contributes": manifest.contributes, + "capabilities": manifest.capabilities, + "permissions": manifest.permissions, + })) + .unwrap_or_default(); + format!("{:x}", sha2::Sha256::digest(bytes)) +} + +#[cfg(test)] +fn read_manifest(plugin_root: &Path) -> AppResult { + let path = plugin_root.join("plugin.json"); + let bytes = std::fs::read(&path).map_err(|err| { + AppError::new( + "PLUGIN_EXTENSION_HOST_MANIFEST_READ_FAILED", + format!( + "failed to read extension host manifest {}: {err}", + path.display() + ), + ) + })?; + serde_json::from_slice(&bytes).map_err(|err| { + AppError::new( + "PLUGIN_EXTENSION_HOST_MANIFEST_DECODE_FAILED", + format!( + "failed to decode extension host manifest {}: {err}", + path.display() + ), + ) + }) +} + +fn map_extension_host_process_error(err: AppError) -> AppError { + match err.code() { + "PLUGIN_EXTENSION_HOST_PROCESS_HOOK_TIMEOUT" => AppError::new( + "PLUGIN_EXTENSION_CALL_TIMEOUT", + "extension host call timed out", + ), + "PLUGIN_EXTENSION_HOST_TIMEOUT" => AppError::new( + "PLUGIN_EXTENSION_CALL_TIMEOUT", + "extension host call timed out", + ), + "PLUGIN_EXTENSION_HOST_PROCESS_START_TIMEOUT" => AppError::new( + "PLUGIN_EXTENSION_START_TIMEOUT", + "extension host worker did not become ready before startup timeout", + ), + "PLUGIN_EXTENSION_HOST_PROCESS_REQUEST_TOO_LARGE" => AppError::new( + "PLUGIN_EXTENSION_REQUEST_TOO_LARGE", + "extension host request exceeded max line bytes", + ), + "PLUGIN_EXTENSION_HOST_PROCESS_RESPONSE_TOO_LARGE" => AppError::new( + "PLUGIN_EXTENSION_RESPONSE_TOO_LARGE", + "extension host response exceeded max line bytes", + ), + _ => err, + } +} + +#[cfg(test)] +mod tests { + use crate::domain::plugins::{PluginInstallSource, PluginManifest, PluginStatus}; + use crate::infra::plugins::repository::{self, InsertPluginInput}; + use serde_json::json; + use std::path::Path; + use std::time::Duration; + + fn write_extension_plugin(root: &Path, extension_js: &str) { + write_extension_plugin_with_capabilities(root, extension_js, &["commands.execute"]); + } + + fn write_extension_plugin_with_capabilities( + root: &Path, + extension_js: &str, + capabilities: &[&str], + ) { + write_extension_plugin_with_manifest_overrides( + root, + extension_js, + capabilities, + serde_json::json!({ + "commands": [ + { "command": "acme.echo", "title": "Echo" }, + { "command": "acme.never", "title": "Never" } + ] + }), + vec!["onCommand:acme.echo", "onCommand:acme.never"], + ); + } + + fn write_gateway_extension_plugin(root: &Path, extension_js: &str, hook_name: &str) { + write_extension_plugin_with_manifest_overrides( + root, + extension_js, + &["gateway.hooks"], + serde_json::json!({ + "gatewayHooks": [ + { "name": hook_name, "priority": 10, "failurePolicy": "fail-open" } + ] + }), + Vec::new(), + ); + } + + fn write_extension_plugin_with_manifest_overrides( + root: &Path, + extension_js: &str, + capabilities: &[&str], + contributes: serde_json::Value, + activation_events: Vec<&str>, + ) { + std::fs::create_dir_all(root.join("dist")).expect("create dist"); + let manifest = json!({ + "id": "acme.echo", + "name": "Acme Echo", + "version": "1.0.0", + "apiVersion": "1.0.0", + "runtime": { "kind": "extensionHost", "language": "typescript" }, + "main": "dist/extension.js", + "activationEvents": activation_events, + "contributes": contributes, + "capabilities": capabilities, + "hostCompatibility": { "app": ">=0.60.0", "pluginApi": "^1.0.0" } + }); + std::fs::write( + root.join("plugin.json"), + serde_json::to_vec_pretty(&manifest).expect("manifest json"), + ) + .expect("write plugin.json"); + std::fs::write(root.join("dist/extension.js"), extension_js).expect("write extension.js"); + } + + fn install_extension_plugin(db: &crate::db::Db, root: &Path) -> PluginManifest { + let manifest = super::read_manifest(root).expect("manifest"); + repository::insert_plugin( + db, + InsertPluginInput { + manifest: manifest.clone(), + install_source: PluginInstallSource::Local, + status: PluginStatus::Enabled, + installed_dir: Some(root.to_string_lossy().to_string()), + }, + ) + .expect("insert plugin"); + manifest + } + + fn init_test_db(root: &Path) -> crate::db::Db { + crate::db::init_for_tests(&root.join("plugins.db")).expect("init db") + } + + #[tokio::test] + async fn extension_host_activates_and_dispatches_command() { + let temp = tempfile::tempdir().expect("tempdir"); + write_extension_plugin( + temp.path(), + r#" + module.exports.activate = function(api) { + api.commands.registerCommand("acme.echo", function(args) { + return { ok: true, echo: args.text }; + }); + }; + "#, + ); + + let mut host = super::ExtensionHost::start_for_tests(temp.path()) + .await + .expect("start extension host"); + + let result = host + .execute_command("acme.echo", json!({ "text": "hello" })) + .await + .expect("execute command"); + + assert_eq!(result, json!({ "ok": true, "echo": "hello" })); + assert!(host.is_running()); + host.dispose().await; + assert!(!host.is_running()); + } + + #[tokio::test] + async fn extension_host_executes_gateway_hook() { + let temp = tempfile::tempdir().expect("tempdir"); + write_gateway_extension_plugin( + temp.path(), + r#" + module.exports.activate = function(api) { + api.gateway.registerHook("gateway.request.afterBodyRead", function(context) { + return { + action: "replace", + requestBody: context.request.body.replace("hello", "hi") + }; + }); + }; + "#, + "gateway.request.afterBodyRead", + ); + + let mut host = super::ExtensionHost::start_for_tests(temp.path()) + .await + .expect("start extension host"); + + let result = host + .execute_gateway_hook( + "gateway.request.afterBodyRead", + json!({ + "hookName": "gateway.request.afterBodyRead", + "traceId": "trace-gateway", + "request": { "body": "hello" }, + "response": {}, + "stream": {}, + "log": {} + }), + ) + .await + .expect("execute gateway hook"); + + assert_eq!(result, json!({ "action": "replace", "requestBody": "hi" })); + host.dispose().await; + } + + #[tokio::test] + async fn extension_host_gateway_hook_registration_requires_manifest_declaration() { + let temp = tempfile::tempdir().expect("tempdir"); + write_gateway_extension_plugin( + temp.path(), + r#" + module.exports.activate = function(api) { + api.gateway.registerHook("gateway.response.after", function() { + return { action: "continue" }; + }); + }; + "#, + "gateway.request.afterBodyRead", + ); + + let mut host = super::ExtensionHost::start_for_tests(temp.path()) + .await + .expect("start extension host"); + + let err = host + .execute_gateway_hook("gateway.request.afterBodyRead", json!({})) + .await + .expect_err("undeclared gateway hook registration should fail activation"); + + assert_eq!(err.code(), "PLUGIN_EXTENSION_HOST_UNDECLARED_GATEWAY_HOOK"); + host.dispose().await; + } + + #[tokio::test] + async fn extension_host_gateway_hook_call_timeout_does_not_cover_activation() { + let temp = tempfile::tempdir().expect("tempdir"); + write_gateway_extension_plugin( + temp.path(), + r#" + module.exports.activate = function(api) { + const start = Date.now(); + while (Date.now() - start < 100) {} + api.gateway.registerHook("gateway.request.afterBodyRead", function() { + return { action: "continue" }; + }); + }; + "#, + "gateway.request.afterBodyRead", + ); + + let mut host = super::ExtensionHost::start_for_tests_with_timeout( + temp.path(), + Duration::from_millis(50), + ) + .await + .expect("start extension host"); + + let result = host + .execute_gateway_hook("gateway.request.afterBodyRead", json!({})) + .await + .expect("activation should use startup budget, not hook invocation budget"); + + assert_eq!(result, json!({ "action": "continue" })); + assert!(host.is_running()); + host.dispose().await; + } + + #[tokio::test] + async fn extension_host_gateway_hook_call_timeout_does_not_cover_module_load() { + let temp = tempfile::tempdir().expect("tempdir"); + write_gateway_extension_plugin( + temp.path(), + r#" + const start = Date.now(); + while (Date.now() - start < 100) {} + module.exports.activate = function(api) { + api.gateway.registerHook("gateway.request.afterBodyRead", function() { + return { action: "continue" }; + }); + }; + "#, + "gateway.request.afterBodyRead", + ); + + let mut host = super::ExtensionHost::start_for_tests_with_timeout( + temp.path(), + Duration::from_millis(50), + ) + .await + .expect("module load should use startup budget, not hook invocation budget"); + + let result = host + .execute_gateway_hook("gateway.request.afterBodyRead", json!({})) + .await + .expect("execute gateway hook"); + + assert_eq!(result, json!({ "action": "continue" })); + assert!(host.is_running()); + host.dispose().await; + } + + #[tokio::test] + async fn extension_host_gateway_hook_call_timeout_covers_handler_execution() { + let temp = tempfile::tempdir().expect("tempdir"); + write_gateway_extension_plugin( + temp.path(), + r#" + module.exports.activate = function(api) { + api.gateway.registerHook("gateway.request.afterBodyRead", function() { + while (true) {} + }); + }; + "#, + "gateway.request.afterBodyRead", + ); + + let mut host = super::ExtensionHost::start_for_tests_with_timeout( + temp.path(), + Duration::from_millis(50), + ) + .await + .expect("start extension host"); + + let started = std::time::Instant::now(); + let err = host + .execute_gateway_hook("gateway.request.afterBodyRead", json!({})) + .await + .expect_err("gateway hook handler should be inside hook timeout"); + + assert_eq!(err.code(), "PLUGIN_EXTENSION_CALL_TIMEOUT"); + assert!( + started.elapsed() < Duration::from_secs(1), + "gateway hook timeout should cover handler execution, elapsed {:?}", + started.elapsed() + ); + assert!(!host.is_running()); + } + + #[tokio::test] + async fn extension_host_storage_api_allows_storage_plugin_capability() { + let temp = tempfile::tempdir().expect("tempdir"); + write_extension_plugin_with_capabilities( + temp.path(), + r#" + module.exports.activate = function(api) { + api.commands.registerCommand("acme.echo", function() { + api.storage.set("key", { ok: true }); + return api.storage.get("key"); + }); + }; + "#, + &["commands.execute", "storage.plugin"], + ); + let db = init_test_db(temp.path()); + let manifest = install_extension_plugin(&db, temp.path()); + + let mut host = + super::ExtensionHost::start_with_host_api(manifest, temp.path().to_path_buf(), db) + .await + .expect("start extension host"); + + let result = host + .execute_command("acme.echo", json!({})) + .await + .expect("execute storage command"); + + assert_eq!(result, json!({ "ok": true })); + host.dispose().await; + } + + #[tokio::test] + async fn extension_host_storage_api_rejects_missing_storage_plugin_capability() { + let temp = tempfile::tempdir().expect("tempdir"); + write_extension_plugin( + temp.path(), + r#" + module.exports.activate = function(api) { + api.commands.registerCommand("acme.echo", function() { + return globalThis.__aioHostApi( + "storage.get", + { pluginId: "acme.echo", key: "key" } + ); + }); + }; + "#, + ); + let db = init_test_db(temp.path()); + let manifest = install_extension_plugin(&db, temp.path()); + + let mut host = + super::ExtensionHost::start_with_host_api(manifest, temp.path().to_path_buf(), db) + .await + .expect("start extension host"); + + let err = host + .execute_command("acme.echo", json!({})) + .await + .expect_err("storage API without capability should fail"); + + assert_eq!(err.code(), "PLUGIN_EXTENSION_HOST_FORBIDDEN"); + host.dispose().await; + } + + #[tokio::test] + async fn extension_host_diagnostics_api_allows_diagnostics_read_capability() { + let temp = tempfile::tempdir().expect("tempdir"); + write_extension_plugin_with_capabilities( + temp.path(), + r#" + module.exports.activate = function(api) { + api.commands.registerCommand("acme.echo", function() { + return api.diagnostics.getRuntimeReports(5); + }); + }; + "#, + &["commands.execute", "diagnostics.read"], + ); + let db = init_test_db(temp.path()); + let manifest = install_extension_plugin(&db, temp.path()); + + let mut host = + super::ExtensionHost::start_with_host_api(manifest, temp.path().to_path_buf(), db) + .await + .expect("start extension host"); + + let result = host + .execute_command("acme.echo", json!({})) + .await + .expect("execute diagnostics command"); + + assert!(result.as_array().is_some()); + host.dispose().await; + } + + #[tokio::test] + async fn extension_host_diagnostics_api_rejects_missing_diagnostics_read_capability() { + let temp = tempfile::tempdir().expect("tempdir"); + write_extension_plugin( + temp.path(), + r#" + module.exports.activate = function(api) { + api.commands.registerCommand("acme.echo", function() { + return globalThis.__aioHostApi( + "diagnostics.getRuntimeReports", + { pluginId: "acme.echo", limit: 5 } + ); + }); + }; + "#, + ); + let db = init_test_db(temp.path()); + let manifest = install_extension_plugin(&db, temp.path()); + + let mut host = + super::ExtensionHost::start_with_host_api(manifest, temp.path().to_path_buf(), db) + .await + .expect("start extension host"); + + let err = host + .execute_command("acme.echo", json!({})) + .await + .expect_err("diagnostics API without capability should fail"); + + assert_eq!(err.code(), "PLUGIN_EXTENSION_HOST_FORBIDDEN"); + host.dispose().await; + } + + #[tokio::test] + async fn extension_host_privacy_api_redacts_text_with_capability() { + let temp = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(temp.path().join("rules")).expect("rules dir"); + std::fs::write(temp.path().join("rules/gitleaks.toml"), "").expect("rules file"); + write_extension_plugin_with_capabilities( + temp.path(), + r#" + module.exports.activate = function(api) { + api.commands.registerCommand("acme.echo", function() { + return api.privacy.redactText("email a@example.com", { sensitiveTypes: ["email"] }); + }); + }; + "#, + &["commands.execute", "privacy.redact"], + ); + let db = init_test_db(temp.path()); + let manifest = install_extension_plugin(&db, temp.path()); + + let mut host = + super::ExtensionHost::start_with_host_api(manifest, temp.path().to_path_buf(), db) + .await + .expect("start extension host"); + + let result = host + .execute_command("acme.echo", json!({})) + .await + .expect("execute privacy command"); + + assert_eq!( + result.get("hit").and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + result.get("redacted").and_then(serde_json::Value::as_str), + Some("email [邮箱]") + ); + host.dispose().await; + } + + #[tokio::test] + async fn extension_host_privacy_api_rejects_missing_capability() { + let temp = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(temp.path().join("rules")).expect("rules dir"); + std::fs::write(temp.path().join("rules/gitleaks.toml"), "").expect("rules file"); + write_extension_plugin( + temp.path(), + r#" + module.exports.activate = function(api) { + api.commands.registerCommand("acme.echo", function() { + return globalThis.__aioHostApi( + "privacy.redactText", + { pluginId: "acme.echo", text: "email a@example.com", options: { sensitiveTypes: ["email"] } } + ); + }); + }; + "#, + ); + let db = init_test_db(temp.path()); + let manifest = install_extension_plugin(&db, temp.path()); + + let mut host = + super::ExtensionHost::start_with_host_api(manifest, temp.path().to_path_buf(), db) + .await + .expect("start extension host"); + + let err = host + .execute_command("acme.echo", json!({})) + .await + .expect_err("privacy API without capability should fail"); + + assert_eq!(err.code(), "PLUGIN_EXTENSION_HOST_FORBIDDEN"); + host.dispose().await; + } + + #[tokio::test] + async fn extension_host_command_dispatch_requires_commands_execute_before_activation() { + let temp = tempfile::tempdir().expect("tempdir"); + write_extension_plugin_with_capabilities( + temp.path(), + r#" + module.exports.activate = function(api) { + api.commands.registerCommand("acme.echo", function() { + return { executed: true }; + }); + }; + "#, + &[], + ); + + let mut host = super::ExtensionHost::start_for_tests(temp.path()) + .await + .expect("start extension host"); + + let err = host + .execute_command("acme.echo", json!({})) + .await + .expect_err("missing commands capability should fail before activation"); + + assert_eq!(err.code(), "PLUGIN_EXTENSION_HOST_FORBIDDEN"); + host.dispose().await; + } + + #[tokio::test] + async fn extension_host_worker_rpc_rejects_registry_mutation_without_commands_execute() { + let temp = tempfile::tempdir().expect("tempdir"); + write_extension_plugin_with_capabilities( + temp.path(), + r#" + globalThis.__aioCommands["acme.echo"] = function() { + return { executed: true }; + }; + module.exports.activate = function() {}; + "#, + &[], + ); + + let mut host = super::ExtensionHost::start_for_tests(temp.path()) + .await + .expect("start extension host"); + + let err = host + .execute_command_rpc_for_tests("acme.echo", json!({})) + .await + .expect_err("missing commands capability should fail before command execution"); + + assert_eq!(err.code(), "PLUGIN_EXTENSION_HOST_FORBIDDEN"); + host.dispose().await; + } + + #[tokio::test] + async fn extension_host_timeout_kills_worker() { + let temp = tempfile::tempdir().expect("tempdir"); + write_extension_plugin( + temp.path(), + r#" + module.exports.activate = function(api) { + api.commands.registerCommand("acme.never", function() { + while (true) {} + }); + }; + "#, + ); + + let mut host = super::ExtensionHost::start_for_tests_with_timeout( + temp.path(), + Duration::from_millis(50), + ) + .await + .expect("start extension host"); + + let err = host + .execute_command("acme.never", json!({})) + .await + .expect_err("command timeout fails"); + + assert_eq!(err.code(), "PLUGIN_EXTENSION_CALL_TIMEOUT"); + assert!(!host.is_running()); + } + + #[tokio::test] + async fn extension_host_rejects_manifest_contribution_hash_mismatch() { + let temp = tempfile::tempdir().expect("tempdir"); + write_extension_plugin( + temp.path(), + r#" + module.exports.activate = function(api) { + api.commands.registerCommand("acme.echo", function(args) { + return args; + }); + }; + "#, + ); + let mut manifest = super::read_manifest(temp.path()).expect("manifest"); + manifest.contributes = None; + + let err = super::ExtensionHost::start_for_tests_with_manifest( + manifest, + temp.path(), + Duration::from_millis(50), + ) + .await + .expect_err("hash mismatch should fail handshake"); + + assert_eq!(err.code(), "PLUGIN_EXTENSION_HOST_PROCESS_CRASHED"); + } +} diff --git a/src-tauri/src/app/plugins/extension_host_process.rs b/src-tauri/src/app/plugins/extension_host_process.rs new file mode 100644 index 00000000..be3c322c --- /dev/null +++ b/src-tauri/src/app/plugins/extension_host_process.rs @@ -0,0 +1,914 @@ +//! Usage: Extension Host JSON-RPC-over-stdio child process transport. +#![allow(dead_code)] + +use crate::shared::error::{AppError, AppResult}; +use serde_json::{json, Value as JsonValue}; +use std::fmt; +use std::io::ErrorKind; +use std::process::Stdio; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; + +pub(crate) struct ExtensionHostProcessConfig { + pub(crate) program: String, + pub(crate) args: Vec, + pub(crate) start_timeout: Duration, + pub(crate) hook_timeout: Duration, + pub(crate) idle_recycle: Duration, + pub(crate) max_line_bytes: usize, + pub(crate) ready_method: String, + pub(crate) allow_startup_noise: bool, + pub(crate) host_handler: Option>, +} + +impl fmt::Debug for ExtensionHostProcessConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ExtensionHostProcessConfig") + .field("program", &self.program) + .field("args", &self.args) + .field("start_timeout", &self.start_timeout) + .field("hook_timeout", &self.hook_timeout) + .field("idle_recycle", &self.idle_recycle) + .field("max_line_bytes", &self.max_line_bytes) + .field("ready_method", &self.ready_method) + .field("allow_startup_noise", &self.allow_startup_noise) + .field("host_handler", &self.host_handler.is_some()) + .finish() + } +} + +impl Default for ExtensionHostProcessConfig { + fn default() -> Self { + Self { + program: String::new(), + args: vec![], + start_timeout: Duration::from_millis(500), + hook_timeout: Duration::from_millis(300), + idle_recycle: Duration::from_secs(30), + max_line_bytes: 256 * 1024, + ready_method: "plugin.ready".to_string(), + allow_startup_noise: false, + host_handler: None, + } + } +} + +pub(crate) trait ExtensionHostMethodHandler: Send + Sync + 'static { + fn handle_host_method(&self, method: &str, params: JsonValue) -> AppResult; +} + +#[derive(Debug)] +pub(crate) struct ExtensionHostChildProcess { + config: ExtensionHostProcessConfig, + child: Option, + stdin: Option, + stdout: Option>, + next_id: u64, + last_used: Instant, +} + +impl ExtensionHostChildProcess { + pub(crate) async fn start(config: ExtensionHostProcessConfig) -> AppResult { + if config.program.trim().is_empty() { + return Err(AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_INVALID_CONFIG", + "extension host process program must not be empty", + )); + } + let mut command = Command::new(&config.program); + command.args(&config.args); + command.env_clear(); + preserve_runtime_environment(&mut command); + command.stdin(Stdio::piped()); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + #[cfg(windows)] + { + command.creation_flags(0x08000000); + } + + let mut child = command.spawn().map_err(|err| { + AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_SPAWN_FAILED", + format!("failed to start extension host process: {err}"), + ) + })?; + let stdin = child.stdin.take().ok_or_else(|| { + AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_STDIN_UNAVAILABLE", + "extension host process stdin was unavailable", + ) + })?; + let stdout = child.stdout.take().ok_or_else(|| { + AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_STDOUT_UNAVAILABLE", + "extension host process stdout was unavailable", + ) + })?; + if let Some(mut stderr) = child.stderr.take() { + tokio::spawn(async move { + let mut sink = tokio::io::sink(); + let _ = tokio::io::copy(&mut stderr, &mut sink).await; + }); + } + let mut runtime = Self { + config, + child: Some(child), + stdin: Some(stdin), + stdout: Some(BufReader::new(stdout)), + next_id: 1, + last_used: Instant::now(), + }; + + let ready = tokio::time::timeout(runtime.config.start_timeout, runtime.read_ready_message()) + .await + .map_err(|_| { + AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_START_TIMEOUT", + format!( + "extension host process did not send ready message before start timeout: program={}, timeout_ms={}", + runtime.config.program, + runtime.config.start_timeout.as_millis() + ), + ) + }); + let _ready = match ready { + Ok(Ok(value)) => value, + Ok(Err(err)) | Err(err) => { + runtime.kill_child().await; + return Err(err); + } + }; + + Ok(runtime) + } + + pub(crate) async fn call_method( + &mut self, + method: &str, + params: JsonValue, + ) -> AppResult { + self.call_method_with_timeout(method, params, self.config.hook_timeout) + .await + } + + pub(crate) async fn call_method_with_timeout( + &mut self, + method: &str, + params: JsonValue, + timeout: Duration, + ) -> AppResult { + let id = self.next_id; + self.next_id = self.next_id.saturating_add(1); + let request = json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }); + let line = serde_json::to_vec(&request).map_err(|err| { + AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_ENCODE_FAILED", + format!("failed to encode JSON-RPC request: {err}"), + ) + })?; + if line.len() + 1 > self.config.max_line_bytes { + return Err(AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_REQUEST_TOO_LARGE", + format!( + "extension host process request exceeded {} bytes", + self.config.max_line_bytes + ), + )); + } + + let result = tokio::time::timeout(timeout, async { + self.write_line(&line).await?; + let response = self.read_response_for_request(id).await?; + validate_json_rpc_response(id, response) + }) + .await; + + match result { + Ok(Ok(value)) => { + self.last_used = Instant::now(); + Ok(value) + } + Ok(Err(err)) => { + self.kill_child().await; + Err(err) + } + Err(_) => { + self.kill_child().await; + Err(AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_HOOK_TIMEOUT", + "extension host process did not respond before hook timeout", + )) + } + } + } + + pub(crate) async fn call_hook(&mut self, params: JsonValue) -> AppResult { + self.call_method("plugin.handleHook", params).await + } + + pub(crate) fn is_running(&mut self) -> bool { + let Some(child) = self.child.as_mut() else { + return false; + }; + matches!(child.try_wait(), Ok(None)) + } + + pub(crate) async fn recycle_if_idle(&mut self) -> AppResult { + if self.child.is_some() && self.last_used.elapsed() >= self.config.idle_recycle { + self.kill_child().await; + return Ok(true); + } + Ok(false) + } + + pub(crate) async fn shutdown(&mut self) { + self.kill_child().await; + } + + async fn write_line(&mut self, bytes: &[u8]) -> AppResult<()> { + let Some(stdin) = self.stdin.as_mut() else { + return Err(AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_CRASHED", + "extension host process stdin is closed", + )); + }; + stdin.write_all(bytes).await.map_err(|err| { + extension_host_process_write_error("write extension host process request", err) + })?; + stdin.write_all(b"\n").await.map_err(|err| { + extension_host_process_write_error("terminate extension host process request line", err) + })?; + stdin.flush().await.map_err(|err| { + extension_host_process_write_error("flush extension host process request", err) + }) + } + + async fn read_json_line(&mut self) -> AppResult { + let line = self.read_line_string().await?; + serde_json::from_str(&line).map_err(|err| { + AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_PROTOCOL_ERROR", + format!("extension host process response was not valid JSON: {err}"), + ) + }) + } + + async fn read_ready_message(&mut self) -> AppResult { + loop { + let line = self.read_line_string().await?; + let value: JsonValue = match serde_json::from_str(&line) { + Ok(value) => value, + Err(err) if self.config.allow_startup_noise => { + tracing::debug!( + "ignoring extension host process startup line that was not JSON: {err}" + ); + continue; + } + Err(err) => { + return Err(AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_PROTOCOL_ERROR", + format!("extension host process response was not valid JSON: {err}"), + )); + } + }; + if value.get("method").and_then(|method| method.as_str()) + == Some(self.config.ready_method.as_str()) + { + return Ok(value); + } + if self.config.allow_startup_noise { + tracing::debug!( + expected = self.config.ready_method.as_str(), + actual = value + .get("method") + .and_then(|method| method.as_str()) + .unwrap_or(""), + "ignoring extension host process startup message before ready" + ); + continue; + } + return Err(AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_PROTOCOL_ERROR", + format!( + "extension host process first message must be {}", + self.config.ready_method + ), + )); + } + } + + async fn read_response_for_request(&mut self, expected_id: u64) -> AppResult { + loop { + let response = self.read_json_line().await?; + if response.get("id").and_then(|value| value.as_u64()) == Some(expected_id) { + return Ok(response); + } + if response.get("method").and_then(|value| value.as_str()) == Some("host.call") { + let id = response.get("id").cloned().unwrap_or(JsonValue::Null); + let result = self.handle_host_call(response); + let message = match result { + Ok(value) => json!({ + "jsonrpc": "2.0", + "id": id, + "result": value, + }), + Err(error) => json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32000, + "message": error.to_string(), + "data": { "code": error.code() }, + }, + }), + }; + let bytes = serde_json::to_vec(&message).map_err(|err| { + AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_ENCODE_FAILED", + format!("failed to encode host JSON-RPC response: {err}"), + ) + })?; + if bytes.len() + 1 > self.config.max_line_bytes { + return Err(AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_RESPONSE_TOO_LARGE", + format!( + "host JSON-RPC response exceeded {} bytes", + self.config.max_line_bytes + ), + )); + } + self.write_line(&bytes).await?; + continue; + } + return Err(AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_PROTOCOL_ERROR", + "extension host process sent an unexpected JSON-RPC message", + )); + } + } + + fn handle_host_call(&self, request: JsonValue) -> AppResult { + let method = request + .get("params") + .and_then(|params| params.get("method")) + .and_then(JsonValue::as_str) + .ok_or_else(|| { + AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_INVALID_HOST_CALL", + "host.call requires params.method", + ) + })?; + let params = request + .get("params") + .and_then(|params| params.get("params")) + .cloned() + .unwrap_or(JsonValue::Null); + let handler = self.config.host_handler.as_ref().ok_or_else(|| { + AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_HOST_CALL_UNAVAILABLE", + format!("host method is not available: {method}"), + ) + })?; + handler.handle_host_method(method, params) + } + + async fn read_line_string(&mut self) -> AppResult { + let max_line_bytes = self.config.max_line_bytes; + let Some(stdout) = self.stdout.as_mut() else { + return Err(AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_CRASHED", + "extension host process stdout is closed", + )); + }; + let bytes = read_bounded_line(stdout, max_line_bytes).await?; + String::from_utf8(bytes).map_err(|err| { + AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_PROTOCOL_ERROR", + format!("extension host process response was not valid UTF-8: {err}"), + ) + }) + } + + async fn kill_child(&mut self) { + self.stdin.take(); + self.stdout.take(); + if let Some(mut child) = self.child.take() { + match child.try_wait() { + Ok(Some(_)) => {} + Ok(None) => { + let _ = child.kill().await; + let _ = child.wait().await; + } + Err(_) => { + let _ = child.kill().await; + let _ = child.wait().await; + } + } + } + } +} + +impl Drop for ExtensionHostChildProcess { + fn drop(&mut self) { + self.stdin.take(); + self.stdout.take(); + if let Some(mut child) = self.child.take() { + let _ = child.start_kill(); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + let _ = child.wait().await; + }); + } + } + } +} + +fn preserve_runtime_environment(command: &mut Command) { + const ENV_ALLOWLIST: &[&str] = &[ + "PATH", + "SystemRoot", + "WINDIR", + "TEMP", + "TMP", + "TMPDIR", + "HOME", + "USERPROFILE", + "APPDATA", + "LOCALAPPDATA", + "APPDIR", + "APPIMAGE", + "ARGV0", + "LD_LIBRARY_PATH", + "DYLD_LIBRARY_PATH", + "DYLD_FALLBACK_LIBRARY_PATH", + ]; + + for key in ENV_ALLOWLIST { + if let Some(value) = std::env::var_os(key) { + command.env(key, value); + } + } +} + +async fn read_bounded_line( + stdout: &mut BufReader, + max_line_bytes: usize, +) -> AppResult> { + let mut line = Vec::new(); + let mut byte = [0_u8; 1]; + loop { + let read = stdout.read(&mut byte).await.map_err(|err| { + AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_READ_FAILED", + format!("failed to read extension host process response: {err}"), + ) + })?; + if read == 0 { + if line.is_empty() { + return Err(AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_CRASHED", + "extension host process exited before sending a response", + )); + } + return Ok(line); + } + line.push(byte[0]); + if line.len() > max_line_bytes { + return Err(AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_RESPONSE_TOO_LARGE", + format!("extension host process response exceeded {max_line_bytes} bytes"), + )); + } + if byte[0] == b'\n' { + return Ok(line); + } + } +} + +fn extension_host_process_write_error(operation: &str, err: std::io::Error) -> AppError { + if matches!( + err.kind(), + ErrorKind::BrokenPipe | ErrorKind::ConnectionReset + ) { + return AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_CRASHED", + format!("extension host process pipe closed while attempting to {operation}: {err}"), + ); + } + AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_WRITE_FAILED", + format!("failed to {operation}: {err}"), + ) +} + +fn validate_json_rpc_response(expected_id: u64, response: JsonValue) -> AppResult { + if response.get("jsonrpc").and_then(|value| value.as_str()) != Some("2.0") { + return Err(AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_PROTOCOL_ERROR", + "extension host process response must use JSON-RPC 2.0", + )); + } + if response.get("id").and_then(|value| value.as_u64()) != Some(expected_id) { + return Err(AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_PROTOCOL_ERROR", + "extension host process response id did not match request id", + )); + } + if let Some(error) = response.get("error") { + if let Some(code) = error + .get("data") + .and_then(|data| data.get("code")) + .and_then(|code| code.as_str()) + { + let message = error + .get("message") + .and_then(|message| message.as_str()) + .unwrap_or("extension host process returned JSON-RPC error"); + return Err(AppError::new(code, message)); + } + return Err(AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_REMOTE_ERROR", + format!("extension host process returned JSON-RPC error: {error}"), + )); + } + response.get("result").cloned().ok_or_else(|| { + AppError::new( + "PLUGIN_EXTENSION_HOST_PROCESS_PROTOCOL_ERROR", + "extension host process response was missing result", + ) + }) +} + +#[cfg(test)] +#[test] +fn extension_host_process_env_probe_for_tests() { + if !std::env::args().any(|arg| arg == "--extension-host-process-env-probe") { + return; + } + + use std::io::{BufRead, Write}; + + let leaked_env = || { + std::env::var_os("AIO_PLUGIN_RUNTIME_SECRET_FOR_TEST") + .map(|value| JsonValue::String(value.to_string_lossy().into_owned())) + .unwrap_or(JsonValue::Null) + }; + + let mut stdout = std::io::stdout(); + writeln!( + stdout, + "{}", + json!({ + "jsonrpc": "2.0", + "method": "plugin.ready", + "leaked": leaked_env(), + }) + ) + .expect("write ready"); + stdout.flush().expect("flush ready"); + + let stdin = std::io::stdin(); + for line in stdin.lock().lines() { + let line = line.expect("read request"); + if line.trim().is_empty() { + continue; + } + let request: JsonValue = serde_json::from_str(&line).expect("parse request"); + writeln!( + stdout, + "{}", + json!({ + "jsonrpc": "2.0", + "id": request.get("id").cloned().unwrap_or(JsonValue::Null), + "result": { + "leaked": leaked_env(), + }, + }) + ) + .expect("write response"); + stdout.flush().expect("flush response"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::time::Duration; + + struct EchoHostHandler; + + impl ExtensionHostMethodHandler for EchoHostHandler { + fn handle_host_method(&self, method: &str, params: JsonValue) -> AppResult { + Ok(json!({ + "method": method, + "params": params, + })) + } + } + + fn write_node_plugin(script: &str) -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("plugin.js"); + std::fs::write(&path, script).expect("write plugin script"); + (dir, path) + } + + const NODE_PROCESS_TEST_START_TIMEOUT: Duration = Duration::from_secs(30); + + fn node_config(script_path: &std::path::Path) -> ExtensionHostProcessConfig { + ExtensionHostProcessConfig { + program: "node".to_string(), + args: vec![script_path.display().to_string()], + start_timeout: NODE_PROCESS_TEST_START_TIMEOUT, + hook_timeout: Duration::from_secs(5), + idle_recycle: Duration::from_millis(50), + max_line_bytes: 256 * 1024, + ready_method: "plugin.ready".to_string(), + allow_startup_noise: false, + host_handler: None, + } + } + + #[test] + fn node_process_test_config_uses_ci_tolerant_start_timeout() { + let path = std::path::Path::new("plugin.js"); + let config = node_config(path); + + assert_eq!(config.start_timeout, NODE_PROCESS_TEST_START_TIMEOUT); + } + + #[tokio::test] + async fn extension_host_process_handles_valid_json_rpc_hook() { + let (_dir, script) = write_node_plugin( + r#" + console.log(JSON.stringify({jsonrpc:"2.0", method:"plugin.ready"})); + process.stdin.setEncoding("utf8"); + let buffer = ""; + process.stdin.on("data", chunk => { + buffer += chunk; + const lines = buffer.split("\n"); + buffer = lines.pop(); + for (const line of lines) { + if (!line.trim()) continue; + const req = JSON.parse(line); + console.log(JSON.stringify({ + jsonrpc: "2.0", + id: req.id, + result: { action: "pass", hook: req.params.hook } + })); + } + }); + "#, + ); + let mut runtime = ExtensionHostChildProcess::start(node_config(&script)) + .await + .expect("start extension host process"); + + let response = runtime + .call_hook(json!({"hook": "gateway.request.afterBodyRead", "context": {}})) + .await + .expect("hook response"); + + assert_eq!( + response, + json!({"action": "pass", "hook": "gateway.request.afterBodyRead"}) + ); + runtime.shutdown().await; + } + + #[tokio::test] + async fn extension_host_process_handles_host_call_before_final_response() { + let (_dir, script) = write_node_plugin( + r#" + console.log(JSON.stringify({jsonrpc:"2.0", method:"plugin.ready"})); + process.stdin.setEncoding("utf8"); + let buffer = ""; + let pendingRequest = null; + process.stdin.on("data", chunk => { + buffer += chunk; + const lines = buffer.split("\n"); + buffer = lines.pop(); + for (const line of lines) { + if (!line.trim()) continue; + const req = JSON.parse(line); + if (pendingRequest && req.id === 99) { + console.log(JSON.stringify({ + jsonrpc: "2.0", + id: pendingRequest.id, + result: { host: req.result } + })); + pendingRequest = null; + continue; + } + pendingRequest = req; + console.log(JSON.stringify({ + jsonrpc: "2.0", + id: 99, + method: "host.call", + params: { method: "echo", params: req.params } + })); + } + }); + "#, + ); + let mut config = node_config(&script); + config.host_handler = Some(Arc::new(EchoHostHandler)); + let mut runtime = ExtensionHostChildProcess::start(config) + .await + .expect("start extension host process"); + + let response = runtime + .call_method( + "plugin.handleHook", + json!({ "hook": "gateway.request.afterBodyRead" }), + ) + .await + .expect("host call response"); + + assert_eq!( + response, + json!({ + "host": { + "method": "echo", + "params": { "hook": "gateway.request.afterBodyRead" } + } + }) + ); + runtime.shutdown().await; + } + + #[tokio::test] + async fn extension_host_process_does_not_inherit_host_environment() { + std::env::set_var("AIO_PLUGIN_RUNTIME_SECRET_FOR_TEST", "host-secret"); + let program = std::env::current_exe().expect("current test executable"); + let mut runtime = ExtensionHostChildProcess::start(ExtensionHostProcessConfig { + program: program.display().to_string(), + args: vec![ + "--exact".to_string(), + "app::plugins::extension_host_process::extension_host_process_env_probe_for_tests" + .to_string(), + "--nocapture".to_string(), + "--".to_string(), + "--extension-host-process-env-probe".to_string(), + ], + start_timeout: NODE_PROCESS_TEST_START_TIMEOUT, + hook_timeout: Duration::from_secs(5), + idle_recycle: Duration::from_millis(50), + max_line_bytes: 256 * 1024, + ready_method: "plugin.ready".to_string(), + allow_startup_noise: true, + host_handler: None, + }) + .await + .expect("start extension host process"); + + let response = runtime + .call_hook(json!({"hook": "gateway.request.afterBodyRead", "context": {}})) + .await + .expect("hook response"); + + std::env::remove_var("AIO_PLUGIN_RUNTIME_SECRET_FOR_TEST"); + runtime.shutdown().await; + assert_eq!(response["leaked"], serde_json::Value::Null); + } + + #[tokio::test] + async fn extension_host_process_reports_start_timeout() { + let (_dir, script) = write_node_plugin( + r#" + setTimeout(() => { + console.log(JSON.stringify({jsonrpc:"2.0", method:"plugin.ready"})); + }, 1000); + "#, + ); + let mut config = node_config(&script); + config.start_timeout = Duration::from_millis(50); + + let err = ExtensionHostChildProcess::start(config) + .await + .expect_err("start timeout fails"); + + assert!(err + .to_string() + .contains("PLUGIN_EXTENSION_HOST_PROCESS_START_TIMEOUT")); + } + + #[tokio::test] + async fn extension_host_process_reports_hook_timeout_and_kills_child() { + let (_dir, script) = write_node_plugin( + r#" + console.log(JSON.stringify({jsonrpc:"2.0", method:"plugin.ready"})); + process.stdin.resume(); + "#, + ); + let mut config = node_config(&script); + config.hook_timeout = Duration::from_millis(50); + let mut runtime = ExtensionHostChildProcess::start(config) + .await + .expect("start extension host process"); + + let err = runtime + .call_hook(json!({"hook": "gateway.request.afterBodyRead", "context": {}})) + .await + .expect_err("hook timeout fails"); + + assert!(err + .to_string() + .contains("PLUGIN_EXTENSION_HOST_PROCESS_HOOK_TIMEOUT")); + assert!(!runtime.is_running()); + } + + #[tokio::test] + async fn extension_host_process_reports_crash_without_host_panic() { + let (_dir, script) = write_node_plugin( + r#" + console.log(JSON.stringify({jsonrpc:"2.0", method:"plugin.ready"})); + process.exit(7); + "#, + ); + let mut runtime = ExtensionHostChildProcess::start(node_config(&script)) + .await + .expect("start extension host process"); + + let err = runtime + .call_hook(json!({"hook": "gateway.request.afterBodyRead", "context": {}})) + .await + .expect_err("crashed child fails hook"); + + assert!(err + .to_string() + .contains("PLUGIN_EXTENSION_HOST_PROCESS_CRASHED")); + assert!(!runtime.is_running()); + } + + #[tokio::test] + async fn extension_host_process_rejects_oversized_response_without_full_line_buffering() { + let (_dir, script) = write_node_plugin( + r#" + console.log(JSON.stringify({jsonrpc:"2.0", method:"plugin.ready"})); + process.stdin.setEncoding("utf8"); + process.stdin.on("data", () => { + process.stdout.write("x".repeat(2048)); + }); + "#, + ); + let mut config = node_config(&script); + config.max_line_bytes = 512; + let mut runtime = ExtensionHostChildProcess::start(config) + .await + .expect("start extension host process"); + + let err = runtime + .call_method("plugin.handleHook", json!({})) + .await + .expect_err("oversized response fails"); + + assert_eq!( + err.code(), + "PLUGIN_EXTENSION_HOST_PROCESS_RESPONSE_TOO_LARGE" + ); + assert!(!runtime.is_running()); + } + + #[tokio::test] + async fn extension_host_process_recycles_idle_child() { + let (_dir, script) = write_node_plugin( + r#" + console.log(JSON.stringify({jsonrpc:"2.0", method:"plugin.ready"})); + process.stdin.resume(); + "#, + ); + let mut config = node_config(&script); + config.idle_recycle = Duration::from_millis(10); + let mut runtime = ExtensionHostChildProcess::start(config) + .await + .expect("start extension host process"); + + tokio::time::sleep(Duration::from_millis(30)).await; + let recycled = runtime.recycle_if_idle().await.expect("idle recycle"); + + assert!(recycled); + assert!(!runtime.is_running()); + } + + #[test] + fn extension_host_process_maps_broken_pipe_write_to_crash() { + let err = extension_host_process_write_error( + "write extension host process request", + std::io::Error::new(ErrorKind::BrokenPipe, "closed pipe"), + ); + + assert!(err + .to_string() + .contains("PLUGIN_EXTENSION_HOST_PROCESS_CRASHED")); + } +} diff --git a/src-tauri/src/app/plugins/extension_host_registry.rs b/src-tauri/src/app/plugins/extension_host_registry.rs new file mode 100644 index 00000000..e6d79bbd --- /dev/null +++ b/src-tauri/src/app/plugins/extension_host_registry.rs @@ -0,0 +1,2011 @@ +//! Usage: Managed Extension Host process instance reuse and disposal. + +use super::extension_host::ExtensionHostInstance; +use super::privacy_redaction_service::PrivacyRedactionService; +use crate::app::app_state::{ensure_db_ready, DbInitState}; +use crate::app::plugins::runtime_lifecycle::PluginRuntimeInstanceRegistry; +use crate::db; +use crate::domain::plugins::{PluginDetail, PluginManifest, PluginRuntime}; +use crate::gateway::plugins::context::{ + GatewayHookAction, GatewayHookResult, GatewayPluginHookName, GatewayVisibleHookContext, +}; +use crate::gateway::plugins::permissions::GatewayPluginError; +use crate::shared::error::{AppError, AppResult}; +use serde_json::{json, Value}; +use sha2::Digest; +use std::collections::{BTreeMap, HashSet}; +use std::future::Future; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::{Duration, Instant}; +use tokio::sync::{Mutex, RwLock}; + +const DEFAULT_MAX_WARM_INSTANCES: usize = 8; +const DEFAULT_IDLE_RECYCLE: Duration = Duration::from_secs(120); + +type BoxFuture<'a, T> = Pin + Send + 'a>>; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct ExtensionHostInstanceKey { + pub(crate) plugin_id: String, + pub(crate) version: String, + pub(crate) installed_dir: String, + pub(crate) main: String, + pub(crate) runtime_kind: String, + pub(crate) runtime_language: String, + pub(crate) contribution_hash: String, + pub(crate) call_timeout_ms: Option, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct ExtensionHostRegistryLimits { + pub(crate) max_warm_instances: usize, + pub(crate) idle_recycle: Duration, +} + +impl Default for ExtensionHostRegistryLimits { + fn default() -> Self { + Self { + max_warm_instances: DEFAULT_MAX_WARM_INSTANCES, + idle_recycle: DEFAULT_IDLE_RECYCLE, + } + } +} + +#[derive(Debug)] +#[allow(dead_code)] +pub(crate) struct ExtensionHostCommandOutput { + pub(crate) value: Value, + pub(crate) cold_start: bool, +} + +trait ExtensionHostProcess: Send { + fn execute_command<'a>( + &'a mut self, + command: &'a str, + args: Value, + ) -> BoxFuture<'a, AppResult>; + fn execute_gateway_hook<'a>( + &'a mut self, + hook: &'a str, + context: Value, + ) -> BoxFuture<'a, AppResult>; + fn is_running(&mut self) -> bool; + fn dispose<'a>(&'a mut self) -> BoxFuture<'a, ()>; +} + +trait ExtensionHostFactory: Send + Sync { + fn start<'a>( + &'a self, + detail: PluginDetail, + db: db::Db, + call_timeout: Duration, + ) -> BoxFuture<'a, AppResult>>; +} + +#[allow(dead_code)] +struct RealExtensionHostFactory { + privacy_redaction: Arc, +} + +impl ExtensionHostFactory for RealExtensionHostFactory { + fn start<'a>( + &'a self, + detail: PluginDetail, + db: db::Db, + call_timeout: Duration, + ) -> BoxFuture<'a, AppResult>> { + Box::pin(async move { + let plugin_root = plugin_root(&detail)?; + let host = + ExtensionHostInstance::start_with_host_api_and_privacy_redaction_with_timeout( + detail.manifest.clone(), + plugin_root, + db, + self.privacy_redaction.clone(), + call_timeout, + ) + .await?; + Ok(Box::new(RealExtensionHostProcess { host }) as Box) + }) + } +} + +#[allow(dead_code)] +struct RealExtensionHostProcess { + host: ExtensionHostInstance, +} + +impl ExtensionHostProcess for RealExtensionHostProcess { + fn execute_command<'a>( + &'a mut self, + command: &'a str, + args: Value, + ) -> BoxFuture<'a, AppResult> { + Box::pin(async move { self.host.execute_command(command, args).await }) + } + + fn execute_gateway_hook<'a>( + &'a mut self, + hook: &'a str, + context: Value, + ) -> BoxFuture<'a, AppResult> { + Box::pin(async move { self.host.execute_gateway_hook(hook, context).await }) + } + + fn is_running(&mut self) -> bool { + self.host.is_running() + } + + fn dispose<'a>(&'a mut self) -> BoxFuture<'a, ()> { + Box::pin(async move { + self.host.dispose().await; + }) + } +} + +struct ManagedExtensionHostInstance { + process: Mutex>, + last_used: StdMutex, +} + +impl ManagedExtensionHostInstance { + fn new(process: Box, last_used: Instant) -> Self { + Self { + process: Mutex::new(process), + last_used: StdMutex::new(last_used), + } + } + + async fn execute_if_running( + &self, + command: &str, + args: Value, + now: Instant, + ) -> AppResult> { + let mut process = self.process.lock().await; + if !process.is_running() { + return Ok(None); + } + let value = process.execute_command(command, args).await?; + *self + .last_used + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = now; + Ok(Some(value)) + } + + async fn execute_gateway_hook_if_running( + &self, + hook: &str, + context: Value, + now: Instant, + ) -> AppResult> { + let mut process = self.process.lock().await; + if !process.is_running() { + return Ok(None); + } + let value = process.execute_gateway_hook(hook, context).await?; + *self + .last_used + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = now; + Ok(Some(value)) + } + + async fn dispose(&self) { + self.process.lock().await.dispose().await; + } + + fn last_used(&self) -> Instant { + *self + .last_used + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } +} + +pub(crate) struct ExtensionHostInstanceRegistry { + db: db::Db, + operation_gate: RwLock<()>, + instances: Mutex>>, + plugin_locks: Mutex>>>, + limits: ExtensionHostRegistryLimits, + factory: Arc, +} + +impl ExtensionHostInstanceRegistry { + #[allow(dead_code)] + pub(crate) fn new(db: db::Db) -> Self { + Self::new_with_privacy_redaction(db, Arc::new(PrivacyRedactionService::default())) + } + + pub(crate) fn new_with_privacy_redaction( + db: db::Db, + privacy_redaction: Arc, + ) -> Self { + Self::with_factory( + db, + Arc::new(RealExtensionHostFactory { privacy_redaction }), + ExtensionHostRegistryLimits::default(), + ) + } + + fn with_factory( + db: db::Db, + factory: Arc, + limits: ExtensionHostRegistryLimits, + ) -> Self { + Self { + db, + operation_gate: RwLock::new(()), + instances: Mutex::new(BTreeMap::new()), + plugin_locks: Mutex::new(BTreeMap::new()), + limits, + factory, + } + } + + #[allow(dead_code)] + pub(crate) async fn execute_command( + &self, + detail: PluginDetail, + command: &str, + args: Value, + ) -> AppResult { + self.execute_command_with_now(detail, command, args, Instant::now()) + .await + } + + async fn execute_command_with_now( + &self, + detail: PluginDetail, + command: &str, + args: Value, + now: Instant, + ) -> AppResult { + let _operation_guard = self.operation_gate.read().await; + let key = ExtensionHostInstanceKey::from_plugin_detail(&detail)?; + let plugin_lock = self.plugin_lock_for(&key.plugin_id).await; + let _plugin_guard = plugin_lock.lock().await; + + if let Some(value) = self + .execute_warm_instance(&key, command, args.clone(), now) + .await? + { + return Ok(ExtensionHostCommandOutput { + value, + cold_start: false, + }); + } + + let mut disposals = { + let mut instances = self.instances.lock().await; + let mut disposals = remove_same_plugin_with_different_key(&mut instances, &key); + disposals.extend(remove_idle_locked( + &mut instances, + self.limits.idle_recycle, + now, + )); + disposals + }; + dispose_instances(disposals.drain(..)).await; + + let mut process = self + .factory + .start( + detail, + self.db.clone(), + crate::app::plugins::extension_host::DEFAULT_EXTENSION_HOST_CALL_TIMEOUT, + ) + .await?; + let value = match process.execute_command(command, args).await { + Ok(value) => value, + Err(error) => { + process.dispose().await; + return Err(error); + } + }; + let instance = Arc::new(ManagedExtensionHostInstance::new(process, now)); + let disposals = { + let mut instances = self.instances.lock().await; + instances.insert(key, instance); + remove_lru_over_limit_locked(&mut instances, self.limits.max_warm_instances) + }; + dispose_instances(disposals).await; + + Ok(ExtensionHostCommandOutput { + value, + cold_start: true, + }) + } + + pub(crate) async fn execute_gateway_hook( + &self, + detail: PluginDetail, + hook: &str, + context: GatewayVisibleHookContext, + call_timeout: Duration, + ) -> Result { + self.execute_gateway_hook_with_now(detail, hook, context, call_timeout, Instant::now()) + .await + } + + async fn execute_gateway_hook_with_now( + &self, + detail: PluginDetail, + hook: &str, + context: GatewayVisibleHookContext, + call_timeout: Duration, + now: Instant, + ) -> Result { + let context_value = serde_json::to_value(&context).map_err(|err| { + GatewayPluginError::new( + "PLUGIN_EXTENSION_HOST_INVALID_CONTEXT", + format!("failed to encode extension host gateway context: {err}"), + ) + })?; + let payload = json!({ + "hook": hook, + "traceId": context.trace_id.clone(), + "config": detail.config.clone(), + "context": context_value, + }); + let _operation_guard = self.operation_gate.read().await; + let key = ExtensionHostInstanceKey::from_gateway_plugin_detail(&detail, call_timeout) + .map_err(extension_host_gateway_error)?; + let plugin_lock = self.plugin_lock_for(&key.plugin_id).await; + let _plugin_guard = plugin_lock.lock().await; + + if let Some(value) = self + .execute_gateway_hook_warm_instance(&key, hook, payload.clone(), now) + .await + .map_err(extension_host_gateway_error)? + { + return gateway_hook_result_from_extension_host_output(hook, &context, value); + } + + let mut disposals = { + let mut instances = self.instances.lock().await; + let mut disposals = remove_same_plugin_with_different_key(&mut instances, &key); + disposals.extend(remove_idle_locked( + &mut instances, + self.limits.idle_recycle, + now, + )); + disposals + }; + dispose_instances(disposals.drain(..)).await; + + let mut process = self + .factory + .start(detail, self.db.clone(), call_timeout) + .await + .map_err(extension_host_gateway_error)?; + let value = match process.execute_gateway_hook(hook, payload).await { + Ok(value) => value, + Err(error) => { + process.dispose().await; + return Err(extension_host_gateway_error(error)); + } + }; + let result = gateway_hook_result_from_extension_host_output(hook, &context, value)?; + let instance = Arc::new(ManagedExtensionHostInstance::new(process, now)); + let disposals = { + let mut instances = self.instances.lock().await; + instances.insert(key, instance); + remove_lru_over_limit_locked(&mut instances, self.limits.max_warm_instances) + }; + dispose_instances(disposals).await; + + Ok(result) + } + + #[allow(dead_code)] + pub(crate) async fn dispose_plugin(&self, plugin_id: &str) { + let _operation_guard = self.operation_gate.read().await; + let plugin_lock = self.plugin_lock_for(plugin_id).await; + let plugin_guard = plugin_lock.lock().await; + let disposals = { + let mut instances = self.instances.lock().await; + let keys = instances + .keys() + .filter(|key| key.plugin_id == plugin_id) + .cloned() + .collect::>(); + keys.into_iter() + .filter_map(|key| instances.remove(&key)) + .collect::>() + }; + dispose_instances(disposals).await; + drop(plugin_guard); + self.remove_plugin_lock_if_unused(plugin_id, &plugin_lock) + .await; + } + + pub(crate) async fn retain_plugins(&self, active_plugin_ids: &HashSet) { + let _operation_guard = self.operation_gate.write().await; + let removals = { + let mut instances = self.instances.lock().await; + let keys = instances + .keys() + .filter(|key| !active_plugin_ids.contains(&key.plugin_id)) + .cloned() + .collect::>(); + keys.into_iter() + .filter_map(|key| { + instances + .remove(&key) + .map(|instance| (key.plugin_id, instance)) + }) + .collect::>() + }; + let removed_plugin_ids = removals + .iter() + .map(|(plugin_id, _)| plugin_id.clone()) + .collect::>(); + dispose_instances(removals.into_iter().map(|(_, instance)| instance)).await; + for plugin_id in removed_plugin_ids { + self.remove_plugin_lock_if_orphaned(&plugin_id).await; + } + } + + #[allow(dead_code)] + pub(crate) async fn dispose_idle(&self, now: Instant) { + let _operation_guard = self.operation_gate.read().await; + let disposals = { + let mut instances = self.instances.lock().await; + remove_idle_locked(&mut instances, self.limits.idle_recycle, now) + }; + dispose_instances(disposals).await; + } + + pub(crate) async fn dispose_all(&self) { + let _operation_guard = self.operation_gate.write().await; + let instances = { + let mut instances = self.instances.lock().await; + std::mem::take(&mut *instances) + .into_values() + .collect::>() + }; + dispose_instances(instances).await; + self.plugin_locks.lock().await.clear(); + } + + #[cfg(test)] + fn new_for_tests( + factory: Arc, + limits: ExtensionHostRegistryLimits, + ) -> Self { + let temp = tempfile::tempdir().expect("tempdir"); + let db = crate::db::init_for_tests(&temp.path().join("registry.db")).expect("init db"); + Self::with_factory(db, factory, limits) + } + + #[cfg(test)] + pub(crate) fn new_real_for_tests() -> Self { + let temp = tempfile::tempdir().expect("tempdir"); + let db = crate::db::init_for_tests(&temp.path().join("registry.db")).expect("init db"); + Self::new(db) + } + + #[cfg(test)] + async fn instance_count(&self) -> usize { + self.instances.lock().await.len() + } + + #[cfg(test)] + pub(crate) async fn instance_count_for_tests(&self) -> usize { + self.instance_count().await + } + + #[cfg(test)] + async fn plugin_instance_count(&self, plugin_id: &str) -> usize { + self.instances + .lock() + .await + .keys() + .filter(|key| key.plugin_id == plugin_id) + .count() + } + + async fn execute_warm_instance( + &self, + key: &ExtensionHostInstanceKey, + command: &str, + args: Value, + now: Instant, + ) -> AppResult> { + let instance = { self.instances.lock().await.get(key).cloned() }; + let Some(instance) = instance else { + return Ok(None); + }; + + match instance.execute_if_running(command, args, now).await { + Ok(Some(value)) => Ok(Some(value)), + Ok(None) => { + self.remove_warm_instance_if_current(key, &instance).await; + Ok(None) + } + Err(error) => { + self.remove_warm_instance_if_current(key, &instance).await; + Err(error) + } + } + } + + async fn execute_gateway_hook_warm_instance( + &self, + key: &ExtensionHostInstanceKey, + hook: &str, + context: Value, + now: Instant, + ) -> AppResult> { + let instance = { self.instances.lock().await.get(key).cloned() }; + let Some(instance) = instance else { + return Ok(None); + }; + + match instance + .execute_gateway_hook_if_running(hook, context, now) + .await + { + Ok(Some(value)) => Ok(Some(value)), + Ok(None) => { + self.remove_warm_instance_if_current(key, &instance).await; + Ok(None) + } + Err(error) => { + self.remove_warm_instance_if_current(key, &instance).await; + Err(error) + } + } + } + + async fn remove_warm_instance_if_current( + &self, + key: &ExtensionHostInstanceKey, + instance: &Arc, + ) { + let removed = { + let mut instances = self.instances.lock().await; + let should_remove = instances + .get(key) + .filter(|current| Arc::ptr_eq(current, instance)) + .is_some(); + should_remove.then(|| instances.remove(key)).flatten() + }; + if let Some(instance) = removed { + instance.dispose().await; + } + } + + async fn plugin_lock_for(&self, plugin_id: &str) -> Arc> { + let mut plugin_locks = self.plugin_locks.lock().await; + plugin_locks + .entry(plugin_id.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + } + + async fn remove_plugin_lock_if_unused(&self, plugin_id: &str, plugin_lock: &Arc>) { + let mut plugin_locks = self.plugin_locks.lock().await; + let should_remove = plugin_locks.get(plugin_id).is_some_and(|current| { + Arc::ptr_eq(current, plugin_lock) && Arc::strong_count(current) == 2 + }); + if should_remove { + plugin_locks.remove(plugin_id); + } + } + + async fn remove_plugin_lock_if_orphaned(&self, plugin_id: &str) { + let mut plugin_locks = self.plugin_locks.lock().await; + let should_remove = plugin_locks + .get(plugin_id) + .is_some_and(|current| Arc::strong_count(current) == 1); + if should_remove { + plugin_locks.remove(plugin_id); + } + } +} + +pub(crate) struct ExtensionHostInstanceLifecycleRegistry { + registry: Arc, +} + +impl ExtensionHostInstanceLifecycleRegistry { + pub(crate) fn new(registry: Arc) -> Self { + Self { registry } + } +} + +impl PluginRuntimeInstanceRegistry for ExtensionHostInstanceLifecycleRegistry { + fn retain_for_plugins(&self, plugins: &[PluginDetail]) { + let active_plugin_ids = plugins + .iter() + .map(|plugin| plugin.summary.plugin_id.clone()) + .collect::>(); + let registry = self.registry.clone(); + tauri::async_runtime::spawn(async move { + registry.retain_plugins(&active_plugin_ids).await; + }); + } + + fn dispose_plugin(&self, plugin_id: &str) { + let registry = self.registry.clone(); + let plugin_id = plugin_id.to_string(); + tauri::async_runtime::spawn(async move { + registry.dispose_plugin(&plugin_id).await; + }); + } + + fn dispose_all(&self) { + let registry = self.registry.clone(); + tauri::async_runtime::spawn(async move { + registry.dispose_all().await; + }); + } +} + +impl ExtensionHostInstanceKey { + pub(crate) fn from_plugin_detail(detail: &PluginDetail) -> AppResult { + let (runtime_kind, runtime_language) = match &detail.manifest.runtime { + PluginRuntime::ExtensionHost { language } => { + ("extensionHost".to_string(), language.clone()) + } + }; + let main = detail + .manifest + .main + .as_ref() + .filter(|main| !main.trim().is_empty()) + .cloned() + .ok_or_else(|| { + AppError::new("PLUGIN_MISSING_MAIN", "extensionHost runtime requires main") + })?; + Ok(Self { + plugin_id: detail.manifest.id.clone(), + version: detail.manifest.version.clone(), + installed_dir: plugin_root(detail)?.display().to_string(), + main, + runtime_kind, + runtime_language, + contribution_hash: contribution_hash(&detail.manifest), + call_timeout_ms: None, + }) + } + + pub(crate) fn from_gateway_plugin_detail( + detail: &PluginDetail, + call_timeout: Duration, + ) -> AppResult { + let mut key = Self::from_plugin_detail(detail)?; + key.call_timeout_ms = Some(call_timeout_millis(call_timeout)); + Ok(key) + } +} + +fn call_timeout_millis(call_timeout: Duration) -> u64 { + call_timeout.as_millis().try_into().unwrap_or(u64::MAX) +} + +#[derive(Default)] +pub(crate) struct ExtensionHostRuntimeState { + registry: Mutex>>, +} + +impl ExtensionHostRuntimeState { + #[allow(dead_code)] + pub(crate) async fn registry( + &self, + app: tauri::AppHandle, + db_state: &DbInitState, + ) -> AppResult> { + if let Some(registry) = { self.registry.lock().await.clone() } { + return Ok(registry.clone()); + } + + let db = ensure_db_ready(app, db_state).await?; + let mut guard = self.registry.lock().await; + if let Some(registry) = guard.as_ref() { + return Ok(registry.clone()); + } + let registry = Arc::new(ExtensionHostInstanceRegistry::new(db)); + *guard = Some(registry.clone()); + Ok(registry) + } + + pub(crate) async fn dispose_all(&self) { + let registry = { self.registry.lock().await.clone() }; + if let Some(registry) = registry { + registry.dispose_all().await; + } + } + + pub(crate) async fn dispose_plugin_if_initialized(&self, plugin_id: &str) { + let registry = { self.registry.lock().await.clone() }; + if let Some(registry) = registry { + registry.dispose_plugin(plugin_id).await; + } + } + + #[cfg(test)] + async fn set_registry_for_tests(&self, registry: Arc) { + *self.registry.lock().await = Some(registry); + } +} + +fn remove_same_plugin_with_different_key( + instances: &mut BTreeMap>, + key: &ExtensionHostInstanceKey, +) -> Vec> { + let keys = instances + .keys() + .filter(|existing| existing.plugin_id == key.plugin_id && *existing != key) + .cloned() + .collect::>(); + keys.into_iter() + .filter_map(|key| instances.remove(&key)) + .collect() +} + +fn remove_idle_locked( + instances: &mut BTreeMap>, + idle_recycle: Duration, + now: Instant, +) -> Vec> { + let idle_keys = instances + .iter() + .filter(|(_, instance)| { + now.checked_duration_since(instance.last_used()) + .is_some_and(|elapsed| elapsed >= idle_recycle) + }) + .map(|(key, _)| key.clone()) + .collect::>(); + idle_keys + .into_iter() + .filter_map(|key| instances.remove(&key)) + .collect() +} + +fn remove_lru_over_limit_locked( + instances: &mut BTreeMap>, + max_warm_instances: usize, +) -> Vec> { + let mut disposals = Vec::new(); + while instances.len() > max_warm_instances { + let Some(key) = instances + .iter() + .min_by_key(|(_, instance)| instance.last_used()) + .map(|(key, _)| key.clone()) + else { + return disposals; + }; + if let Some(instance) = instances.remove(&key) { + disposals.push(instance); + } + } + disposals +} + +async fn dispose_instances(instances: impl IntoIterator>) { + for instance in instances { + instance.dispose().await; + } +} + +fn plugin_root(detail: &PluginDetail) -> AppResult { + detail + .installed_dir + .as_ref() + .map(PathBuf::from) + .ok_or_else(|| { + AppError::new( + "PLUGIN_EXTENSION_HOST_ROOT_UNAVAILABLE", + format!( + "plugin {} does not have an installed extension host directory", + detail.summary.plugin_id + ), + ) + }) +} + +fn contribution_hash(manifest: &PluginManifest) -> String { + let bytes = serde_json::to_vec(&json!({ + "runtime": manifest.runtime, + "main": manifest.main, + "activationEvents": manifest.activation_events, + "contributes": manifest.contributes, + "capabilities": manifest.capabilities, + "permissions": manifest.permissions, + })) + .unwrap_or_default(); + format!("{:x}", sha2::Sha256::digest(bytes)) +} + +fn extension_host_gateway_error(err: AppError) -> GatewayPluginError { + match err.code() { + "PLUGIN_EXTENSION_HOST_FORBIDDEN" => GatewayPluginError::new( + "PLUGIN_PERMISSION_DENIED", + format!("extension host gateway hook permission denied: {err}"), + ), + "PLUGIN_EXTENSION_CALL_TIMEOUT" => GatewayPluginError::new( + "PLUGIN_EXTENSION_HOST_TIMEOUT", + format!("extension host gateway hook timed out: {err}"), + ), + "PLUGIN_EXTENSION_HOST_INVALID_OUTPUT" | "PLUGIN_EXTENSION_HOST_DECODE_FAILED" => { + GatewayPluginError::new( + "PLUGIN_EXTENSION_HOST_INVALID_OUTPUT", + format!("extension host gateway hook returned invalid output: {err}"), + ) + } + _ => GatewayPluginError::new( + "PLUGIN_EXTENSION_HOST_GATEWAY_FAILED", + format!("extension host gateway hook failed: {err}"), + ), + } +} + +fn gateway_hook_result_from_extension_host_output( + hook: &str, + context: &GatewayVisibleHookContext, + value: Value, +) -> Result { + let object = value.as_object().ok_or_else(|| { + GatewayPluginError::new( + "PLUGIN_EXTENSION_HOST_INVALID_OUTPUT", + "extension host gateway hook output must be a JSON object", + ) + })?; + if object.contains_key("contextPatch") { + return Err(GatewayPluginError::new( + "PLUGIN_EXTENSION_HOST_INVALID_OUTPUT", + "extension host gateway hook output used legacy contextPatch; use requestBody, responseBody, streamChunk, logMessage, or headers", + )); + } + let action = object + .get("action") + .and_then(Value::as_str) + .ok_or_else(|| { + GatewayPluginError::new( + "PLUGIN_EXTENSION_HOST_INVALID_OUTPUT", + "extension host gateway hook output must include string action", + ) + })?; + let mut result = GatewayHookResult::continue_unchanged(); + match action { + "continue" | "pass" => {} + "warn" => { + result.reason = Some(required_string(object, "message", "warn action")?); + } + "block" => { + if hook_kind(hook) == Some(crate::gateway::plugins::contract::HookKind::Log) { + return Err(GatewayPluginError::new( + "PLUGIN_EXTENSION_HOST_INVALID_OUTPUT", + format!("block action is not allowed in {hook}"), + )); + } + result.action = GatewayHookAction::Block; + result.reason = optional_string(object, "reason")?; + } + "replace" => { + result.request_body = optional_string(object, "requestBody")?; + result.response_body = optional_string(object, "responseBody")?; + result.stream_chunk = optional_string(object, "streamChunk")?; + result.log_message = optional_string(object, "logMessage")?; + result.headers = optional_string_map(object, "headers")?.unwrap_or_default(); + } + "appendMessage" => { + result.request_body = Some(append_message_request_body(hook, context, object)?); + } + other => { + return Err(GatewayPluginError::new( + "PLUGIN_EXTENSION_HOST_INVALID_OUTPUT", + format!("unsupported extension host gateway action: {other}"), + )); + } + } + Ok(result) +} + +fn hook_kind(hook: &str) -> Option { + let hook_name = GatewayPluginHookName::from_str(hook)?; + crate::gateway::plugins::registry::HookRegistry::new() + .descriptor(hook_name) + .map(|descriptor| descriptor.kind) +} + +fn append_message_request_body( + hook: &str, + context: &GatewayVisibleHookContext, + object: &serde_json::Map, +) -> Result { + let hook_name = GatewayPluginHookName::from_str(hook).ok_or_else(|| { + GatewayPluginError::new( + "PLUGIN_EXTENSION_HOST_INVALID_OUTPUT", + format!("unknown gateway hook for appendMessage action: {hook}"), + ) + })?; + if !hook_name.is_request_hook() { + return Err(GatewayPluginError::new( + "PLUGIN_EXTENSION_HOST_INVALID_OUTPUT", + format!("appendMessage action is only allowed in request hooks: {hook}"), + )); + } + let role = required_string(object, "role", "appendMessage action")?; + if role != "system" && role != "developer" { + return Err(GatewayPluginError::new( + "PLUGIN_EXTENSION_HOST_INVALID_OUTPUT", + "appendMessage role must be system or developer", + )); + } + let content = required_string(object, "content", "appendMessage action")?; + if content.trim().is_empty() { + return Err(GatewayPluginError::new( + "PLUGIN_EXTENSION_HOST_INVALID_OUTPUT", + "appendMessage content must not be empty", + )); + } + let body = context.request.body.as_deref().ok_or_else(|| { + GatewayPluginError::new( + "PLUGIN_EXTENSION_HOST_INVALID_OUTPUT", + "appendMessage requires visible request body", + ) + })?; + let mut root: Value = serde_json::from_str(body).map_err(|err| { + GatewayPluginError::new( + "PLUGIN_EXTENSION_HOST_INVALID_OUTPUT", + format!("appendMessage request body must be JSON: {err}"), + ) + })?; + let messages = root + .get_mut("messages") + .and_then(Value::as_array_mut) + .ok_or_else(|| { + GatewayPluginError::new( + "PLUGIN_EXTENSION_HOST_INVALID_OUTPUT", + "appendMessage requires request body messages array", + ) + })?; + messages.push(json!({ + "role": role, + "content": content, + })); + serde_json::to_string(&root).map_err(|err| { + GatewayPluginError::new( + "PLUGIN_EXTENSION_HOST_INVALID_OUTPUT", + format!("failed to encode appendMessage request body: {err}"), + ) + }) +} + +fn required_string( + object: &serde_json::Map, + key: &'static str, + action: &'static str, +) -> Result { + optional_string(object, key)?.ok_or_else(|| { + GatewayPluginError::new( + "PLUGIN_EXTENSION_HOST_INVALID_OUTPUT", + format!("{action} requires string {key}"), + ) + }) +} + +fn optional_string( + object: &serde_json::Map, + key: &'static str, +) -> Result, GatewayPluginError> { + match object.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => Ok(Some(value.clone())), + Some(_) => Err(GatewayPluginError::new( + "PLUGIN_EXTENSION_HOST_INVALID_OUTPUT", + format!("extension host gateway output field {key} must be a string"), + )), + } +} + +fn optional_string_map( + object: &serde_json::Map, + key: &'static str, +) -> Result>, GatewayPluginError> { + let Some(value) = object.get(key) else { + return Ok(None); + }; + if value.is_null() { + return Ok(None); + } + let Some(map) = value.as_object() else { + return Err(GatewayPluginError::new( + "PLUGIN_EXTENSION_HOST_INVALID_OUTPUT", + format!("extension host gateway output field {key} must be an object"), + )); + }; + let mut out = BTreeMap::new(); + for (name, value) in map { + let Some(header_value) = value.as_str() else { + return Err(GatewayPluginError::new( + "PLUGIN_EXTENSION_HOST_INVALID_OUTPUT", + format!("extension host gateway output header {name} must be a string"), + )); + }; + out.insert(name.clone(), header_value.to_string()); + } + Ok(Some(out)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::plugins::{ + PluginDetail, PluginHostCompatibility, PluginInstallSource, PluginManifest, + PluginPermissionRisk, PluginRuntime, PluginStatus, PluginSummary, + }; + use serde_json::json; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex as StdMutex}; + use std::time::{Duration, Instant}; + use tokio::sync::Notify; + + struct FakeExtensionHostFactory { + state: Arc>, + } + + #[derive(Default)] + struct FakeFactoryState { + next_id: u64, + starts: Vec, + start_timeouts: Vec, + executions: Vec, + disposals: Vec, + } + + struct FakeExtensionHostProcess { + id: u64, + state: Arc>, + running: bool, + } + + #[derive(Default)] + struct BlockingExtensionHostFactory { + slow_start: Arc, + slow_command: Arc, + slow_dispose: Arc, + starts: Arc, + disposals: Arc, + } + + #[derive(Default)] + struct BlockingStartControl { + started: Notify, + release: Notify, + } + + #[derive(Default)] + struct BlockingCommandControl { + starts: AtomicUsize, + started: Notify, + release: Notify, + } + + #[derive(Default)] + struct BlockingDisposeControl { + started: Notify, + release: Notify, + } + + struct BlockingExtensionHostProcess { + plugin_id: String, + slow_command: Arc, + slow_dispose: Arc, + disposals: Arc, + running: bool, + } + + impl Default for FakeExtensionHostFactory { + fn default() -> Self { + Self { + state: Arc::new(StdMutex::new(FakeFactoryState::default())), + } + } + } + + impl FakeExtensionHostFactory { + fn start_count(&self) -> usize { + self.state.lock().unwrap().starts.len() + } + + fn dispose_count(&self) -> usize { + self.state.lock().unwrap().disposals.len() + } + + fn executed_instance_ids(&self) -> Vec { + self.state.lock().unwrap().executions.clone() + } + + fn disposed_instance_ids(&self) -> Vec { + self.state.lock().unwrap().disposals.clone() + } + + fn start_timeouts(&self) -> Vec { + self.state.lock().unwrap().start_timeouts.clone() + } + } + + impl ExtensionHostFactory for FakeExtensionHostFactory { + fn start<'a>( + &'a self, + _detail: PluginDetail, + _db: db::Db, + call_timeout: Duration, + ) -> BoxFuture<'a, AppResult>> { + Box::pin(async move { + let mut state = self.state.lock().unwrap(); + state.next_id += 1; + let id = state.next_id; + state.starts.push(id); + state.start_timeouts.push(call_timeout); + Ok(Box::new(FakeExtensionHostProcess { + id, + state: self.state.clone(), + running: true, + }) as Box) + }) + } + } + + impl ExtensionHostProcess for FakeExtensionHostProcess { + fn execute_command<'a>( + &'a mut self, + command: &'a str, + args: Value, + ) -> BoxFuture<'a, AppResult> { + Box::pin(async move { + if command == "fail-warm" { + return Err(AppError::new( + "PLUGIN_EXTENSION_CALL_TIMEOUT", + "warm command failed", + )); + } + self.state.lock().unwrap().executions.push(self.id); + Ok(json!({ + "instanceId": self.id, + "command": command, + "args": args, + })) + }) + } + + fn execute_gateway_hook<'a>( + &'a mut self, + hook: &'a str, + context: Value, + ) -> BoxFuture<'a, AppResult> { + Box::pin(async move { + if hook == "gateway.failWarm" { + return Err(AppError::new( + "PLUGIN_EXTENSION_CALL_TIMEOUT", + "warm gateway hook failed", + )); + } + self.state.lock().unwrap().executions.push(self.id); + Ok(json!({ + "action": "continue", + "hook": hook, + "context": context, + })) + }) + } + + fn is_running(&mut self) -> bool { + self.running + } + + fn dispose<'a>(&'a mut self) -> BoxFuture<'a, ()> { + Box::pin(async move { + self.running = false; + self.state.lock().unwrap().disposals.push(self.id); + }) + } + } + + impl ExtensionHostFactory for BlockingExtensionHostFactory { + fn start<'a>( + &'a self, + detail: PluginDetail, + _db: db::Db, + _call_timeout: Duration, + ) -> BoxFuture<'a, AppResult>> { + Box::pin(async move { + self.starts.fetch_add(1, Ordering::SeqCst); + if detail.summary.plugin_id == "acme.start" { + self.slow_start.started.notify_waiters(); + self.slow_start.release.notified().await; + } + Ok(Box::new(BlockingExtensionHostProcess { + plugin_id: detail.summary.plugin_id, + slow_command: self.slow_command.clone(), + slow_dispose: self.slow_dispose.clone(), + disposals: self.disposals.clone(), + running: true, + }) as Box) + }) + } + } + + impl ExtensionHostProcess for BlockingExtensionHostProcess { + fn execute_command<'a>( + &'a mut self, + command: &'a str, + _args: Value, + ) -> BoxFuture<'a, AppResult> { + Box::pin(async move { + if (self.plugin_id == "acme.slow" && command == "slow") + || (self.plugin_id == "acme.race" && command == "race") + { + self.slow_command.starts.fetch_add(1, Ordering::SeqCst); + self.slow_command.started.notify_waiters(); + self.slow_command.release.notified().await; + } + Ok(json!({ + "pluginId": self.plugin_id, + "command": command, + })) + }) + } + + fn execute_gateway_hook<'a>( + &'a mut self, + hook: &'a str, + _context: Value, + ) -> BoxFuture<'a, AppResult> { + Box::pin(async move { + Ok(json!({ + "action": "continue", + "pluginId": self.plugin_id, + "hook": hook, + })) + }) + } + + fn is_running(&mut self) -> bool { + self.running + } + + fn dispose<'a>(&'a mut self) -> BoxFuture<'a, ()> { + Box::pin(async move { + self.disposals.fetch_add(1, Ordering::SeqCst); + if self.plugin_id == "acme.slow" { + self.slow_dispose.started.notify_waiters(); + self.slow_dispose.release.notified().await; + } + self.running = false; + }) + } + } + + impl BlockingExtensionHostFactory { + fn start_count(&self) -> usize { + self.starts.load(Ordering::SeqCst) + } + + fn dispose_count(&self) -> usize { + self.disposals.load(Ordering::SeqCst) + } + + fn command_start_count(&self) -> usize { + self.slow_command.starts.load(Ordering::SeqCst) + } + + async fn wait_for_command_start_count(&self, target: usize) { + loop { + let notified = self.slow_command.started.notified(); + if self.command_start_count() >= target { + return; + } + notified.await; + } + } + } + + fn plugin_detail(plugin_id: &str, contribution_hash_seed: &str) -> PluginDetail { + PluginDetail { + summary: PluginSummary { + id: 1, + plugin_id: plugin_id.to_string(), + name: "Acme Echo".to_string(), + current_version: Some("1.0.0".to_string()), + status: PluginStatus::Enabled, + runtime: "extensionHost".to_string(), + permission_risk: PluginPermissionRisk::Low, + update_available: false, + last_error: None, + created_at: 0, + updated_at: 0, + }, + manifest: PluginManifest { + id: plugin_id.to_string(), + name: "Acme Echo".to_string(), + version: "1.0.0".to_string(), + api_version: "1.0.0".to_string(), + runtime: PluginRuntime::ExtensionHost { + language: "typescript".to_string(), + }, + hooks: Vec::new(), + permissions: Vec::new(), + main: Some("dist/extension.js".to_string()), + activation_events: Vec::new(), + contributes: None, + capabilities: vec![ + "commands.execute".to_string(), + contribution_hash_seed.to_string(), + ], + host_compatibility: PluginHostCompatibility { + app: ">=0.60.0".to_string(), + plugin_api: "^1.0.0".to_string(), + platforms: Vec::new(), + }, + entry: None, + config_schema: None, + config_version: None, + description: None, + author: None, + homepage: None, + repository: None, + license: None, + checksum: None, + signature: None, + category: None, + }, + install_source: PluginInstallSource::Local, + installed_dir: Some(format!("/tmp/{plugin_id}")), + config: json!({}), + granted_permissions: Vec::new(), + pending_permissions: Vec::new(), + audit_logs: Vec::new(), + runtime_failures: Vec::new(), + rollback_versions: Vec::new(), + } + } + + #[tokio::test] + async fn registry_reuses_warm_instance_for_same_key() { + let factory = Arc::new(FakeExtensionHostFactory::default()); + let registry = ExtensionHostInstanceRegistry::new_for_tests( + factory.clone(), + ExtensionHostRegistryLimits { + max_warm_instances: 8, + idle_recycle: Duration::from_secs(120), + }, + ); + let detail = plugin_detail("acme.echo", "same"); + + let first = registry + .execute_command_with_now( + detail.clone(), + "acme.echo", + json!({ "n": 1 }), + Instant::now(), + ) + .await + .expect("first command"); + let second = registry + .execute_command_with_now(detail, "acme.echo", json!({ "n": 2 }), Instant::now()) + .await + .expect("second command"); + + assert!(first.cold_start); + assert!(!second.cold_start); + assert_eq!(factory.start_count(), 1); + assert_eq!(factory.executed_instance_ids(), vec![1, 1]); + } + + #[tokio::test] + async fn registry_drops_warm_command_instance_after_execution_error() { + let factory = Arc::new(FakeExtensionHostFactory::default()); + let registry = ExtensionHostInstanceRegistry::new_for_tests( + factory.clone(), + ExtensionHostRegistryLimits { + max_warm_instances: 8, + idle_recycle: Duration::from_secs(120), + }, + ); + let detail = plugin_detail("acme.echo", "same"); + + registry + .execute_command_with_now(detail.clone(), "warm", json!({}), Instant::now()) + .await + .expect("first command warms instance"); + let err = registry + .execute_command_with_now(detail.clone(), "fail-warm", json!({}), Instant::now()) + .await + .expect_err("warm command error should propagate"); + + assert_eq!(err.code(), "PLUGIN_EXTENSION_CALL_TIMEOUT"); + assert_eq!(registry.instance_count().await, 0); + assert_eq!(factory.disposed_instance_ids(), vec![1]); + + let next = registry + .execute_command_with_now(detail, "warm", json!({}), Instant::now()) + .await + .expect("next command should cold start"); + + assert!(next.cold_start); + assert_eq!(factory.start_count(), 2); + assert_eq!(factory.executed_instance_ids(), vec![1, 2]); + } + + #[tokio::test] + async fn registry_drops_warm_gateway_hook_instance_after_execution_error() { + let factory = Arc::new(FakeExtensionHostFactory::default()); + let registry = ExtensionHostInstanceRegistry::new_for_tests( + factory.clone(), + ExtensionHostRegistryLimits { + max_warm_instances: 8, + idle_recycle: Duration::from_secs(120), + }, + ); + let detail = plugin_detail("acme.gateway", "same"); + let context = GatewayVisibleHookContext { + hook_name: GatewayPluginHookName::RequestAfterBodyRead + .as_str() + .to_string(), + trace_id: "trace-gateway".to_string(), + request: Default::default(), + response: Default::default(), + stream: Default::default(), + log: Default::default(), + }; + + registry + .execute_gateway_hook_with_now( + detail.clone(), + GatewayPluginHookName::RequestAfterBodyRead.as_str(), + context.clone(), + Duration::from_millis(150), + Instant::now(), + ) + .await + .expect("first hook warms instance"); + let err = registry + .execute_gateway_hook_with_now( + detail.clone(), + "gateway.failWarm", + context.clone(), + Duration::from_millis(150), + Instant::now(), + ) + .await + .expect_err("warm gateway hook error should propagate"); + + assert_eq!(err.code(), "PLUGIN_EXTENSION_HOST_TIMEOUT"); + assert_eq!(registry.instance_count().await, 0); + assert_eq!(factory.disposed_instance_ids(), vec![1]); + + registry + .execute_gateway_hook_with_now( + detail, + GatewayPluginHookName::RequestAfterBodyRead.as_str(), + context, + Duration::from_millis(150), + Instant::now(), + ) + .await + .expect("next hook should cold start"); + + assert_eq!(factory.start_count(), 2); + assert_eq!(factory.executed_instance_ids(), vec![1, 2]); + } + + #[tokio::test] + async fn registry_starts_new_gateway_instance_when_call_timeout_changes() { + let factory = Arc::new(FakeExtensionHostFactory::default()); + let registry = ExtensionHostInstanceRegistry::new_for_tests( + factory.clone(), + ExtensionHostRegistryLimits { + max_warm_instances: 8, + idle_recycle: Duration::from_secs(120), + }, + ); + let detail = plugin_detail("acme.gateway", "same"); + let context = GatewayVisibleHookContext { + hook_name: GatewayPluginHookName::RequestAfterBodyRead + .as_str() + .to_string(), + trace_id: "trace-gateway-timeout".to_string(), + request: Default::default(), + response: Default::default(), + stream: Default::default(), + log: Default::default(), + }; + + registry + .execute_gateway_hook_with_now( + detail.clone(), + GatewayPluginHookName::RequestAfterBodyRead.as_str(), + context.clone(), + Duration::from_millis(37), + Instant::now(), + ) + .await + .expect("first hook warms instance"); + registry + .execute_gateway_hook_with_now( + detail, + GatewayPluginHookName::RequestAfterBodyRead.as_str(), + context, + Duration::from_millis(91), + Instant::now(), + ) + .await + .expect("changed timeout should cold start a matching instance"); + + assert_eq!(factory.start_count(), 2); + assert_eq!( + factory.start_timeouts(), + vec![Duration::from_millis(37), Duration::from_millis(91)] + ); + assert_eq!(factory.disposed_instance_ids(), vec![1]); + assert_eq!(registry.instance_count().await, 1); + } + + #[tokio::test] + async fn registry_replaces_instance_when_contribution_hash_changes() { + let factory = Arc::new(FakeExtensionHostFactory::default()); + let registry = ExtensionHostInstanceRegistry::new_for_tests( + factory.clone(), + ExtensionHostRegistryLimits { + max_warm_instances: 8, + idle_recycle: Duration::from_secs(120), + }, + ); + + registry + .execute_command_with_now( + plugin_detail("acme.echo", "before"), + "acme.echo", + json!({}), + Instant::now(), + ) + .await + .expect("first command"); + let changed = registry + .execute_command_with_now( + plugin_detail("acme.echo", "after"), + "acme.echo", + json!({}), + Instant::now(), + ) + .await + .expect("changed command"); + + assert!(changed.cold_start); + assert_eq!(factory.start_count(), 2); + assert_eq!(factory.dispose_count(), 1); + assert_eq!(factory.disposed_instance_ids(), vec![1]); + } + + #[tokio::test] + async fn registry_disposes_plugin_instances() { + let factory = Arc::new(FakeExtensionHostFactory::default()); + let registry = ExtensionHostInstanceRegistry::new_for_tests( + factory.clone(), + ExtensionHostRegistryLimits { + max_warm_instances: 8, + idle_recycle: Duration::from_secs(120), + }, + ); + + registry + .execute_command_with_now( + plugin_detail("acme.echo", "one"), + "acme.echo", + json!({}), + Instant::now(), + ) + .await + .expect("first command"); + registry + .execute_command_with_now( + plugin_detail("acme.other", "two"), + "acme.other", + json!({}), + Instant::now(), + ) + .await + .expect("second command"); + + registry.dispose_plugin("acme.echo").await; + + assert_eq!(factory.disposed_instance_ids(), vec![1]); + assert_eq!(registry.instance_count().await, 1); + } + + #[tokio::test] + async fn registry_retain_plugins_disposes_removed_plugin_instances() { + let factory = Arc::new(FakeExtensionHostFactory::default()); + let registry = ExtensionHostInstanceRegistry::new_for_tests( + factory.clone(), + ExtensionHostRegistryLimits { + max_warm_instances: 8, + idle_recycle: Duration::from_secs(120), + }, + ); + + registry + .execute_command_with_now( + plugin_detail("acme.keep", "one"), + "acme.keep", + json!({}), + Instant::now(), + ) + .await + .expect("first command"); + registry + .execute_command_with_now( + plugin_detail("acme.remove", "two"), + "acme.remove", + json!({}), + Instant::now(), + ) + .await + .expect("second command"); + + registry + .retain_plugins(&HashSet::from(["acme.keep".to_string()])) + .await; + + assert_eq!(factory.disposed_instance_ids(), vec![2]); + assert_eq!(registry.plugin_instance_count("acme.keep").await, 1); + assert_eq!(registry.plugin_instance_count("acme.remove").await, 0); + } + + #[tokio::test] + async fn registry_disposes_idle_instances() { + let factory = Arc::new(FakeExtensionHostFactory::default()); + let registry = ExtensionHostInstanceRegistry::new_for_tests( + factory.clone(), + ExtensionHostRegistryLimits { + max_warm_instances: 8, + idle_recycle: Duration::from_secs(10), + }, + ); + let now = Instant::now(); + + registry + .execute_command_with_now( + plugin_detail("acme.echo", "idle"), + "acme.echo", + json!({}), + now, + ) + .await + .expect("first command"); + registry.dispose_idle(now + Duration::from_secs(11)).await; + + assert_eq!(factory.disposed_instance_ids(), vec![1]); + assert_eq!(registry.instance_count().await, 0); + } + + #[tokio::test] + async fn registry_evicts_least_recently_used_idle_instance() { + let factory = Arc::new(FakeExtensionHostFactory::default()); + let registry = ExtensionHostInstanceRegistry::new_for_tests( + factory.clone(), + ExtensionHostRegistryLimits { + max_warm_instances: 2, + idle_recycle: Duration::from_secs(120), + }, + ); + let now = Instant::now(); + + registry + .execute_command_with_now(plugin_detail("acme.one", "one"), "acme.one", json!({}), now) + .await + .expect("first command"); + registry + .execute_command_with_now( + plugin_detail("acme.two", "two"), + "acme.two", + json!({}), + now + Duration::from_secs(1), + ) + .await + .expect("second command"); + registry + .execute_command_with_now( + plugin_detail("acme.one", "one"), + "acme.one", + json!({}), + now + Duration::from_secs(2), + ) + .await + .expect("touch first command"); + registry + .execute_command_with_now( + plugin_detail("acme.three", "three"), + "acme.three", + json!({}), + now + Duration::from_secs(3), + ) + .await + .expect("third command"); + + assert_eq!(factory.disposed_instance_ids(), vec![2]); + assert_eq!(registry.instance_count().await, 2); + } + + #[tokio::test] + async fn registry_allows_different_plugin_commands_while_one_plugin_command_is_slow() { + let factory = Arc::new(BlockingExtensionHostFactory::default()); + let registry = Arc::new(ExtensionHostInstanceRegistry::new_for_tests( + factory.clone(), + ExtensionHostRegistryLimits { + max_warm_instances: 8, + idle_recycle: Duration::from_secs(120), + }, + )); + let slow_registry = registry.clone(); + + let slow_task = tokio::spawn(async move { + slow_registry + .execute_command_with_now( + plugin_detail("acme.slow", "slow"), + "slow", + json!({}), + Instant::now(), + ) + .await + }); + tokio::time::timeout( + Duration::from_secs(1), + factory.slow_command.started.notified(), + ) + .await + .expect("slow command should start"); + + let fast_result = tokio::time::timeout( + Duration::from_millis(100), + registry.execute_command_with_now( + plugin_detail("acme.fast", "fast"), + "fast", + json!({}), + Instant::now(), + ), + ) + .await; + + factory.slow_command.release.notify_waiters(); + slow_task + .await + .expect("slow task join") + .expect("slow command result"); + + assert!( + fast_result.is_ok(), + "fast plugin command should not wait for slow plugin command" + ); + fast_result + .expect("fast command should complete") + .expect("fast command result"); + } + + #[tokio::test] + async fn registry_dispose_plugin_does_not_block_other_plugin_commands() { + let factory = Arc::new(BlockingExtensionHostFactory::default()); + let registry = Arc::new(ExtensionHostInstanceRegistry::new_for_tests( + factory.clone(), + ExtensionHostRegistryLimits { + max_warm_instances: 8, + idle_recycle: Duration::from_secs(120), + }, + )); + registry + .execute_command_with_now( + plugin_detail("acme.slow", "slow"), + "warm", + json!({}), + Instant::now(), + ) + .await + .expect("warm slow plugin"); + registry + .execute_command_with_now( + plugin_detail("acme.fast", "fast"), + "warm", + json!({}), + Instant::now(), + ) + .await + .expect("warm fast plugin"); + + let dispose_registry = registry.clone(); + let dispose_task = tokio::spawn(async move { + dispose_registry.dispose_plugin("acme.slow").await; + }); + tokio::time::timeout( + Duration::from_secs(1), + factory.slow_dispose.started.notified(), + ) + .await + .expect("slow dispose should start"); + + let fast_result = tokio::time::timeout( + Duration::from_millis(100), + registry.execute_command_with_now( + plugin_detail("acme.fast", "fast"), + "fast", + json!({}), + Instant::now(), + ), + ) + .await; + + factory.slow_dispose.release.notify_waiters(); + dispose_task.await.expect("dispose task join"); + + assert!( + fast_result.is_ok(), + "fast plugin command should not wait for slow plugin dispose" + ); + fast_result + .expect("fast command should complete") + .expect("fast command result"); + } + + #[tokio::test] + async fn registry_serializes_same_plugin_replacement_during_concurrent_cold_start() { + let factory = Arc::new(BlockingExtensionHostFactory::default()); + let registry = Arc::new(ExtensionHostInstanceRegistry::new_for_tests( + factory.clone(), + ExtensionHostRegistryLimits { + max_warm_instances: 8, + idle_recycle: Duration::from_secs(120), + }, + )); + let first_registry = registry.clone(); + let second_registry = registry.clone(); + + let first_task = tokio::spawn(async move { + first_registry + .execute_command_with_now( + plugin_detail("acme.race", "before"), + "race", + json!({}), + Instant::now(), + ) + .await + }); + factory.wait_for_command_start_count(1).await; + + let second_task = tokio::spawn(async move { + second_registry + .execute_command_with_now( + plugin_detail("acme.race", "after"), + "race", + json!({}), + Instant::now(), + ) + .await + }); + let second_started_while_first_running = tokio::time::timeout( + Duration::from_millis(100), + factory.wait_for_command_start_count(2), + ) + .await; + + factory.slow_command.release.notify_waiters(); + factory.wait_for_command_start_count(2).await; + factory.slow_command.release.notify_waiters(); + first_task + .await + .expect("first task join") + .expect("first command result"); + second_task + .await + .expect("second task join") + .expect("second command result"); + + assert!( + second_started_while_first_running.is_err(), + "same plugin replacement should wait for the first execution to finish" + ); + assert_eq!(factory.start_count(), 2); + assert_eq!(factory.dispose_count(), 1); + assert_eq!(registry.plugin_instance_count("acme.race").await, 1); + assert_eq!(registry.instance_count().await, 1); + } + + #[tokio::test] + async fn registry_dispose_all_waits_for_in_flight_cold_start_before_returning() { + let factory = Arc::new(BlockingExtensionHostFactory::default()); + let registry = Arc::new(ExtensionHostInstanceRegistry::new_for_tests( + factory.clone(), + ExtensionHostRegistryLimits { + max_warm_instances: 8, + idle_recycle: Duration::from_secs(120), + }, + )); + let command_registry = registry.clone(); + + let command_task = tokio::spawn(async move { + command_registry + .execute_command_with_now( + plugin_detail("acme.start", "start"), + "start", + json!({}), + Instant::now(), + ) + .await + }); + tokio::time::timeout( + Duration::from_secs(1), + factory.slow_start.started.notified(), + ) + .await + .expect("cold start should begin"); + + let dispose_registry = registry.clone(); + let dispose_task = tokio::spawn(async move { + dispose_registry.dispose_all().await; + }); + let dispose_finished_before_start_released = + tokio::time::timeout(Duration::from_millis(100), async { + while !dispose_task.is_finished() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await; + + factory.slow_start.release.notify_waiters(); + command_task + .await + .expect("command task join") + .expect("command result"); + dispose_task.await.expect("dispose_all task join"); + + assert!( + dispose_finished_before_start_released.is_err(), + "dispose_all should wait for in-flight cold start to finish" + ); + assert_eq!(registry.instance_count().await, 0); + } + + #[tokio::test] + async fn runtime_state_dispose_plugin_if_initialized_is_noop_before_registry_init() { + let state = ExtensionHostRuntimeState::default(); + + state.dispose_plugin_if_initialized("acme.echo").await; + } + + #[tokio::test] + async fn runtime_state_dispose_plugin_if_initialized_disposes_existing_registry_instance() { + let state = ExtensionHostRuntimeState::default(); + let factory = Arc::new(FakeExtensionHostFactory::default()); + let registry = Arc::new(ExtensionHostInstanceRegistry::new_for_tests( + factory.clone(), + ExtensionHostRegistryLimits { + max_warm_instances: 8, + idle_recycle: Duration::from_secs(120), + }, + )); + state.set_registry_for_tests(registry.clone()).await; + + registry + .execute_command_with_now( + plugin_detail("acme.echo", "dispose"), + "acme.echo", + json!({}), + Instant::now(), + ) + .await + .expect("execute command"); + + state.dispose_plugin_if_initialized("acme.echo").await; + + assert_eq!(factory.disposed_instance_ids(), vec![1]); + assert_eq!(registry.instance_count().await, 0); + } +} diff --git a/src-tauri/src/app/plugins/extension_host_worker.rs b/src-tauri/src/app/plugins/extension_host_worker.rs new file mode 100644 index 00000000..88e00f06 --- /dev/null +++ b/src-tauri/src/app/plugins/extension_host_worker.rs @@ -0,0 +1,1222 @@ +//! Usage: Extension host stdio worker process. + +use crate::domain::plugin_contributions::PluginContributes; +use crate::domain::plugins::PluginManifest; +use rquickjs::{CatchResultExt, CaughtError, Context, Function, Object, Runtime, Value as JsValue}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::BTreeSet; +use std::fs; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +const WORKER_VERSION: u32 = 1; +const EXTENSION_HOST_JSON_RPC_BODY_EXPANSION_FACTOR: usize = 6; +const EXTENSION_HOST_JSON_RPC_OVERHEAD_BYTES: usize = 1024 * 1024; +const DEFAULT_JS_TIMEOUT_MS: u64 = 30_000; + +pub(crate) fn default_extension_host_max_line_bytes() -> usize { + crate::gateway::util::max_request_body_bytes() + .saturating_mul(EXTENSION_HOST_JSON_RPC_BODY_EXPANSION_FACTOR) + .saturating_add(EXTENSION_HOST_JSON_RPC_OVERHEAD_BYTES) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ExtensionHostWorkerConfig { + pub(crate) plugin_root: PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) contribution_hash: Option, + #[serde(default = "default_max_line_bytes")] + pub(crate) max_line_bytes: usize, + #[serde(default = "default_js_timeout_ms")] + pub(crate) startup_js_timeout_ms: u64, + #[serde(default = "default_js_timeout_ms")] + pub(crate) js_timeout_ms: u64, +} + +#[derive(Debug, Deserialize)] +struct JsonRpcRequest { + id: Value, + method: String, + #[serde(default)] + params: Value, +} + +#[derive(Debug, Serialize)] +struct JsonRpcErrorBody { + code: i32, + message: String, + data: Value, +} + +struct WorkerState { + manifest: PluginManifest, + expected_contribution_hash: Option, + manifest_contribution_hash: String, + declared_commands: BTreeSet, + declared_gateway_hooks: BTreeSet, + context: Context, + activated: bool, + deadline: Arc>>, + startup_js_timeout: Duration, + js_timeout: Duration, + host_calls: Arc>, +} + +struct WorkerHostCallState { + next_host_call_id: u64, + max_line_bytes: usize, +} + +pub fn run_stdio_worker() { + let result = run_stdio_worker_inner(); + if let Err(err) = result { + let _ = writeln!( + io::stderr(), + "{}: {}", + err.code, + err.message.replace('\n', " ") + ); + std::process::exit(1); + } +} + +#[cfg(test)] +#[test] +fn extension_host_worker_process_entry_for_tests() { + if !std::env::args().any(|arg| arg == "--extension-host-config") { + return; + } + run_stdio_worker(); +} + +fn run_stdio_worker_inner() -> Result<(), WorkerError> { + let config = read_config_from_args(std::env::args())?; + if config.max_line_bytes == 0 { + return Err(WorkerError::new( + "PLUGIN_EXTENSION_HOST_INVALID_CONFIG", + "maxLineBytes must be greater than zero", + )); + } + let mut state = WorkerState::load(config.clone())?; + + emit_notification( + "extension.ready", + json!({ "workerVersion": WORKER_VERSION }), + config.max_line_bytes, + )?; + + loop { + let line = { + let stdin = io::stdin(); + let mut stdin = stdin.lock(); + read_bounded_stdin_line(&mut stdin, config.max_line_bytes)? + }; + let line = match line { + WorkerStdinLine::Line(line) => line, + WorkerStdinLine::TooLarge => { + emit_protocol_error( + Value::Null, + "PLUGIN_EXTENSION_HOST_REQUEST_TOO_LARGE", + format!( + "extension host request exceeded {} bytes", + config.max_line_bytes + ), + config.max_line_bytes, + )?; + continue; + } + WorkerStdinLine::Eof => break, + }; + if line.is_empty() { + continue; + } + let request: JsonRpcRequest = match serde_json::from_slice(&line) { + Ok(request) => request, + Err(err) => { + emit_protocol_error( + Value::Null, + "PLUGIN_EXTENSION_HOST_PROTOCOL_ERROR", + format!("extension host request was not valid JSON-RPC: {err}"), + config.max_line_bytes, + )?; + continue; + } + }; + + let id = request.id.clone(); + let result = state.handle_request(request); + match result { + Ok(value) => emit_result(id, value, config.max_line_bytes)?, + Err(err) => emit_error(id, err, config.max_line_bytes)?, + } + } + Ok(()) +} + +enum WorkerStdinLine { + Line(Vec), + TooLarge, + Eof, +} + +fn read_bounded_stdin_line( + reader: &mut impl Read, + max_line_bytes: usize, +) -> Result { + let mut line = Vec::new(); + let mut byte = [0_u8; 1]; + loop { + let read = reader.read(&mut byte).map_err(|err| { + WorkerError::new( + "PLUGIN_EXTENSION_HOST_READ_FAILED", + format!("failed to read worker stdin: {err}"), + ) + })?; + if read == 0 { + return if line.is_empty() { + Ok(WorkerStdinLine::Eof) + } else { + Ok(WorkerStdinLine::Line(line)) + }; + } + if byte[0] == b'\n' { + return Ok(WorkerStdinLine::Line(line)); + } + line.push(byte[0]); + if line.len() > max_line_bytes { + discard_stdin_line(reader)?; + return Ok(WorkerStdinLine::TooLarge); + } + } +} + +fn discard_stdin_line(reader: &mut impl Read) -> Result<(), WorkerError> { + let mut byte = [0_u8; 1]; + loop { + let read = reader.read(&mut byte).map_err(|err| { + WorkerError::new( + "PLUGIN_EXTENSION_HOST_READ_FAILED", + format!("failed to discard oversized worker stdin line: {err}"), + ) + })?; + if read == 0 || byte[0] == b'\n' { + return Ok(()); + } + } +} + +impl WorkerState { + fn load(config: ExtensionHostWorkerConfig) -> Result { + let manifest_path = config.plugin_root.join("plugin.json"); + let manifest: PluginManifest = read_json_file(&manifest_path)?; + let manifest_contribution_hash = contribution_hash(&manifest); + if config.contribution_hash.as_deref() != Some(manifest_contribution_hash.as_str()) { + return Err(WorkerError::new( + "PLUGIN_EXTENSION_HOST_HANDSHAKE_FAILED", + "extension host contribution hash did not match manifest on disk", + )); + } + let main = manifest.main.as_deref().ok_or_else(|| { + WorkerError::new( + "PLUGIN_EXTENSION_HOST_INVALID_MANIFEST", + "extensionHost manifest requires main", + ) + })?; + let main_path = resolve_child_path(&config.plugin_root, main)?; + if !main_path.is_file() { + return Err(WorkerError::new( + "PLUGIN_EXTENSION_HOST_MAIN_NOT_FOUND", + format!( + "extension host main file does not exist: {}", + main_path.display() + ), + )); + } + let declared_commands = declared_commands(manifest.contributes.as_ref()); + let declared_gateway_hooks = declared_gateway_hooks(manifest.contributes.as_ref()); + let runtime = Runtime::new().map_err(js_init_error)?; + runtime.set_memory_limit(32 * 1024 * 1024); + runtime.set_max_stack_size(512 * 1024); + let deadline = Arc::new(Mutex::new(None)); + let interrupt_deadline = Arc::clone(&deadline); + runtime.set_interrupt_handler(Some(Box::new(move || { + let deadline = interrupt_deadline + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + deadline.is_some_and(|deadline| Instant::now() >= deadline) + }))); + let context = Context::full(&runtime).map_err(js_init_error)?; + let mut state = Self { + manifest, + expected_contribution_hash: config.contribution_hash, + manifest_contribution_hash, + declared_commands, + declared_gateway_hooks, + context, + activated: false, + deadline, + startup_js_timeout: Duration::from_millis(config.startup_js_timeout_ms), + js_timeout: Duration::from_millis(config.js_timeout_ms), + host_calls: Arc::new(Mutex::new(WorkerHostCallState { + next_host_call_id: 1, + max_line_bytes: config.max_line_bytes, + })), + }; + state.load_main(&main_path)?; + Ok(state) + } + + fn handle_request(&mut self, request: JsonRpcRequest) -> Result { + match request.method.as_str() { + "extension.handshake" => self.handshake(request.params), + "extension.activate" => { + self.activate()?; + Ok(json!({ "activated": true })) + } + "extension.deactivate" => { + self.deactivate()?; + Ok(json!({ "deactivated": true })) + } + "commands.execute" => { + let command = request + .params + .get("command") + .and_then(Value::as_str) + .ok_or_else(|| { + WorkerError::new( + "PLUGIN_EXTENSION_HOST_INVALID_REQUEST", + "commands.execute requires command", + ) + })?; + let args = request.params.get("args").cloned().unwrap_or(Value::Null); + self.execute_command(command, args) + } + "gatewayHooks.execute" => { + let hook = request + .params + .get("hook") + .and_then(Value::as_str) + .ok_or_else(|| { + WorkerError::new( + "PLUGIN_EXTENSION_HOST_INVALID_REQUEST", + "gatewayHooks.execute requires hook", + ) + })?; + let context = request + .params + .get("context") + .cloned() + .unwrap_or(Value::Null); + self.execute_gateway_hook(hook, context) + } + method => Err(WorkerError::new( + "PLUGIN_EXTENSION_HOST_METHOD_NOT_FOUND", + format!("unsupported extension host method: {method}"), + )), + } + } + + fn load_main(&mut self, main_path: &Path) -> Result<(), WorkerError> { + let source = fs::read_to_string(main_path).map_err(|err| { + WorkerError::new( + "PLUGIN_EXTENSION_HOST_MAIN_READ_FAILED", + format!("failed to read extension host main: {err}"), + ) + })?; + let escaped_source = serde_json::to_string(&source).map_err(|err| { + WorkerError::new( + "PLUGIN_EXTENSION_HOST_ENCODE_FAILED", + format!("failed to encode extension host main: {err}"), + ) + })?; + let escaped_path = + serde_json::to_string(&main_path.display().to_string()).map_err(|err| { + WorkerError::new( + "PLUGIN_EXTENSION_HOST_ENCODE_FAILED", + format!("failed to encode extension host main path: {err}"), + ) + })?; + let bootstrap = format!( + r#" + globalThis.__aioCommands = Object.create(null); + globalThis.__aioGatewayHooks = Object.create(null); + globalThis.module = {{ exports: {{}} }}; + globalThis.exports = globalThis.module.exports; + globalThis.__filename = {escaped_path}; + globalThis.__dirname = ""; + (function(module, exports) {{ + const require = function(name) {{ + throw new Error("PLUGIN_EXTENSION_HOST_REQUIRE_UNSUPPORTED: require is not available: " + name); + }}; + const fn = new Function("module", "exports", "require", "__filename", "__dirname", {escaped_source}); + fn(module, exports, require, __filename, __dirname); + }})(globalThis.module, globalThis.exports); + "# + ); + self.with_startup_js_deadline(|| { + self.context.with(|ctx| { + ctx.eval::<(), _>(bootstrap.as_str()) + .catch(&ctx) + .map_err(|err| self.js_caught_error(err)) + }) + }) + } + + fn handshake(&self, params: Value) -> Result { + let plugin_id = params.get("pluginId").and_then(Value::as_str); + let version = params.get("version").and_then(Value::as_str); + let api_version = params.get("apiVersion").and_then(Value::as_str); + let contribution_hash = params.get("contributionHash").and_then(Value::as_str); + if plugin_id != Some(self.manifest.id.as_str()) + || version != Some(self.manifest.version.as_str()) + || api_version != Some(self.manifest.api_version.as_str()) + { + return Err(WorkerError::new( + "PLUGIN_EXTENSION_HOST_HANDSHAKE_FAILED", + "extension host handshake metadata did not match manifest", + )); + } + if self.expected_contribution_hash.as_deref() != contribution_hash { + return Err(WorkerError::new( + "PLUGIN_EXTENSION_HOST_HANDSHAKE_FAILED", + "extension host contribution hash did not match worker config", + )); + } + if Some(self.manifest_contribution_hash.as_str()) != contribution_hash { + return Err(WorkerError::new( + "PLUGIN_EXTENSION_HOST_HANDSHAKE_FAILED", + "extension host contribution hash did not match manifest on disk", + )); + } + Ok(json!({ + "pluginId": self.manifest.id, + "version": self.manifest.version, + "apiVersion": self.manifest.api_version, + "workerVersion": WORKER_VERSION, + })) + } + + fn activate(&mut self) -> Result<(), WorkerError> { + if self.activated { + return Ok(()); + } + let declared_commands = self.declared_commands.clone(); + let declared_gateway_hooks = self.declared_gateway_hooks.clone(); + let plugin_id = self.manifest.id.clone(); + let capabilities: BTreeSet = self.manifest.capabilities.iter().cloned().collect(); + let host_calls = Arc::clone(&self.host_calls); + self.with_startup_js_deadline(|| { + self.context.with(|ctx| { + let globals = ctx.globals(); + let api = Object::new(ctx.clone()).map_err(js_runtime_error)?; + if capabilities.contains("commands.execute") { + let commands = Object::new(ctx.clone()).map_err(js_runtime_error)?; + let declared_for_register = declared_commands.clone(); + let register = Function::new( + ctx.clone(), + move |command: String, handler: Function<'_>| -> rquickjs::Result<()> { + if !declared_for_register.contains(&command) { + return Err(rquickjs::Error::new_from_js_message( + "command", + "declared command", + format!( + "PLUGIN_EXTENSION_HOST_UNDECLARED_COMMAND: command {command} is not declared by manifest" + ), + )); + } + let globals = handler.ctx().globals(); + let registry: Object = globals.get("__aioCommands")?; + registry.set(command.as_str(), handler) + }, + ) + .map_err(js_runtime_error)?; + commands + .set("registerCommand", register) + .map_err(js_runtime_error)?; + api.set("commands", commands).map_err(js_runtime_error)?; + } + if capabilities.contains("gateway.hooks") { + let gateway = Object::new(ctx.clone()).map_err(js_runtime_error)?; + let declared_for_register = declared_gateway_hooks.clone(); + let register = Function::new( + ctx.clone(), + move |hook: String, handler: Function<'_>| -> rquickjs::Result<()> { + if !declared_for_register.contains(&hook) { + return Err(rquickjs::Error::new_from_js_message( + "gateway hook", + "declared gateway hook", + format!( + "PLUGIN_EXTENSION_HOST_UNDECLARED_GATEWAY_HOOK: gateway hook {hook} is not declared by manifest" + ), + )); + } + let globals = handler.ctx().globals(); + let registry: Object = globals.get("__aioGatewayHooks")?; + registry.set(hook.as_str(), handler) + }, + ) + .map_err(js_runtime_error)?; + gateway.set("registerHook", register).map_err(js_runtime_error)?; + api.set("gateway", gateway).map_err(js_runtime_error)?; + } + + let host_calls_for_api = Arc::clone(&host_calls); + let host_call_fn = Function::new( + ctx.clone(), + move |method: String, params_json: String| -> rquickjs::Result { + let params: Value = serde_json::from_str(¶ms_json).map_err(|err| { + rquickjs::Error::new_from_js_message( + "JSON string", + "host API params", + format!( + "PLUGIN_EXTENSION_HOST_DECODE_FAILED: failed to decode host API params: {err}" + ), + ) + })?; + let value = match host_call(&method, params, &host_calls_for_api) { + Ok(value) => json!({ "ok": true, "value": value }), + Err(err) => json!({ + "ok": false, + "code": err.code, + "message": err.message, + }), + }; + serde_json::to_string(&value).map_err(|err| { + worker_error_to_js(WorkerError::new( + "PLUGIN_EXTENSION_HOST_ENCODE_FAILED", + format!("failed to encode host API result: {err}"), + )) + }) + }, + ) + .map_err(js_runtime_error)?; + globals + .set("__aioHostCall", host_call_fn) + .map_err(js_runtime_error)?; + ctx.eval::<(), _>( + r#" + globalThis.__aioHostApi = function(method, params) { + const response = JSON.parse(globalThis.__aioHostCall( + method, + JSON.stringify(params) + )); + if (!response || response.ok !== true) { + const code = response && response.code + ? response.code + : "PLUGIN_EXTENSION_HOST_API_ERROR"; + const message = response && response.message + ? response.message + : "host API returned an error"; + throw new Error(code + ": " + message); + } + return response.value; + }; + "#, + ) + .map_err(js_runtime_error)?; + + let plugin_id_json = serde_json::to_string(&plugin_id).map_err(|err| { + WorkerError::new( + "PLUGIN_EXTENSION_HOST_ENCODE_FAILED", + format!("failed to encode plugin id for host API: {err}"), + ) + })?; + if capabilities.contains("storage.plugin") { + let storage_source = format!( + r#" + ({{ + get(key) {{ + return globalThis.__aioHostApi( + "storage.get", + {{ pluginId: {plugin_id_json}, key }} + ); + }}, + set(key, value) {{ + globalThis.__aioHostApi( + "storage.set", + {{ pluginId: {plugin_id_json}, key, value }} + ); + }} + }}) + "# + ); + let storage: Object = + ctx.eval(storage_source.as_str()).map_err(js_runtime_error)?; + api.set("storage", storage).map_err(js_runtime_error)?; + } + + if capabilities.contains("diagnostics.read") { + let diagnostics_source = format!( + r#" + ({{ + getRuntimeReports(limit) {{ + return globalThis.__aioHostApi( + "diagnostics.getRuntimeReports", + {{ pluginId: {plugin_id_json}, limit }} + ); + }} + }}) + "# + ); + let diagnostics: Object = ctx + .eval(diagnostics_source.as_str()) + .map_err(js_runtime_error)?; + api.set("diagnostics", diagnostics) + .map_err(js_runtime_error)?; + } + + if capabilities.contains("privacy.redact") { + let privacy_source = format!( + r#" + ({{ + redactText(text, options) {{ + return globalThis.__aioHostApi( + "privacy.redactText", + {{ pluginId: {plugin_id_json}, text, options: options || {{}} }} + ); + }}, + redactRequestBody(body, options) {{ + return globalThis.__aioHostApi( + "privacy.redactRequestBody", + {{ pluginId: {plugin_id_json}, body, options: options || {{}} }} + ); + }} + }}) + "# + ); + let privacy: Object = + ctx.eval(privacy_source.as_str()).map_err(js_runtime_error)?; + api.set("privacy", privacy).map_err(js_runtime_error)?; + } + + let module: Object = globals.get("module").map_err(js_runtime_error)?; + let exports: Object = module.get("exports").map_err(js_runtime_error)?; + if !exports.contains_key("activate").map_err(js_runtime_error)? { + return Ok(()); + } + let activate: Function = exports.get("activate").map_err(js_runtime_error)?; + activate + .call::<_, ()>((api,)) + .catch(&ctx) + .map_err(|err| self.js_caught_error(err)) + }) + })?; + self.activated = true; + Ok(()) + } + + fn deactivate(&mut self) -> Result<(), WorkerError> { + if !self.activated { + return Ok(()); + } + self.with_startup_js_deadline(|| { + self.context.with(|ctx| { + let globals = ctx.globals(); + let module: Object = globals.get("module").map_err(js_runtime_error)?; + let exports: Object = module.get("exports").map_err(js_runtime_error)?; + if exports + .contains_key("deactivate") + .map_err(js_runtime_error)? + { + let deactivate: Function = + exports.get("deactivate").map_err(js_runtime_error)?; + deactivate + .call::<_, ()>(()) + .catch(&ctx) + .map_err(|err| self.js_caught_error(err))?; + } + Ok(()) + }) + })?; + self.activated = false; + Ok(()) + } + + fn execute_command(&mut self, command: &str, args: Value) -> Result { + if !self.declared_commands.contains(command) { + return Err(WorkerError::new( + "PLUGIN_EXTENSION_HOST_UNDECLARED_COMMAND", + format!("command {command} is not declared by manifest"), + )); + } + if !self + .manifest + .capabilities + .iter() + .any(|capability| capability == "commands.execute") + { + return Err(WorkerError::new( + "PLUGIN_EXTENSION_HOST_FORBIDDEN", + "extension host API requires commands.execute", + )); + } + self.activate()?; + let args_json = serde_json::to_string(&args).map_err(|err| { + WorkerError::new( + "PLUGIN_EXTENSION_HOST_ENCODE_FAILED", + format!("failed to encode command args: {err}"), + ) + })?; + let command_name = command.to_string(); + self.with_js_deadline(|| { + self.context.with(|ctx| { + let globals = ctx.globals(); + let registry: Object = globals.get("__aioCommands").map_err(js_runtime_error)?; + if !registry + .contains_key(command_name.as_str()) + .map_err(js_runtime_error)? + { + return Err(WorkerError::new( + "PLUGIN_EXTENSION_HOST_COMMAND_NOT_REGISTERED", + format!("command {command_name} was not registered during activation"), + )); + } + let handler: Function = registry + .get(command_name.as_str()) + .map_err(js_runtime_error)?; + let parsed_args: JsValue = ctx + .eval(format!("JSON.parse({})", json_string_literal(&args_json)).as_str()) + .map_err(js_runtime_error)?; + let result: JsValue = handler + .call((parsed_args,)) + .catch(&ctx) + .map_err(|err| self.js_caught_error(err))?; + let globals = ctx.globals(); + let json_obj: Object = globals.get("JSON").map_err(js_runtime_error)?; + let stringify: Function = json_obj.get("stringify").map_err(js_runtime_error)?; + let json_result: Option = + stringify.call((result,)).map_err(js_runtime_error)?; + let Some(json_result) = json_result else { + return Ok(Value::Null); + }; + serde_json::from_str(&json_result).map_err(|err| { + WorkerError::new( + "PLUGIN_EXTENSION_HOST_DECODE_FAILED", + format!("command result was not JSON serializable: {err}"), + ) + }) + }) + }) + } + + fn execute_gateway_hook(&mut self, hook: &str, context: Value) -> Result { + if !self.declared_gateway_hooks.contains(hook) { + return Err(WorkerError::new( + "PLUGIN_EXTENSION_HOST_UNDECLARED_GATEWAY_HOOK", + format!("gateway hook {hook} is not declared by manifest"), + )); + } + if !self + .manifest + .capabilities + .iter() + .any(|capability| capability == "gateway.hooks") + { + return Err(WorkerError::new( + "PLUGIN_EXTENSION_HOST_FORBIDDEN", + "extension host API requires gateway.hooks", + )); + } + self.activate()?; + let context_json = serde_json::to_string(&context).map_err(|err| { + WorkerError::new( + "PLUGIN_EXTENSION_HOST_ENCODE_FAILED", + format!("failed to encode gateway hook context: {err}"), + ) + })?; + let hook_name = hook.to_string(); + self.with_js_deadline(|| { + self.context.with(|ctx| { + let globals = ctx.globals(); + let registry: Object = + globals.get("__aioGatewayHooks").map_err(js_runtime_error)?; + if !registry + .contains_key(hook_name.as_str()) + .map_err(js_runtime_error)? + { + return Err(WorkerError::new( + "PLUGIN_EXTENSION_HOST_GATEWAY_HOOK_NOT_REGISTERED", + format!("gateway hook {hook_name} was not registered during activation"), + )); + } + let handler: Function = + registry.get(hook_name.as_str()).map_err(js_runtime_error)?; + let parsed_context: JsValue = ctx + .eval(format!("JSON.parse({})", json_string_literal(&context_json)).as_str()) + .map_err(js_runtime_error)?; + let result: JsValue = handler + .call((parsed_context,)) + .catch(&ctx) + .map_err(|err| self.js_caught_error(err))?; + let globals = ctx.globals(); + let json_obj: Object = globals.get("JSON").map_err(js_runtime_error)?; + let stringify: Function = json_obj.get("stringify").map_err(js_runtime_error)?; + let json_result: Option = + stringify.call((result,)).map_err(js_runtime_error)?; + let Some(json_result) = json_result else { + return Err(WorkerError::new( + "PLUGIN_EXTENSION_HOST_INVALID_OUTPUT", + "gateway hook result must be JSON serializable", + )); + }; + serde_json::from_str(&json_result).map_err(|err| { + WorkerError::new( + "PLUGIN_EXTENSION_HOST_DECODE_FAILED", + format!("gateway hook result was not JSON serializable: {err}"), + ) + }) + }) + }) + } + + fn with_js_deadline( + &self, + f: impl FnOnce() -> Result, + ) -> Result { + self.with_js_deadline_for(self.js_timeout, f) + } + + fn with_startup_js_deadline( + &self, + f: impl FnOnce() -> Result, + ) -> Result { + self.with_js_deadline_for(self.startup_js_timeout, f) + } + + fn with_js_deadline_for( + &self, + timeout: Duration, + f: impl FnOnce() -> Result, + ) -> Result { + { + let mut deadline = self + .deadline + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *deadline = Some(Instant::now() + timeout); + } + let result = f(); + let mut deadline = self + .deadline + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *deadline = None; + result + } + + fn js_caught_error(&self, err: CaughtError<'_>) -> WorkerError { + self.js_error_message(err.to_string()) + } + + fn js_error_message(&self, message: String) -> WorkerError { + let deadline_expired = self + .deadline + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_some_and(|deadline| Instant::now() >= deadline); + if deadline_expired { + return WorkerError::new( + "PLUGIN_EXTENSION_HOST_TIMEOUT", + "extension host JavaScript execution timed out", + ); + } + if message.contains("interrupted") + || message.contains("interrupted by") + || message.contains("InternalError: interrupted") + { + return WorkerError::new( + "PLUGIN_EXTENSION_HOST_TIMEOUT", + "extension host JavaScript execution timed out", + ); + } + if let Some((code, rest)) = split_error_code(&message) { + return WorkerError::new(code, rest); + } + WorkerError::new( + "PLUGIN_EXTENSION_HOST_JS_ERROR", + format!("extension host JavaScript error: {message}"), + ) + } +} + +fn read_config_from_args( + args: impl IntoIterator, +) -> Result { + let mut args = args.into_iter(); + let mut config_path = None; + while let Some(arg) = args.next() { + if arg == "--extension-host-config" { + config_path = args.next(); + break; + } + } + let config_path = config_path.ok_or_else(|| { + WorkerError::new( + "PLUGIN_EXTENSION_HOST_INVALID_CONFIG", + "--extension-host-config is required", + ) + })?; + read_json_file(Path::new(&config_path)) +} + +fn read_json_file Deserialize<'de>>(path: &Path) -> Result { + let bytes = fs::read(path).map_err(|err| { + WorkerError::new( + "PLUGIN_EXTENSION_HOST_CONFIG_READ_FAILED", + format!("failed to read {}: {err}", path.display()), + ) + })?; + serde_json::from_slice(&bytes).map_err(|err| { + WorkerError::new( + "PLUGIN_EXTENSION_HOST_CONFIG_DECODE_FAILED", + format!("failed to decode {}: {err}", path.display()), + ) + }) +} + +fn declared_commands(contributes: Option<&PluginContributes>) -> BTreeSet { + contributes + .map(|contributes| { + contributes + .commands + .iter() + .map(|command| command.command.clone()) + .collect() + }) + .unwrap_or_default() +} + +fn declared_gateway_hooks(contributes: Option<&PluginContributes>) -> BTreeSet { + contributes + .map(|contributes| { + contributes + .gateway_hooks + .iter() + .map(|hook| hook.name.clone()) + .collect() + }) + .unwrap_or_default() +} + +fn contribution_hash(manifest: &PluginManifest) -> String { + use sha2::Digest; + + let bytes = serde_json::to_vec(&json!({ + "runtime": manifest.runtime, + "main": manifest.main, + "activationEvents": manifest.activation_events, + "contributes": manifest.contributes, + "capabilities": manifest.capabilities, + "permissions": manifest.permissions, + })) + .unwrap_or_default(); + format!("{:x}", sha2::Sha256::digest(bytes)) +} + +fn resolve_child_path(root: &Path, child: &str) -> Result { + let child_path = Path::new(child); + if child_path.is_absolute() + || child_path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(WorkerError::new( + "PLUGIN_EXTENSION_HOST_INVALID_MANIFEST", + "extension host main must be a relative path inside the plugin root", + )); + } + Ok(root.join(child_path)) +} + +fn emit_notification( + method: &str, + params: Value, + max_line_bytes: usize, +) -> Result<(), WorkerError> { + emit_line( + json!({ + "jsonrpc": "2.0", + "method": method, + "params": params, + }), + max_line_bytes, + ) +} + +fn emit_result(id: Value, result: Value, max_line_bytes: usize) -> Result<(), WorkerError> { + emit_line( + json!({ + "jsonrpc": "2.0", + "id": id, + "result": result, + }), + max_line_bytes, + ) +} + +fn emit_error(id: Value, err: WorkerError, max_line_bytes: usize) -> Result<(), WorkerError> { + emit_protocol_error(id, err.code, err.message, max_line_bytes) +} + +fn emit_protocol_error( + id: Value, + code: impl Into, + message: impl Into, + max_line_bytes: usize, +) -> Result<(), WorkerError> { + let message = message.into(); + emit_line( + json!({ + "jsonrpc": "2.0", + "id": id, + "error": JsonRpcErrorBody { + code: -32000, + message: message.clone(), + data: json!({ "code": code.into() }), + }, + }), + max_line_bytes, + ) +} + +fn emit_line(value: Value, max_line_bytes: usize) -> Result<(), WorkerError> { + let mut bytes = serde_json::to_vec(&value).map_err(|err| { + WorkerError::new( + "PLUGIN_EXTENSION_HOST_ENCODE_FAILED", + format!("failed to encode worker response: {err}"), + ) + })?; + if bytes.len() + 1 > max_line_bytes { + bytes = serde_json::to_vec(&json!({ + "jsonrpc": "2.0", + "id": value.get("id").cloned().unwrap_or(Value::Null), + "error": { + "code": -32000, + "message": "extension host response exceeded max line bytes", + "data": { "code": "PLUGIN_EXTENSION_HOST_RESPONSE_TOO_LARGE" } + } + })) + .map_err(|err| { + WorkerError::new( + "PLUGIN_EXTENSION_HOST_ENCODE_FAILED", + format!("failed to encode worker error response: {err}"), + ) + })?; + } + if bytes.len() + 1 > max_line_bytes { + return Err(WorkerError::new( + "PLUGIN_EXTENSION_HOST_RESPONSE_TOO_LARGE", + format!("extension host response exceeded {max_line_bytes} bytes"), + )); + } + let stdout = io::stdout(); + let mut lock = stdout.lock(); + lock.write_all(&bytes).map_err(write_error)?; + lock.write_all(b"\n").map_err(write_error)?; + lock.flush().map_err(write_error) +} + +fn host_call( + method: &str, + params: Value, + host_calls: &Arc>, +) -> Result { + let (id, max_line_bytes) = { + let mut state = host_calls + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let id = state.next_host_call_id; + state.next_host_call_id = state.next_host_call_id.saturating_add(1); + (id, state.max_line_bytes) + }; + emit_line( + json!({ + "jsonrpc": "2.0", + "id": id, + "method": "host.call", + "params": { + "method": method, + "params": params, + }, + }), + max_line_bytes, + )?; + + let line = { + let stdin = io::stdin(); + let mut stdin = stdin.lock(); + read_bounded_stdin_line(&mut stdin, max_line_bytes)? + }; + let line = match line { + WorkerStdinLine::Line(line) => line, + WorkerStdinLine::TooLarge => { + return Err(WorkerError::new( + "PLUGIN_EXTENSION_HOST_RESPONSE_TOO_LARGE", + format!("host API response exceeded {max_line_bytes} bytes"), + )); + } + WorkerStdinLine::Eof => { + return Err(WorkerError::new( + "PLUGIN_EXTENSION_HOST_HOST_CLOSED", + "host closed stdin before returning host API response", + )); + } + }; + let response: Value = serde_json::from_slice(&line).map_err(|err| { + WorkerError::new( + "PLUGIN_EXTENSION_HOST_PROTOCOL_ERROR", + format!("host API response was not valid JSON-RPC: {err}"), + ) + })?; + if response.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { + return Err(WorkerError::new( + "PLUGIN_EXTENSION_HOST_PROTOCOL_ERROR", + "host API response must use JSON-RPC 2.0", + )); + } + if response.get("id").and_then(Value::as_u64) != Some(id) { + return Err(WorkerError::new( + "PLUGIN_EXTENSION_HOST_PROTOCOL_ERROR", + "host API response id did not match request id", + )); + } + if let Some(error) = response.get("error") { + let code = error + .get("data") + .and_then(|data| data.get("code")) + .and_then(Value::as_str) + .unwrap_or("PLUGIN_EXTENSION_HOST_API_ERROR"); + let message = error + .get("message") + .and_then(Value::as_str) + .unwrap_or("host API returned an error"); + return Err(WorkerError::new(code, message)); + } + response.get("result").cloned().ok_or_else(|| { + WorkerError::new( + "PLUGIN_EXTENSION_HOST_PROTOCOL_ERROR", + "host API response was missing result", + ) + }) +} + +fn worker_error_to_js(err: WorkerError) -> rquickjs::Error { + rquickjs::Error::new_from_js_message( + "host API", + "successful host response", + format!("{}: {}", err.code, err.message), + ) +} + +fn json_string_literal(value: &str) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "\"null\"".to_string()) +} + +fn js_init_error(err: rquickjs::Error) -> WorkerError { + WorkerError::new( + "PLUGIN_EXTENSION_HOST_JS_INIT_FAILED", + format!("failed to initialize extension host JavaScript runtime: {err}"), + ) +} + +fn js_runtime_error(err: rquickjs::Error) -> WorkerError { + let message = err.to_string(); + if message.contains("interrupted") + || message.contains("interrupted by") + || message.contains("InternalError: interrupted") + { + return WorkerError::new( + "PLUGIN_EXTENSION_HOST_TIMEOUT", + "extension host JavaScript execution timed out", + ); + } + if let Some((code, rest)) = split_error_code(&message) { + return WorkerError::new(code, rest); + } + WorkerError::new( + "PLUGIN_EXTENSION_HOST_JS_ERROR", + format!("extension host JavaScript error: {message}"), + ) +} + +fn split_error_code(raw: &str) -> Option<(&str, String)> { + let message = raw.trim(); + if let Some(start) = message.find("PLUGIN_") { + let code_and_rest = &message[start..]; + let (code, rest) = code_and_rest.trim().split_once(':')?; + let code = code.trim(); + if is_plugin_error_code(code) { + return Some((code, rest.trim().to_string())); + } + } + let (_prefix, code_and_rest) = message.split_once(':').unwrap_or(("", message)); + let (code, rest) = code_and_rest.trim().split_once(':')?; + let code = code.trim(); + if is_plugin_error_code(code) { + return Some((code, rest.trim().to_string())); + } + None +} + +fn is_plugin_error_code(code: &str) -> bool { + code.starts_with("PLUGIN_") + && code + .chars() + .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_') +} + +fn write_error(err: io::Error) -> WorkerError { + WorkerError::new( + "PLUGIN_EXTENSION_HOST_WRITE_FAILED", + format!("failed to write worker response: {err}"), + ) +} + +fn default_max_line_bytes() -> usize { + default_extension_host_max_line_bytes() +} + +fn default_js_timeout_ms() -> u64 { + DEFAULT_JS_TIMEOUT_MS +} + +#[derive(Debug)] +struct WorkerError { + code: String, + message: String, +} + +impl WorkerError { + fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn worker_stdin_reader_rejects_oversized_line_without_buffering_remainder() { + let mut input = Cursor::new([vec![b'x'; 128], b"\n{}".to_vec(), b"\n".to_vec()].concat()); + + let first = read_bounded_stdin_line(&mut input, 64).expect("read oversized"); + assert!(matches!(first, WorkerStdinLine::TooLarge)); + + let second = read_bounded_stdin_line(&mut input, 64).expect("read next line"); + match second { + WorkerStdinLine::Line(line) => assert_eq!(line, b"{}"), + WorkerStdinLine::TooLarge | WorkerStdinLine::Eof => { + panic!("expected next valid line after oversized discard") + } + } + } +} diff --git a/src-tauri/src/app/plugins/extension_protocol_bridge.rs b/src-tauri/src/app/plugins/extension_protocol_bridge.rs new file mode 100644 index 00000000..96965746 --- /dev/null +++ b/src-tauri/src/app/plugins/extension_protocol_bridge.rs @@ -0,0 +1,84 @@ +use serde_json::Value; + +use crate::shared::error::{AppError, AppResult}; + +#[derive(Debug, Default, Clone)] +pub(crate) struct ExtensionProtocolBridgeRegistry; + +impl ExtensionProtocolBridgeRegistry { + pub(crate) fn contribution_id(plugin_id: &str, bridge_type: &str) -> String { + if is_namespaced_by_plugin(plugin_id, bridge_type) { + bridge_type.to_string() + } else { + format!("{plugin_id}:{bridge_type}") + } + } + + #[allow(dead_code)] + pub(crate) async fn dispatch( + &self, + plugin_id: &str, + bridge_type: &str, + payload: Value, + ) -> AppResult { + self.execute(plugin_id, bridge_type, payload).await + } + + pub(crate) async fn execute( + &self, + plugin_id: &str, + bridge_type: &str, + _payload: Value, + ) -> AppResult { + Err(AppError::new( + "PLUGIN_EXTENSION_PROTOCOL_BRIDGE_NOT_IMPLEMENTED", + format!( + "extension protocol bridge {} is not implemented", + Self::contribution_id(plugin_id, bridge_type) + ), + )) + } +} + +fn is_namespaced_by_plugin(plugin_id: &str, value: &str) -> bool { + if value == plugin_id { + return true; + } + value + .strip_prefix(plugin_id) + .is_some_and(|suffix| matches!(suffix.as_bytes().first(), Some(b'.' | b'/' | b':'))) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::ExtensionProtocolBridgeRegistry; + + #[test] + fn contribution_id_namespaces_bridge_type() { + assert_eq!( + ExtensionProtocolBridgeRegistry::contribution_id("acme.bridge", "openai-gemini"), + "acme.bridge:openai-gemini" + ); + } + + #[tokio::test] + async fn dispatch_returns_not_implemented() { + let registry = ExtensionProtocolBridgeRegistry; + + let err = registry + .dispatch( + "acme.bridge", + "acme.bridge.openai-gemini", + json!({ "body": "hello" }), + ) + .await + .unwrap_err(); + + assert_eq!( + err.code(), + "PLUGIN_EXTENSION_PROTOCOL_BRIDGE_NOT_IMPLEMENTED" + ); + } +} diff --git a/src-tauri/src/app/plugins/mod.rs b/src-tauri/src/app/plugins/mod.rs index 31e6097c..c45c3631 100644 --- a/src-tauri/src/app/plugins/mod.rs +++ b/src-tauri/src/app/plugins/mod.rs @@ -1,9 +1,17 @@ //! Usage: Application-level plugin runtimes and official plugin catalog. +pub(crate) mod access_policy; +pub(crate) mod contribution_registry; +pub(crate) mod extension_host; +pub(crate) mod extension_host_process; +pub(crate) mod extension_host_registry; +pub(crate) mod extension_host_worker; +pub(crate) mod extension_protocol_bridge; pub(crate) mod official; pub(crate) mod official_assets; pub(crate) mod privacy_filter; -pub(crate) mod process_runtime; -pub(crate) mod rule_runtime; +pub(crate) mod privacy_redaction_service; +pub(crate) mod runtime_cache; pub(crate) mod runtime_executor; -pub(crate) mod wasm_runtime; +pub(crate) mod runtime_lifecycle; +pub(crate) mod runtime_manager; diff --git a/src-tauri/src/app/plugins/official.rs b/src-tauri/src/app/plugins/official.rs index 08e9996a..6d948bbc 100644 --- a/src-tauri/src/app/plugins/official.rs +++ b/src-tauri/src/app/plugins/official.rs @@ -1,6 +1,6 @@ //! Usage: Built-in official plugin catalog. -use crate::plugins::{validate_manifest, PluginManifest}; +use crate::plugins::{validate_manifest_for_official_plugin, PluginManifest}; use crate::shared::error::{AppError, AppResult}; use serde_json::Value; use std::path::{Path, PathBuf}; @@ -41,7 +41,7 @@ pub(crate) fn official_plugin_from_root( ), )); } - validate_manifest(&manifest, env!("CARGO_PKG_VERSION"))?; + validate_manifest_for_official_plugin(&manifest, env!("CARGO_PKG_VERSION"))?; let default_config = official_default_config(plugin_id); Ok(OfficialPluginFixture { @@ -114,9 +114,10 @@ fn official_default_config(plugin_id: &str) -> Value { mod tests { use super::*; use crate::app::plugins::runtime_executor::RuntimeGatewayPluginExecutor; - use crate::domain::plugins::{PluginInstallSource, PluginRuntime, PluginStatus}; + use crate::domain::plugins::{PluginInstallSource, PluginStatus}; use crate::gateway::plugins::context::{GatewayPluginHookName, GatewayRequestHookInput}; use crate::gateway::plugins::pipeline::{GatewayPluginPipeline, GatewayPluginPipelineConfig}; + use crate::infra::plugins::repository::{self, InsertPluginInput}; use axum::body::Bytes; use axum::http::{HeaderMap, Method}; use serde_json::json; @@ -124,12 +125,6 @@ mod tests { fn enabled_official_plugin(plugin_id: &str) -> crate::domain::plugins::PluginDetail { let fixture = official_plugin(plugin_id).expect("official plugin fixture"); - let permissions = fixture.manifest.permissions.clone(); - let runtime = match &fixture.manifest.runtime { - PluginRuntime::DeclarativeRules { .. } => "declarativeRules".to_string(), - PluginRuntime::Native { engine } => format!("native:{engine}"), - PluginRuntime::Wasm { .. } => "wasm".to_string(), - }; crate::domain::plugins::PluginDetail { summary: crate::domain::plugins::PluginSummary { id: 1, @@ -137,7 +132,7 @@ mod tests { name: fixture.manifest.name.clone(), current_version: Some(fixture.manifest.version.clone()), status: PluginStatus::Enabled, - runtime, + runtime: "extensionHost".to_string(), permission_risk: crate::domain::plugins::PluginPermissionRisk::High, update_available: false, last_error: None, @@ -148,13 +143,54 @@ mod tests { install_source: PluginInstallSource::Official, installed_dir: Some(fixture.root_dir.to_string_lossy().to_string()), config: fixture.default_config, - granted_permissions: permissions, + granted_permissions: vec![], pending_permissions: vec![], audit_logs: vec![], runtime_failures: vec![], + rollback_versions: vec![], } } + fn official_privacy_filter_pipeline() -> (tempfile::TempDir, GatewayPluginPipeline) { + let temp = tempfile::tempdir().expect("tempdir"); + let db = crate::db::init_for_tests(&temp.path().join("official-privacy-filter.db")) + .expect("init db"); + let plugin = enabled_official_plugin("official.privacy-filter"); + repository::insert_plugin( + &db, + InsertPluginInput { + manifest: plugin.manifest.clone(), + install_source: PluginInstallSource::Official, + status: PluginStatus::Enabled, + installed_dir: plugin.installed_dir.clone(), + }, + ) + .expect("insert official plugin"); + repository::save_plugin_config( + &db, + &plugin.summary.plugin_id, + plugin.manifest.config_version.unwrap_or(1), + &plugin.config, + &[], + ) + .expect("save official plugin config"); + repository::save_plugin_permissions( + &db, + &plugin.summary.plugin_id, + &plugin.granted_permissions, + &[], + ) + .expect("save official plugin permissions"); + let plugin = repository::get_plugin(&db, &plugin.summary.plugin_id) + .expect("reload official plugin detail from db"); + let pipeline = GatewayPluginPipeline::for_tests( + vec![plugin], + Arc::new(RuntimeGatewayPluginExecutor::with_db(db)), + GatewayPluginPipelineConfig::default(), + ); + (temp, pipeline) + } + #[test] fn official_catalog_exposes_only_privacy_filter() { assert_eq!(official_plugin_ids(), &["official.privacy-filter"]); @@ -215,12 +251,7 @@ mod tests { #[tokio::test] async fn official_privacy_filter_plugin_redacts_pii_and_secrets_before_upstream_and_logs() { - let plugin = enabled_official_plugin("official.privacy-filter"); - let pipeline = GatewayPluginPipeline::for_tests( - vec![plugin], - Arc::new(RuntimeGatewayPluginExecutor::default()), - GatewayPluginPipelineConfig::default(), - ); + let (_temp, pipeline) = official_privacy_filter_pipeline(); let request = pipeline .run_request_hook(GatewayRequestHookInput { @@ -285,12 +316,7 @@ mod tests { #[tokio::test] async fn official_privacy_filter_plugin_redacts_responses_input_text_parts() { - let plugin = enabled_official_plugin("official.privacy-filter"); - let pipeline = GatewayPluginPipeline::for_tests( - vec![plugin], - Arc::new(RuntimeGatewayPluginExecutor::default()), - GatewayPluginPipelineConfig::default(), - ); + let (_temp, pipeline) = official_privacy_filter_pipeline(); let request = pipeline .run_request_hook(GatewayRequestHookInput { @@ -324,14 +350,53 @@ mod tests { assert!(!request_text.contains("13344441520")); } + #[tokio::test] + async fn official_privacy_filter_plugin_redacts_large_responses_input_text_without_truncation() + { + let (_temp, pipeline) = official_privacy_filter_pipeline(); + + let request = pipeline + .run_request_hook(GatewayRequestHookInput { + hook_name: GatewayPluginHookName::RequestAfterBodyRead, + trace_id: "trace-privacy-filter-large-responses".to_string(), + cli_key: "codex".to_string(), + method: Method::POST, + path: "/v1/responses".to_string(), + query: None, + headers: HeaderMap::new(), + body: Bytes::from( + json!({ + "input": [{ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": format!( + "{} 你知道 13344441520 是哪里的手机号嘛", + "x".repeat(300 * 1024) + ) + }] + }] + }) + .to_string(), + ), + requested_model: Some("gpt-test".to_string()), + }) + .await + .expect("privacy filter request hook should accept large request body"); + let request_text = String::from_utf8(request.body.to_vec()).expect("utf8 body"); + + assert!(request.blocked.is_none()); + assert_eq!(request.execution_reports.len(), 1); + assert_eq!(request.execution_reports[0].status, "completed"); + assert_eq!(request.execution_reports[0].error_code, None); + assert!(request_text.contains("[电话]")); + assert!(!request_text.contains("13344441520")); + } + #[tokio::test] async fn official_privacy_filter_plugin_matches_upstream_algorithmic_behavior() { - let plugin = enabled_official_plugin("official.privacy-filter"); - let pipeline = GatewayPluginPipeline::for_tests( - vec![plugin], - Arc::new(RuntimeGatewayPluginExecutor::default()), - GatewayPluginPipelineConfig::default(), - ); + let (_temp, pipeline) = official_privacy_filter_pipeline(); let request = pipeline .run_request_hook(GatewayRequestHookInput { diff --git a/src-tauri/src/app/plugins/privacy_redaction_service.rs b/src-tauri/src/app/plugins/privacy_redaction_service.rs new file mode 100644 index 00000000..fd4899ee --- /dev/null +++ b/src-tauri/src/app/plugins/privacy_redaction_service.rs @@ -0,0 +1,1220 @@ +//! Usage: Host privacy redaction service for Extension Host plugins. + +use super::privacy_filter::{PrivacyFilter, PrivacyFilterError, PrivacyFilterOptions}; +use super::runtime_cache::{runtime_cache_key, RuntimeCacheKeyInput}; +use super::runtime_lifecycle::PluginRuntimeCache; +use crate::plugins::PluginDetail; +use serde::Serialize; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::sync::{Arc, Mutex}; + +pub(crate) const MAX_PRIVACY_FILTER_RULE_FILE_BYTES: usize = 1024 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PrivacyRedactionOutput { + pub(crate) hit: bool, + pub(crate) count: usize, + pub(crate) redacted: String, +} + +#[derive(Default)] +pub(crate) struct PrivacyRedactionService { + cache: Mutex>>, +} + +impl PrivacyRedactionService { + pub(crate) fn redact_text( + &self, + plugin: &PluginDetail, + text: &str, + options: &Value, + ) -> Result { + let filter = self.get_or_load_privacy_filter(plugin)?; + let options = privacy_filter_options_from_config(options); + let redacted = filter.redact_with_options(text, &options); + Ok(PrivacyRedactionOutput { + hit: redacted.hit, + count: redacted.count, + redacted: redacted.redacted, + }) + } + + pub(crate) fn redact_request_body( + &self, + plugin: &PluginDetail, + body: &str, + options: &Value, + ) -> Result { + let filter = self.get_or_load_privacy_filter(plugin)?; + let filter_options = privacy_filter_options_from_config(options); + let scopes = privacy_filter_redaction_scopes_from_config(options); + let redacted = redact_request_body_strings(&filter, body, &filter_options, &scopes)?; + Ok(redacted.unwrap_or_else(|| PrivacyRedactionOutput { + hit: false, + count: 0, + redacted: body.to_string(), + })) + } + + #[allow(dead_code)] + pub(crate) fn clear_runtime_caches(&self) { + self.cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clear(); + } + + fn get_or_load_privacy_filter( + &self, + plugin: &PluginDetail, + ) -> Result, PrivacyFilterError> { + let cache_key = privacy_filter_cache_key(plugin); + let mut cache = self + .cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(filter) = cache.get(&cache_key) { + return Ok(Arc::clone(filter)); + } + + let filter = Arc::new(load_privacy_filter(plugin)?); + Ok(Arc::clone( + cache + .entry(cache_key) + .or_insert_with(|| Arc::clone(&filter)), + )) + } + + #[cfg(test)] + pub(crate) fn cache_size_for_tests(&self) -> usize { + self.cache.lock().unwrap().len() + } +} + +impl PluginRuntimeCache for PrivacyRedactionService { + fn retain_for_plugins(&self, plugins: &[PluginDetail]) { + let privacy_plugins = plugins + .iter() + .filter(|plugin| has_privacy_redact_capability(plugin)) + .collect::>(); + let privacy_keys = privacy_plugins + .iter() + .map(|plugin| privacy_filter_cache_key(plugin)) + .collect::>(); + + for plugin in privacy_plugins { + if let Err(err) = self.get_or_load_privacy_filter(plugin) { + tracing::warn!( + plugin_id = %plugin.summary.plugin_id, + error = %err, + "failed to prewarm privacy redaction service" + ); + } + } + + self.cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .retain(|key, _| privacy_keys.contains(key)); + } + + fn clear_all(&self) { + self.clear_runtime_caches(); + } +} + +fn has_privacy_redact_capability(plugin: &PluginDetail) -> bool { + plugin + .manifest + .capabilities + .iter() + .any(|capability| capability == "privacy.redact") +} + +fn privacy_filter_cache_key(plugin: &PluginDetail) -> String { + let version = plugin + .summary + .current_version + .as_deref() + .unwrap_or(plugin.manifest.version.as_str()); + let installed_dir = plugin.installed_dir.as_deref().unwrap_or(""); + let updated_at = plugin.summary.updated_at; + runtime_cache_key(RuntimeCacheKeyInput { + plugin_id: plugin.summary.plugin_id.as_str(), + version, + installed_dir, + updated_at, + runtime_key: "privacy.redact", + }) +} + +fn load_privacy_filter(plugin: &PluginDetail) -> Result { + let root_dir = plugin.installed_dir.as_deref().ok_or_else(|| { + PrivacyFilterError::new(format!( + "plugin {} has no installed_dir for privacy-filter rule loading", + plugin.summary.plugin_id + )) + })?; + let rules_path = std::path::Path::new(root_dir).join("rules/gitleaks.toml"); + let metadata = fs::metadata(&rules_path).map_err(|err| { + PrivacyFilterError::new(format!( + "failed to read privacy-filter gitleaks rules metadata for plugin {}: {err}", + plugin.summary.plugin_id + )) + })?; + if metadata.len() > MAX_PRIVACY_FILTER_RULE_FILE_BYTES as u64 { + return Err(PrivacyFilterError::new(format!( + "privacy filter rule file exceeds {MAX_PRIVACY_FILTER_RULE_FILE_BYTES} bytes" + ))); + } + let raw = fs::read_to_string(&rules_path).map_err(|err| { + PrivacyFilterError::new(format!( + "failed to read privacy-filter gitleaks rules for plugin {}: {err}", + plugin.summary.plugin_id + )) + })?; + PrivacyFilter::from_gitleaks_toml(&raw) +} + +fn privacy_filter_options_from_config(config: &Value) -> PrivacyFilterOptions { + let sensitive_types = config + .get("sensitiveTypes") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect::>() + }); + PrivacyFilterOptions::from_sensitive_types(sensitive_types.as_deref()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PrivacyFilterRedactionScopes { + system_instructions: bool, + user_prompts: bool, + assistant_context: bool, + tool_results: bool, + legacy_prompt: bool, +} + +impl PrivacyFilterRedactionScopes { + fn default_enabled() -> Self { + Self { + system_instructions: true, + user_prompts: true, + assistant_context: false, + tool_results: true, + legacy_prompt: true, + } + } + + fn empty() -> Self { + Self { + system_instructions: false, + user_prompts: false, + assistant_context: false, + tool_results: false, + legacy_prompt: false, + } + } + + fn enable(&mut self, scope: &str) { + match scope { + "system_instructions" => self.system_instructions = true, + "user_prompts" => self.user_prompts = true, + "assistant_context" => self.assistant_context = true, + "tool_results" => self.tool_results = true, + "legacy_prompt" => self.legacy_prompt = true, + _ => {} + } + } + + fn message_content_enabled(&self, role: Option<&str>) -> bool { + match role.unwrap_or("user") { + "system" | "developer" => self.system_instructions, + "user" => self.user_prompts, + "assistant" => self.assistant_context, + "tool" => self.tool_results, + _ => false, + } + } +} + +fn privacy_filter_redaction_scopes_from_config(config: &Value) -> PrivacyFilterRedactionScopes { + let Some(items) = config.get("redactionScopes").and_then(Value::as_array) else { + return PrivacyFilterRedactionScopes::default_enabled(); + }; + let mut scopes = PrivacyFilterRedactionScopes::empty(); + for item in items { + if let Some(scope) = item.as_str() { + scopes.enable(scope); + } + } + scopes +} + +fn redact_request_body_strings( + filter: &PrivacyFilter, + body: &str, + options: &PrivacyFilterOptions, + scopes: &PrivacyFilterRedactionScopes, +) -> Result, PrivacyFilterError> { + let Ok(mut root) = serde_json::from_str::(body) else { + if !scopes.legacy_prompt { + return Ok(None); + } + let redacted = filter.redact_with_options(body, options); + return Ok(redacted.hit.then_some(PrivacyRedactionOutput { + hit: true, + count: redacted.count, + redacted: redacted.redacted, + })); + }; + let mut matched = false; + redact_request_json_allowlist(&mut root, filter, options, scopes, &mut matched); + if !matched { + return Ok(None); + } + let redacted = serde_json::to_string(&root).map_err(|err| { + PrivacyFilterError::new(format!("failed to serialize redacted JSON: {err}")) + })?; + Ok(Some(PrivacyRedactionOutput { + hit: true, + count: 1, + redacted, + })) +} + +fn redact_request_json_allowlist( + value: &mut Value, + filter: &PrivacyFilter, + options: &PrivacyFilterOptions, + scopes: &PrivacyFilterRedactionScopes, + matched: &mut bool, +) { + let Some(map) = value.as_object_mut() else { + return; + }; + + if scopes.system_instructions { + if let Some(system) = map.get_mut("system") { + redact_text_or_text_blocks(system, filter, options, matched, &["text"]); + } + if let Some(instructions) = map.get_mut("instructions") { + redact_text_value(instructions, filter, options, matched); + } + } + + if let Some(input) = map.get_mut("input") { + redact_responses_input(input, filter, options, scopes, matched); + } + + if let Some(messages) = map.get_mut("messages") { + redact_messages(messages, filter, options, scopes, matched); + } + + if scopes.legacy_prompt { + if let Some(prompt) = map.get_mut("prompt") { + redact_text_value(prompt, filter, options, matched); + } + } +} + +fn redact_messages( + value: &mut Value, + filter: &PrivacyFilter, + options: &PrivacyFilterOptions, + scopes: &PrivacyFilterRedactionScopes, + matched: &mut bool, +) { + let Some(messages) = value.as_array_mut() else { + return; + }; + for message in messages { + redact_message(message, filter, options, scopes, matched); + } +} + +fn redact_message( + value: &mut Value, + filter: &PrivacyFilter, + options: &PrivacyFilterOptions, + scopes: &PrivacyFilterRedactionScopes, + matched: &mut bool, +) { + let Some(map) = value.as_object_mut() else { + return; + }; + let role = map.get("role").and_then(Value::as_str).map(str::to_string); + if let Some(content) = map.get_mut("content") { + redact_message_content(content, role.as_deref(), filter, options, scopes, matched); + } +} + +fn redact_message_content( + value: &mut Value, + role: Option<&str>, + filter: &PrivacyFilter, + options: &PrivacyFilterOptions, + scopes: &PrivacyFilterRedactionScopes, + matched: &mut bool, +) { + match value { + Value::String(_) => { + if scopes.message_content_enabled(role) { + redact_text_value(value, filter, options, matched); + } + } + Value::Array(parts) => { + for part in parts { + redact_message_content_part(part, role, filter, options, scopes, matched); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::Object(_) => {} + } +} + +fn redact_message_content_part( + value: &mut Value, + role: Option<&str>, + filter: &PrivacyFilter, + options: &PrivacyFilterOptions, + scopes: &PrivacyFilterRedactionScopes, + matched: &mut bool, +) { + let Some(map) = value.as_object_mut() else { + return; + }; + match map.get("type").and_then(Value::as_str) { + Some("tool_result") => { + if scopes.tool_results { + redact_tool_result_content(map.get_mut("content"), filter, options, matched); + } + } + Some("text") if scopes.message_content_enabled(role) => { + if let Some(text) = map.get_mut("text") { + redact_text_value(text, filter, options, matched); + } + } + _ => {} + } +} + +fn redact_responses_input( + value: &mut Value, + filter: &PrivacyFilter, + options: &PrivacyFilterOptions, + scopes: &PrivacyFilterRedactionScopes, + matched: &mut bool, +) { + match value { + Value::String(_) => { + if scopes.user_prompts { + redact_text_value(value, filter, options, matched); + } + } + Value::Array(items) => { + for item in items { + redact_responses_input_item(item, filter, options, scopes, matched); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::Object(_) => {} + } +} + +fn redact_responses_input_item( + value: &mut Value, + filter: &PrivacyFilter, + options: &PrivacyFilterOptions, + scopes: &PrivacyFilterRedactionScopes, + matched: &mut bool, +) { + let Some(map) = value.as_object_mut() else { + return; + }; + match map.get("type").and_then(Value::as_str) { + Some("function_call_output") => { + if scopes.tool_results { + redact_tool_result_content(map.get_mut("output"), filter, options, matched); + } + } + Some("message") | None => { + let role = map.get("role").and_then(Value::as_str).map(str::to_string); + if let Some(content) = map.get_mut("content") { + redact_responses_content( + content, + role.as_deref(), + filter, + options, + scopes, + matched, + ); + } + } + _ => {} + } +} + +fn redact_responses_content( + value: &mut Value, + role: Option<&str>, + filter: &PrivacyFilter, + options: &PrivacyFilterOptions, + scopes: &PrivacyFilterRedactionScopes, + matched: &mut bool, +) { + match value { + Value::String(_) => { + if scopes.message_content_enabled(role) { + redact_text_value(value, filter, options, matched); + } + } + Value::Array(parts) => { + if !scopes.message_content_enabled(role) { + return; + } + for part in parts { + redact_typed_text_part( + part, + filter, + options, + matched, + &["input_text", "output_text"], + ); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::Object(_) => {} + } +} + +fn redact_tool_result_content( + value: Option<&mut Value>, + filter: &PrivacyFilter, + options: &PrivacyFilterOptions, + matched: &mut bool, +) { + let Some(value) = value else { + return; + }; + match value { + Value::String(_) => redact_text_value(value, filter, options, matched), + Value::Array(parts) => { + for part in parts { + redact_typed_text_part( + part, + filter, + options, + matched, + &["text", "input_text", "output_text"], + ); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::Object(_) => {} + } +} + +fn redact_text_or_text_blocks( + value: &mut Value, + filter: &PrivacyFilter, + options: &PrivacyFilterOptions, + matched: &mut bool, + allowed_types: &[&str], +) { + match value { + Value::String(_) => redact_text_value(value, filter, options, matched), + Value::Array(parts) => { + for part in parts { + redact_typed_text_part(part, filter, options, matched, allowed_types); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::Object(_) => {} + } +} + +fn redact_typed_text_part( + value: &mut Value, + filter: &PrivacyFilter, + options: &PrivacyFilterOptions, + matched: &mut bool, + allowed_types: &[&str], +) { + let Some(map) = value.as_object_mut() else { + return; + }; + let Some(kind) = map.get("type").and_then(Value::as_str) else { + return; + }; + if !allowed_types.contains(&kind) { + return; + } + if let Some(text) = map.get_mut("text") { + redact_text_value(text, filter, options, matched); + } +} + +fn redact_text_value( + value: &mut Value, + filter: &PrivacyFilter, + options: &PrivacyFilterOptions, + matched: &mut bool, +) { + let Value::String(text) = value else { + return; + }; + let redacted = filter.redact_with_options(text, options); + if redacted.hit { + *text = redacted.redacted; + *matched = true; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::plugins::runtime_lifecycle::PluginRuntimeCache; + use crate::plugins::{ + PluginDetail, PluginInstallSource, PluginPermissionRisk, PluginStatus, PluginSummary, + }; + use serde_json::json; + + fn privacy_filter_detail(config: serde_json::Value) -> PluginDetail { + let fixture = crate::app::plugins::official::official_plugin("official.privacy-filter") + .expect("official privacy filter fixture"); + PluginDetail { + summary: PluginSummary { + id: 1, + plugin_id: fixture.manifest.id.clone(), + name: fixture.manifest.name.clone(), + current_version: Some(fixture.manifest.version.clone()), + status: PluginStatus::Enabled, + runtime: "extensionHost".to_string(), + permission_risk: PluginPermissionRisk::High, + update_available: false, + last_error: None, + created_at: 1, + updated_at: 1, + }, + manifest: fixture.manifest, + install_source: PluginInstallSource::Official, + installed_dir: Some(fixture.root_dir.to_string_lossy().to_string()), + config, + granted_permissions: vec![], + pending_permissions: vec![], + audit_logs: vec![], + runtime_failures: vec![], + rollback_versions: vec![], + } + } + + fn privacy_filter_plugin_detail_with_dir( + installed_dir: String, + config: serde_json::Value, + ) -> PluginDetail { + let mut plugin = privacy_filter_detail(config); + plugin.installed_dir = Some(installed_dir); + plugin + } + + fn execute_privacy_filter_request( + config: serde_json::Value, + body: impl Into, + ) -> serde_json::Value { + let service = PrivacyRedactionService::default(); + let plugin = privacy_filter_detail(json!({})); + let output = service + .redact_request_body(&plugin, &body.into(), &config) + .expect("privacy filter request redaction"); + assert!(output.hit, "request body should be redacted"); + serde_json::from_str(&output.redacted).unwrap_or_else(|err| { + panic!( + "redacted request body should remain valid JSON: {err}; body={}", + output.redacted + ) + }) + } + + fn default_privacy_filter_config() -> serde_json::Value { + json!({ + "redactBeforeUpstream": true, + "redactLogs": true + }) + } + + #[test] + fn privacy_redaction_service_rejects_rule_file_over_byte_limit() { + let dir = tempfile::tempdir().expect("temp plugin dir"); + let rules_dir = dir.path().join("rules"); + std::fs::create_dir_all(&rules_dir).expect("rules dir"); + std::fs::write( + rules_dir.join("gitleaks.toml"), + format!( + "title = \"rules\"\n{}", + " ".repeat(MAX_PRIVACY_FILTER_RULE_FILE_BYTES + 1) + ), + ) + .expect("rules file"); + + let plugin = privacy_filter_plugin_detail_with_dir( + dir.path().to_string_lossy().to_string(), + serde_json::json!({ "redactLogs": true }), + ); + + let err = load_privacy_filter(&plugin).expect_err("oversized rules should fail"); + + assert!(err.to_string().contains("privacy filter rule file exceeds")); + } + + #[test] + fn privacy_redaction_service_retain_prewarms_and_prunes_privacy_redact_plugins() { + let dir = tempfile::tempdir().expect("temp plugin dir"); + let rules_dir = dir.path().join("rules"); + std::fs::create_dir_all(&rules_dir).expect("rules dir"); + std::fs::write(rules_dir.join("gitleaks.toml"), "").expect("rules file"); + let plugin = privacy_filter_plugin_detail_with_dir( + dir.path().to_string_lossy().to_string(), + json!({}), + ); + let service = PrivacyRedactionService::default(); + + service.retain_for_plugins(&[plugin]); + + assert_eq!(service.cache_size_for_tests(), 1); + + service.retain_for_plugins(&[]); + + assert_eq!(service.cache_size_for_tests(), 0); + } + + #[test] + fn privacy_redaction_service_redacts_phone_numbers_in_provider_request_shapes() { + let service = PrivacyRedactionService::default(); + let plugin = privacy_filter_detail(json!({})); + let config = json!({}); + + for (name, body) in [ + ( + "claude", + r#"{"messages":[{"role":"user","content":[{"type":"text","text":"phone 13344441520"}]}]}"#, + ), + ( + "openai_chat", + r#"{"messages":[{"role":"user","content":"phone 13344441520"}]}"#, + ), + ( + "codex_responses", + r#"{"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"phone 13344441520"}]}]}"#, + ), + ("raw_text", "phone 13344441520"), + ] { + let output = service + .redact_request_body(&plugin, body, &config) + .unwrap_or_else(|err| panic!("{name} privacy filter failed: {err}")) + .redacted; + assert!( + !output.contains("13344441520"), + "{name} leaked phone number: {output}" + ); + } + } + + #[test] + fn privacy_redaction_service_redacts_before_send_request_bodies() { + let service = PrivacyRedactionService::default(); + let plugin = privacy_filter_detail(json!({})); + + let output = service + .redact_request_body( + &plugin, + r#"{"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"phone 13344441520"}]}]}"#, + &json!({}), + ) + .expect("privacy filter request body redaction") + .redacted; + assert!(output.contains("[电话]")); + assert!(!output.contains("13344441520")); + } + + #[test] + fn privacy_redaction_service_redacts_only_claude_allowlisted_fields() { + let tool_use_id = "toolu_123"; + let output = execute_privacy_filter_request( + default_privacy_filter_config(), + json!({ + "system": [ + { "type": "text", "text": "系统邮箱 sys@example.com" } + ], + "tools": [ + { + "name": "send_email", + "description": "Send to admin@example.com", + "input_schema": { + "type": "object", + "properties": { + "recipient": { + "type": "string", + "description": "Email like schema@example.com" + } + } + } + } + ], + "messages": [ + { + "role": "user", + "content": [ + { "type": "text", "text": "我的邮箱 user@example.com" } + ] + }, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": tool_use_id, + "name": "send_email", + "input": { "recipient": "tool-input@example.com" } + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": "工具读取到 result@example.com" + } + ] + } + ] + }) + .to_string(), + ); + + assert_eq!( + output["tools"][0]["description"], + "Send to admin@example.com" + ); + assert_eq!( + output["tools"][0]["input_schema"]["properties"]["recipient"]["description"], + "Email like schema@example.com" + ); + assert_eq!( + output["messages"][1]["content"][0]["input"]["recipient"], + "tool-input@example.com" + ); + assert_eq!(output["messages"][1]["content"][0]["id"], tool_use_id); + assert!(output["system"][0]["text"] + .as_str() + .is_some_and(|text| text.contains("[邮箱]"))); + assert!(output["messages"][0]["content"][0]["text"] + .as_str() + .is_some_and(|text| text.contains("[邮箱]"))); + assert!(output["messages"][2]["content"][0]["content"] + .as_str() + .is_some_and(|text| text.contains("[邮箱]"))); + } + + #[test] + fn privacy_redaction_service_respects_disabled_tool_result_scope() { + let output = execute_privacy_filter_request( + json!({ + "redactBeforeUpstream": true, + "redactLogs": true, + "redactionScopes": ["system_instructions", "user_prompts", "legacy_prompt"] + }), + json!({ + "messages": [ + { + "role": "user", + "content": [ + { "type": "text", "text": "用户邮箱 user@example.com" } + ] + }, + { + "role": "user", + "content": [ + { "type": "tool_result", "tool_use_id": "toolu_123", "content": "工具结果 result@example.com" } + ] + } + ] + }) + .to_string(), + ); + + assert_eq!( + output["messages"][1]["content"][0]["content"], + "工具结果 result@example.com" + ); + assert!(output["messages"][0]["content"][0]["text"] + .as_str() + .is_some_and(|text| text.contains("[邮箱]"))); + } + + #[test] + fn privacy_redaction_service_redacts_only_openai_responses_allowlisted_fields() { + let output = execute_privacy_filter_request( + default_privacy_filter_config(), + json!({ + "instructions": "系统邮箱 sys@example.com", + "tools": [ + { + "type": "function", + "name": "lookup", + "description": "Lookup admin@example.com", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Query schema@example.com" + } + } + } + } + ], + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { "type": "input_text", "text": "用户邮箱 user@example.com" } + ] + }, + { + "type": "function_call", + "call_id": "call_123", + "name": "lookup", + "arguments": "{\"email\":\"args@example.com\"}" + }, + { + "type": "function_call_output", + "call_id": "call_123", + "output": "工具输出 result@example.com" + } + ] + }) + .to_string(), + ); + + assert_eq!( + output["tools"][0]["description"], + "Lookup admin@example.com" + ); + assert_eq!( + output["tools"][0]["parameters"]["properties"]["query"]["description"], + "Query schema@example.com" + ); + assert_eq!( + output["input"][1]["arguments"], + "{\"email\":\"args@example.com\"}" + ); + assert!(output["instructions"] + .as_str() + .is_some_and(|text| text.contains("[邮箱]"))); + assert!(output["input"][0]["content"][0]["text"] + .as_str() + .is_some_and(|text| text.contains("[邮箱]"))); + assert!(output["input"][2]["output"] + .as_str() + .is_some_and(|text| text.contains("[邮箱]"))); + } + + #[test] + fn privacy_redaction_service_redacts_codex_responses_payload_shape() { + let output = execute_privacy_filter_request( + default_privacy_filter_config(), + json!({ + "model": "gpt-5.5", + "instructions": "developer prompt with sys@example.com", + "input": [ + { + "type": "message", + "role": "developer", + "content": [ + { + "type": "input_text", + "text": "developer-visible phone 13344441521" + } + ] + }, + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "你知道 13344441520 是哪里的手机号嘛" + } + ] + }, + { + "type": "function_call", + "call_id": "call_123", + "name": "lookup_phone", + "arguments": "{\"phone\":\"13344441522\"}" + } + ], + "tools": [ + { + "type": "function", + "name": "lookup_phone", + "description": "Lookup 13344441523", + "parameters": { + "type": "object", + "properties": { + "phone": { + "type": "string", + "description": "Phone like 13344441524" + } + } + } + } + ], + "tool_choice": "auto", + "reasoning": { "effort": "xhigh" }, + "client_metadata": { + "x-codex-window-id": "13344441525" + } + }) + .to_string(), + ); + + assert!(output["instructions"] + .as_str() + .is_some_and(|text| text.contains("[邮箱]"))); + assert!(output["input"][0]["content"][0]["text"] + .as_str() + .is_some_and(|text| text.contains("[电话]"))); + assert!(output["input"][1]["content"][0]["text"] + .as_str() + .is_some_and(|text| text.contains("[电话]"))); + assert_eq!( + output["input"][2]["arguments"], + "{\"phone\":\"13344441522\"}" + ); + assert_eq!(output["tools"][0]["description"], "Lookup 13344441523"); + assert_eq!( + output["tools"][0]["parameters"]["properties"]["phone"]["description"], + "Phone like 13344441524" + ); + assert_eq!( + output["client_metadata"]["x-codex-window-id"], + "13344441525" + ); + } + + #[test] + fn privacy_redaction_service_redacts_only_chat_allowlisted_fields() { + let output = execute_privacy_filter_request( + default_privacy_filter_config(), + json!({ + "messages": [ + { "role": "system", "content": "系统邮箱 sys@example.com" }, + { "role": "user", "content": "用户邮箱 user@example.com" }, + { + "role": "assistant", + "tool_calls": [ + { + "id": "call_123", + "type": "function", + "function": { + "name": "lookup", + "arguments": "{\"email\":\"args@example.com\"}" + } + } + ] + }, + { + "role": "tool", + "tool_call_id": "call_123", + "content": "工具输出 result@example.com" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "lookup", + "description": "Lookup admin@example.com", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Schema schema@example.com" } + } + } + } + } + ] + }) + .to_string(), + ); + + assert_eq!( + output["messages"][2]["tool_calls"][0]["function"]["arguments"], + "{\"email\":\"args@example.com\"}" + ); + assert_eq!( + output["tools"][0]["function"]["description"], + "Lookup admin@example.com" + ); + assert_eq!( + output["tools"][0]["function"]["parameters"]["properties"]["email"]["description"], + "Schema schema@example.com" + ); + assert!(output["messages"][0]["content"] + .as_str() + .is_some_and(|text| text.contains("[邮箱]"))); + assert!(output["messages"][1]["content"] + .as_str() + .is_some_and(|text| text.contains("[邮箱]"))); + assert!(output["messages"][3]["content"] + .as_str() + .is_some_and(|text| text.contains("[邮箱]"))); + } + + #[test] + fn privacy_redaction_service_respects_legacy_prompt_scope_for_raw_text() { + let service = PrivacyRedactionService::default(); + let plugin = privacy_filter_detail(json!({})); + + let result = service + .redact_request_body( + &plugin, + "raw email raw@example.com", + &json!({ + "redactionScopes": ["system_instructions", "user_prompts", "tool_results"] + }), + ) + .expect("privacy filter request redaction"); + + assert!(!result.hit); + assert_eq!(result.redacted, "raw email raw@example.com"); + } + + #[test] + fn privacy_redaction_service_log_redaction_ignores_request_redaction_scopes() { + let service = PrivacyRedactionService::default(); + let plugin = privacy_filter_detail(json!({})); + + let result = service + .redact_text( + &plugin, + "trace email log@example.com", + &json!({ "redactionScopes": [] }), + ) + .expect("privacy filter log redaction"); + + assert!(result.redacted.contains("[邮箱]")); + assert!(!result.redacted.contains("log@example.com")); + } + + #[test] + fn privacy_redaction_service_preserves_claude_tool_use_protocol_ids() { + let service = PrivacyRedactionService::default(); + let plugin = privacy_filter_detail(json!({})); + let tool_use_id = "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ"; + let tool_use_input_phone = "13344441520"; + let tool_result_phone = "13344441521"; + let body = + json!({ + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": tool_use_id, + "name": "lookup_phone", + "input": { "query": format!("你知道 {tool_use_input_phone} 是哪里的手机号嘛") } + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": format!("手机号 {tool_result_phone} 查询完成") + } + ] + } + ] + }) + .to_string(); + + let output = service + .redact_request_body(&plugin, &body, &json!({})) + .expect("privacy filter request redaction") + .redacted; + assert!( + output.contains(tool_use_id), + "tool id was changed: {output}" + ); + assert!( + output.contains(tool_use_input_phone), + "tool input should remain unchanged: {output}" + ); + assert!(output.contains("[电话]")); + assert!(!output.contains(tool_result_phone)); + } + + #[test] + fn privacy_redaction_service_redacts_log_messages_after_request_redaction() { + let service = PrivacyRedactionService::default(); + let plugin = privacy_filter_detail(json!({})); + + let result = service + .redact_text(&plugin, "trace log 13344441520", &json!({})) + .expect("privacy filter log redaction"); + + assert!(!result.redacted.contains("13344441520")); + } + + #[test] + fn privacy_redaction_service_respects_sensitive_types_config() { + let service = PrivacyRedactionService::default(); + let plugin = privacy_filter_detail(json!({})); + + let output = service + .redact_request_body( + &plugin, + r#"{"messages":[{"role":"user","content":"email test@example.com phone 13344441520"}]}"#, + &json!({ "sensitiveTypes": ["email"] }), + ) + .expect("privacy filter request redaction") + .redacted; + assert!(output.contains("[邮箱]")); + assert!(!output.contains("test@example.com")); + assert!( + output.contains("13344441520"), + "cn_phone should remain visible when sensitiveTypes omits it: {output}" + ); + } + + #[test] + fn privacy_redaction_service_allows_disabling_all_sensitive_types() { + let service = PrivacyRedactionService::default(); + let plugin = privacy_filter_detail(json!({})); + + let result = service + .redact_request_body( + &plugin, + r#"{"messages":[{"role":"user","content":"email test@example.com phone 13344441520"}]}"#, + &json!({ "sensitiveTypes": [] }), + ) + .expect("privacy filter request redaction"); + + assert!(!result.hit); + assert_eq!( + result.redacted, + r#"{"messages":[{"role":"user","content":"email test@example.com phone 13344441520"}]}"# + ); + } +} diff --git a/src-tauri/src/app/plugins/process_runtime.rs b/src-tauri/src/app/plugins/process_runtime.rs deleted file mode 100644 index 8939fc8b..00000000 --- a/src-tauri/src/app/plugins/process_runtime.rs +++ /dev/null @@ -1,513 +0,0 @@ -//! Usage: Experimental JSON-RPC-over-stdio process plugin runtime PoC. -#![allow(dead_code)] - -use crate::shared::error::{AppError, AppResult}; -use serde_json::{json, Value}; -use std::io::ErrorKind; -use std::process::Stdio; -use std::time::{Duration, Instant}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::process::{Child, ChildStdin, ChildStdout, Command}; - -#[derive(Debug, Clone)] -pub(crate) struct ProcessRuntimeConfig { - pub(crate) program: String, - pub(crate) args: Vec, - pub(crate) start_timeout: Duration, - pub(crate) hook_timeout: Duration, - pub(crate) idle_recycle: Duration, - pub(crate) max_line_bytes: usize, -} - -impl Default for ProcessRuntimeConfig { - fn default() -> Self { - Self { - program: String::new(), - args: vec![], - start_timeout: Duration::from_millis(500), - hook_timeout: Duration::from_millis(300), - idle_recycle: Duration::from_secs(30), - max_line_bytes: 256 * 1024, - } - } -} - -#[derive(Debug)] -pub(crate) struct JsonRpcProcessRuntime { - config: ProcessRuntimeConfig, - child: Option, - stdin: Option, - stdout: Option>, - next_id: u64, - last_used: Instant, -} - -impl JsonRpcProcessRuntime { - pub(crate) async fn start(config: ProcessRuntimeConfig) -> AppResult { - if config.program.trim().is_empty() { - return Err(AppError::new( - "PLUGIN_PROCESS_INVALID_CONFIG", - "process runtime program must not be empty", - )); - } - let mut command = Command::new(&config.program); - command.args(&config.args); - command.env_clear(); - if let Some(path) = std::env::var_os("PATH") { - command.env("PATH", path); - } - command.stdin(Stdio::piped()); - command.stdout(Stdio::piped()); - command.stderr(Stdio::piped()); - #[cfg(windows)] - { - command.creation_flags(0x08000000); - } - - let mut child = command.spawn().map_err(|err| { - AppError::new( - "PLUGIN_PROCESS_SPAWN_FAILED", - format!("failed to start process plugin: {err}"), - ) - })?; - let stdin = child.stdin.take().ok_or_else(|| { - AppError::new( - "PLUGIN_PROCESS_STDIN_UNAVAILABLE", - "process plugin stdin was unavailable", - ) - })?; - let stdout = child.stdout.take().ok_or_else(|| { - AppError::new( - "PLUGIN_PROCESS_STDOUT_UNAVAILABLE", - "process plugin stdout was unavailable", - ) - })?; - let mut runtime = Self { - config, - child: Some(child), - stdin: Some(stdin), - stdout: Some(BufReader::new(stdout)), - next_id: 1, - last_used: Instant::now(), - }; - - let ready = tokio::time::timeout(runtime.config.start_timeout, runtime.read_json_line()) - .await - .map_err(|_| { - AppError::new( - "PLUGIN_PROCESS_START_TIMEOUT", - format!( - "process plugin did not send ready message before start timeout: program={}, timeout_ms={}", - runtime.config.program, - runtime.config.start_timeout.as_millis() - ), - ) - }); - let ready = match ready { - Ok(Ok(value)) => value, - Ok(Err(err)) | Err(err) => { - runtime.kill_child().await; - return Err(err); - } - }; - if ready.get("method").and_then(Value::as_str) != Some("plugin.ready") { - runtime.kill_child().await; - return Err(AppError::new( - "PLUGIN_PROCESS_PROTOCOL_ERROR", - "process plugin first message must be plugin.ready", - )); - } - - Ok(runtime) - } - - pub(crate) async fn call_hook(&mut self, params: Value) -> AppResult { - let id = self.next_id; - self.next_id = self.next_id.saturating_add(1); - let request = json!({ - "jsonrpc": "2.0", - "id": id, - "method": "plugin.handleHook", - "params": params, - }); - let line = serde_json::to_vec(&request).map_err(|err| { - AppError::new( - "PLUGIN_PROCESS_ENCODE_FAILED", - format!("failed to encode JSON-RPC request: {err}"), - ) - })?; - if line.len() + 1 > self.config.max_line_bytes { - return Err(AppError::new( - "PLUGIN_PROCESS_REQUEST_TOO_LARGE", - format!( - "process plugin request exceeded {} bytes", - self.config.max_line_bytes - ), - )); - } - - let hook_timeout = self.config.hook_timeout; - let result = tokio::time::timeout(hook_timeout, async { - self.write_line(&line).await?; - let response = self.read_json_line().await?; - validate_json_rpc_response(id, response) - }) - .await; - - match result { - Ok(Ok(value)) => { - self.last_used = Instant::now(); - Ok(value) - } - Ok(Err(err)) => { - self.kill_child().await; - Err(err) - } - Err(_) => { - self.kill_child().await; - Err(AppError::new( - "PLUGIN_PROCESS_HOOK_TIMEOUT", - "process plugin did not respond before hook timeout", - )) - } - } - } - - pub(crate) fn is_running(&mut self) -> bool { - let Some(child) = self.child.as_mut() else { - return false; - }; - matches!(child.try_wait(), Ok(None)) - } - - pub(crate) async fn recycle_if_idle(&mut self) -> AppResult { - if self.child.is_some() && self.last_used.elapsed() >= self.config.idle_recycle { - self.kill_child().await; - return Ok(true); - } - Ok(false) - } - - pub(crate) async fn shutdown(&mut self) { - self.kill_child().await; - } - - async fn write_line(&mut self, bytes: &[u8]) -> AppResult<()> { - let Some(stdin) = self.stdin.as_mut() else { - return Err(AppError::new( - "PLUGIN_PROCESS_CRASHED", - "process plugin stdin is closed", - )); - }; - stdin - .write_all(bytes) - .await - .map_err(|err| process_write_error("write process plugin request", err))?; - stdin - .write_all(b"\n") - .await - .map_err(|err| process_write_error("terminate process plugin request line", err))?; - stdin - .flush() - .await - .map_err(|err| process_write_error("flush process plugin request", err)) - } - - async fn read_json_line(&mut self) -> AppResult { - let max_line_bytes = self.config.max_line_bytes; - let Some(stdout) = self.stdout.as_mut() else { - return Err(AppError::new( - "PLUGIN_PROCESS_CRASHED", - "process plugin stdout is closed", - )); - }; - let mut line = String::new(); - let bytes = stdout.read_line(&mut line).await.map_err(|err| { - AppError::new( - "PLUGIN_PROCESS_READ_FAILED", - format!("failed to read process plugin response: {err}"), - ) - })?; - if bytes == 0 { - return Err(AppError::new( - "PLUGIN_PROCESS_CRASHED", - "process plugin exited before sending a response", - )); - } - if bytes > max_line_bytes || line.len() > max_line_bytes { - return Err(AppError::new( - "PLUGIN_PROCESS_RESPONSE_TOO_LARGE", - format!("process plugin response exceeded {max_line_bytes} bytes"), - )); - } - serde_json::from_str(&line).map_err(|err| { - AppError::new( - "PLUGIN_PROCESS_PROTOCOL_ERROR", - format!("process plugin response was not valid JSON: {err}"), - ) - }) - } - - async fn kill_child(&mut self) { - self.stdin.take(); - self.stdout.take(); - if let Some(mut child) = self.child.take() { - match child.try_wait() { - Ok(Some(_)) => {} - Ok(None) => { - let _ = child.kill().await; - let _ = child.wait().await; - } - Err(_) => { - let _ = child.kill().await; - let _ = child.wait().await; - } - } - } - } -} - -fn process_write_error(operation: &str, err: std::io::Error) -> AppError { - if matches!( - err.kind(), - ErrorKind::BrokenPipe | ErrorKind::ConnectionReset - ) { - return AppError::new( - "PLUGIN_PROCESS_CRASHED", - format!("process plugin pipe closed while attempting to {operation}: {err}"), - ); - } - AppError::new( - "PLUGIN_PROCESS_WRITE_FAILED", - format!("failed to {operation}: {err}"), - ) -} - -fn validate_json_rpc_response(expected_id: u64, response: Value) -> AppResult { - if response.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { - return Err(AppError::new( - "PLUGIN_PROCESS_PROTOCOL_ERROR", - "process plugin response must use JSON-RPC 2.0", - )); - } - if response.get("id").and_then(Value::as_u64) != Some(expected_id) { - return Err(AppError::new( - "PLUGIN_PROCESS_PROTOCOL_ERROR", - "process plugin response id did not match request id", - )); - } - if let Some(error) = response.get("error") { - return Err(AppError::new( - "PLUGIN_PROCESS_REMOTE_ERROR", - format!("process plugin returned JSON-RPC error: {error}"), - )); - } - response.get("result").cloned().ok_or_else(|| { - AppError::new( - "PLUGIN_PROCESS_PROTOCOL_ERROR", - "process plugin response was missing result", - ) - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use std::time::Duration; - - fn write_node_plugin(script: &str) -> (tempfile::TempDir, std::path::PathBuf) { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("plugin.js"); - std::fs::write(&path, script).expect("write plugin script"); - (dir, path) - } - - fn node_config(script_path: &std::path::Path) -> ProcessRuntimeConfig { - ProcessRuntimeConfig { - program: "node".to_string(), - args: vec![script_path.display().to_string()], - start_timeout: Duration::from_secs(5), - hook_timeout: Duration::from_secs(5), - idle_recycle: Duration::from_millis(50), - max_line_bytes: 256 * 1024, - } - } - - #[tokio::test] - async fn plugin_process_runtime_poc_handles_valid_json_rpc_hook() { - let (_dir, script) = write_node_plugin( - r#" - console.log(JSON.stringify({jsonrpc:"2.0", method:"plugin.ready"})); - process.stdin.setEncoding("utf8"); - let buffer = ""; - process.stdin.on("data", chunk => { - buffer += chunk; - const lines = buffer.split("\n"); - buffer = lines.pop(); - for (const line of lines) { - if (!line.trim()) continue; - const req = JSON.parse(line); - console.log(JSON.stringify({ - jsonrpc: "2.0", - id: req.id, - result: { action: "pass", hook: req.params.hook } - })); - } - }); - "#, - ); - let mut runtime = JsonRpcProcessRuntime::start(node_config(&script)) - .await - .expect("start process runtime"); - - let response = runtime - .call_hook(json!({"hook": "gateway.request.afterBodyRead", "context": {}})) - .await - .expect("hook response"); - - assert_eq!( - response, - json!({"action": "pass", "hook": "gateway.request.afterBodyRead"}) - ); - runtime.shutdown().await; - } - - #[tokio::test] - async fn plugin_process_runtime_poc_does_not_inherit_host_environment() { - std::env::set_var("AIO_PLUGIN_RUNTIME_SECRET_FOR_TEST", "host-secret"); - let (_dir, script) = write_node_plugin( - r#" - console.log(JSON.stringify({ - jsonrpc: "2.0", - method: "plugin.ready", - leaked: process.env.AIO_PLUGIN_RUNTIME_SECRET_FOR_TEST || null - })); - process.stdin.setEncoding("utf8"); - let buffer = ""; - process.stdin.on("data", chunk => { - buffer += chunk; - const lines = buffer.split("\n"); - buffer = lines.pop(); - for (const line of lines) { - if (!line.trim()) continue; - const req = JSON.parse(line); - console.log(JSON.stringify({ - jsonrpc: "2.0", - id: req.id, - result: { - leaked: process.env.AIO_PLUGIN_RUNTIME_SECRET_FOR_TEST || null - } - })); - } - }); - "#, - ); - let mut runtime = JsonRpcProcessRuntime::start(node_config(&script)) - .await - .expect("start process runtime"); - - let response = runtime - .call_hook(json!({"hook": "gateway.request.afterBodyRead", "context": {}})) - .await - .expect("hook response"); - - std::env::remove_var("AIO_PLUGIN_RUNTIME_SECRET_FOR_TEST"); - runtime.shutdown().await; - assert_eq!(response["leaked"], serde_json::Value::Null); - } - - #[tokio::test] - async fn plugin_process_runtime_poc_reports_start_timeout() { - let (_dir, script) = write_node_plugin( - r#" - setTimeout(() => { - console.log(JSON.stringify({jsonrpc:"2.0", method:"plugin.ready"})); - }, 1000); - "#, - ); - let mut config = node_config(&script); - config.start_timeout = Duration::from_millis(50); - - let err = JsonRpcProcessRuntime::start(config) - .await - .expect_err("start timeout fails"); - - assert!(err.to_string().contains("PLUGIN_PROCESS_START_TIMEOUT")); - } - - #[tokio::test] - async fn plugin_process_runtime_poc_reports_hook_timeout_and_kills_child() { - let (_dir, script) = write_node_plugin( - r#" - console.log(JSON.stringify({jsonrpc:"2.0", method:"plugin.ready"})); - process.stdin.resume(); - "#, - ); - let mut config = node_config(&script); - config.hook_timeout = Duration::from_millis(50); - let mut runtime = JsonRpcProcessRuntime::start(config) - .await - .expect("start process runtime"); - - let err = runtime - .call_hook(json!({"hook": "gateway.request.afterBodyRead", "context": {}})) - .await - .expect_err("hook timeout fails"); - - assert!(err.to_string().contains("PLUGIN_PROCESS_HOOK_TIMEOUT")); - assert!(!runtime.is_running()); - } - - #[tokio::test] - async fn plugin_process_runtime_poc_reports_crash_without_host_panic() { - let (_dir, script) = write_node_plugin( - r#" - console.log(JSON.stringify({jsonrpc:"2.0", method:"plugin.ready"})); - process.exit(7); - "#, - ); - let mut runtime = JsonRpcProcessRuntime::start(node_config(&script)) - .await - .expect("start process runtime"); - - let err = runtime - .call_hook(json!({"hook": "gateway.request.afterBodyRead", "context": {}})) - .await - .expect_err("crashed child fails hook"); - - assert!(err.to_string().contains("PLUGIN_PROCESS_CRASHED")); - assert!(!runtime.is_running()); - } - - #[tokio::test] - async fn plugin_process_runtime_poc_recycles_idle_child() { - let (_dir, script) = write_node_plugin( - r#" - console.log(JSON.stringify({jsonrpc:"2.0", method:"plugin.ready"})); - process.stdin.resume(); - "#, - ); - let mut config = node_config(&script); - config.idle_recycle = Duration::from_millis(10); - let mut runtime = JsonRpcProcessRuntime::start(config) - .await - .expect("start process runtime"); - - tokio::time::sleep(Duration::from_millis(30)).await; - let recycled = runtime.recycle_if_idle().await.expect("idle recycle"); - - assert!(recycled); - assert!(!runtime.is_running()); - } - - #[test] - fn plugin_process_runtime_maps_broken_pipe_write_to_crash() { - let err = process_write_error( - "write process plugin request", - std::io::Error::new(ErrorKind::BrokenPipe, "closed pipe"), - ); - - assert!(err.to_string().contains("PLUGIN_PROCESS_CRASHED")); - } -} diff --git a/src-tauri/src/app/plugins/rule_runtime.rs b/src-tauri/src/app/plugins/rule_runtime.rs deleted file mode 100644 index 812b9141..00000000 --- a/src-tauri/src/app/plugins/rule_runtime.rs +++ /dev/null @@ -1,2522 +0,0 @@ -//! Usage: Declarative, no-code plugin rule runtime. - -use super::privacy_filter::{PrivacyFilter, PrivacyFilterError, PrivacyFilterOptions}; -use crate::gateway::plugins::context::{ - GatewayHookAction, GatewayHookResult, GatewayVisibleHookContext, -}; -use crate::gateway::plugins::permissions::GatewayPluginError; -use crate::gateway::plugins::pipeline::{GatewayHookFuture, GatewayPluginExecutor}; -use crate::plugins::{PluginDetail, PluginRuntime}; -use regex::{Regex, RegexBuilder}; -use serde::Deserialize; -use serde_json::Value; -use std::collections::{HashMap, HashSet}; -use std::fmt; -use std::fs; -#[cfg(test)] -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; - -pub(crate) const MAX_RULE_REGEX_PATTERN_BYTES: usize = 4 * 1024; -const MAX_RULE_REGEX_COMPILED_BYTES: usize = 2 * 1024 * 1024; -const MAX_RULES_PER_RUNTIME: usize = 256; - -#[cfg(test)] -static RULE_RUNTIME_TEST_DELAY_MS: AtomicU64 = AtomicU64::new(0); -#[cfg(test)] -thread_local! { - static RULE_RUNTIME_TEST_JSON_PARSE_COUNT: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -#[cfg(test)] -fn reset_json_parse_count_for_tests() { - RULE_RUNTIME_TEST_JSON_PARSE_COUNT.with(|count| count.set(0)); -} - -#[cfg(test)] -fn json_parse_count_for_tests() -> u64 { - RULE_RUNTIME_TEST_JSON_PARSE_COUNT.with(|count| count.get()) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct RuleRuntimeError { - code: &'static str, - message: String, -} - -impl RuleRuntimeError { - fn new(code: &'static str, message: impl Into) -> Self { - Self { - code, - message: message.into(), - } - } - - pub(crate) fn code(&self) -> &'static str { - self.code - } -} - -impl fmt::Display for RuleRuntimeError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}: {}", self.code, self.message) - } -} - -impl std::error::Error for RuleRuntimeError {} - -#[derive(Debug, Clone)] -pub(crate) struct RuleRuntime { - rules: Vec, -} - -impl RuleRuntime { - #[cfg(test)] - pub(crate) fn from_value(value: Value) -> Result { - let raw: RuleDocument = serde_json::from_value(value).map_err(|err| { - RuleRuntimeError::new( - "PLUGIN_RULE_INVALID_DOCUMENT", - format!("failed to parse declarative rules: {err}"), - ) - })?; - Self::from_document(raw) - } - - fn from_document(raw: RuleDocument) -> Result { - if raw.rules.len() > MAX_RULES_PER_RUNTIME { - return Err(RuleRuntimeError::new( - "PLUGIN_RULE_TOO_MANY_RULES", - format!("rule document has more than {MAX_RULES_PER_RUNTIME} rules"), - )); - } - - let mut rules = Vec::with_capacity(raw.rules.len()); - for rule in raw.rules { - rules.push(CompiledRule::compile(rule)?); - } - Ok(Self { rules }) - } - - pub(crate) fn execute( - &self, - context: &GatewayVisibleHookContext, - config: &Value, - ) -> Result { - #[cfg(test)] - if let delay @ 1.. = RULE_RUNTIME_TEST_DELAY_MS.load(Ordering::SeqCst) { - std::thread::sleep(std::time::Duration::from_millis(delay)); - } - - let mut result = GatewayHookResult::continue_unchanged(); - let mut request_body = context.request.body.clone(); - let mut response_body = context.response.body.clone(); - let mut stream_chunk = context.stream.chunk.clone(); - let mut log_message = context.log.message.clone(); - - let rules = self - .rules - .iter() - .filter(|rule| rule.hook == context.hook_name) - .filter(|rule| rule.when.matches(context, config)) - .collect::>(); - let mut index = 0usize; - while index < rules.len() { - let rule = rules[index]; - let batch_end = json_replace_batch_end(&rules, index); - let batch = &rules[index..batch_end]; - let matched = if batch.len() > 1 { - match rule.target.field { - TargetField::RequestBody => { - apply_json_replace_batch_to_text(&mut request_body, batch)? - } - TargetField::ResponseBody => { - apply_json_replace_batch_to_text(&mut response_body, batch)? - } - TargetField::StreamChunk => { - apply_json_replace_batch_to_text(&mut stream_chunk, batch)? - } - TargetField::LogMessage => { - apply_json_replace_batch_to_text(&mut log_message, batch)? - } - } - } else { - match rule.target.field { - TargetField::RequestBody => { - apply_rule_to_text(&mut request_body, rule, OutputField::RequestBody)? - } - TargetField::ResponseBody => { - apply_rule_to_text(&mut response_body, rule, OutputField::ResponseBody)? - } - TargetField::StreamChunk => { - apply_rule_to_text(&mut stream_chunk, rule, OutputField::StreamChunk)? - } - TargetField::LogMessage => { - apply_rule_to_text(&mut log_message, rule, OutputField::LogMessage)? - } - } - }; - - if !matched { - index = batch_end; - continue; - } - - match &rule.action { - RuleAction::Replace { .. } => match rule.target.field { - TargetField::RequestBody => result.request_body = request_body.clone(), - TargetField::ResponseBody => result.response_body = response_body.clone(), - TargetField::StreamChunk => result.stream_chunk = stream_chunk.clone(), - TargetField::LogMessage => result.log_message = log_message.clone(), - }, - RuleAction::Block { reason } => { - result.action = GatewayHookAction::Block; - result.reason = Some(reason.clone()); - return Ok(result); - } - RuleAction::Warn { message } => { - result.reason = Some(message.clone()); - } - RuleAction::AppendMessage { role, content } => { - if let Some(next_body) = - append_chat_message(request_body.as_deref(), role, content)? - { - request_body = Some(next_body); - result.request_body = request_body.clone(); - } - } - } - index = batch_end; - } - - Ok(result) - } -} - -#[derive(Default)] -pub(crate) struct RuleRuntimeGatewayPluginExecutor { - cache: Mutex>>, - privacy_filter_cache: Mutex>>, -} - -impl RuleRuntimeGatewayPluginExecutor { - pub(crate) fn execute_declarative_rules_plugin( - &self, - plugin: &PluginDetail, - context: GatewayVisibleHookContext, - ) -> Result { - let runtime = self.get_or_load_rule_runtime(plugin)?; - runtime - .execute(&context, &plugin.config) - .map_err(to_gateway_plugin_error) - } - - pub(crate) fn execute_official_privacy_filter_plugin( - &self, - plugin: &PluginDetail, - context: GatewayVisibleHookContext, - ) -> Result { - let filter = self.get_or_load_privacy_filter(plugin)?; - execute_official_privacy_filter_hook(&filter, &context, &plugin.config) - .map_err(to_privacy_filter_error) - } - - pub(crate) fn retain_runtime_caches_for_plugins(&self, plugins: &[PluginDetail]) { - let rule_keys: HashSet = plugins - .iter() - .filter(|plugin| { - matches!( - plugin.manifest.runtime, - PluginRuntime::DeclarativeRules { .. } - ) - }) - .map(rule_runtime_cache_key) - .collect(); - self.cache - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .retain(|key, _| rule_keys.contains(key)); - - let privacy_keys: HashSet = plugins - .iter() - .filter(|plugin| plugin.summary.plugin_id == "official.privacy-filter") - .map(privacy_filter_cache_key) - .collect(); - self.privacy_filter_cache - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .retain(|key, _| privacy_keys.contains(key)); - } - - fn get_or_load_rule_runtime( - &self, - plugin: &PluginDetail, - ) -> Result, GatewayPluginError> { - let cache_key = rule_runtime_cache_key(plugin); - { - let cache = self - .cache - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if let Some(runtime) = cache.get(&cache_key) { - return Ok(Arc::clone(runtime)); - } - } - - let runtime = Arc::new(load_rule_runtime(plugin).map_err(to_gateway_plugin_error)?); - let mut cache = self - .cache - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - Ok(Arc::clone( - cache - .entry(cache_key) - .or_insert_with(|| Arc::clone(&runtime)), - )) - } - - fn get_or_load_privacy_filter( - &self, - plugin: &PluginDetail, - ) -> Result, GatewayPluginError> { - let cache_key = privacy_filter_cache_key(plugin); - { - let cache = self - .privacy_filter_cache - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if let Some(filter) = cache.get(&cache_key) { - return Ok(Arc::clone(filter)); - } - } - - let filter = - Arc::new(load_official_privacy_filter(plugin).map_err(to_privacy_filter_error)?); - let mut cache = self - .privacy_filter_cache - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - Ok(Arc::clone( - cache - .entry(cache_key) - .or_insert_with(|| Arc::clone(&filter)), - )) - } -} - -fn rule_runtime_cache_key(plugin: &PluginDetail) -> String { - let version = plugin - .summary - .current_version - .as_deref() - .unwrap_or(plugin.manifest.version.as_str()); - let installed_dir = plugin.installed_dir.as_deref().unwrap_or(""); - let updated_at = plugin.summary.updated_at; - let rules = match &plugin.manifest.runtime { - PluginRuntime::DeclarativeRules { rules } => rules.join("\u{1f}"), - PluginRuntime::Native { engine } => format!("native:{engine}"), - PluginRuntime::Wasm { abi_version, .. } => format!("wasm:{abi_version}"), - }; - format!( - "{}\u{1e}{}\u{1e}{}\u{1e}{}\u{1e}{}", - plugin.summary.plugin_id, version, installed_dir, updated_at, rules - ) -} - -fn privacy_filter_cache_key(plugin: &PluginDetail) -> String { - let version = plugin - .summary - .current_version - .as_deref() - .unwrap_or(plugin.manifest.version.as_str()); - let installed_dir = plugin.installed_dir.as_deref().unwrap_or(""); - let updated_at = plugin.summary.updated_at; - format!( - "{}\u{1e}{}\u{1e}{}\u{1e}{}", - plugin.summary.plugin_id, version, installed_dir, updated_at - ) -} - -#[cfg(test)] -impl RuleRuntimeGatewayPluginExecutor { - fn cache_sizes_for_tests(&self) -> (usize, usize) { - ( - self.cache.lock().unwrap().len(), - self.privacy_filter_cache.lock().unwrap().len(), - ) - } -} - -impl GatewayPluginExecutor for RuleRuntimeGatewayPluginExecutor { - fn execute_request_hook( - &self, - plugin: &PluginDetail, - context: GatewayVisibleHookContext, - ) -> GatewayHookFuture { - let result = self.execute_declarative_rules_plugin(plugin, context); - Box::pin(async move { result }) - } - - fn execute_response_hook( - &self, - plugin: &PluginDetail, - context: GatewayVisibleHookContext, - ) -> GatewayHookFuture { - let result = self.execute_declarative_rules_plugin(plugin, context); - Box::pin(async move { result }) - } - - fn execute_stream_hook( - &self, - plugin: &PluginDetail, - context: GatewayVisibleHookContext, - ) -> GatewayHookFuture { - let result = self.execute_declarative_rules_plugin(plugin, context); - Box::pin(async move { result }) - } - - fn execute_log_hook( - &self, - plugin: &PluginDetail, - context: GatewayVisibleHookContext, - ) -> GatewayHookFuture { - let result = self.execute_declarative_rules_plugin(plugin, context); - Box::pin(async move { result }) - } -} - -fn load_rule_runtime(plugin: &PluginDetail) -> Result { - let PluginRuntime::DeclarativeRules { rules } = &plugin.manifest.runtime else { - return Err(RuleRuntimeError::new( - "PLUGIN_RULE_UNSUPPORTED_RUNTIME", - format!( - "plugin {} does not use declarativeRules runtime", - plugin.summary.plugin_id - ), - )); - }; - let root_dir = plugin.installed_dir.as_deref().ok_or_else(|| { - RuleRuntimeError::new( - "PLUGIN_RULE_MISSING_INSTALL_DIR", - format!( - "plugin {} has no installed_dir for rule loading", - plugin.summary.plugin_id - ), - ) - })?; - - let mut merged_rules = Vec::new(); - for rule_path in rules { - if rule_path.contains("..") || rule_path.starts_with('/') || rule_path.starts_with('\\') { - return Err(RuleRuntimeError::new( - "PLUGIN_RULE_INVALID_PATH", - format!( - "invalid rule path for plugin {}: {rule_path}", - plugin.summary.plugin_id - ), - )); - } - let bytes = fs::read(std::path::Path::new(root_dir).join(rule_path)).map_err(|err| { - RuleRuntimeError::new( - "PLUGIN_RULE_READ_FAILED", - format!( - "failed to read rule file for plugin {}: {err}", - plugin.summary.plugin_id - ), - ) - })?; - let document: RuleDocument = serde_json::from_slice(&bytes).map_err(|err| { - RuleRuntimeError::new( - "PLUGIN_RULE_INVALID_DOCUMENT", - format!( - "failed to parse rule file for plugin {}: {err}", - plugin.summary.plugin_id - ), - ) - })?; - merged_rules.extend(document.rules); - } - - RuleRuntime::from_document(RuleDocument { - rules: merged_rules, - }) -} - -fn load_official_privacy_filter( - plugin: &PluginDetail, -) -> Result { - let root_dir = plugin.installed_dir.as_deref().ok_or_else(|| { - PrivacyFilterError::new(format!( - "plugin {} has no installed_dir for privacy-filter rule loading", - plugin.summary.plugin_id - )) - })?; - let rules_path = std::path::Path::new(root_dir).join("rules/gitleaks.toml"); - let raw = fs::read_to_string(&rules_path).map_err(|err| { - PrivacyFilterError::new(format!( - "failed to read privacy-filter gitleaks rules for plugin {}: {err}", - plugin.summary.plugin_id - )) - })?; - PrivacyFilter::from_gitleaks_toml(&raw) -} - -fn execute_official_privacy_filter_hook( - filter: &PrivacyFilter, - context: &GatewayVisibleHookContext, - config: &Value, -) -> Result { - let mut result = GatewayHookResult::continue_unchanged(); - let options = privacy_filter_options_from_config(config); - let scopes = privacy_filter_redaction_scopes_from_config(config); - match context.hook_name.as_str() { - "gateway.request.afterBodyRead" | "gateway.request.beforeSend" => { - if config.get("redactBeforeUpstream") != Some(&Value::Bool(true)) { - return Ok(result); - } - let Some(body) = context.request.body.as_deref() else { - return Ok(result); - }; - if let Some(next_body) = redact_request_body_strings(filter, body, &options, &scopes)? { - result.request_body = Some(next_body); - } - } - "log.beforePersist" => { - if config.get("redactLogs") != Some(&Value::Bool(true)) { - return Ok(result); - } - let Some(message) = context.log.message.as_deref() else { - return Ok(result); - }; - let redacted = filter.redact_with_options(message, &options); - if redacted.hit { - result.log_message = Some(redacted.redacted); - } - } - _ => {} - } - Ok(result) -} - -fn privacy_filter_options_from_config(config: &Value) -> PrivacyFilterOptions { - let sensitive_types = config - .get("sensitiveTypes") - .and_then(Value::as_array) - .map(|items| { - items - .iter() - .filter_map(Value::as_str) - .map(str::to_string) - .collect::>() - }); - PrivacyFilterOptions::from_sensitive_types(sensitive_types.as_deref()) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct PrivacyFilterRedactionScopes { - system_instructions: bool, - user_prompts: bool, - assistant_context: bool, - tool_results: bool, - legacy_prompt: bool, -} - -impl PrivacyFilterRedactionScopes { - fn default_enabled() -> Self { - Self { - system_instructions: true, - user_prompts: true, - assistant_context: false, - tool_results: true, - legacy_prompt: true, - } - } - - fn empty() -> Self { - Self { - system_instructions: false, - user_prompts: false, - assistant_context: false, - tool_results: false, - legacy_prompt: false, - } - } - - fn enable(&mut self, scope: &str) { - match scope { - "system_instructions" => self.system_instructions = true, - "user_prompts" => self.user_prompts = true, - "assistant_context" => self.assistant_context = true, - "tool_results" => self.tool_results = true, - "legacy_prompt" => self.legacy_prompt = true, - _ => {} - } - } - - fn message_content_enabled(&self, role: Option<&str>) -> bool { - match role.unwrap_or("user") { - "system" | "developer" => self.system_instructions, - "user" => self.user_prompts, - "assistant" => self.assistant_context, - "tool" => self.tool_results, - _ => false, - } - } -} - -fn privacy_filter_redaction_scopes_from_config(config: &Value) -> PrivacyFilterRedactionScopes { - let Some(items) = config.get("redactionScopes").and_then(Value::as_array) else { - return PrivacyFilterRedactionScopes::default_enabled(); - }; - let mut scopes = PrivacyFilterRedactionScopes::empty(); - for item in items { - if let Some(scope) = item.as_str() { - scopes.enable(scope); - } - } - scopes -} - -fn redact_request_body_strings( - filter: &PrivacyFilter, - body: &str, - options: &PrivacyFilterOptions, - scopes: &PrivacyFilterRedactionScopes, -) -> Result, PrivacyFilterError> { - let Ok(mut root) = serde_json::from_str::(body) else { - if !scopes.legacy_prompt { - return Ok(None); - } - let redacted = filter.redact_with_options(body, options); - return Ok(redacted.hit.then_some(redacted.redacted)); - }; - let mut matched = false; - redact_request_json_allowlist(&mut root, filter, options, scopes, &mut matched); - if !matched { - return Ok(None); - } - serde_json::to_string(&root) - .map(Some) - .map_err(|err| PrivacyFilterError::new(format!("failed to serialize redacted JSON: {err}"))) -} - -fn redact_request_json_allowlist( - value: &mut Value, - filter: &PrivacyFilter, - options: &PrivacyFilterOptions, - scopes: &PrivacyFilterRedactionScopes, - matched: &mut bool, -) { - let Some(map) = value.as_object_mut() else { - return; - }; - - if scopes.system_instructions { - if let Some(system) = map.get_mut("system") { - redact_text_or_text_blocks(system, filter, options, matched, &["text"]); - } - if let Some(instructions) = map.get_mut("instructions") { - redact_text_value(instructions, filter, options, matched); - } - } - - if let Some(input) = map.get_mut("input") { - redact_responses_input(input, filter, options, scopes, matched); - } - - if let Some(messages) = map.get_mut("messages") { - redact_messages(messages, filter, options, scopes, matched); - } - - if scopes.legacy_prompt { - if let Some(prompt) = map.get_mut("prompt") { - redact_text_value(prompt, filter, options, matched); - } - } -} - -fn redact_messages( - value: &mut Value, - filter: &PrivacyFilter, - options: &PrivacyFilterOptions, - scopes: &PrivacyFilterRedactionScopes, - matched: &mut bool, -) { - let Some(messages) = value.as_array_mut() else { - return; - }; - for message in messages { - redact_message(message, filter, options, scopes, matched); - } -} - -fn redact_message( - value: &mut Value, - filter: &PrivacyFilter, - options: &PrivacyFilterOptions, - scopes: &PrivacyFilterRedactionScopes, - matched: &mut bool, -) { - let Some(map) = value.as_object_mut() else { - return; - }; - let role = map.get("role").and_then(Value::as_str).map(str::to_string); - if let Some(content) = map.get_mut("content") { - redact_message_content(content, role.as_deref(), filter, options, scopes, matched); - } -} - -fn redact_message_content( - value: &mut Value, - role: Option<&str>, - filter: &PrivacyFilter, - options: &PrivacyFilterOptions, - scopes: &PrivacyFilterRedactionScopes, - matched: &mut bool, -) { - match value { - Value::String(_) => { - if scopes.message_content_enabled(role) { - redact_text_value(value, filter, options, matched); - } - } - Value::Array(parts) => { - for part in parts { - redact_message_content_part(part, role, filter, options, scopes, matched); - } - } - Value::Null | Value::Bool(_) | Value::Number(_) | Value::Object(_) => {} - } -} - -fn redact_message_content_part( - value: &mut Value, - role: Option<&str>, - filter: &PrivacyFilter, - options: &PrivacyFilterOptions, - scopes: &PrivacyFilterRedactionScopes, - matched: &mut bool, -) { - let Some(map) = value.as_object_mut() else { - return; - }; - match map.get("type").and_then(Value::as_str) { - Some("tool_result") => { - if scopes.tool_results { - redact_tool_result_content(map.get_mut("content"), filter, options, matched); - } - } - Some("text") if scopes.message_content_enabled(role) => { - if let Some(text) = map.get_mut("text") { - redact_text_value(text, filter, options, matched); - } - } - _ => {} - } -} - -fn redact_responses_input( - value: &mut Value, - filter: &PrivacyFilter, - options: &PrivacyFilterOptions, - scopes: &PrivacyFilterRedactionScopes, - matched: &mut bool, -) { - match value { - Value::String(_) => { - if scopes.user_prompts { - redact_text_value(value, filter, options, matched); - } - } - Value::Array(items) => { - for item in items { - redact_responses_input_item(item, filter, options, scopes, matched); - } - } - Value::Null | Value::Bool(_) | Value::Number(_) | Value::Object(_) => {} - } -} - -fn redact_responses_input_item( - value: &mut Value, - filter: &PrivacyFilter, - options: &PrivacyFilterOptions, - scopes: &PrivacyFilterRedactionScopes, - matched: &mut bool, -) { - let Some(map) = value.as_object_mut() else { - return; - }; - match map.get("type").and_then(Value::as_str) { - Some("function_call_output") => { - if scopes.tool_results { - redact_tool_result_content(map.get_mut("output"), filter, options, matched); - } - } - Some("message") | None => { - let role = map.get("role").and_then(Value::as_str).map(str::to_string); - if let Some(content) = map.get_mut("content") { - redact_responses_content( - content, - role.as_deref(), - filter, - options, - scopes, - matched, - ); - } - } - _ => {} - } -} - -fn redact_responses_content( - value: &mut Value, - role: Option<&str>, - filter: &PrivacyFilter, - options: &PrivacyFilterOptions, - scopes: &PrivacyFilterRedactionScopes, - matched: &mut bool, -) { - match value { - Value::String(_) => { - if scopes.message_content_enabled(role) { - redact_text_value(value, filter, options, matched); - } - } - Value::Array(parts) => { - if !scopes.message_content_enabled(role) { - return; - } - for part in parts { - redact_typed_text_part( - part, - filter, - options, - matched, - &["input_text", "output_text"], - ); - } - } - Value::Null | Value::Bool(_) | Value::Number(_) | Value::Object(_) => {} - } -} - -fn redact_tool_result_content( - value: Option<&mut Value>, - filter: &PrivacyFilter, - options: &PrivacyFilterOptions, - matched: &mut bool, -) { - let Some(value) = value else { - return; - }; - match value { - Value::String(_) => redact_text_value(value, filter, options, matched), - Value::Array(parts) => { - for part in parts { - redact_typed_text_part( - part, - filter, - options, - matched, - &["text", "input_text", "output_text"], - ); - } - } - Value::Null | Value::Bool(_) | Value::Number(_) | Value::Object(_) => {} - } -} - -fn redact_text_or_text_blocks( - value: &mut Value, - filter: &PrivacyFilter, - options: &PrivacyFilterOptions, - matched: &mut bool, - allowed_types: &[&str], -) { - match value { - Value::String(_) => redact_text_value(value, filter, options, matched), - Value::Array(parts) => { - for part in parts { - redact_typed_text_part(part, filter, options, matched, allowed_types); - } - } - Value::Null | Value::Bool(_) | Value::Number(_) | Value::Object(_) => {} - } -} - -fn redact_typed_text_part( - value: &mut Value, - filter: &PrivacyFilter, - options: &PrivacyFilterOptions, - matched: &mut bool, - allowed_types: &[&str], -) { - let Some(map) = value.as_object_mut() else { - return; - }; - let Some(kind) = map.get("type").and_then(Value::as_str) else { - return; - }; - if !allowed_types.contains(&kind) { - return; - } - if let Some(text) = map.get_mut("text") { - redact_text_value(text, filter, options, matched); - } -} - -fn redact_text_value( - value: &mut Value, - filter: &PrivacyFilter, - options: &PrivacyFilterOptions, - matched: &mut bool, -) { - let Value::String(text) = value else { - return; - }; - let redacted = filter.redact_with_options(text, options); - if redacted.hit { - *text = redacted.redacted; - *matched = true; - } -} - -fn to_privacy_filter_error(err: PrivacyFilterError) -> GatewayPluginError { - GatewayPluginError::new("PLUGIN_PRIVACY_FILTER_FAILED", err.to_string()) -} - -fn to_gateway_plugin_error(err: RuleRuntimeError) -> GatewayPluginError { - GatewayPluginError::new(err.code(), err.to_string()) -} - -#[derive(Debug, Clone)] -struct CompiledRule { - id: String, - hook: String, - target: RuleTarget, - regex: Regex, - action: RuleAction, - when: RuleWhen, -} - -impl CompiledRule { - fn compile(raw: RawRule) -> Result { - let regex = compile_regex(&raw.id, &raw.matcher.regex, raw.matcher.case_sensitive)?; - Ok(Self { - id: raw.id, - hook: raw.hook, - target: RuleTarget::compile(raw.target)?, - regex, - action: raw.action.validate()?, - when: raw.when.unwrap_or_default(), - }) - } -} - -#[derive(Debug, Deserialize)] -struct RuleDocument { - rules: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct RawRule { - id: String, - hook: String, - target: RawRuleTarget, - #[serde(rename = "match")] - matcher: RuleMatcher, - action: RuleAction, - #[serde(default)] - when: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct RawRuleTarget { - field: String, - #[serde(default)] - json_path: Option, -} - -#[derive(Debug, Clone)] -struct RuleTarget { - field: TargetField, - json_path: Option>, -} - -impl RuleTarget { - fn compile(raw: RawRuleTarget) -> Result { - Ok(Self { - field: TargetField::parse(&raw.field)?, - json_path: raw.json_path.as_deref().map(parse_json_path).transpose()?, - }) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum TargetField { - RequestBody, - ResponseBody, - StreamChunk, - LogMessage, -} - -impl TargetField { - fn parse(value: &str) -> Result { - match value { - "request.body" => Ok(Self::RequestBody), - "response.body" => Ok(Self::ResponseBody), - "stream.chunk" => Ok(Self::StreamChunk), - "log.message" => Ok(Self::LogMessage), - _ => Err(RuleRuntimeError::new( - "PLUGIN_RULE_INVALID_TARGET", - format!("unsupported rule target field: {value}"), - )), - } - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct RuleMatcher { - regex: String, - #[serde(default)] - case_sensitive: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "kind", rename_all = "camelCase")] -enum RuleAction { - Replace { replacement: String }, - Block { reason: String }, - Warn { message: String }, - AppendMessage { role: String, content: String }, -} - -impl RuleAction { - fn validate(self) -> Result { - if let Self::AppendMessage { role, content } = &self { - if !matches!(role.as_str(), "system" | "developer") { - return Err(RuleRuntimeError::new( - "PLUGIN_RULE_INVALID_ACTION", - "appendMessage role must be system or developer", - )); - } - if content.trim().is_empty() { - return Err(RuleRuntimeError::new( - "PLUGIN_RULE_INVALID_ACTION", - "appendMessage content must not be empty", - )); - } - } - Ok(self) - } -} - -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -struct RuleWhen { - #[serde(default)] - cli_keys: Vec, - #[serde(default)] - models: Vec, - #[serde(default)] - config_equals: std::collections::BTreeMap, -} - -impl RuleWhen { - fn matches(&self, context: &GatewayVisibleHookContext, config: &Value) -> bool { - if !self.cli_keys.is_empty() { - let Some(cli_key) = context.request.cli_key.as_deref() else { - return false; - }; - if !self.cli_keys.iter().any(|item| item == cli_key) { - return false; - } - } - - if !self.models.is_empty() { - let Some(model) = context.request.requested_model.as_deref() else { - return false; - }; - if !self.models.iter().any(|item| item == model) { - return false; - } - } - - for (key, expected) in &self.config_equals { - if config.get(key) != Some(expected) { - return false; - } - } - - true - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum JsonPathSegment { - Key(String), - WildcardArray, -} - -#[derive(Debug, Clone, Copy)] -enum OutputField { - RequestBody, - ResponseBody, - StreamChunk, - LogMessage, -} - -fn compile_regex( - rule_id: &str, - pattern: &str, - case_sensitive: Option, -) -> Result { - if pattern.len() > MAX_RULE_REGEX_PATTERN_BYTES { - return Err(RuleRuntimeError::new( - "PLUGIN_RULE_REGEX_TOO_LARGE", - format!("regex pattern is too large for rule {rule_id}"), - )); - } - RegexBuilder::new(pattern) - .case_insensitive(!case_sensitive.unwrap_or(true)) - .size_limit(MAX_RULE_REGEX_COMPILED_BYTES) - .build() - .map_err(|err| { - RuleRuntimeError::new( - "PLUGIN_RULE_INVALID_REGEX", - format!("invalid regex for rule {rule_id}: {err}"), - ) - }) -} - -fn json_replace_batch_end(rules: &[&CompiledRule], start: usize) -> usize { - let Some(first) = rules.get(start) else { - return start; - }; - let Some(path) = first.target.json_path.as_deref() else { - return start.saturating_add(1); - }; - if !matches!(first.action, RuleAction::Replace { .. }) { - return start.saturating_add(1); - } - - let mut end = start.saturating_add(1); - while let Some(rule) = rules.get(end) { - if rule.target.field != first.target.field - || rule.target.json_path.as_deref() != Some(path) - || !matches!(rule.action, RuleAction::Replace { .. }) - { - break; - } - end = end.saturating_add(1); - } - end -} - -fn apply_json_replace_batch_to_text( - text: &mut Option, - rules: &[&CompiledRule], -) -> Result { - let Some(first) = rules.first() else { - return Ok(false); - }; - let Some(path) = first.target.json_path.as_deref() else { - return Ok(false); - }; - let Some(current) = text.as_mut() else { - return Ok(false); - }; - let Some(mut root) = parse_json_or_skip(current)? else { - return Ok(false); - }; - - let mut matched = false; - apply_to_json_strings_mut(&mut root, path, &mut |candidate| { - for rule in rules { - let RuleAction::Replace { replacement } = &rule.action else { - continue; - }; - if rule.regex.is_match(candidate) { - let next = rule - .regex - .replace_all(candidate, replacement.as_str()) - .into_owned(); - *candidate = next; - matched = true; - } - } - }); - - if matched { - *current = serde_json::to_string(&root).map_err(|err| { - RuleRuntimeError::new( - "PLUGIN_RULE_INVALID_OUTPUT", - format!( - "failed to serialize rewritten JSON for rule {}: {err}", - first.id - ), - ) - })?; - } - - Ok(matched) -} - -fn apply_rule_to_text( - text: &mut Option, - rule: &CompiledRule, - _output_field: OutputField, -) -> Result { - let Some(current) = text.as_mut() else { - return Ok(false); - }; - - match (&rule.target.json_path, &rule.action) { - (Some(path), RuleAction::Replace { replacement }) => { - let Some(mut root) = parse_json_or_skip(current)? else { - return Ok(false); - }; - let mut matched = false; - apply_to_json_strings_mut(&mut root, path, &mut |candidate| { - if rule.regex.is_match(candidate) { - let next = rule - .regex - .replace_all(candidate, replacement.as_str()) - .into_owned(); - *candidate = next; - matched = true; - } - }); - if matched { - *current = serde_json::to_string(&root).map_err(|err| { - RuleRuntimeError::new( - "PLUGIN_RULE_INVALID_OUTPUT", - format!( - "failed to serialize rewritten JSON for rule {}: {err}", - rule.id - ), - ) - })?; - } - Ok(matched) - } - (Some(path), _) => { - let Some(mut root) = parse_json_or_skip(current)? else { - return Ok(false); - }; - let mut matched = false; - apply_to_json_strings_mut(&mut root, path, &mut |candidate| { - if rule.regex.is_match(candidate) { - matched = true; - } - }); - Ok(matched) - } - (None, RuleAction::Replace { replacement }) => { - if !rule.regex.is_match(current) { - return Ok(false); - } - *current = rule - .regex - .replace_all(current, replacement.as_str()) - .into_owned(); - Ok(true) - } - (None, _) => Ok(rule.regex.is_match(current)), - } -} - -fn parse_json_or_skip(text: &str) -> Result, RuleRuntimeError> { - #[cfg(test)] - RULE_RUNTIME_TEST_JSON_PARSE_COUNT.with(|count| { - count.set(count.get().saturating_add(1)); - }); - - match serde_json::from_str::(text) { - Ok(value) => Ok(Some(value)), - Err(err) if err.is_syntax() || err.is_eof() => Ok(None), - Err(err) => Err(RuleRuntimeError::new( - "PLUGIN_RULE_INVALID_JSON", - format!("failed to parse target JSON: {err}"), - )), - } -} - -fn append_chat_message( - request_body: Option<&str>, - role: &str, - content: &str, -) -> Result, RuleRuntimeError> { - let Some(request_body) = request_body else { - return Ok(None); - }; - let Some(mut root) = parse_json_or_skip(request_body)? else { - return Ok(None); - }; - let Some(messages) = root.get_mut("messages").and_then(Value::as_array_mut) else { - return Ok(None); - }; - messages.push(serde_json::json!({ - "role": role, - "content": content, - })); - serde_json::to_string(&root).map(Some).map_err(|err| { - RuleRuntimeError::new( - "PLUGIN_RULE_INVALID_OUTPUT", - format!("failed to serialize appended chat message: {err}"), - ) - }) -} - -fn apply_to_json_strings_mut(value: &mut Value, path: &[JsonPathSegment], f: &mut F) -where - F: FnMut(&mut String), -{ - if path.is_empty() { - if let Value::String(value) = value { - f(value); - } - return; - } - - match &path[0] { - JsonPathSegment::Key(key) => { - if let Some(next) = value.get_mut(key) { - apply_to_json_strings_mut(next, &path[1..], f); - } - } - JsonPathSegment::WildcardArray => { - if let Value::Array(items) = value { - for item in items { - apply_to_json_strings_mut(item, &path[1..], f); - } - } - } - } -} - -fn parse_json_path(path: &str) -> Result, RuleRuntimeError> { - let bytes = path.as_bytes(); - if bytes.first() != Some(&b'$') { - return Err(RuleRuntimeError::new( - "PLUGIN_RULE_INVALID_JSON_PATH", - format!("JSON path must start with $: {path}"), - )); - } - - let mut segments = Vec::new(); - let mut index = 1usize; - while index < bytes.len() { - match bytes[index] { - b'.' => { - index += 1; - let start = index; - while index < bytes.len() && !matches!(bytes[index], b'.' | b'[') { - index += 1; - } - if start == index { - return Err(RuleRuntimeError::new( - "PLUGIN_RULE_INVALID_JSON_PATH", - format!("empty JSON path segment: {path}"), - )); - } - let key = &path[start..index]; - if key.contains('"') || key.contains('\'') { - return Err(RuleRuntimeError::new( - "PLUGIN_RULE_INVALID_JSON_PATH", - format!("quoted JSON path keys are not supported: {path}"), - )); - } - segments.push(JsonPathSegment::Key(key.to_string())); - } - b'[' => { - if bytes.get(index..index + 3) != Some(b"[*]") { - return Err(RuleRuntimeError::new( - "PLUGIN_RULE_INVALID_JSON_PATH", - format!("only [*] array wildcards are supported: {path}"), - )); - } - segments.push(JsonPathSegment::WildcardArray); - index += 3; - } - _ => { - return Err(RuleRuntimeError::new( - "PLUGIN_RULE_INVALID_JSON_PATH", - format!("unsupported JSON path syntax: {path}"), - )); - } - } - } - - Ok(segments) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::gateway::plugins::context::{ - GatewayHookAction, GatewayVisibleHookContext, GatewayVisibleLogContext, - GatewayVisibleRequestContext, GatewayVisibleResponseContext, GatewayVisibleStreamContext, - }; - use crate::plugins::{ - PluginDetail, PluginHook, PluginHostCompatibility, PluginInstallSource, PluginManifest, - PluginPermissionRisk, PluginRuntime, PluginStatus, PluginSummary, - }; - use serde_json::json; - use std::fs; - - fn context_for_request_body(body: serde_json::Value) -> GatewayVisibleHookContext { - context_for_request_body_text(body.to_string()) - } - - fn context_for_request_body_text(body: impl Into) -> GatewayVisibleHookContext { - GatewayVisibleHookContext { - hook_name: "gateway.request.afterBodyRead".to_string(), - trace_id: "trace-rule-test".to_string(), - request: GatewayVisibleRequestContext { - cli_key: Some("codex".to_string()), - method: Some("POST".to_string()), - path: Some("/v1/chat/completions".to_string()), - query: None, - headers: None, - body: Some(body.into()), - requested_model: Some("gpt-test".to_string()), - ..GatewayVisibleRequestContext::default() - }, - response: GatewayVisibleResponseContext::default(), - stream: GatewayVisibleStreamContext::default(), - log: GatewayVisibleLogContext::default(), - } - } - - fn context_for_log_message(message: &str) -> GatewayVisibleHookContext { - GatewayVisibleHookContext { - hook_name: "log.beforePersist".to_string(), - trace_id: "trace-rule-test".to_string(), - request: GatewayVisibleRequestContext::default(), - response: GatewayVisibleResponseContext::default(), - stream: GatewayVisibleStreamContext::default(), - log: GatewayVisibleLogContext { - message: Some(message.to_string()), - }, - } - } - - fn context_for_response_body(body: serde_json::Value) -> GatewayVisibleHookContext { - GatewayVisibleHookContext { - hook_name: "gateway.response.after".to_string(), - trace_id: "trace-rule-test".to_string(), - request: GatewayVisibleRequestContext::default(), - response: GatewayVisibleResponseContext { - status: Some(200), - headers: None, - body: Some(body.to_string()), - }, - stream: GatewayVisibleStreamContext::default(), - log: GatewayVisibleLogContext::default(), - } - } - - fn context_for_stream_chunk(chunk: &str) -> GatewayVisibleHookContext { - GatewayVisibleHookContext { - hook_name: "gateway.response.chunk".to_string(), - trace_id: "trace-rule-test".to_string(), - request: GatewayVisibleRequestContext::default(), - response: GatewayVisibleResponseContext::default(), - stream: GatewayVisibleStreamContext { - sequence: Some(1), - chunk: Some(chunk.to_string()), - }, - log: GatewayVisibleLogContext::default(), - } - } - - fn rule_plugin(plugin_id: &str, version: &str, installed_dir: String) -> PluginDetail { - PluginDetail { - summary: PluginSummary { - id: 1, - plugin_id: plugin_id.to_string(), - name: plugin_id.to_string(), - current_version: Some(version.to_string()), - status: PluginStatus::Enabled, - runtime: "declarativeRules".to_string(), - permission_risk: PluginPermissionRisk::High, - update_available: false, - last_error: None, - created_at: 1, - updated_at: 1, - }, - manifest: PluginManifest { - id: plugin_id.to_string(), - name: plugin_id.to_string(), - version: version.to_string(), - api_version: "1.0.0".to_string(), - runtime: PluginRuntime::DeclarativeRules { - rules: vec!["rules/main.json".to_string()], - }, - hooks: vec![PluginHook { - name: "gateway.request.afterBodyRead".to_string(), - priority: 10, - failure_policy: Some("fail-open".to_string()), - }], - permissions: vec![ - "request.body.read".to_string(), - "request.body.write".to_string(), - ], - host_compatibility: PluginHostCompatibility { - app: ">=0.56.0 <1.0.0".to_string(), - plugin_api: "^1.0.0".to_string(), - platforms: vec![], - }, - entry: None, - config_schema: None, - config_version: None, - description: None, - author: None, - homepage: None, - repository: None, - license: None, - checksum: None, - signature: None, - category: None, - }, - install_source: PluginInstallSource::Official, - installed_dir: Some(installed_dir), - config: json!({}), - granted_permissions: vec![ - "request.body.read".to_string(), - "request.body.write".to_string(), - ], - pending_permissions: vec![], - audit_logs: vec![], - runtime_failures: vec![], - } - } - - fn rule_plugin_with_updated_at( - plugin_id: &str, - version: &str, - installed_dir: String, - updated_at: i64, - ) -> PluginDetail { - let mut plugin = rule_plugin(plugin_id, version, installed_dir); - plugin.summary.updated_at = updated_at; - plugin - } - - fn official_privacy_filter_detail(config: serde_json::Value) -> PluginDetail { - let fixture = crate::app::plugins::official::official_plugin("official.privacy-filter") - .expect("official privacy filter fixture"); - let permissions = fixture.manifest.permissions.clone(); - PluginDetail { - summary: PluginSummary { - id: 1, - plugin_id: fixture.manifest.id.clone(), - name: fixture.manifest.name.clone(), - current_version: Some(fixture.manifest.version.clone()), - status: PluginStatus::Enabled, - runtime: "native:privacyFilter".to_string(), - permission_risk: PluginPermissionRisk::High, - update_available: false, - last_error: None, - created_at: 1, - updated_at: 1, - }, - manifest: fixture.manifest, - install_source: PluginInstallSource::Official, - installed_dir: Some(fixture.root_dir.to_string_lossy().to_string()), - config, - granted_permissions: permissions, - pending_permissions: vec![], - audit_logs: vec![], - runtime_failures: vec![], - } - } - - fn execute_official_privacy_filter_request( - config: serde_json::Value, - body: impl Into, - ) -> serde_json::Value { - let executor = RuleRuntimeGatewayPluginExecutor::default(); - let plugin = official_privacy_filter_detail(config); - let result = executor - .execute_official_privacy_filter_plugin(&plugin, context_for_request_body_text(body)) - .expect("privacy filter request hook"); - let output = result - .request_body - .expect("request body should be redacted"); - serde_json::from_str(&output).unwrap_or_else(|err| { - panic!("redacted request body should remain valid JSON: {err}; body={output}") - }) - } - - fn default_privacy_filter_config() -> serde_json::Value { - json!({ - "redactBeforeUpstream": true, - "redactLogs": true - }) - } - - fn write_rule_file(root: &std::path::Path, replacement: &str) { - let rules_dir = root.join("rules"); - fs::create_dir_all(&rules_dir).expect("create rules dir"); - fs::write( - rules_dir.join("main.json"), - json!({ - "rules": [{ - "id": "replace-secret", - "hook": "gateway.request.afterBodyRead", - "target": { - "field": "request.body", - "jsonPath": "$.messages[*].content" - }, - "match": { "regex": "secret" }, - "action": { - "kind": "replace", - "replacement": replacement - } - }] - }) - .to_string(), - ) - .expect("write rule file"); - } - - #[test] - fn official_privacy_filter_redacts_phone_numbers_in_provider_request_shapes() { - let executor = RuleRuntimeGatewayPluginExecutor::default(); - let plugin = official_privacy_filter_detail(json!({ - "redactBeforeUpstream": true, - "redactLogs": true - })); - - for (name, body) in [ - ( - "claude", - r#"{"messages":[{"role":"user","content":[{"type":"text","text":"phone 13344441520"}]}]}"#, - ), - ( - "openai_chat", - r#"{"messages":[{"role":"user","content":"phone 13344441520"}]}"#, - ), - ( - "codex_responses", - r#"{"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"phone 13344441520"}]}]}"#, - ), - ("raw_text", "phone 13344441520"), - ] { - let context = context_for_request_body_text(body); - let result = executor - .execute_official_privacy_filter_plugin(&plugin, context) - .unwrap_or_else(|err| panic!("{name} privacy filter failed: {err}")); - let output = result - .request_body - .expect("request body should be redacted"); - assert!( - !output.contains("13344441520"), - "{name} leaked phone number: {output}" - ); - } - } - - #[test] - fn official_privacy_filter_redacts_before_send_request_bodies() { - let executor = RuleRuntimeGatewayPluginExecutor::default(); - let plugin = official_privacy_filter_detail(json!({ - "redactBeforeUpstream": true, - "redactLogs": true - })); - - let mut context = context_for_request_body_text( - r#"{"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"phone 13344441520"}]}]}"#, - ); - context.hook_name = "gateway.request.beforeSend".to_string(); - - let result = executor - .execute_official_privacy_filter_plugin(&plugin, context) - .expect("privacy filter beforeSend hook"); - - let output = result - .request_body - .expect("request body should be redacted"); - assert!(output.contains("[电话]")); - assert!(!output.contains("13344441520")); - } - - #[test] - fn official_privacy_filter_redacts_only_claude_allowlisted_fields() { - let tool_use_id = "toolu_123"; - let output = execute_official_privacy_filter_request( - default_privacy_filter_config(), - json!({ - "system": [ - { "type": "text", "text": "系统邮箱 sys@example.com" } - ], - "tools": [ - { - "name": "send_email", - "description": "Send to admin@example.com", - "input_schema": { - "type": "object", - "properties": { - "recipient": { - "type": "string", - "description": "Email like schema@example.com" - } - } - } - } - ], - "messages": [ - { - "role": "user", - "content": [ - { "type": "text", "text": "我的邮箱 user@example.com" } - ] - }, - { - "role": "assistant", - "content": [ - { - "type": "tool_use", - "id": tool_use_id, - "name": "send_email", - "input": { "recipient": "tool-input@example.com" } - } - ] - }, - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": tool_use_id, - "content": "工具读取到 result@example.com" - } - ] - } - ] - }) - .to_string(), - ); - - assert_eq!( - output["tools"][0]["description"], - "Send to admin@example.com" - ); - assert_eq!( - output["tools"][0]["input_schema"]["properties"]["recipient"]["description"], - "Email like schema@example.com" - ); - assert_eq!( - output["messages"][1]["content"][0]["input"]["recipient"], - "tool-input@example.com" - ); - assert_eq!(output["messages"][1]["content"][0]["id"], tool_use_id); - assert!(output["system"][0]["text"] - .as_str() - .is_some_and(|text| text.contains("[邮箱]"))); - assert!(output["messages"][0]["content"][0]["text"] - .as_str() - .is_some_and(|text| text.contains("[邮箱]"))); - assert!(output["messages"][2]["content"][0]["content"] - .as_str() - .is_some_and(|text| text.contains("[邮箱]"))); - } - - #[test] - fn official_privacy_filter_respects_disabled_tool_result_scope() { - let output = execute_official_privacy_filter_request( - json!({ - "redactBeforeUpstream": true, - "redactLogs": true, - "redactionScopes": ["system_instructions", "user_prompts", "legacy_prompt"] - }), - json!({ - "messages": [ - { - "role": "user", - "content": [ - { "type": "text", "text": "用户邮箱 user@example.com" } - ] - }, - { - "role": "user", - "content": [ - { "type": "tool_result", "tool_use_id": "toolu_123", "content": "工具结果 result@example.com" } - ] - } - ] - }) - .to_string(), - ); - - assert_eq!( - output["messages"][1]["content"][0]["content"], - "工具结果 result@example.com" - ); - assert!(output["messages"][0]["content"][0]["text"] - .as_str() - .is_some_and(|text| text.contains("[邮箱]"))); - } - - #[test] - fn official_privacy_filter_redacts_only_openai_responses_allowlisted_fields() { - let output = execute_official_privacy_filter_request( - default_privacy_filter_config(), - json!({ - "instructions": "系统邮箱 sys@example.com", - "tools": [ - { - "type": "function", - "name": "lookup", - "description": "Lookup admin@example.com", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Query schema@example.com" - } - } - } - } - ], - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { "type": "input_text", "text": "用户邮箱 user@example.com" } - ] - }, - { - "type": "function_call", - "call_id": "call_123", - "name": "lookup", - "arguments": "{\"email\":\"args@example.com\"}" - }, - { - "type": "function_call_output", - "call_id": "call_123", - "output": "工具输出 result@example.com" - } - ] - }) - .to_string(), - ); - - assert_eq!( - output["tools"][0]["description"], - "Lookup admin@example.com" - ); - assert_eq!( - output["tools"][0]["parameters"]["properties"]["query"]["description"], - "Query schema@example.com" - ); - assert_eq!( - output["input"][1]["arguments"], - "{\"email\":\"args@example.com\"}" - ); - assert!(output["instructions"] - .as_str() - .is_some_and(|text| text.contains("[邮箱]"))); - assert!(output["input"][0]["content"][0]["text"] - .as_str() - .is_some_and(|text| text.contains("[邮箱]"))); - assert!(output["input"][2]["output"] - .as_str() - .is_some_and(|text| text.contains("[邮箱]"))); - } - - #[test] - fn official_privacy_filter_redacts_codex_responses_payload_shape() { - let output = execute_official_privacy_filter_request( - default_privacy_filter_config(), - json!({ - "model": "gpt-5.5", - "instructions": "developer prompt with sys@example.com", - "input": [ - { - "type": "message", - "role": "developer", - "content": [ - { - "type": "input_text", - "text": "developer-visible phone 13344441521" - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "你知道 13344441520 是哪里的手机号嘛" - } - ] - }, - { - "type": "function_call", - "call_id": "call_123", - "name": "lookup_phone", - "arguments": "{\"phone\":\"13344441522\"}" - } - ], - "tools": [ - { - "type": "function", - "name": "lookup_phone", - "description": "Lookup 13344441523", - "parameters": { - "type": "object", - "properties": { - "phone": { - "type": "string", - "description": "Phone like 13344441524" - } - } - } - } - ], - "tool_choice": "auto", - "reasoning": { "effort": "xhigh" }, - "client_metadata": { - "x-codex-window-id": "13344441525" - } - }) - .to_string(), - ); - - assert!(output["instructions"] - .as_str() - .is_some_and(|text| text.contains("[邮箱]"))); - assert!(output["input"][0]["content"][0]["text"] - .as_str() - .is_some_and(|text| text.contains("[电话]"))); - assert!(output["input"][1]["content"][0]["text"] - .as_str() - .is_some_and(|text| text.contains("[电话]"))); - assert_eq!( - output["input"][2]["arguments"], - "{\"phone\":\"13344441522\"}" - ); - assert_eq!(output["tools"][0]["description"], "Lookup 13344441523"); - assert_eq!( - output["tools"][0]["parameters"]["properties"]["phone"]["description"], - "Phone like 13344441524" - ); - assert_eq!( - output["client_metadata"]["x-codex-window-id"], - "13344441525" - ); - } - - #[test] - fn official_privacy_filter_redacts_only_chat_allowlisted_fields() { - let output = execute_official_privacy_filter_request( - default_privacy_filter_config(), - json!({ - "messages": [ - { "role": "system", "content": "系统邮箱 sys@example.com" }, - { "role": "user", "content": "用户邮箱 user@example.com" }, - { - "role": "assistant", - "tool_calls": [ - { - "id": "call_123", - "type": "function", - "function": { - "name": "lookup", - "arguments": "{\"email\":\"args@example.com\"}" - } - } - ] - }, - { - "role": "tool", - "tool_call_id": "call_123", - "content": "工具输出 result@example.com" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "lookup", - "description": "Lookup admin@example.com", - "parameters": { - "type": "object", - "properties": { - "email": { "type": "string", "description": "Schema schema@example.com" } - } - } - } - } - ] - }) - .to_string(), - ); - - assert_eq!( - output["messages"][2]["tool_calls"][0]["function"]["arguments"], - "{\"email\":\"args@example.com\"}" - ); - assert_eq!( - output["tools"][0]["function"]["description"], - "Lookup admin@example.com" - ); - assert_eq!( - output["tools"][0]["function"]["parameters"]["properties"]["email"]["description"], - "Schema schema@example.com" - ); - assert!(output["messages"][0]["content"] - .as_str() - .is_some_and(|text| text.contains("[邮箱]"))); - assert!(output["messages"][1]["content"] - .as_str() - .is_some_and(|text| text.contains("[邮箱]"))); - assert!(output["messages"][3]["content"] - .as_str() - .is_some_and(|text| text.contains("[邮箱]"))); - } - - #[test] - fn official_privacy_filter_respects_legacy_prompt_scope_for_raw_text() { - let executor = RuleRuntimeGatewayPluginExecutor::default(); - let plugin = official_privacy_filter_detail(json!({ - "redactBeforeUpstream": true, - "redactLogs": true, - "redactionScopes": ["system_instructions", "user_prompts", "tool_results"] - })); - - let result = executor - .execute_official_privacy_filter_plugin( - &plugin, - context_for_request_body_text("raw email raw@example.com"), - ) - .expect("privacy filter request hook"); - - assert!( - result.request_body.is_none(), - "raw text should not be redacted when legacy_prompt scope is disabled" - ); - } - - #[test] - fn official_privacy_filter_log_redaction_ignores_request_redaction_scopes() { - let executor = RuleRuntimeGatewayPluginExecutor::default(); - let plugin = official_privacy_filter_detail(json!({ - "redactBeforeUpstream": true, - "redactLogs": true, - "redactionScopes": [] - })); - let context = context_for_log_message("trace email log@example.com"); - - let result = executor - .execute_official_privacy_filter_plugin(&plugin, context) - .expect("privacy filter log hook"); - - let message = result.log_message.expect("log message should be redacted"); - assert!(message.contains("[邮箱]")); - assert!(!message.contains("log@example.com")); - } - - #[test] - fn official_privacy_filter_preserves_claude_tool_use_protocol_ids() { - let executor = RuleRuntimeGatewayPluginExecutor::default(); - let plugin = official_privacy_filter_detail(json!({ - "redactBeforeUpstream": true, - "redactLogs": true - })); - let tool_use_id = "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ"; - let tool_use_input_phone = "13344441520"; - let tool_result_phone = "13344441521"; - let context = context_for_request_body_text( - json!({ - "messages": [ - { - "role": "assistant", - "content": [ - { - "type": "tool_use", - "id": tool_use_id, - "name": "lookup_phone", - "input": { "query": format!("你知道 {tool_use_input_phone} 是哪里的手机号嘛") } - } - ] - }, - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": tool_use_id, - "content": format!("手机号 {tool_result_phone} 查询完成") - } - ] - } - ] - }) - .to_string(), - ); - - let result = executor - .execute_official_privacy_filter_plugin(&plugin, context) - .expect("privacy filter request hook"); - - let output = result - .request_body - .expect("request body should be redacted"); - assert!( - output.contains(tool_use_id), - "tool id was changed: {output}" - ); - assert!( - output.contains(tool_use_input_phone), - "tool input should remain unchanged: {output}" - ); - assert!(output.contains("[电话]")); - assert!(!output.contains(tool_result_phone)); - } - - #[test] - fn official_privacy_filter_redacts_log_messages_after_request_redaction() { - let executor = RuleRuntimeGatewayPluginExecutor::default(); - let plugin = official_privacy_filter_detail(json!({ - "redactBeforeUpstream": true, - "redactLogs": true - })); - let context = context_for_log_message("trace log 13344441520"); - - let result = executor - .execute_official_privacy_filter_plugin(&plugin, context) - .expect("privacy filter log hook"); - - let message = result.log_message.expect("log message should be redacted"); - assert!(!message.contains("13344441520")); - } - - #[test] - fn official_privacy_filter_respects_sensitive_types_config() { - let executor = RuleRuntimeGatewayPluginExecutor::default(); - let plugin = official_privacy_filter_detail(json!({ - "redactBeforeUpstream": true, - "redactLogs": true, - "sensitiveTypes": ["email"] - })); - - let result = executor - .execute_official_privacy_filter_plugin( - &plugin, - context_for_request_body_text( - r#"{"messages":[{"role":"user","content":"email test@example.com phone 13344441520"}]}"#, - ), - ) - .expect("privacy filter request hook"); - - let output = result - .request_body - .expect("request body should be redacted"); - assert!(output.contains("[邮箱]")); - assert!(!output.contains("test@example.com")); - assert!( - output.contains("13344441520"), - "cn_phone should remain visible when sensitiveTypes omits it: {output}" - ); - } - - #[test] - fn official_privacy_filter_allows_disabling_all_sensitive_types() { - let executor = RuleRuntimeGatewayPluginExecutor::default(); - let plugin = official_privacy_filter_detail(json!({ - "redactBeforeUpstream": true, - "redactLogs": true, - "sensitiveTypes": [] - })); - - let result = executor - .execute_official_privacy_filter_plugin( - &plugin, - context_for_request_body_text( - r#"{"messages":[{"role":"user","content":"email test@example.com phone 13344441520"}]}"#, - ), - ) - .expect("privacy filter request hook"); - - assert!( - result.request_body.is_none(), - "empty sensitiveTypes should disable every configured strategy" - ); - } - - #[test] - fn rule_plugin_runtime_replaces_regex_hits_at_json_path() { - let runtime = RuleRuntime::from_value(json!({ - "rules": [{ - "id": "redact-api-key", - "hook": "gateway.request.afterBodyRead", - "target": { - "field": "request.body", - "jsonPath": "$.messages[*].content" - }, - "match": { "regex": "sk-[A-Za-z0-9]{8}" }, - "action": { - "kind": "replace", - "replacement": "[REDACTED]" - } - }] - })) - .expect("rules parse"); - let ctx = context_for_request_body(json!({ - "messages": [ - { "role": "user", "content": "token sk-12345678 should disappear" } - ] - })); - - let result = runtime.execute(&ctx, &json!({})).expect("rules execute"); - - assert_eq!(result.action, GatewayHookAction::Continue); - let body = result.request_body.expect("rewritten body"); - assert!(body.contains("[REDACTED]")); - assert!(!body.contains("sk-12345678")); - } - - #[test] - fn rule_runtime_batches_same_target_json_rewrites() { - let runtime = RuleRuntime::from_value(json!({ - "rules": [ - { - "id": "redact-api-key", - "hook": "gateway.request.afterBodyRead", - "target": { - "field": "request.body", - "jsonPath": "$.messages[*].content" - }, - "match": { "regex": "sk-[A-Za-z0-9]{8}" }, - "action": { - "kind": "replace", - "replacement": "[KEY]" - } - }, - { - "id": "redact-phone", - "hook": "gateway.request.afterBodyRead", - "target": { - "field": "request.body", - "jsonPath": "$.messages[*].content" - }, - "match": { "regex": "1[3-9][0-9]{9}" }, - "action": { - "kind": "replace", - "replacement": "[PHONE]" - } - } - ] - })) - .expect("rules parse"); - let ctx = context_for_request_body(json!({ - "messages": [ - { - "role": "user", - "content": "token sk-12345678 phone 13812345678" - } - ] - })); - - reset_json_parse_count_for_tests(); - let result = runtime.execute(&ctx, &json!({})).expect("rules execute"); - - let body = result.request_body.expect("rewritten body"); - assert!(body.contains("[KEY]")); - assert!(body.contains("[PHONE]")); - assert!(!body.contains("sk-12345678")); - assert!(!body.contains("13812345678")); - assert_eq!( - json_parse_count_for_tests(), - 1, - "same target JSON rewrites should parse the body once" - ); - } - - #[test] - fn rule_plugin_runtime_blocks_regex_hits_in_response_body() { - let runtime = RuleRuntime::from_value(json!({ - "rules": [{ - "id": "dangerous-shell", - "hook": "gateway.response.after", - "target": { - "field": "response.body", - "jsonPath": "$.choices[*].message.content" - }, - "match": { "regex": "rm\\s+-rf\\s+/" }, - "action": { - "kind": "block", - "reason": "dangerous shell command detected" - } - }] - })) - .expect("rules parse"); - let ctx = context_for_response_body(json!({ - "choices": [ - { "message": { "content": "Run rm -rf / to clean up." } } - ] - })); - - let result = runtime.execute(&ctx, &json!({})).expect("rules execute"); - - assert_eq!(result.action, GatewayHookAction::Block); - assert_eq!( - result.reason.as_deref(), - Some("dangerous shell command detected") - ); - } - - #[test] - fn rule_plugin_runtime_warns_without_mutating_stream_chunks() { - let runtime = RuleRuntime::from_value(json!({ - "rules": [{ - "id": "curl-pipe-shell", - "hook": "gateway.response.chunk", - "target": { "field": "stream.chunk" }, - "match": { "regex": "curl\\s+[^|]+\\|\\s*sh" }, - "action": { - "kind": "warn", - "message": "curl pipe shell pattern detected" - } - }] - })) - .expect("rules parse"); - let ctx = context_for_stream_chunk("data: curl https://example.test/install.sh | sh\n\n"); - - let result = runtime.execute(&ctx, &json!({})).expect("rules execute"); - - assert_eq!(result.action, GatewayHookAction::Continue); - assert_eq!( - result.reason.as_deref(), - Some("curl pipe shell pattern detected") - ); - assert_eq!(result.stream_chunk, None); - } - - #[test] - fn rule_plugin_runtime_appends_system_or_developer_messages() { - let runtime = RuleRuntime::from_value(json!({ - "rules": [{ - "id": "append-system-instruction", - "hook": "gateway.request.afterBodyRead", - "target": { "field": "request.body" }, - "match": { "regex": "." }, - "action": { - "kind": "appendMessage", - "role": "system", - "content": "Answer concisely." - } - }] - })) - .expect("rules parse"); - let ctx = context_for_request_body(json!({ - "messages": [ - { "role": "user", "content": "hello" } - ] - })); - - let result = runtime.execute(&ctx, &json!({})).expect("rules execute"); - let body: serde_json::Value = - serde_json::from_str(&result.request_body.expect("rewritten body")).unwrap(); - let messages = body["messages"].as_array().expect("messages array"); - - assert_eq!(messages.len(), 2); - assert_eq!(messages[1]["role"], "system"); - assert_eq!(messages[1]["content"], "Answer concisely."); - } - - #[test] - fn rule_plugin_runtime_rejects_oversized_regex_patterns() { - let pattern = "a".repeat(MAX_RULE_REGEX_PATTERN_BYTES + 1); - let err = RuleRuntime::from_value(json!({ - "rules": [{ - "id": "too-large-regex", - "hook": "log.beforePersist", - "target": { "field": "log.message" }, - "match": { "regex": pattern }, - "action": { "kind": "warn", "message": "never" } - }] - })) - .unwrap_err(); - - assert_eq!(err.code(), "PLUGIN_RULE_REGEX_TOO_LARGE"); - } - - #[test] - fn rule_runtime_executor_reloads_when_same_plugin_id_version_changes() { - let dir = tempfile::tempdir().expect("temp dir"); - let v1_dir = dir.path().join("plugin-v1"); - let v2_dir = dir.path().join("plugin-v2"); - write_rule_file(&v1_dir, "[V1]"); - write_rule_file(&v2_dir, "[V2]"); - let executor = RuleRuntimeGatewayPluginExecutor::default(); - let ctx = context_for_request_body(json!({ - "messages": [{ "role": "user", "content": "secret" }] - })); - - let v1 = executor - .execute_declarative_rules_plugin( - &rule_plugin( - "test.same-plugin", - "1.0.0", - v1_dir.to_string_lossy().to_string(), - ), - ctx.clone(), - ) - .expect("execute v1"); - assert!(v1.request_body.expect("v1 body").contains("[V1]")); - - let v2 = executor - .execute_declarative_rules_plugin( - &rule_plugin( - "test.same-plugin", - "2.0.0", - v2_dir.to_string_lossy().to_string(), - ), - ctx, - ) - .expect("execute v2"); - - let body = v2.request_body.expect("v2 body"); - assert!( - body.contains("[V2]"), - "expected reloaded v2 rules, got {body}" - ); - assert!( - !body.contains("[V1]"), - "stale v1 rules leaked into v2: {body}" - ); - } - - #[test] - fn rule_runtime_executor_reloads_when_same_version_path_updated_at_changes() { - let dir = tempfile::tempdir().expect("temp dir"); - write_rule_file(dir.path(), "[OLD]"); - let executor = RuleRuntimeGatewayPluginExecutor::default(); - let ctx = context_for_request_body(json!({ - "messages": [{ "role": "user", "content": "secret" }] - })); - let root = dir.path().to_string_lossy().to_string(); - - let old = executor - .execute_declarative_rules_plugin( - &rule_plugin_with_updated_at("test.same-plugin", "1.0.0", root.clone(), 1), - ctx.clone(), - ) - .expect("execute old rules"); - assert!(old.request_body.expect("old body").contains("[OLD]")); - - write_rule_file(dir.path(), "[NEW]"); - let new = executor - .execute_declarative_rules_plugin( - &rule_plugin_with_updated_at("test.same-plugin", "1.0.0", root, 2), - ctx, - ) - .expect("execute new rules"); - - let body = new.request_body.expect("new body"); - assert!( - body.contains("[NEW]"), - "expected updated same-version rules, got {body}" - ); - assert!( - !body.contains("[OLD]"), - "stale same-version rules leaked after updated_at changed: {body}" - ); - } - - #[test] - fn rule_runtime_prunes_cache_entries_not_in_active_plugin_keys() { - let dir = tempfile::tempdir().expect("temp dir"); - let first_dir = dir.path().join("first"); - let second_dir = dir.path().join("second"); - write_rule_file(&first_dir, "[FIRST]"); - write_rule_file(&second_dir, "[SECOND]"); - let executor = RuleRuntimeGatewayPluginExecutor::default(); - let first = rule_plugin( - "acme.rules", - "1.0.0", - first_dir.to_string_lossy().to_string(), - ); - let second = rule_plugin( - "acme.other", - "1.0.0", - second_dir.to_string_lossy().to_string(), - ); - let context = context_for_request_body(json!({ - "messages": [{ "role": "user", "content": "secret" }] - })); - - executor - .execute_declarative_rules_plugin(&first, context.clone()) - .expect("first rule runtime loads"); - executor - .execute_declarative_rules_plugin(&second, context) - .expect("second rule runtime loads"); - assert_eq!(executor.cache_sizes_for_tests().0, 2); - - executor.retain_runtime_caches_for_plugins(&[first]); - - assert_eq!(executor.cache_sizes_for_tests().0, 1); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn rule_runtime_cache_does_not_hold_mutex_during_execution() { - let dir = tempfile::tempdir().expect("temp dir"); - write_rule_file(dir.path(), "[REDACTED]"); - let plugin = Arc::new(rule_plugin( - "test.slow-plugin", - "1.0.0", - dir.path().to_string_lossy().to_string(), - )); - let context = Arc::new(context_for_request_body(json!({ - "messages": [{ - "role": "user", - "content": "aaaaaaaaaaaaaaaaaaaaaaaaa" - }] - }))); - let executor = Arc::new(RuleRuntimeGatewayPluginExecutor::default()); - executor - .execute_declarative_rules_plugin(&plugin, (*context).clone()) - .expect("warm cache"); - - RULE_RUNTIME_TEST_DELAY_MS.store(120, Ordering::SeqCst); - let start = std::time::Instant::now(); - let first_executor = Arc::clone(&executor); - let first_plugin = Arc::clone(&plugin); - let first_context = Arc::clone(&context); - let second_executor = Arc::clone(&executor); - let second_plugin = Arc::clone(&plugin); - let second_context = Arc::clone(&context); - let (first, second) = tokio::join!( - tokio::task::spawn_blocking(move || { - first_executor - .execute_declarative_rules_plugin(&first_plugin, (*first_context).clone()) - }), - tokio::task::spawn_blocking(move || { - second_executor - .execute_declarative_rules_plugin(&second_plugin, (*second_context).clone()) - }), - ); - - first.expect("first join").expect("first execution"); - second.expect("second join").expect("second execution"); - RULE_RUNTIME_TEST_DELAY_MS.store(0, Ordering::SeqCst); - assert!( - start.elapsed() < std::time::Duration::from_millis(180), - "runtime executions appear serialized by cache lock: {:?}", - start.elapsed() - ); - } -} diff --git a/src-tauri/src/app/plugins/runtime_cache.rs b/src-tauri/src/app/plugins/runtime_cache.rs new file mode 100644 index 00000000..a8e96b57 --- /dev/null +++ b/src-tauri/src/app/plugins/runtime_cache.rs @@ -0,0 +1,67 @@ +//! Usage: Shared plugin runtime cache key helpers. + +pub(crate) struct RuntimeCacheKeyInput<'a> { + pub(crate) plugin_id: &'a str, + pub(crate) version: &'a str, + pub(crate) installed_dir: &'a str, + pub(crate) updated_at: i64, + pub(crate) runtime_key: &'a str, +} + +pub(crate) fn runtime_cache_key(input: RuntimeCacheKeyInput<'_>) -> String { + format!( + "{}\u{1e}{}\u{1e}{}\u{1e}{}\u{1e}{}", + input.plugin_id, input.version, input.installed_dir, input.updated_at, input.runtime_key + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cache_key_changes_with_updated_at() { + let first = runtime_cache_key(RuntimeCacheKeyInput { + plugin_id: "plugin.alpha", + version: "1.0.0", + installed_dir: "/plugins/alpha", + updated_at: 10, + runtime_key: "rules/a.json", + }); + let second = runtime_cache_key(RuntimeCacheKeyInput { + plugin_id: "plugin.alpha", + version: "1.0.0", + installed_dir: "/plugins/alpha", + updated_at: 11, + runtime_key: "rules/a.json", + }); + + assert_ne!(first, second); + assert_eq!( + first, + "plugin.alpha\u{1e}1.0.0\u{1e}/plugins/alpha\u{1e}10\u{1e}rules/a.json" + ); + } + + #[test] + fn cache_key_includes_runtime_key() { + let rule_key = runtime_cache_key(RuntimeCacheKeyInput { + plugin_id: "plugin.alpha", + version: "1.0.0", + installed_dir: "/plugins/alpha", + updated_at: 10, + runtime_key: "rules/a.json\u{1f}rules/b.json", + }); + let native_key = runtime_cache_key(RuntimeCacheKeyInput { + plugin_id: "plugin.alpha", + version: "1.0.0", + installed_dir: "/plugins/alpha", + updated_at: 10, + runtime_key: "native:hostPrivateRedactor", + }); + + assert_ne!(rule_key, native_key); + assert!(rule_key.ends_with("\u{1e}rules/a.json\u{1f}rules/b.json")); + assert!(native_key.ends_with("\u{1e}native:hostPrivateRedactor")); + } +} diff --git a/src-tauri/src/app/plugins/runtime_executor.rs b/src-tauri/src/app/plugins/runtime_executor.rs index 090b7506..8cd5b0c9 100644 --- a/src-tauri/src/app/plugins/runtime_executor.rs +++ b/src-tauri/src/app/plugins/runtime_executor.rs @@ -1,66 +1,144 @@ //! Usage: Runtime dispatch for gateway plugin execution. -use crate::domain::plugins::{PluginDetail, PluginRuntime}; -use crate::gateway::plugins::context::{GatewayHookResult, GatewayVisibleHookContext}; +use crate::app::plugins::extension_host_registry::{ + ExtensionHostInstanceLifecycleRegistry, ExtensionHostInstanceRegistry, +}; +use crate::app::plugins::privacy_redaction_service::PrivacyRedactionService; +use crate::app::plugins::runtime_lifecycle::RuntimeLifecycleRegistry; +use crate::app::plugins::runtime_manager::{PluginRuntimeManager, RuntimeDispatch}; +use crate::db; +use crate::domain::plugins::PluginDetail; +#[cfg(test)] +use crate::gateway::plugins::context::GatewayHookResult; +use crate::gateway::plugins::context::GatewayVisibleHookContext; use crate::gateway::plugins::permissions::GatewayPluginError; use crate::gateway::plugins::pipeline::{GatewayHookFuture, GatewayPluginExecutor}; +use std::sync::Arc; +use std::time::Duration; -#[derive(Debug, Clone, Copy, Default)] -pub(crate) struct RuntimeExecutionPolicy { - pub(crate) wasm_enabled: bool, -} - -#[derive(Default)] pub(crate) struct RuntimeGatewayPluginExecutor { - rule_runtime: crate::app::plugins::rule_runtime::RuleRuntimeGatewayPluginExecutor, - policy: RuntimeExecutionPolicy, + lifecycle: RuntimeLifecycleRegistry, + extension_host_registry: Option>, } impl RuntimeGatewayPluginExecutor { - #[cfg(test)] - pub(crate) fn for_tests(policy: RuntimeExecutionPolicy) -> Self { + pub(crate) fn new() -> Self { + Self::with_extension_host_registry(None, Arc::new(PrivacyRedactionService::default())) + } + + pub(crate) fn with_db(db: db::Db) -> Self { + let privacy_redaction = Arc::new(PrivacyRedactionService::default()); + Self::with_extension_host_registry( + Some(Arc::new( + ExtensionHostInstanceRegistry::new_with_privacy_redaction( + db, + privacy_redaction.clone(), + ), + )), + privacy_redaction, + ) + } + + fn with_extension_host_registry( + extension_host_registry: Option>, + privacy_redaction: Arc, + ) -> Self { + let lifecycle = RuntimeLifecycleRegistry::default(); + lifecycle.register_cache(privacy_redaction.clone()); + if let Some(registry) = extension_host_registry.clone() { + lifecycle.register_instance_registry(Arc::new( + ExtensionHostInstanceLifecycleRegistry::new(registry), + )); + } Self { - rule_runtime: - crate::app::plugins::rule_runtime::RuleRuntimeGatewayPluginExecutor::default(), - policy, + lifecycle, + extension_host_registry, } } + #[cfg(test)] + fn for_tests_with_extension_host_registry( + extension_host_registry: Arc, + ) -> Self { + Self::with_extension_host_registry( + Some(extension_host_registry), + Arc::new(PrivacyRedactionService::default()), + ) + } + + #[cfg(test)] + pub(crate) fn for_tests() -> Self { + let temp = tempfile::tempdir().expect("tempdir"); + let db = crate::db::init_for_tests(&temp.path().join("runtime-executor.db")) + .expect("init test db"); + Self::with_db(db) + } + + #[cfg(test)] pub(crate) fn execute_plugin_sync( &self, plugin: &PluginDetail, - context: GatewayVisibleHookContext, + _context: GatewayVisibleHookContext, ) -> Result { - match &plugin.manifest.runtime { - PluginRuntime::DeclarativeRules { .. } => self - .rule_runtime - .execute_declarative_rules_plugin(plugin, context), - PluginRuntime::Native { engine } - if plugin.summary.plugin_id == "official.privacy-filter" - && engine == "privacyFilter" => - { - self.rule_runtime - .execute_official_privacy_filter_plugin(plugin, context) - } - PluginRuntime::Native { engine } => Err(GatewayPluginError::new( - "PLUGIN_UNSUPPORTED_RUNTIME", - format!("unsupported native plugin runtime engine: {engine}"), - )), - PluginRuntime::Wasm { .. } if !self.policy.wasm_enabled => { + let manager = PluginRuntimeManager::new(); + + match manager.runtime_dispatch(&plugin.summary.plugin_id, &plugin.manifest.runtime)? { + RuntimeDispatch::ExtensionHost => { + ensure_gateway_hooks_capability(plugin)?; Err(GatewayPluginError::new( - "PLUGIN_RUNTIME_DISABLED", - "wasm runtime execution is disabled by host policy", + "PLUGIN_EXTENSION_HOST_GATEWAY_ASYNC_REQUIRED", + "extension host gateway hook execution requires async dispatch", )) } - PluginRuntime::Wasm { .. } => Err(GatewayPluginError::new( - "PLUGIN_WASM_NOT_WIRED", - "wasm runtime policy is enabled but gateway execution is not wired in this release", - )), + } + } + + fn execute_plugin( + &self, + plugin: &PluginDetail, + context: GatewayVisibleHookContext, + hook_timeout: Duration, + ) -> GatewayHookFuture { + let manager = PluginRuntimeManager::new(); + match manager.runtime_dispatch(&plugin.summary.plugin_id, &plugin.manifest.runtime) { + Ok(RuntimeDispatch::ExtensionHost) => { + if let Err(err) = ensure_gateway_hooks_capability(plugin) { + return Box::pin(async move { Err(err) }); + } + let Some(registry) = self.extension_host_registry.clone() else { + return Box::pin(async { + Err(GatewayPluginError::new( + "PLUGIN_EXTENSION_HOST_GATEWAY_NOT_CONFIGURED", + "extension host gateway hook registry is not configured", + )) + }); + }; + let detail = plugin.clone(); + let hook = context.hook_name.clone(); + Box::pin(async move { + registry + .execute_gateway_hook(detail, &hook, context, hook_timeout) + .await + }) + } + Err(err) => Box::pin(async move { Err(err) }), } } pub(crate) fn retain_runtime_caches_for_plugins(&self, plugins: &[PluginDetail]) { - self.rule_runtime.retain_runtime_caches_for_plugins(plugins); + self.lifecycle.retain_for_plugins(plugins); + } + + #[cfg(test)] + #[allow(dead_code)] + pub(crate) fn dispose_runtime_caches_for_tests(&self) { + self.lifecycle.dispose_all(); + } +} + +impl Default for RuntimeGatewayPluginExecutor { + fn default() -> Self { + Self::new() } } @@ -73,101 +151,316 @@ impl GatewayPluginExecutor for RuntimeGatewayPluginExecutor { &self, plugin: &PluginDetail, context: GatewayVisibleHookContext, + hook_timeout: Duration, ) -> GatewayHookFuture { - let result = self.execute_plugin_sync(plugin, context); - Box::pin(async move { result }) + self.execute_plugin(plugin, context, hook_timeout) } fn execute_response_hook( &self, plugin: &PluginDetail, context: GatewayVisibleHookContext, + hook_timeout: Duration, ) -> GatewayHookFuture { - let result = self.execute_plugin_sync(plugin, context); - Box::pin(async move { result }) + self.execute_plugin(plugin, context, hook_timeout) } fn execute_stream_hook( &self, plugin: &PluginDetail, context: GatewayVisibleHookContext, + hook_timeout: Duration, ) -> GatewayHookFuture { - let result = self.execute_plugin_sync(plugin, context); - Box::pin(async move { result }) + self.execute_plugin(plugin, context, hook_timeout) } fn execute_log_hook( &self, plugin: &PluginDetail, context: GatewayVisibleHookContext, + hook_timeout: Duration, ) -> GatewayHookFuture { - let result = self.execute_plugin_sync(plugin, context); - Box::pin(async move { result }) + self.execute_plugin(plugin, context, hook_timeout) + } +} + +fn ensure_gateway_hooks_capability(plugin: &PluginDetail) -> Result<(), GatewayPluginError> { + if plugin + .manifest + .capabilities + .iter() + .any(|capability| capability == "gateway.hooks") + { + Ok(()) + } else { + Err(GatewayPluginError::new( + "PLUGIN_PERMISSION_DENIED", + "extension host gateway hooks require gateway.hooks capability", + )) } } #[cfg(test)] mod tests { use super::*; + use crate::domain::plugin_contributions::PluginContributes; use crate::domain::plugins::{ PluginDetail, PluginHook, PluginHostCompatibility, PluginInstallSource, PluginManifest, PluginPermissionRisk, PluginRuntime, PluginStatus, PluginSummary, }; use crate::gateway::plugins::context::{ - GatewayHookAction, GatewayVisibleHookContext, GatewayVisibleLogContext, - GatewayVisibleRequestContext, GatewayVisibleResponseContext, GatewayVisibleStreamContext, + GatewayHookResult, GatewayPluginHookName, GatewayRequestHookInput, + GatewayVisibleHookContext, GatewayVisibleLogContext, GatewayVisibleRequestContext, + GatewayVisibleResponseContext, GatewayVisibleStreamContext, }; + use crate::gateway::plugins::pipeline::{GatewayPluginPipeline, GatewayPluginPipelineConfig}; + use axum::body::Bytes; + use axum::http::{HeaderMap, Method}; use serde_json::json; + use std::collections::BTreeMap; + use std::path::Path; + use std::time::Duration; #[test] - fn runtime_executor_returns_clear_error_for_policy_disabled_wasm() { - let executor = RuntimeGatewayPluginExecutor::for_tests(RuntimeExecutionPolicy { - wasm_enabled: false, - }); - let plugin = wasm_plugin_detail("example.wasm"); - let context = hook_context("gateway.request.afterBodyRead", "trace-1"); + fn runtime_executor_rejects_extension_host_gateway_hook_without_capability() { + let mut plugin = extension_host_plugin_detail("example.extension"); + plugin.manifest.capabilities.clear(); + let context = hook_context("gateway.request.afterBodyRead", "trace-extension"); - let err = executor + let err = executor() .execute_plugin_sync(&plugin, context) - .expect_err("wasm disabled"); + .expect_err("extension host gateway hooks require gateway.hooks capability"); - assert_eq!(err.code(), "PLUGIN_RUNTIME_DISABLED"); - assert!(err.to_string().contains("wasm")); + assert_eq!(err.code(), "PLUGIN_PERMISSION_DENIED"); } - #[test] - fn runtime_executor_delegates_declarative_rules_to_rule_runtime() { - let dir = tempfile::tempdir().expect("temp plugin dir"); - let rules_dir = dir.path().join("rules"); - std::fs::create_dir_all(&rules_dir).expect("rules dir"); - std::fs::write( - rules_dir.join("main.json"), - json!({ - "rules": [{ - "id": "no-op-warn", - "hook": "gateway.request.afterBodyRead", - "target": { "field": "request.body" }, - "match": { "regex": "not-present" }, - "action": { "kind": "warn", "message": "not used" } - }] + #[tokio::test] + async fn runtime_executor_extension_host_request_continue_maps_to_unchanged_result() { + let temp = tempfile::tempdir().expect("tempdir"); + write_gateway_extension_plugin( + temp.path(), + "gateway.request.afterBodyRead", + r#"{ action: "continue" }"#, + ); + let plugin = extension_host_plugin_detail_with_root("example.extension", temp.path()); + let context = hook_context("gateway.request.afterBodyRead", "trace-extension"); + + let result = executor() + .execute_request_hook(&plugin, context, test_hook_timeout()) + .await + .expect("extension host gateway hook executes"); + + assert_eq!(result, GatewayHookResult::continue_unchanged()); + } + + #[tokio::test] + async fn runtime_executor_extension_host_request_replace_maps_request_body() { + let temp = tempfile::tempdir().expect("tempdir"); + write_gateway_extension_plugin( + temp.path(), + "gateway.request.afterBodyRead", + r#"{ action: "replace", requestBody: "{\"messages\":[]}" }"#, + ); + let plugin = extension_host_plugin_detail_with_root("example.extension", temp.path()); + let context = hook_context("gateway.request.afterBodyRead", "trace-extension"); + + let result = executor() + .execute_request_hook(&plugin, context, test_hook_timeout()) + .await + .expect("extension host gateway hook executes"); + + assert_eq!(result.request_body.as_deref(), Some(r#"{"messages":[]}"#)); + } + + #[tokio::test] + async fn runtime_executor_extension_host_request_hooks_receive_large_bodies_without_truncation() + { + let temp = tempfile::tempdir().expect("tempdir"); + write_gateway_extension_plugin( + temp.path(), + "gateway.request.afterBodyRead", + r#"(() => { + const body = arguments[0].context.request.body; + if (arguments[0].context.request.body_truncated) { + return { action: "block", reason: "body was truncated" }; + } + return { + action: "replace", + requestBody: body.replace("13344441520", "[电话]") + }; + })()"#, + ); + let plugin = extension_host_plugin_detail_with_root("example.extension", temp.path()); + let body = json!({ + "messages": [{ + "role": "user", + "content": format!( + "{} 你知道 13344441520 是哪里的手机号嘛", + "x".repeat(300 * 1024) + ) + }] + }) + .to_string(); + let pipeline = GatewayPluginPipeline::for_tests( + vec![plugin], + Arc::new(RuntimeGatewayPluginExecutor::for_tests()), + GatewayPluginPipelineConfig::default(), + ); + + let output = pipeline + .run_request_hook(GatewayRequestHookInput { + hook_name: GatewayPluginHookName::RequestAfterBodyRead, + trace_id: "trace-extension-large-body".to_string(), + cli_key: "codex".to_string(), + method: Method::POST, + path: "/v1/responses".to_string(), + query: None, + headers: HeaderMap::new(), + body: Bytes::from(body), + requested_model: None, }) - .to_string(), - ) - .expect("rule file"); - let plugin = rule_plugin_detail("example.rules", dir.path().to_string_lossy().to_string()); - let context = hook_context("gateway.request.afterBodyRead", "trace-2"); + .await + .expect("large extension host request body should be available to plugins"); + let redacted = String::from_utf8(output.body.to_vec()).expect("utf8 body"); + + assert!(redacted.contains("[电话]")); + assert!(!redacted.contains("13344441520")); + assert!(output.blocked.is_none()); + } + + #[tokio::test] + async fn runtime_executor_extension_host_response_warn_maps_reason() { + let temp = tempfile::tempdir().expect("tempdir"); + write_gateway_extension_plugin( + temp.path(), + "gateway.response.after", + r#"{ action: "warn", message: "response looked risky" }"#, + ); + let mut plugin = extension_host_plugin_detail_with_root("example.extension", temp.path()); + plugin + .manifest + .contributes + .as_mut() + .expect("contributes") + .gateway_hooks[0] + .name = "gateway.response.after".to_string(); + let context = hook_context("gateway.response.after", "trace-extension"); let result = executor() - .execute_plugin_sync(&plugin, context) - .expect("rule runtime executes"); + .execute_response_hook(&plugin, context, test_hook_timeout()) + .await + .expect("extension host gateway hook executes"); - assert_eq!(result.action, GatewayHookAction::Continue); + assert_eq!(result.reason.as_deref(), Some("response looked risky")); + assert_eq!(result.request_body, None); + assert_eq!(result.response_body, None); } - fn executor() -> RuntimeGatewayPluginExecutor { - RuntimeGatewayPluginExecutor::for_tests(RuntimeExecutionPolicy { - wasm_enabled: false, + #[tokio::test] + async fn runtime_executor_extension_host_unsupported_action_is_rejected() { + let temp = tempfile::tempdir().expect("tempdir"); + write_gateway_extension_plugin( + temp.path(), + "gateway.request.afterBodyRead", + r#"{ action: "teleport" }"#, + ); + let plugin = extension_host_plugin_detail_with_root("example.extension", temp.path()); + let context = hook_context("gateway.request.afterBodyRead", "trace-extension"); + + let err = executor() + .execute_request_hook(&plugin, context, test_hook_timeout()) + .await + .expect_err("unsupported gateway hook action should be rejected"); + + assert_eq!(err.code(), "PLUGIN_EXTENSION_HOST_INVALID_OUTPUT"); + } + + #[tokio::test] + async fn runtime_executor_extension_host_pipeline_timeout_kills_warm_instance() { + let temp = tempfile::tempdir().expect("tempdir"); + write_gateway_extension_plugin( + temp.path(), + "gateway.request.afterBodyRead", + r#"(() => { + globalThis.__gatewayHookCalls = (globalThis.__gatewayHookCalls || 0) + 1; + if (globalThis.__gatewayHookCalls === 2) { + while (true) {} + } + return { action: "continue" }; + })()"#, + ); + let plugin = extension_host_plugin_detail_with_root("example.extension", temp.path()); + let pipeline = GatewayPluginPipeline::for_tests( + vec![plugin], + Arc::new(RuntimeGatewayPluginExecutor::for_tests()), + GatewayPluginPipelineConfig::default(), + ); + let input = || request_input_for_pipeline(); + + pipeline + .run_request_hook(input()) + .await + .expect("first extension host hook should warm an instance"); + let timed_out = pipeline + .run_request_hook(input()) + .await + .expect("extension host timeout should fail open through pipeline"); + + assert_eq!(timed_out.body.as_ref(), b"hello"); + assert_eq!(timed_out.execution_reports.len(), 1); + assert_eq!( + timed_out.execution_reports[0].error_code.as_deref(), + Some("PLUGIN_EXTENSION_HOST_TIMEOUT") + ); + + let recovered = pipeline + .run_request_hook(input()) + .await + .expect("timeout should kill the warm instance so a fresh instance can run"); + assert_eq!(recovered.body.as_ref(), b"hello"); + assert_eq!(recovered.execution_reports[0].status, "completed"); + } + + #[tokio::test] + async fn runtime_executor_retain_prunes_extension_host_gateway_instances() { + let temp = tempfile::tempdir().expect("tempdir"); + write_gateway_extension_plugin( + temp.path(), + "gateway.request.afterBodyRead", + r#"{ action: "continue" }"#, + ); + let plugin = extension_host_plugin_detail_with_root("example.extension", temp.path()); + let registry = Arc::new(ExtensionHostInstanceRegistry::new_real_for_tests()); + let executor = + RuntimeGatewayPluginExecutor::for_tests_with_extension_host_registry(registry.clone()); + let context = hook_context("gateway.request.afterBodyRead", "trace-extension"); + + executor + .execute_request_hook(&plugin, context, test_hook_timeout()) + .await + .expect("extension host gateway hook warms an instance"); + assert_eq!(registry.instance_count_for_tests().await, 1); + + executor.retain_runtime_caches_for_plugins(&[]); + + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if registry.instance_count_for_tests().await == 0 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } }) + .await + .expect("retain should dispose inactive extension host gateway instances"); + } + + fn executor() -> RuntimeGatewayPluginExecutor { + RuntimeGatewayPluginExecutor::for_tests() + } + + fn test_hook_timeout() -> Duration { + Duration::from_secs(5) } fn hook_context(hook_name: &str, trace_id: &str) -> GatewayVisibleHookContext { @@ -186,27 +479,75 @@ mod tests { } } - fn wasm_plugin_detail(plugin_id: &str) -> PluginDetail { + fn request_input_for_pipeline() -> GatewayRequestHookInput { + GatewayRequestHookInput { + hook_name: GatewayPluginHookName::RequestAfterBodyRead, + trace_id: "trace-extension-pipeline".to_string(), + cli_key: "codex".to_string(), + method: Method::POST, + path: "/v1/responses".to_string(), + query: None, + headers: HeaderMap::new(), + body: Bytes::from_static(b"hello"), + requested_model: None, + } + } + + fn extension_host_plugin_detail(plugin_id: &str) -> PluginDetail { plugin_detail( plugin_id, - PluginRuntime::Wasm { - abi_version: "1.0.0".to_string(), - memory_limit_bytes: Some(16 * 1024 * 1024), + PluginRuntime::ExtensionHost { + language: "typescript".to_string(), }, - "wasm".to_string(), + "extensionHost".to_string(), None, ) } - fn rule_plugin_detail(plugin_id: &str, installed_dir: String) -> PluginDetail { + fn extension_host_plugin_detail_with_root(plugin_id: &str, root: &Path) -> PluginDetail { plugin_detail( plugin_id, - PluginRuntime::DeclarativeRules { - rules: vec!["rules/main.json".to_string()], + PluginRuntime::ExtensionHost { + language: "typescript".to_string(), }, - "declarativeRules".to_string(), - Some(installed_dir), + "extensionHost".to_string(), + Some(root.to_string_lossy().to_string()), + ) + } + + fn write_gateway_extension_plugin(root: &Path, hook_name: &str, result_source: &str) { + std::fs::create_dir_all(root.join("dist")).expect("create dist"); + let manifest = json!({ + "id": "example.extension", + "name": "Example Extension", + "version": "1.0.0", + "apiVersion": "1.0.0", + "runtime": { "kind": "extensionHost", "language": "typescript" }, + "main": "dist/index.js", + "contributes": { + "gatewayHooks": [{ "name": hook_name, "priority": 10, "failurePolicy": "fail-open" }] + }, + "capabilities": ["gateway.hooks"], + "hostCompatibility": { "app": ">=0.56.0 <1.0.0", "pluginApi": "^1.0.0" } + }); + std::fs::write( + root.join("plugin.json"), + serde_json::to_vec_pretty(&manifest).expect("manifest json"), + ) + .expect("write plugin manifest"); + std::fs::write( + root.join("dist/index.js"), + format!( + r#" + module.exports.activate = function(api) {{ + api.gateway.registerHook("{hook_name}", function() {{ + return {result_source}; + }}); + }}; + "# + ), ) + .expect("write extension"); } fn plugin_detail( @@ -235,15 +576,24 @@ mod tests { version: "1.0.0".to_string(), api_version: "1.0.0".to_string(), runtime, - hooks: vec![PluginHook { - name: "gateway.request.afterBodyRead".to_string(), - priority: 10, - failure_policy: Some("fail-open".to_string()), - }], - permissions: vec![ - "request.body.read".to_string(), - "request.body.write".to_string(), - ], + hooks: vec![], + permissions: vec![], + main: Some("dist/index.js".to_string()), + activation_events: vec![], + contributes: Some(PluginContributes { + providers: vec![], + protocols: vec![], + protocol_bridges: vec![], + commands: vec![], + gateway_hooks: vec![PluginHook { + name: "gateway.request.afterBodyRead".to_string(), + priority: 10, + failure_policy: Some("fail-open".to_string()), + timeout_ms: None, + }], + ui: BTreeMap::new(), + }), + capabilities: vec!["gateway.hooks".to_string()], host_compatibility: PluginHostCompatibility { app: ">=0.56.0 <1.0.0".to_string(), plugin_api: "^1.0.0".to_string(), @@ -271,6 +621,7 @@ mod tests { pending_permissions: vec![], audit_logs: vec![], runtime_failures: vec![], + rollback_versions: vec![], } } } diff --git a/src-tauri/src/app/plugins/runtime_lifecycle.rs b/src-tauri/src/app/plugins/runtime_lifecycle.rs new file mode 100644 index 00000000..26a4f256 --- /dev/null +++ b/src-tauri/src/app/plugins/runtime_lifecycle.rs @@ -0,0 +1,197 @@ +//! Usage: Host-owned plugin runtime lifecycle and cache retention boundary. + +use crate::plugins::PluginDetail; +use std::sync::{Arc, RwLock}; + +pub(crate) trait PluginRuntimeCache: Send + Sync { + fn retain_for_plugins(&self, plugins: &[PluginDetail]); + #[allow(dead_code)] + fn clear_all(&self); +} + +pub(crate) trait PluginRuntimeInstanceRegistry: Send + Sync { + fn retain_for_plugins(&self, plugins: &[PluginDetail]); + fn dispose_plugin(&self, plugin_id: &str); + fn dispose_all(&self); +} + +#[derive(Default)] +pub(crate) struct RuntimeLifecycleRegistry { + caches: RwLock>>, + instances: RwLock>>, +} + +impl RuntimeLifecycleRegistry { + pub(crate) fn register_cache(&self, cache: Arc) { + self.caches + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(cache); + } + + #[allow(dead_code)] + pub(crate) fn register_instance_registry( + &self, + registry: Arc, + ) { + self.instances + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(registry); + } + + pub(crate) fn retain_for_plugins(&self, plugins: &[PluginDetail]) { + for cache in self.caches_snapshot() { + cache.retain_for_plugins(plugins); + } + for registry in self.instances_snapshot() { + registry.retain_for_plugins(plugins); + } + } + + #[allow(dead_code)] + pub(crate) fn dispose_plugin(&self, plugin_id: &str) { + for registry in self.instances_snapshot() { + registry.dispose_plugin(plugin_id); + } + } + + #[allow(dead_code)] + pub(crate) fn dispose_all(&self) { + for cache in self.caches_snapshot() { + cache.clear_all(); + } + for registry in self.instances_snapshot() { + registry.dispose_all(); + } + } + + fn caches_snapshot(&self) -> Vec> { + self.caches + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + fn instances_snapshot(&self) -> Vec> { + self.instances + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + #[derive(Default)] + struct RecordingCache { + retain_calls: Mutex>>, + clear_calls: Mutex, + } + + #[derive(Default)] + struct RecordingInstanceRegistry { + retain_calls: Mutex>>, + dispose_plugin_calls: Mutex>, + dispose_all_calls: Mutex, + } + + impl RecordingCache { + fn retain_calls(&self) -> Vec> { + self.retain_calls.lock().unwrap().clone() + } + + fn clear_calls(&self) -> u32 { + *self.clear_calls.lock().unwrap() + } + } + + impl PluginRuntimeCache for RecordingCache { + fn retain_for_plugins(&self, plugins: &[PluginDetail]) { + self.retain_calls.lock().unwrap().push( + plugins + .iter() + .map(|plugin| plugin.summary.plugin_id.clone()) + .collect(), + ); + } + + fn clear_all(&self) { + *self.clear_calls.lock().unwrap() += 1; + } + } + + impl RecordingInstanceRegistry { + fn retain_calls(&self) -> Vec> { + self.retain_calls.lock().unwrap().clone() + } + + fn dispose_plugin_calls(&self) -> Vec { + self.dispose_plugin_calls.lock().unwrap().clone() + } + + fn dispose_all_calls(&self) -> u32 { + *self.dispose_all_calls.lock().unwrap() + } + } + + impl PluginRuntimeInstanceRegistry for RecordingInstanceRegistry { + fn retain_for_plugins(&self, plugins: &[PluginDetail]) { + self.retain_calls.lock().unwrap().push( + plugins + .iter() + .map(|plugin| plugin.summary.plugin_id.clone()) + .collect(), + ); + } + + fn dispose_plugin(&self, plugin_id: &str) { + self.dispose_plugin_calls + .lock() + .unwrap() + .push(plugin_id.to_string()); + } + + fn dispose_all(&self) { + *self.dispose_all_calls.lock().unwrap() += 1; + } + } + + #[test] + fn lifecycle_registry_retains_and_disposes_all_registered_runtime_caches() { + let registry = RuntimeLifecycleRegistry::default(); + let first = std::sync::Arc::new(RecordingCache::default()); + let second = std::sync::Arc::new(RecordingCache::default()); + + registry.register_cache(first.clone()); + registry.register_cache(second.clone()); + registry.retain_for_plugins(&[]); + registry.dispose_all(); + + assert_eq!(first.retain_calls(), vec![Vec::::new()]); + assert_eq!(second.retain_calls(), vec![Vec::::new()]); + assert_eq!(first.clear_calls(), 1); + assert_eq!(second.clear_calls(), 1); + } + + #[test] + fn lifecycle_registry_retains_and_disposes_registered_runtime_instances() { + let registry = RuntimeLifecycleRegistry::default(); + let instances = std::sync::Arc::new(RecordingInstanceRegistry::default()); + + registry.register_instance_registry(instances.clone()); + registry.retain_for_plugins(&[]); + registry.dispose_plugin("acme.echo"); + registry.dispose_all(); + + assert_eq!(instances.retain_calls(), vec![Vec::::new()]); + assert_eq!( + instances.dispose_plugin_calls(), + vec!["acme.echo".to_string()] + ); + assert_eq!(instances.dispose_all_calls(), 1); + } +} diff --git a/src-tauri/src/app/plugins/runtime_manager.rs b/src-tauri/src/app/plugins/runtime_manager.rs new file mode 100644 index 00000000..91e8b30c --- /dev/null +++ b/src-tauri/src/app/plugins/runtime_manager.rs @@ -0,0 +1,51 @@ +//! Usage: Runtime policy facade for plugin runtime execution. + +use crate::domain::plugins::PluginRuntime; +use crate::gateway::plugins::permissions::GatewayPluginError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RuntimeDispatch { + ExtensionHost, +} + +pub(crate) struct PluginRuntimeManager; + +impl PluginRuntimeManager { + pub(crate) fn new() -> Self { + Self + } + + #[cfg(test)] + pub(crate) fn for_tests() -> Self { + Self::new() + } + + pub(crate) fn runtime_dispatch( + &self, + _plugin_id: &str, + _runtime: &PluginRuntime, + ) -> Result { + Ok(RuntimeDispatch::ExtensionHost) + } +} + +#[cfg(test)] +mod tests { + use super::{PluginRuntimeManager, RuntimeDispatch}; + use crate::domain::plugins::PluginRuntime; + + #[test] + fn runtime_manager_returns_extension_host_dispatch() { + let manager = PluginRuntimeManager::for_tests(); + let runtime = PluginRuntime::ExtensionHost { + language: "typescript".to_string(), + }; + + assert_eq!( + manager + .runtime_dispatch("acme.extension", &runtime) + .expect("extension host runtime should be recognized"), + RuntimeDispatch::ExtensionHost + ); + } +} diff --git a/src-tauri/src/app/plugins/wasm_runtime.rs b/src-tauri/src/app/plugins/wasm_runtime.rs deleted file mode 100644 index ed657b55..00000000 --- a/src-tauri/src/app/plugins/wasm_runtime.rs +++ /dev/null @@ -1,478 +0,0 @@ -//! Usage: Sandboxed WASM plugin runtime foundation for community code plugins. -#![allow(dead_code)] - -use crate::gateway::plugins::context::{GatewayHookAction, GatewayHookResult}; -use crate::shared::error::{AppError, AppResult}; -use serde::Serialize; -use serde_json::Value; -use std::collections::BTreeMap; -use wasmtime::{Config, Engine, Linker, Module, ResourceLimiter, Store}; - -const DEFAULT_MAX_JSON_BYTES: usize = 256 * 1024; -const DEFAULT_MEMORY_LIMIT_BYTES: usize = 16 * 1024 * 1024; -const DEFAULT_FUEL: u64 = 5_000_000; -const WASM_PAGE_BYTES: usize = 64 * 1024; - -#[derive(Debug, Clone)] -pub(crate) struct WasmRuntimeLimits { - pub(crate) max_input_bytes: usize, - pub(crate) max_output_bytes: usize, - pub(crate) memory_limit_bytes: usize, - pub(crate) fuel: u64, -} - -impl Default for WasmRuntimeLimits { - fn default() -> Self { - Self { - max_input_bytes: DEFAULT_MAX_JSON_BYTES, - max_output_bytes: DEFAULT_MAX_JSON_BYTES, - memory_limit_bytes: DEFAULT_MEMORY_LIMIT_BYTES, - fuel: DEFAULT_FUEL, - } - } -} - -#[derive(Debug, Clone)] -pub(crate) struct WasmHookInvocation { - pub(crate) plugin_id: String, - pub(crate) hook: String, - pub(crate) trace_id: Option, - pub(crate) config: Value, - pub(crate) context: Value, -} - -#[derive(Debug, Clone, Default)] -pub(crate) struct WasmPluginExecutor { - pub(crate) limits: WasmRuntimeLimits, -} - -#[derive(Debug)] -struct StoreState { - memory_limit_bytes: usize, -} - -impl ResourceLimiter for StoreState { - fn memory_growing( - &mut self, - _current: usize, - desired: usize, - _maximum: Option, - ) -> wasmtime::Result { - Ok(desired <= self.memory_limit_bytes) - } - - fn table_growing( - &mut self, - _current: usize, - desired: usize, - _maximum: Option, - ) -> wasmtime::Result { - Ok(desired <= 1024) - } - - fn instances(&self) -> usize { - 1 - } - - fn memories(&self) -> usize { - 1 - } - - fn tables(&self) -> usize { - 4 - } -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct WasmRequestEnvelope<'a> { - abi_version: &'static str, - plugin_id: &'a str, - hook: &'a str, - trace_id: Option<&'a str>, - config: &'a Value, - context: &'a Value, -} - -impl WasmPluginExecutor { - pub(crate) fn execute_module_bytes( - &self, - module_bytes: &[u8], - invocation: WasmHookInvocation, - ) -> AppResult { - if self.limits.memory_limit_bytes < WASM_PAGE_BYTES { - return Err(AppError::new( - "PLUGIN_WASM_INVALID_LIMIT", - "WASM memory limit must be at least one WebAssembly page", - )); - } - - let request = WasmRequestEnvelope { - abi_version: "1.0.0", - plugin_id: &invocation.plugin_id, - hook: &invocation.hook, - trace_id: invocation.trace_id.as_deref(), - config: &invocation.config, - context: &invocation.context, - }; - let input = serde_json::to_vec(&request).map_err(|err| { - AppError::new( - "PLUGIN_WASM_INPUT_ENCODE_FAILED", - format!("failed to encode WASM plugin input: {err}"), - ) - })?; - if input.len() > self.limits.max_input_bytes { - return Err(AppError::new( - "PLUGIN_WASM_INPUT_TOO_LARGE", - format!( - "WASM plugin input exceeded {} bytes", - self.limits.max_input_bytes - ), - )); - } - - let mut config = Config::new(); - config.consume_fuel(true); - let engine = Engine::new(&config).map_err(|err| { - AppError::new( - "PLUGIN_WASM_ENGINE_FAILED", - format!("failed to create WASM engine: {err}"), - ) - })?; - let module = Module::new(&engine, module_bytes).map_err(|err| { - AppError::new( - "PLUGIN_WASM_INVALID_MODULE", - format!("failed to compile WASM module: {err}"), - ) - })?; - let mut store = Store::new( - &engine, - StoreState { - memory_limit_bytes: self.limits.memory_limit_bytes, - }, - ); - store.limiter(|state| state); - store.set_fuel(self.limits.fuel).map_err(|err| { - AppError::new( - "PLUGIN_WASM_FUEL_SETUP_FAILED", - format!("failed to configure WASM fuel: {err}"), - ) - })?; - - let linker: Linker = Linker::new(&engine); - let instance = linker.instantiate(&mut store, &module).map_err(|err| { - AppError::new( - "PLUGIN_WASM_IMPORT_DENIED", - format!("failed to instantiate WASM module without host imports: {err}"), - ) - })?; - let memory = instance.get_memory(&mut store, "memory").ok_or_else(|| { - AppError::new( - "PLUGIN_WASM_MISSING_MEMORY", - "WASM plugin must export memory named memory", - ) - })?; - memory.write(&mut store, 0, &input).map_err(|_| { - AppError::new( - "PLUGIN_WASM_MEMORY_WRITE_FAILED", - "failed to write WASM plugin input into guest memory", - ) - })?; - - let handle = instance - .get_typed_func::<(i32, i32), i64>(&mut store, "aio_plugin_handle") - .map_err(|err| { - AppError::new( - "PLUGIN_WASM_MISSING_ENTRYPOINT", - format!("missing WASM guest entrypoint aio_plugin_handle: {err}"), - ) - })?; - let packed = handle - .call(&mut store, (0, input.len() as i32)) - .map_err(|err| { - let fuel_exhausted = store.get_fuel().map(|fuel| fuel == 0).unwrap_or(false); - map_wasm_call_error("WASM plugin execution failed", err, fuel_exhausted) - })?; - let ptr = (packed >> 32) as usize; - let len = (packed & 0xffff_ffff) as usize; - if len > self.limits.max_output_bytes { - return Err(AppError::new( - "PLUGIN_WASM_OUTPUT_TOO_LARGE", - format!( - "WASM plugin output exceeded {} bytes", - self.limits.max_output_bytes - ), - )); - } - let mut output = vec![0_u8; len]; - memory.read(&store, ptr, &mut output).map_err(|_| { - AppError::new( - "PLUGIN_WASM_MEMORY_READ_FAILED", - "failed to read WASM plugin output from guest memory", - ) - })?; - serde_json::from_slice(&output).map_err(|err| { - AppError::new( - "PLUGIN_WASM_INVALID_OUTPUT", - format!("WASM plugin output was not valid JSON: {err}"), - ) - }) - } -} - -fn map_wasm_call_error(prefix: &str, err: wasmtime::Error, fuel_exhausted: bool) -> AppError { - let message = err.to_string(); - if fuel_exhausted || message.to_ascii_lowercase().contains("fuel") { - return AppError::new( - "PLUGIN_WASM_FUEL_EXHAUSTED", - format!("{prefix}: fuel exhausted"), - ); - } - AppError::new( - "PLUGIN_WASM_EXECUTION_FAILED", - format!("{prefix}: {message}"), - ) -} - -fn gateway_hook_result_from_wasm_output(value: Value) -> AppResult { - let object = value.as_object().ok_or_else(|| { - AppError::new( - "PLUGIN_WASM_INVALID_OUTPUT", - "WASM plugin output must be a JSON object", - ) - })?; - if object.contains_key("contextPatch") { - return Err(AppError::new( - "PLUGIN_WASM_INVALID_OUTPUT", - "WASM plugin output used legacy contextPatch; use requestBody, responseBody, streamChunk, logMessage, or headers", - )); - } - - let action = object - .get("action") - .and_then(Value::as_str) - .ok_or_else(|| { - AppError::new( - "PLUGIN_WASM_INVALID_OUTPUT", - "WASM plugin output must include string action", - ) - })?; - let mut result = GatewayHookResult::continue_unchanged(); - match action { - "pass" => {} - "warn" => { - result.reason = optional_string(object, "message")?; - } - "block" => { - result.action = GatewayHookAction::Block; - result.reason = optional_string(object, "reason")?; - } - "replace" => { - result.request_body = optional_string(object, "requestBody")?; - result.response_body = optional_string(object, "responseBody")?; - result.stream_chunk = optional_string(object, "streamChunk")?; - result.log_message = optional_string(object, "logMessage")?; - result.headers = optional_string_map(object, "headers")?.unwrap_or_default(); - } - other => { - return Err(AppError::new( - "PLUGIN_WASM_INVALID_OUTPUT", - format!("unsupported WASM plugin action: {other}"), - )); - } - } - Ok(result) -} - -fn optional_string( - object: &serde_json::Map, - key: &'static str, -) -> AppResult> { - match object.get(key) { - None | Some(Value::Null) => Ok(None), - Some(Value::String(value)) => Ok(Some(value.clone())), - Some(_) => Err(AppError::new( - "PLUGIN_WASM_INVALID_OUTPUT", - format!("WASM plugin output field {key} must be a string"), - )), - } -} - -fn optional_string_map( - object: &serde_json::Map, - key: &'static str, -) -> AppResult>> { - let Some(value) = object.get(key) else { - return Ok(None); - }; - if value.is_null() { - return Ok(None); - } - let Some(map) = value.as_object() else { - return Err(AppError::new( - "PLUGIN_WASM_INVALID_OUTPUT", - format!("WASM plugin output field {key} must be an object"), - )); - }; - let mut out = BTreeMap::new(); - for (name, value) in map { - let Some(header_value) = value.as_str() else { - return Err(AppError::new( - "PLUGIN_WASM_INVALID_OUTPUT", - format!("WASM plugin output header {name} must be a string"), - )); - }; - out.insert(name.clone(), header_value.to_string()); - } - Ok(Some(out)) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn valid_pass_module() -> Vec { - wat::parse_str( - r#" - (module - (memory (export "memory") 1) - (data (i32.const 1024) "{\"action\":\"pass\"}") - (func (export "aio_plugin_handle") (param i32 i32) (result i64) - i64.const 1024 - i64.const 32 - i64.shl - i64.const 17 - i64.or)) - "#, - ) - .expect("valid wat") - } - - fn wasi_filesystem_import_module() -> Vec { - wat::parse_str( - r#" - (module - (import "wasi_snapshot_preview1" "path_open" - (func $path_open - (param i32 i32 i32 i32 i32 i64 i64 i32 i32) - (result i32))) - (memory (export "memory") 1) - (data (i32.const 1024) "{\"action\":\"pass\"}") - (func (export "aio_plugin_handle") (param i32 i32) (result i64) - i64.const 1024 - i64.const 32 - i64.shl - i64.const 17 - i64.or)) - "#, - ) - .expect("valid wat") - } - - fn dead_loop_module() -> Vec { - wat::parse_str( - r#" - (module - (memory (export "memory") 1) - (func (export "aio_plugin_handle") (param i32 i32) (result i64) - (loop - br 0) - i64.const 0)) - "#, - ) - .expect("valid wat") - } - - #[test] - fn plugin_wasm_executes_valid_module() { - let executor = WasmPluginExecutor::default(); - let output = executor - .execute_module_bytes( - &valid_pass_module(), - WasmHookInvocation { - plugin_id: "example.echo".to_string(), - hook: "gateway.request.afterBodyRead".to_string(), - trace_id: Some("trace-1".to_string()), - config: json!({}), - context: json!({"body": {"messages": []}}), - }, - ) - .expect("valid wasm execution"); - - assert_eq!(output, json!({"action": "pass"})); - } - - #[test] - fn plugin_wasm_denies_wasi_filesystem_imports() { - let executor = WasmPluginExecutor::default(); - let err = executor - .execute_module_bytes( - &wasi_filesystem_import_module(), - WasmHookInvocation { - plugin_id: "example.fs".to_string(), - hook: "gateway.request.afterBodyRead".to_string(), - trace_id: None, - config: json!({}), - context: json!({}), - }, - ) - .expect_err("WASI filesystem imports are not linked"); - - assert!(err.to_string().contains("PLUGIN_WASM_IMPORT_DENIED")); - } - - #[test] - fn plugin_wasm_terminates_dead_loop_with_fuel() { - let executor = WasmPluginExecutor { - limits: WasmRuntimeLimits { - fuel: 10_000, - ..WasmRuntimeLimits::default() - }, - }; - let err = executor - .execute_module_bytes( - &dead_loop_module(), - WasmHookInvocation { - plugin_id: "example.loop".to_string(), - hook: "gateway.request.afterBodyRead".to_string(), - trace_id: None, - config: json!({}), - context: json!({}), - }, - ) - .expect_err("dead loop exhausts fuel"); - - assert!( - err.to_string().contains("PLUGIN_WASM_FUEL_EXHAUSTED"), - "unexpected error: {err}" - ); - } - - #[test] - fn wasm_output_replace_request_body_maps_to_gateway_hook_result() { - let result = gateway_hook_result_from_wasm_output(json!({ - "action": "replace", - "requestBody": "{\"messages\":[]}", - "headers": { "x-plugin-redacted": "1" } - })) - .expect("wasm output maps"); - - assert_eq!(result.request_body.as_deref(), Some("{\"messages\":[]}")); - assert_eq!( - result.headers.get("x-plugin-redacted").map(String::as_str), - Some("1") - ); - } - - #[test] - fn wasm_output_rejects_legacy_context_patch_in_vnext() { - let err = gateway_hook_result_from_wasm_output(json!({ - "action": "replace", - "contextPatch": { "request": { "body": "x" } } - })) - .expect_err("legacy contextPatch is not active vNext ABI"); - - assert_eq!(err.code(), "PLUGIN_WASM_INVALID_OUTPUT"); - } -} diff --git a/src-tauri/src/app/provider_service.rs b/src-tauri/src/app/provider_service.rs index 7e8734cb..f08904fa 100644 --- a/src-tauri/src/app/provider_service.rs +++ b/src-tauri/src/app/provider_service.rs @@ -30,6 +30,7 @@ pub(crate) struct ProviderUpsertInput { pub source_provider_id: Option, pub bridge_type: Option, pub stream_idle_timeout_seconds: Option, + pub extension_values: Option>, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -149,6 +150,7 @@ pub(crate) async fn provider_upsert( source_provider_id, bridge_type, stream_idle_timeout_seconds, + extension_values, } = input; let is_create = provider_id.is_none(); @@ -195,6 +197,7 @@ pub(crate) async fn provider_upsert( source_provider_id, bridge_type, stream_idle_timeout_seconds, + extension_values, }, )?; @@ -249,8 +252,10 @@ pub(crate) async fn provider_duplicate( ) -> Result { let db = ensure_db_ready(app.clone(), db_state.inner()).await?; let result = blocking::run("provider_duplicate", move || { - let conn = db.open_connection()?; - let source = providers::get_by_id(&conn, provider_id)?; + let source = { + let conn = db.open_connection()?; + providers::get_by_id(&conn, provider_id)? + }; let siblings = providers::list_by_cli(&db, &source.cli_key)?; let api_key = if source.auth_mode == "api_key" && source.source_provider_id.is_none() { Some(providers::get_api_key_plaintext(&db, provider_id)?) @@ -258,8 +263,9 @@ pub(crate) async fn provider_duplicate( None }; - providers::upsert( + providers::duplicate( &db, + source.id, providers::ProviderUpsertParams { provider_id: None, cli_key: source.cli_key.clone(), @@ -287,6 +293,7 @@ pub(crate) async fn provider_duplicate( source_provider_id: source.source_provider_id, bridge_type: source.bridge_type.clone(), stream_idle_timeout_seconds: source.stream_idle_timeout_seconds, + extension_values: None, }, ) }) @@ -544,6 +551,7 @@ mod tests { source_provider_id: None, bridge_type: None, stream_idle_timeout_seconds: None, + extension_values: vec![], api_key_configured: true, }; @@ -628,6 +636,7 @@ mod tests { source_provider_id: None, bridge_type: None, stream_idle_timeout_seconds: None, + extension_values: vec![], api_key_configured: true, }; diff --git a/src-tauri/src/app/resident.rs b/src-tauri/src/app/resident.rs index 7f8b9051..c57a4f61 100644 --- a/src-tauri/src/app/resident.rs +++ b/src-tauri/src/app/resident.rs @@ -159,6 +159,10 @@ pub fn show_main_window(app: &tauri::AppHandle) { #[cfg(target_os = "macos")] set_dock_visibility(app, true); + + // A WebView that died while the window was hidden should be repaired the + // moment the user opens the window, not on the next watchdog tick. + crate::app::heartbeat_watchdog::on_main_window_shown(app); } /// Called on startup when `start_minimized` is enabled. @@ -215,6 +219,14 @@ pub fn on_window_event(window: &tauri::Window, event: &tauri::WindowEvent) { return; } + // OS-level restore paths (taskbar unminimize, Mission Control) never go + // through show_main_window; the focus event covers them so a WebView that + // died while minimized is repaired the moment the user comes back. + if matches!(event, tauri::WindowEvent::Focused(true)) { + crate::app::heartbeat_watchdog::on_main_window_shown(window.app_handle()); + return; + } + let tauri::WindowEvent::CloseRequested { api, .. } = event else { return; }; diff --git a/src-tauri/src/app/settings_service.rs b/src-tauri/src/app/settings_service.rs index f685d49b..d8ebb4ed 100644 --- a/src-tauri/src/app/settings_service.rs +++ b/src-tauri/src/app/settings_service.rs @@ -49,6 +49,8 @@ pub(crate) struct SettingsUpdate { pub tray_enabled: Option, pub enable_cli_proxy_startup_recovery: Option, pub log_retention_days: u32, + // Option keeps older frontend payloads valid (0 = keep forever). + pub request_log_retention_days: Option, pub provider_cooldown_seconds: Option, pub provider_base_url_ping_cache_ttl_seconds: Option, pub upstream_first_byte_timeout_seconds: Option, @@ -150,6 +152,7 @@ pub(crate) struct SettingsView { pub tray_enabled: bool, pub enable_cli_proxy_startup_recovery: bool, pub log_retention_days: u32, + pub request_log_retention_days: u32, pub provider_cooldown_seconds: u32, pub provider_base_url_ping_cache_ttl_seconds: u32, pub upstream_first_byte_timeout_seconds: u32, @@ -269,6 +272,7 @@ impl From<&settings::AppSettings> for SettingsView { tray_enabled: value.tray_enabled, enable_cli_proxy_startup_recovery: value.enable_cli_proxy_startup_recovery, log_retention_days: value.log_retention_days, + request_log_retention_days: value.request_log_retention_days, provider_cooldown_seconds: value.provider_cooldown_seconds, provider_base_url_ping_cache_ttl_seconds: value .provider_base_url_ping_cache_ttl_seconds, @@ -545,6 +549,7 @@ pub(crate) async fn settings_set_impl( tray_enabled, enable_cli_proxy_startup_recovery, log_retention_days, + request_log_retention_days, provider_cooldown_seconds, provider_base_url_ping_cache_ttl_seconds, upstream_first_byte_timeout_seconds, @@ -610,6 +615,8 @@ pub(crate) async fn settings_set_impl( let start_minimized = start_minimized.unwrap_or(previous.start_minimized); let enable_cli_proxy_startup_recovery = enable_cli_proxy_startup_recovery .unwrap_or(previous.enable_cli_proxy_startup_recovery); + let request_log_retention_days = + request_log_retention_days.unwrap_or(previous.request_log_retention_days); let provider_cooldown_seconds = provider_cooldown_seconds.unwrap_or(previous.provider_cooldown_seconds); let gateway_listen_mode = gateway_listen_mode.unwrap_or(previous.gateway_listen_mode); @@ -764,6 +771,7 @@ pub(crate) async fn settings_set_impl( tray_enabled, enable_cli_proxy_startup_recovery, log_retention_days, + request_log_retention_days, provider_cooldown_seconds, provider_base_url_ping_cache_ttl_seconds, upstream_first_byte_timeout_seconds, diff --git a/src-tauri/src/app/startup_tasks.rs b/src-tauri/src/app/startup_tasks.rs index de2ce72f..23cab52e 100644 --- a/src-tauri/src/app/startup_tasks.rs +++ b/src-tauri/src/app/startup_tasks.rs @@ -56,6 +56,8 @@ async fn run(app_handle: tauri::AppHandle) { } } + crate::request_logs::spawn_retention_task(app_handle.clone(), db.clone()); + set_startup_stage(&app_handle, AppStartupStage::ReadingSettings); let settings = match crate::app::startup_settings::read(&app_handle).await { Ok(settings) => settings, diff --git a/src-tauri/src/commands/data_management.rs b/src-tauri/src/commands/data_management.rs index d0e83c60..1c6e8027 100644 --- a/src-tauri/src/commands/data_management.rs +++ b/src-tauri/src/commands/data_management.rs @@ -30,6 +30,18 @@ pub(crate) async fn db_disk_usage_get( .map_err(Into::into) } +#[tauri::command] +#[specta::specta] +pub(crate) async fn db_compact( + app: tauri::AppHandle, + db_state: tauri::State<'_, DbInitState>, +) -> Result { + let db = ensure_db_ready(app.clone(), db_state.inner()).await?; + blocking::run("db_compact", move || data_management::db_compact(&app, &db)) + .await + .map_err(Into::into) +} + #[tauri::command] #[specta::specta] pub(crate) async fn request_logs_clear_all( diff --git a/src-tauri/src/commands/plugins.rs b/src-tauri/src/commands/plugins.rs index 852bd13a..e4e4221e 100644 --- a/src-tauri/src/commands/plugins.rs +++ b/src-tauri/src/commands/plugins.rs @@ -1,8 +1,13 @@ //! Usage: Community plugin management related Tauri commands. use crate::app::plugin_service; +use crate::app::plugins::contribution_registry::ActiveContributionSnapshot; +use crate::app::plugins::extension_host_registry::ExtensionHostRuntimeState; use crate::app_state::{ensure_db_ready, DbInitState}; -use crate::domain::plugins::{PluginAuditLog, PluginDetail, PluginInstallSource}; +use crate::domain::plugins::{ + PluginAuditLog, PluginDetail, PluginExtensionExecutionReport, PluginHookExecutionReport, + PluginInstallPreview, PluginInstallSource, PluginReplayFixture, PluginUpdateDiff, +}; use crate::infra::plugins::market::PluginMarketListing; use crate::{blocking, plugins}; use std::collections::HashMap; @@ -21,6 +26,18 @@ pub(crate) struct PluginInstallFromFileInput { pub file_path: String, } +#[derive(Debug, Clone, serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PluginPreviewFromFileInput { + pub file_path: String, +} + +#[derive(Debug, Clone, serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PluginPreviewUpdateFromFileInput { + pub file_path: String, +} + #[derive(Debug, Clone, serde::Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] pub(crate) struct PluginSaveConfigInput { @@ -49,6 +66,40 @@ pub(crate) struct PluginListAuditLogsInput { pub limit: Option, } +#[derive(Debug, Clone, serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PluginListRuntimeReportsInput { + pub plugin_id: Option, + pub hook_name: Option, + pub trace_id: Option, + pub limit: Option, +} + +#[derive(Debug, Clone, serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PluginListExtensionRuntimeReportsInput { + pub plugin_id: Option, + pub contribution_type: Option, + pub contribution_id: Option, + pub trace_id: Option, + pub limit: Option, +} + +#[derive(Debug, Clone, serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PluginExportReplayFixtureInput { + pub trace_id: String, + pub hook_name: String, + pub plugin_id: Option, +} + +#[derive(Debug, Clone, serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PluginExecuteCommandInput { + pub command: String, + pub args: serde_json::Value, +} + #[derive(Debug, Clone, serde::Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] pub(crate) struct PluginMarketIndexInput { @@ -72,6 +123,7 @@ pub(crate) struct PluginInstallRemoteInput { pub checksum: String, pub signature: Option, pub public_key: Option, + pub market_source_url: Option, pub source: Option, } @@ -110,6 +162,10 @@ fn official_resource_root_exists(root: &std::path::Path) -> bool { root.join("privacy-filter").join("plugin.json").exists() } +fn local_plugin_preview_policy() -> plugin_service::LocalPackageInstallPolicy { + plugin_service::LocalPackageInstallPolicy::default() +} + #[tauri::command] #[specta::specta] pub(crate) async fn plugin_list( @@ -137,15 +193,123 @@ pub(crate) async fn plugin_get( .map_err(Into::into) } +#[tauri::command] +#[specta::specta] +pub(crate) async fn plugin_active_contributions( + app: tauri::AppHandle, + db_state: tauri::State<'_, DbInitState>, +) -> Result { + let db = ensure_db_ready(app.clone(), db_state.inner()).await?; + blocking::run("plugin_active_contributions", move || { + plugin_service::active_plugin_contributions(&db) + }) + .await + .map_err(Into::into) +} + +#[tauri::command] +#[specta::specta] +pub(crate) async fn plugin_execute_command( + app: tauri::AppHandle, + db_state: tauri::State<'_, DbInitState>, + registry_state: tauri::State<'_, ExtensionHostRuntimeState>, + input: PluginExecuteCommandInput, +) -> Result { + let db = ensure_db_ready(app.clone(), db_state.inner()).await?; + let registry = registry_state.registry(app, db_state.inner()).await?; + plugin_service::execute_plugin_command(&db, registry.as_ref(), &input.command, input.args) + .await + .map_err(Into::into) +} + +#[tauri::command] +#[specta::specta] +pub(crate) async fn plugin_preview_from_file( + app: tauri::AppHandle, + db_state: tauri::State<'_, DbInitState>, + input: PluginPreviewFromFileInput, +) -> Result { + let db = ensure_db_ready(app.clone(), db_state.inner()).await?; + blocking::run("plugin_preview_from_file", move || { + let path = PathBuf::from(&input.file_path); + let cache_dir = crate::app_paths::plugins_cache_dir(&app)?; + plugin_service::preview_plugin_from_local_package_with_policy( + &db, + &path, + &cache_dir, + env!("CARGO_PKG_VERSION"), + local_plugin_preview_policy(), + ) + }) + .await + .map_err(Into::into) +} + +#[tauri::command] +#[specta::specta] +pub(crate) async fn plugin_preview_update_from_file( + app: tauri::AppHandle, + db_state: tauri::State<'_, DbInitState>, + input: PluginPreviewUpdateFromFileInput, +) -> Result { + let db = ensure_db_ready(app.clone(), db_state.inner()).await?; + blocking::run("plugin_preview_update_from_file", move || { + let path = PathBuf::from(&input.file_path); + let cache_dir = crate::app_paths::plugins_cache_dir(&app)?; + plugin_service::preview_plugin_update_from_local_package( + &db, + &path, + &cache_dir, + env!("CARGO_PKG_VERSION"), + local_plugin_preview_policy(), + ) + }) + .await + .map_err(Into::into) +} + +#[tauri::command] +#[specta::specta] +pub(crate) async fn plugin_preview_remote_update( + app: tauri::AppHandle, + db_state: tauri::State<'_, DbInitState>, + input: PluginInstallRemoteInput, +) -> Result { + let db = ensure_db_ready(app.clone(), db_state.inner()).await?; + let package_bytes = download_remote_plugin_package(&input.download_url).await?; + blocking::run("plugin_preview_remote_update", move || { + let cache_dir = crate::app_paths::plugins_cache_dir(&app)?; + let install_source = remote_install_source(input.source.as_deref(), &input.download_url)?; + plugin_service::preview_plugin_update_from_remote_package_bytes( + &db, + package_bytes, + &input.download_url, + &cache_dir, + env!("CARGO_PKG_VERSION"), + plugin_service::RemotePackageInstallPolicy { + install_source, + expected_plugin_id: input.plugin_id, + expected_checksum: input.checksum, + signature: input.signature, + public_key: input.public_key, + market_source_url: input.market_source_url, + }, + ) + }) + .await + .map_err(Into::into) +} + #[tauri::command] #[specta::specta] pub(crate) async fn plugin_install_from_file( app: tauri::AppHandle, db_state: tauri::State<'_, DbInitState>, + registry_state: tauri::State<'_, ExtensionHostRuntimeState>, input: PluginInstallFromFileInput, ) -> Result { let db = ensure_db_ready(app.clone(), db_state.inner()).await?; - blocking::run("plugin_install_from_file", move || { + let detail = blocking::run("plugin_install_from_file", move || { let path = PathBuf::from(&input.file_path); let cache_dir = crate::app_paths::plugins_cache_dir(&app)?; let installed_dir = crate::app_paths::plugins_installed_dir(&app)?; @@ -160,7 +324,9 @@ pub(crate) async fn plugin_install_from_file( .and_then(|detail| ensure_plugin_runtime_dirs(&app, detail)) }) .await - .map_err(Into::into) + .map_err(String::from)?; + dispose_plugin_extension_host_after_lifecycle_change(®istry_state, &detail).await; + Ok(detail) } #[tauri::command] @@ -168,10 +334,11 @@ pub(crate) async fn plugin_install_from_file( pub(crate) async fn plugin_update_from_file( app: tauri::AppHandle, db_state: tauri::State<'_, DbInitState>, + registry_state: tauri::State<'_, ExtensionHostRuntimeState>, input: PluginInstallFromFileInput, ) -> Result { let db = ensure_db_ready(app.clone(), db_state.inner()).await?; - blocking::run("plugin_update_from_file", move || { + let detail = blocking::run("plugin_update_from_file", move || { let path = PathBuf::from(&input.file_path); let cache_dir = crate::app_paths::plugins_cache_dir(&app)?; let installed_dir = crate::app_paths::plugins_installed_dir(&app)?; @@ -187,7 +354,9 @@ pub(crate) async fn plugin_update_from_file( .and_then(|detail| ensure_plugin_runtime_dirs(&app, detail)) }) .await - .map_err(Into::into) + .map_err(String::from)?; + dispose_plugin_extension_host_after_lifecycle_change(®istry_state, &detail).await; + Ok(detail) } #[tauri::command] @@ -195,15 +364,18 @@ pub(crate) async fn plugin_update_from_file( pub(crate) async fn plugin_rollback( app: tauri::AppHandle, db_state: tauri::State<'_, DbInitState>, + registry_state: tauri::State<'_, ExtensionHostRuntimeState>, input: PluginRollbackInput, ) -> Result { let db = ensure_db_ready(app.clone(), db_state.inner()).await?; - blocking::run("plugin_rollback", move || { + let detail = blocking::run("plugin_rollback", move || { plugin_service::rollback_plugin_to_version(&db, &input.plugin_id, &input.version) .and_then(|detail| refresh_running_gateway_plugins(&app, &db, detail)) }) .await - .map_err(Into::into) + .map_err(String::from)?; + dispose_plugin_extension_host_after_lifecycle_change(®istry_state, &detail).await; + Ok(detail) } fn market_index_trusted_public_key( @@ -253,6 +425,7 @@ pub(crate) async fn plugin_parse_market_index( (Some(signature), Some(public_key)) => { crate::infra::plugins::market::parse_signed_market_index( input.index_json.as_bytes(), + input.index_url.as_deref(), signature, public_key, env!("CARGO_PKG_VERSION"), @@ -261,6 +434,7 @@ pub(crate) async fn plugin_parse_market_index( } (None, None) => crate::infra::plugins::market::parse_market_index( input.index_json.as_bytes(), + input.index_url.as_deref(), env!("CARGO_PKG_VERSION"), &installed, ), @@ -279,11 +453,12 @@ pub(crate) async fn plugin_parse_market_index( pub(crate) async fn plugin_install_remote( app: tauri::AppHandle, db_state: tauri::State<'_, DbInitState>, + registry_state: tauri::State<'_, ExtensionHostRuntimeState>, input: PluginInstallRemoteInput, ) -> Result { let db = ensure_db_ready(app.clone(), db_state.inner()).await?; let package_bytes = download_remote_plugin_package(&input.download_url).await?; - blocking::run("plugin_install_remote", move || { + let detail = blocking::run("plugin_install_remote", move || { let cache_dir = crate::app_paths::plugins_cache_dir(&app)?; let installed_dir = crate::app_paths::plugins_installed_dir(&app)?; let install_source = remote_install_source(input.source.as_deref(), &input.download_url)?; @@ -300,13 +475,55 @@ pub(crate) async fn plugin_install_remote( expected_checksum: input.checksum, signature: input.signature, public_key: input.public_key, + market_source_url: input.market_source_url, }, ) .and_then(|detail| refresh_running_gateway_plugins(&app, &db, detail)) .and_then(|detail| ensure_plugin_runtime_dirs(&app, detail)) }) .await - .map_err(Into::into) + .map_err(String::from)?; + dispose_plugin_extension_host_after_lifecycle_change(®istry_state, &detail).await; + Ok(detail) +} + +#[tauri::command] +#[specta::specta] +pub(crate) async fn plugin_update_remote( + app: tauri::AppHandle, + db_state: tauri::State<'_, DbInitState>, + registry_state: tauri::State<'_, ExtensionHostRuntimeState>, + input: PluginInstallRemoteInput, +) -> Result { + let db = ensure_db_ready(app.clone(), db_state.inner()).await?; + let package_bytes = download_remote_plugin_package(&input.download_url).await?; + let detail = blocking::run("plugin_update_remote", move || { + let cache_dir = crate::app_paths::plugins_cache_dir(&app)?; + let installed_dir = crate::app_paths::plugins_installed_dir(&app)?; + let install_source = remote_install_source(input.source.as_deref(), &input.download_url)?; + plugin_service::update_plugin_from_remote_package_bytes( + &db, + package_bytes, + &input.download_url, + &cache_dir, + &installed_dir, + env!("CARGO_PKG_VERSION"), + plugin_service::RemotePackageInstallPolicy { + install_source, + expected_plugin_id: input.plugin_id, + expected_checksum: input.checksum, + signature: input.signature, + public_key: input.public_key, + market_source_url: input.market_source_url, + }, + ) + .and_then(|detail| refresh_running_gateway_plugins(&app, &db, detail)) + .and_then(|detail| ensure_plugin_runtime_dirs(&app, detail)) + }) + .await + .map_err(String::from)?; + dispose_plugin_extension_host_after_lifecycle_change(®istry_state, &detail).await; + Ok(detail) } #[tauri::command] @@ -314,11 +531,12 @@ pub(crate) async fn plugin_install_remote( pub(crate) async fn plugin_install_official( app: tauri::AppHandle, db_state: tauri::State<'_, DbInitState>, + registry_state: tauri::State<'_, ExtensionHostRuntimeState>, input: PluginGetInput, ) -> Result { let db = ensure_db_ready(app.clone(), db_state.inner()).await?; let official_resource_root = official_resource_root(&app)?; - blocking::run("plugin_install_official", move || { + let detail = blocking::run("plugin_install_official", move || { let installed_dir = crate::app_paths::plugins_installed_dir(&app)?; plugin_service::install_official_plugin( &db, @@ -331,7 +549,9 @@ pub(crate) async fn plugin_install_official( .and_then(|detail| ensure_plugin_runtime_dirs(&app, detail)) }) .await - .map_err(Into::into) + .map_err(String::from)?; + dispose_plugin_extension_host_after_lifecycle_change(®istry_state, &detail).await; + Ok(detail) } #[tauri::command] @@ -339,10 +559,11 @@ pub(crate) async fn plugin_install_official( pub(crate) async fn plugin_quarantine_revoked( app: tauri::AppHandle, db_state: tauri::State<'_, DbInitState>, + registry_state: tauri::State<'_, ExtensionHostRuntimeState>, input: PluginGetInput, ) -> Result { let db = ensure_db_ready(app.clone(), db_state.inner()).await?; - blocking::run("plugin_quarantine_revoked", move || { + let detail = blocking::run("plugin_quarantine_revoked", move || { plugin_service::quarantine_revoked_plugin( &db, &input.plugin_id, @@ -351,7 +572,9 @@ pub(crate) async fn plugin_quarantine_revoked( .and_then(|detail| refresh_running_gateway_plugins(&app, &db, detail)) }) .await - .map_err(Into::into) + .map_err(String::from)?; + dispose_plugin_extension_host_after_lifecycle_change(®istry_state, &detail).await; + Ok(detail) } #[tauri::command] @@ -400,20 +623,32 @@ fn refresh_running_gateway_plugins( Ok(detail) } +async fn dispose_plugin_extension_host_after_lifecycle_change( + registry_state: &ExtensionHostRuntimeState, + detail: &PluginDetail, +) { + registry_state + .dispose_plugin_if_initialized(&detail.summary.plugin_id) + .await; +} + #[tauri::command] #[specta::specta] pub(crate) async fn plugin_disable( app: tauri::AppHandle, db_state: tauri::State<'_, DbInitState>, + registry_state: tauri::State<'_, ExtensionHostRuntimeState>, input: PluginGetInput, ) -> Result { let db = ensure_db_ready(app.clone(), db_state.inner()).await?; - blocking::run("plugin_disable", move || { + let detail = blocking::run("plugin_disable", move || { plugin_service::disable_plugin(&db, &input.plugin_id) .and_then(|detail| refresh_running_gateway_plugins(&app, &db, detail)) }) .await - .map_err(Into::into) + .map_err(String::from)?; + dispose_plugin_extension_host_after_lifecycle_change(®istry_state, &detail).await; + Ok(detail) } #[tauri::command] @@ -421,15 +656,18 @@ pub(crate) async fn plugin_disable( pub(crate) async fn plugin_uninstall( app: tauri::AppHandle, db_state: tauri::State<'_, DbInitState>, + registry_state: tauri::State<'_, ExtensionHostRuntimeState>, input: PluginGetInput, ) -> Result { let db = ensure_db_ready(app.clone(), db_state.inner()).await?; - blocking::run("plugin_uninstall", move || { + let detail = blocking::run("plugin_uninstall", move || { plugin_service::uninstall_plugin(&db, &input.plugin_id) .and_then(|detail| refresh_running_gateway_plugins(&app, &db, detail)) }) .await - .map_err(Into::into) + .map_err(String::from)?; + dispose_plugin_extension_host_after_lifecycle_change(®istry_state, &detail).await; + Ok(detail) } #[tauri::command] @@ -499,6 +737,71 @@ pub(crate) async fn plugin_list_audit_logs( .map_err(Into::into) } +#[tauri::command] +#[specta::specta] +pub(crate) async fn plugin_list_runtime_reports( + app: tauri::AppHandle, + db_state: tauri::State<'_, DbInitState>, + input: PluginListRuntimeReportsInput, +) -> Result, String> { + let db = ensure_db_ready(app, db_state.inner()).await?; + blocking::run("plugin_list_runtime_reports", move || { + crate::infra::plugins::runtime_reports::list_hook_execution_reports( + &db, + input.plugin_id.as_deref(), + input.hook_name.as_deref(), + input.trace_id.as_deref(), + input.limit.unwrap_or(50), + ) + }) + .await + .map_err(Into::into) +} + +#[tauri::command] +#[specta::specta] +pub(crate) async fn plugin_list_extension_runtime_reports( + app: tauri::AppHandle, + db_state: tauri::State<'_, DbInitState>, + input: PluginListExtensionRuntimeReportsInput, +) -> Result, String> { + let db = ensure_db_ready(app, db_state.inner()).await?; + blocking::run("plugin_list_extension_runtime_reports", move || { + crate::infra::plugins::runtime_reports::list_extension_execution_reports( + &db, + input.plugin_id.as_deref(), + input.contribution_type.as_deref(), + input.contribution_id.as_deref(), + input.trace_id.as_deref(), + input.limit.unwrap_or(50), + ) + }) + .await + .map_err(Into::into) +} + +#[tauri::command] +#[specta::specta] +pub(crate) async fn plugin_export_replay_fixture( + app: tauri::AppHandle, + db_state: tauri::State<'_, DbInitState>, + input: PluginExportReplayFixtureInput, +) -> Result { + let db = ensure_db_ready(app, db_state.inner()).await?; + blocking::run("plugin_export_replay_fixture", move || { + crate::infra::plugins::replay_export::export_plugin_replay_fixture( + &db, + crate::infra::plugins::replay_export::ExportPluginReplayFixtureInput { + trace_id: input.trace_id, + hook_name: input.hook_name, + plugin_id: input.plugin_id, + }, + ) + }) + .await + .map_err(Into::into) +} + const MAX_REMOTE_PLUGIN_PACKAGE_BYTES: usize = 32 * 1024 * 1024; fn remote_install_source( @@ -673,4 +976,58 @@ INSERT INTO plugin_market_sources( assert_eq!(public_key.as_deref(), Some("trusted-key")); } + + #[test] + fn local_preview_policy_matches_local_install_policy() { + let policy = local_plugin_preview_policy(); + + assert!(!policy.developer_mode); + assert!(policy.expected_plugin_id.is_none()); + assert!(policy.expected_checksum.is_none()); + assert!(policy.signature.is_none()); + assert!(policy.public_key.is_none()); + } + + #[test] + fn plugin_lifecycle_commands_dispose_extension_host_instances_after_success() { + let source = std::fs::read_to_string(file!()).expect("read plugin commands source"); + for function_name in [ + "plugin_install_from_file", + "plugin_install_remote", + "plugin_install_official", + "plugin_disable", + "plugin_uninstall", + "plugin_update_from_file", + "plugin_update_remote", + "plugin_rollback", + "plugin_quarantine_revoked", + ] { + let body = command_body(&source, function_name); + assert!( + body.contains("registry_state: tauri::State<'_, ExtensionHostRuntimeState>"), + "{function_name} should receive ExtensionHostRuntimeState" + ); + assert!( + body.contains("dispose_plugin_extension_host_after_lifecycle_change"), + "{function_name} should invoke extension host disposal after lifecycle success" + ); + } + let helper = source + .split("async fn dispose_plugin_extension_host_after_lifecycle_change") + .nth(1) + .expect("dispose helper should exist"); + assert!( + helper.contains("dispose_plugin_if_initialized"), + "dispose helper should no-op unless the extension host registry is initialized" + ); + } + + fn command_body<'a>(source: &'a str, function_name: &str) -> &'a str { + let start = source + .find(&format!("pub(crate) async fn {function_name}")) + .unwrap_or_else(|| panic!("missing {function_name}")); + let rest = &source[start..]; + let next = rest.find("\n#[tauri::command]").unwrap_or(rest.len()); + &rest[..next] + } } diff --git a/src-tauri/src/commands/registry.rs b/src-tauri/src/commands/registry.rs index 57f3463e..63d21a29 100644 --- a/src-tauri/src/commands/registry.rs +++ b/src-tauri/src/commands/registry.rs @@ -166,11 +166,17 @@ macro_rules! generated_command_registry { // ── plugins ── plugin_list => crate::commands::plugins::plugin_list, plugin_get => crate::commands::plugins::plugin_get, + plugin_active_contributions => crate::commands::plugins::plugin_active_contributions, + plugin_execute_command => crate::commands::plugins::plugin_execute_command, + plugin_preview_from_file => crate::commands::plugins::plugin_preview_from_file, + plugin_preview_update_from_file => crate::commands::plugins::plugin_preview_update_from_file, + plugin_preview_remote_update => crate::commands::plugins::plugin_preview_remote_update, plugin_install_from_file => crate::commands::plugins::plugin_install_from_file, plugin_update_from_file => crate::commands::plugins::plugin_update_from_file, plugin_rollback => crate::commands::plugins::plugin_rollback, plugin_parse_market_index => crate::commands::plugins::plugin_parse_market_index, plugin_install_remote => crate::commands::plugins::plugin_install_remote, + plugin_update_remote => crate::commands::plugins::plugin_update_remote, plugin_install_official => crate::commands::plugins::plugin_install_official, plugin_quarantine_revoked => crate::commands::plugins::plugin_quarantine_revoked, plugin_enable => crate::commands::plugins::plugin_enable, @@ -180,6 +186,9 @@ macro_rules! generated_command_registry { plugin_grant_permissions => crate::commands::plugins::plugin_grant_permissions, plugin_revoke_permission => crate::commands::plugins::plugin_revoke_permission, plugin_list_audit_logs => crate::commands::plugins::plugin_list_audit_logs, + plugin_list_runtime_reports => crate::commands::plugins::plugin_list_runtime_reports, + plugin_list_extension_runtime_reports => crate::commands::plugins::plugin_list_extension_runtime_reports, + plugin_export_replay_fixture => crate::commands::plugins::plugin_export_replay_fixture, // ── request_logs ── request_logs_list => crate::commands::request_logs::request_logs_list, request_logs_list_all => crate::commands::request_logs::request_logs_list_all, @@ -188,9 +197,11 @@ macro_rules! generated_command_registry { request_log_get => crate::commands::request_logs::request_log_get, request_log_get_by_trace_id => crate::commands::request_logs::request_log_get_by_trace_id, request_attempt_logs_by_trace_id => crate::commands::request_logs::request_attempt_logs_by_trace_id, + active_request_logs_snapshot => crate::commands::request_logs::active_request_logs_snapshot, cli_sessions_folder_lookup_by_ids => crate::commands::cli_sessions::cli_sessions_folder_lookup_by_ids, // ── data_management ── db_disk_usage_get => crate::commands::data_management::db_disk_usage_get, + db_compact => crate::commands::data_management::db_compact, request_logs_clear_all => crate::commands::data_management::request_logs_clear_all, app_data_reset => crate::commands::data_management::app_data_reset, // ── usage ── @@ -199,6 +210,7 @@ macro_rules! generated_command_registry { usage_leaderboard_provider => crate::commands::usage::usage_leaderboard_provider, usage_leaderboard_day => crate::commands::usage::usage_leaderboard_day, usage_leaderboard_v2 => crate::commands::usage::usage_leaderboard_v2, + usage_leaderboard_csv_export => crate::commands::usage::usage_leaderboard_csv_export, usage_hourly_series => crate::commands::usage::usage_hourly_series, usage_day_detail_v1 => crate::commands::usage::usage_day_detail_v1, usage_folder_options_v1 => crate::commands::usage::usage_folder_options_v1, @@ -255,7 +267,16 @@ pub(crate) fn export_typescript_bindings(output_path: &str) -> Result<(), String }; } - let builder = generated_command_registry!(collect_exported_commands); + // Gateway event payload types (gateway:* wire contract). Registered on the + // export builder only; runtime emit paths are untouched (no tauri_specta + // Event mechanism, event names stay guarded by constants + contract tests). + let builder = generated_command_registry!(collect_exported_commands) + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::(); builder .export( @@ -266,7 +287,18 @@ pub(crate) fn export_typescript_bindings(output_path: &str) -> Result<(), String .bigint(specta_typescript::BigIntExportBehavior::Number), output_path, ) - .map_err(|error| format!("failed to export specta TypeScript bindings: {error}")) + .map_err(|error| format!("failed to export specta TypeScript bindings: {error}"))?; + + let source = std::fs::read_to_string(output_path) + .map_err(|error| format!("failed to read generated TypeScript bindings: {error}"))?; + let normalized = source.replace("error: e as any", "error: e as any"); + if normalized != source { + std::fs::write(output_path, normalized).map_err(|error| { + format!("failed to normalize generated TypeScript bindings: {error}") + })?; + } + + Ok(()) } #[cfg(test)] @@ -315,11 +347,16 @@ mod tests { for command in [ "plugin_list", "plugin_get", + "plugin_active_contributions", + "plugin_preview_from_file", + "plugin_preview_update_from_file", + "plugin_preview_remote_update", "plugin_install_from_file", "plugin_update_from_file", "plugin_rollback", "plugin_parse_market_index", "plugin_install_remote", + "plugin_update_remote", "plugin_install_official", "plugin_quarantine_revoked", "plugin_enable", diff --git a/src-tauri/src/commands/request_logs.rs b/src-tauri/src/commands/request_logs.rs index a41ebda3..84f18a95 100644 --- a/src-tauri/src/commands/request_logs.rs +++ b/src-tauri/src/commands/request_logs.rs @@ -2,6 +2,7 @@ use crate::app_state::{ensure_db_ready, DbInitState}; use crate::commands::limit::normalize_limit; +use crate::gateway_runtime_access::app_gateway_active_requests_snapshot; use crate::{blocking, request_attempt_logs, request_logs}; const REQUEST_LOGS_DEFAULT_LIMIT: u32 = 50; @@ -136,6 +137,14 @@ pub(crate) async fn request_attempt_logs_by_trace_id( .map_err(Into::into) } +#[tauri::command] +#[specta::specta] +pub(crate) fn active_request_logs_snapshot( + app: tauri::AppHandle, +) -> Result, String> { + Ok(app_gateway_active_requests_snapshot(&app)) +} + #[cfg(test)] mod tests { use super::{request_attempt_logs_limit, request_logs_limit}; diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 9d0e3281..388f641d 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -3,6 +3,8 @@ use crate::app_state::{ensure_db_ready, DbInitState}; use crate::{blocking, cli_sessions, usage_stats}; +const USAGE_LEADERBOARD_CSV_EXPORT_MAX_BYTES: usize = 1024 * 1024; + fn usage_folder_lookup( app: &tauri::AppHandle, items: &[usage_stats::UsageSessionLookupKey], @@ -33,6 +35,38 @@ fn usage_folder_lookup( .collect() } +fn normalize_usage_leaderboard_csv_export_input( + file_path: String, + csv: String, +) -> Result<(String, String), String> { + let file_path = file_path.trim().to_string(); + if file_path.is_empty() { + return Err("SEC_INVALID_INPUT: file_path is required".to_string()); + } + + if csv.trim_matches('\u{feff}').trim().is_empty() { + return Err("SEC_INVALID_INPUT: csv is required".to_string()); + } + + let byte_len = csv.len(); + if byte_len > USAGE_LEADERBOARD_CSV_EXPORT_MAX_BYTES { + return Err(format!( + "SEC_INVALID_INPUT: csv is too large (max {} bytes)", + USAGE_LEADERBOARD_CSV_EXPORT_MAX_BYTES + )); + } + + Ok((file_path, csv)) +} + +fn write_usage_leaderboard_csv_export(file_path: String, csv: String) -> Result { + let (file_path, csv) = normalize_usage_leaderboard_csv_export_input(file_path, csv)?; + std::fs::write(&file_path, csv).map_err(|err| { + format!("SYSTEM_ERROR: failed to write usage leaderboard csv file: {err}") + })?; + Ok(true) +} + #[tauri::command] #[specta::specta] pub(crate) async fn usage_summary( @@ -120,6 +154,19 @@ pub(crate) async fn usage_leaderboard_v2( .map_err(Into::into) } +#[tauri::command] +#[specta::specta] +pub(crate) async fn usage_leaderboard_csv_export( + file_path: String, + csv: String, +) -> Result { + blocking::run("usage_leaderboard_csv_export", move || { + write_usage_leaderboard_csv_export(file_path, csv) + }) + .await + .map_err(Into::into) +} + #[tauri::command] #[specta::specta] pub(crate) async fn usage_hourly_series( @@ -136,6 +183,55 @@ pub(crate) async fn usage_hourly_series( .map_err(Into::into) } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn usage_leaderboard_csv_export_writes_valid_csv() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("usage.csv"); + let content = "\u{feff}排名,供应商\r\n1,OpenAI\r\n".to_string(); + + let ok = + write_usage_leaderboard_csv_export(path.to_string_lossy().to_string(), content.clone()) + .expect("csv export should succeed"); + + assert!(ok); + assert_eq!(std::fs::read_to_string(path).expect("read csv"), content); + } + + #[test] + fn usage_leaderboard_csv_export_rejects_empty_path() { + let err = write_usage_leaderboard_csv_export(" ".to_string(), "排名\r\n".to_string()) + .expect_err("empty path should fail"); + + assert!(err.contains("SEC_INVALID_INPUT: file_path is required")); + } + + #[test] + fn usage_leaderboard_csv_export_rejects_empty_csv() { + let err = write_usage_leaderboard_csv_export( + "/tmp/usage.csv".to_string(), + "\u{feff} ".to_string(), + ) + .expect_err("empty csv should fail"); + + assert!(err.contains("SEC_INVALID_INPUT: csv is required")); + } + + #[test] + fn usage_leaderboard_csv_export_rejects_oversized_csv() { + let err = write_usage_leaderboard_csv_export( + "/tmp/usage.csv".to_string(), + "x".repeat(USAGE_LEADERBOARD_CSV_EXPORT_MAX_BYTES + 1), + ) + .expect_err("oversized csv should fail"); + + assert!(err.contains("SEC_INVALID_INPUT: csv is too large")); + } +} + #[tauri::command] #[specta::specta] pub(crate) async fn usage_day_detail_v1( diff --git a/src-tauri/src/domain/mod.rs b/src-tauri/src/domain/mod.rs index 16b8125c..f48bb22e 100644 --- a/src-tauri/src/domain/mod.rs +++ b/src-tauri/src/domain/mod.rs @@ -10,6 +10,7 @@ pub(crate) mod cli_sessions; pub(crate) mod cost; pub(crate) mod cost_stats; pub(crate) mod mcp; +pub(crate) mod plugin_contributions; pub(crate) mod plugins; pub(crate) mod prompts; pub(crate) mod provider_availability; diff --git a/src-tauri/src/domain/plugin_contributions.rs b/src-tauri/src/domain/plugin_contributions.rs new file mode 100644 index 00000000..0059d3b2 --- /dev/null +++ b/src-tauri/src/domain/plugin_contributions.rs @@ -0,0 +1,220 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::plugins::PluginHook; + +pub(crate) const ACTIVE_UI_SLOTS: &[&str] = &[ + "app.sidebar.items", + "home.overview.cards", + "providers.editor.sections", + "providers.editor.fields", + "providers.card.badges", + "providers.card.actions", + "settings.sections", + "logs.detail.tabs", + "logs.detail.actions", + "usage.panels", + "plugins.detail.panels", +]; + +const ACTIVE_CAPABILITIES: &[&str] = &[ + "commands.execute", + "storage.plugin", + "diagnostics.read", + "provider.extensionValues", + "provider.requestPreparation", + "provider.modelDiscovery", + "provider.healthCheck", + "protocol.bridge", + "gateway.hooks", + "privacy.redact", +]; + +pub fn is_known_ui_slot(slot: &str) -> bool { + ACTIVE_UI_SLOTS.contains(&slot) +} + +pub fn is_known_capability(capability: &str) -> bool { + ACTIVE_CAPABILITIES.contains(&capability) +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginContributes { + #[serde(default)] + pub providers: Vec, + #[serde(default)] + pub protocols: Vec, + #[serde(rename = "protocolBridges")] + #[serde(default)] + pub protocol_bridges: Vec, + #[serde(default)] + pub commands: Vec, + #[serde(rename = "gatewayHooks")] + #[serde(default)] + pub gateway_hooks: Vec, + #[serde(default)] + pub ui: BTreeMap>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ProviderContribution { + #[serde(rename = "providerType")] + pub provider_type: String, + #[serde(rename = "displayName")] + pub display_name: String, + #[serde(rename = "targetCliKeys")] + pub target_cli_keys: Vec, + #[serde(rename = "extensionNamespace")] + pub extension_namespace: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum TargetCliKey { + Claude, + Codex, + Gemini, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ProtocolContribution { + #[serde(rename = "protocolId")] + pub protocol_id: String, + pub direction: ProtocolDirection, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ProtocolDirection { + Inbound, + Outbound, + Both, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ProtocolBridgeContribution { + #[serde(rename = "bridgeType")] + pub bridge_type: String, + #[serde(rename = "inboundProtocol")] + pub inbound_protocol: String, + #[serde(rename = "outboundProtocol")] + pub outbound_protocol: String, + #[serde(rename = "supportsStreaming")] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub supports_streaming: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CommandContribution { + pub command: String, + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct UiContribution { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub order: Option, + pub schema: HostRenderedSchema, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub when: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum HostRenderedSchema { + Section { + fields: Vec, + }, + Panel { + fields: Vec, + }, + Badge { + label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + tone: Option, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum HostRenderedBadgeTone { + Neutral, + Success, + Warning, + Danger, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum HostRenderedField { + Text { + key: String, + label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + placeholder: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + required: Option, + }, + Password { + key: String, + label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + placeholder: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + required: Option, + }, + Number { + key: String, + label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + min: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + max: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + step: Option, + }, + Boolean { + key: String, + label: String, + }, + Select { + key: String, + label: String, + options: Vec, + }, + Textarea { + key: String, + label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + rows: Option, + }, + Info { + key: String, + label: String, + value: String, + }, + Button { + key: String, + label: String, + command: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct HostRenderedSelectOption { + pub value: String, + pub label: String, +} diff --git a/src-tauri/src/domain/plugins.rs b/src-tauri/src/domain/plugins.rs index b8218b3a..e2f4ddd9 100644 --- a/src-tauri/src/domain/plugins.rs +++ b/src-tauri/src/domain/plugins.rs @@ -1,5 +1,12 @@ +use std::collections::BTreeMap; + use serde::{Deserialize, Serialize}; +use super::plugin_contributions::{ + is_known_capability, is_known_ui_slot, HostRenderedField, HostRenderedSchema, + PluginContributes, UiContribution, +}; + pub type PluginId = String; const SUPPORTED_PLUGIN_API_MAJOR: u64 = 1; @@ -12,8 +19,19 @@ pub struct PluginManifest { #[serde(rename = "apiVersion")] pub api_version: String, pub runtime: PluginRuntime, + #[serde(default)] pub hooks: Vec, + #[serde(default)] pub permissions: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub main: Option, + #[serde(rename = "activationEvents")] + #[serde(default)] + pub activation_events: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub contributes: Option, + #[serde(default)] + pub capabilities: Vec, #[serde(rename = "hostCompatibility")] pub host_compatibility: PluginHostCompatibility, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -45,19 +63,7 @@ pub struct PluginManifest { #[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum PluginRuntime { - DeclarativeRules { - rules: Vec, - }, - Native { - engine: String, - }, - Wasm { - #[serde(rename = "abiVersion")] - abi_version: String, - #[serde(rename = "memoryLimitBytes")] - #[serde(default, skip_serializing_if = "Option::is_none")] - memory_limit_bytes: Option, - }, + ExtensionHost { language: String }, } #[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] @@ -68,6 +74,9 @@ pub struct PluginHook { #[serde(rename = "failurePolicy")] #[serde(default, skip_serializing_if = "Option::is_none")] pub failure_policy: Option, + #[serde(rename = "timeoutMs")] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, } #[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] @@ -204,6 +213,7 @@ pub struct PluginDetail { pub pending_permissions: Vec, pub audit_logs: Vec, pub runtime_failures: Vec, + pub rollback_versions: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq)] @@ -229,6 +239,292 @@ pub struct PluginRuntimeFailure { pub created_at: i64, } +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq)] +pub struct PluginHookExecutionReport { + pub id: i64, + pub plugin_id: String, + pub trace_id: Option, + pub hook_name: String, + pub runtime_kind: String, + pub status: String, + pub started_at_ms: i64, + pub duration_ms: i64, + pub failure_kind: Option, + pub error_code: Option, + pub failure_policy: Option, + pub circuit_state: Option, + pub context_budget: serde_json::Value, + pub output_budget: serde_json::Value, + pub mutation_summary: serde_json::Value, + pub replayable: bool, + pub replay_export_reason: Option, + pub created_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PluginExtensionExecutionReport { + pub id: i64, + pub plugin_id: String, + pub contribution_type: String, + pub contribution_id: String, + pub command_or_hook: Option, + pub trace_id: Option, + pub status: String, + pub started_at_ms: i64, + pub duration_ms: i64, + pub failure_kind: Option, + pub error_code: Option, + pub input_budget: serde_json::Value, + pub output_budget: serde_json::Value, + pub mutation_summary: serde_json::Value, + pub replayable: bool, + pub created_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PluginReplayFixture { + pub schema_version: u32, + pub trace_id: String, + pub source: PluginReplayFixtureSource, + pub hook_name: String, + pub plugin_id: Option, + pub request: PluginReplayFixtureRequest, + pub response: PluginReplayFixtureResponse, + pub log: PluginReplayFixtureLog, + pub attempts: Vec, + pub runtime_reports: Vec, + pub notes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PluginReplayFixtureSource { + pub app_version: String, + pub trace_id: String, + pub exported_at_ms: i64, + pub request_log_id: i64, + pub created_at_ms: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PluginReplayFixtureRequest { + pub cli_key: String, + pub session_id: Option, + pub method: Option, + pub path: Option, + pub query: Option, + pub provider: Option, + pub provider_source: Option, + pub model: Option, + pub headers: Option, + pub body: Option, + pub normalized_messages: Vec, + pub meta: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PluginReplayFixtureResponse { + pub status: Option, + pub error_code: Option, + pub headers: Option, + pub body: Option, + pub chunks: Vec, + pub meta: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PluginReplayFixtureLog { + pub body: Option, + pub meta: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PluginReplayFixtureAttempt { + pub id: i64, + pub trace_id: String, + pub cli_key: String, + pub attempt_index: i64, + pub provider_id: i64, + pub provider_name: String, + pub base_url: String, + pub outcome: String, + pub status: Option, + pub attempt_started_ms: i64, + pub attempt_duration_ms: i64, + pub created_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PluginLifecycleNotice { + pub severity: String, + pub code: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PluginRuntimeLifecycleSummary { + pub kind: String, + pub label: String, + pub supported: bool, + pub blocking_reasons: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PluginHookLifecycleSummary { + pub name: String, + pub priority: i32, + pub failure_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PluginPermissionLifecycleSummary { + pub permission: String, + pub risk: PluginPermissionRisk, + pub granted: bool, + pub pending: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PluginCompatibilitySummary { + pub compatible: bool, + pub host_version: String, + pub app_range: String, + pub plugin_api_range: String, + pub platforms: Vec, + pub blocking_reasons: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PluginTrustSummary { + pub checksum: String, + pub expected_checksum: Option, + pub checksum_verified: bool, + pub signature_verified: bool, + pub unsigned: bool, + pub developer_mode: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PluginContributionImpact { + pub providers: Vec, + pub protocols: Vec, + pub protocol_bridges: Vec, + pub ui_slots: Vec, + pub commands: Vec, + pub gateway: Vec, + pub capabilities: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PluginContributionImpactItem { + pub id: String, + pub label: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PluginUiSlotImpact { + pub slot_id: String, + pub contribution_id: String, + pub title: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PluginCommandImpact { + pub command: String, + pub title: String, + pub category: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PluginInstallPreview { + pub plugin_id: String, + pub name: String, + pub version: String, + pub source: PluginInstallSource, + pub description: Option, + pub author: Option, + pub homepage: Option, + pub repository: Option, + pub license: Option, + pub category: Option, + pub runtime: PluginRuntimeLifecycleSummary, + pub hooks: Vec, + pub permissions: Vec, + pub contribution_impact: PluginContributionImpact, + pub compatibility: PluginCompatibilitySummary, + pub trust: PluginTrustSummary, + pub existing_status: Option, + pub existing_version: Option, + pub blocking_reasons: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PluginLifecycleChange { + pub name: String, + pub change: String, + pub before: Option, + pub after: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PluginPermissionLifecycleChange { + pub permission: String, + pub risk: PluginPermissionRisk, + pub change: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PluginContributionChange { + pub kind: String, + pub name: String, + pub label: Option, + pub change: String, + pub before: Option, + pub after: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PluginUpdateDiff { + pub plugin_id: String, + pub from_version: String, + pub to_version: String, + pub version_direction: String, + pub runtime_change: Option, + pub hook_changes: Vec, + pub permission_changes: Vec, + pub contribution_changes: Vec, + pub config_version_change: Option, + pub compatibility: PluginCompatibilitySummary, + pub trust: PluginTrustSummary, + pub rollback_available: bool, + pub blocking_reasons: Vec, + pub warnings: Vec, +} + impl From for crate::shared::error::AppError { fn from(value: PluginValidationError) -> Self { crate::shared::error::AppError::new(value.code, value.message) @@ -239,19 +535,38 @@ pub fn validate_manifest( manifest: &PluginManifest, host_version: &str, ) -> Result<(), PluginValidationError> { - validate_plugin_id(&manifest.id)?; - validate_semver(&manifest.version, "PLUGIN_INVALID_VERSION")?; - validate_semver(&manifest.api_version, "PLUGIN_INVALID_API_VERSION")?; - validate_runtime(manifest)?; - validate_hooks(&manifest.hooks)?; - validate_permissions(&manifest.permissions)?; - validate_hook_permissions(&manifest.hooks, &manifest.permissions)?; - validate_permission_scope(&manifest.hooks, &manifest.permissions)?; + validate_manifest_identity(manifest)?; + match &manifest.runtime { + PluginRuntime::ExtensionHost { language } => { + validate_extension_host_manifest(manifest, language)?; + } + } + validate_config_schema(manifest.config_schema.as_ref())?; + validate_host_compatibility(&manifest.host_compatibility, host_version)?; + Ok(()) +} + +pub fn validate_manifest_for_official_plugin( + manifest: &PluginManifest, + host_version: &str, +) -> Result<(), PluginValidationError> { + validate_manifest_identity(manifest)?; + match &manifest.runtime { + PluginRuntime::ExtensionHost { language } => { + validate_extension_host_manifest(manifest, language)?; + } + } validate_config_schema(manifest.config_schema.as_ref())?; validate_host_compatibility(&manifest.host_compatibility, host_version)?; Ok(()) } +fn validate_manifest_identity(manifest: &PluginManifest) -> Result<(), PluginValidationError> { + validate_plugin_id(&manifest.id)?; + validate_semver(&manifest.version, "PLUGIN_INVALID_VERSION")?; + validate_manifest_api_version(&manifest.api_version) +} + pub fn permission_risk(permission: &str) -> Option { match permission { "request.meta.read" => Some(PluginPermissionRisk::Low), @@ -276,36 +591,95 @@ pub fn permission_risk(permission: &str) -> Option { } } +pub fn manifest_effective_permissions(manifest: &PluginManifest) -> Vec { + extension_host_effective_permissions(manifest) +} + +pub fn gateway_hook_effective_permissions( + manifest: &PluginManifest, + hook_name: &str, +) -> Vec { + extension_host_hook_permissions(manifest, hook_name) +} + +pub fn manifest_permission_risk(manifest: &PluginManifest) -> PluginPermissionRisk { + manifest_effective_permissions(manifest) + .iter() + .filter_map(|permission| permission_risk(permission)) + .max_by_key(|risk| match risk { + PluginPermissionRisk::Low => 0, + PluginPermissionRisk::Medium => 1, + PluginPermissionRisk::High => 2, + PluginPermissionRisk::Critical => 3, + }) + .unwrap_or(PluginPermissionRisk::Low) +} + +fn extension_host_effective_permissions(manifest: &PluginManifest) -> Vec { + if !manifest + .capabilities + .iter() + .any(|capability| capability == "gateway.hooks") + { + return Vec::new(); + } + let Some(contributes) = manifest.contributes.as_ref() else { + return Vec::new(); + }; + + let mut permissions = contributes + .gateway_hooks + .iter() + .flat_map(|hook| extension_host_hook_permissions(manifest, &hook.name)) + .collect::>(); + permissions.sort(); + permissions.dedup(); + permissions +} + +fn extension_host_hook_permissions(manifest: &PluginManifest, hook_name: &str) -> Vec { + if !manifest + .capabilities + .iter() + .any(|capability| capability == "gateway.hooks") + { + return Vec::new(); + } + let Some(contributes) = manifest.contributes.as_ref() else { + return Vec::new(); + }; + if !contributes + .gateway_hooks + .iter() + .any(|hook| hook.name == hook_name) + { + return Vec::new(); + } + let Some(contract) = crate::gateway::plugins::contract::hook_contract(hook_name) else { + return Vec::new(); + }; + + let mut permissions = contract + .read_permissions + .iter() + .chain(contract.write_permissions.iter()) + .map(|permission| (*permission).to_string()) + .collect::>(); + permissions.sort(); + permissions.dedup(); + permissions +} + pub fn is_known_hook(hook: &str) -> bool { is_active_gateway_hook(hook) || is_reserved_gateway_hook(hook) } pub fn is_active_gateway_hook(hook: &str) -> bool { - matches!( - hook, - "gateway.request.afterBodyRead" - | "gateway.request.beforeSend" - | "gateway.response.chunk" - | "gateway.response.after" - | "gateway.error" - | "log.beforePersist" - ) + crate::gateway::plugins::contract::is_active_hook(hook) } pub fn is_reserved_gateway_hook(hook: &str) -> bool { - matches!( - hook, - "gateway.request.received" - | "gateway.request.beforeProviderResolution" - | "gateway.response.headers" - ) -} - -pub fn is_reserved_permission(permission: &str) -> bool { - matches!( - permission, - "plugin.storage" | "network.fetch" | "file.read" | "file.write" | "secret.read" - ) + crate::gateway::plugins::contract::is_reserved_hook(hook) } fn validate_plugin_id(plugin_id: &str) -> Result<(), PluginValidationError> { @@ -324,164 +698,401 @@ fn validate_semver(version: &str, code: &str) -> Result<(), PluginValidationErro .ok_or_else(|| PluginValidationError::new(code, format!("invalid SemVer: {version}"))) } -fn validate_runtime(manifest: &PluginManifest) -> Result<(), PluginValidationError> { - match &manifest.runtime { - PluginRuntime::DeclarativeRules { rules } => { - if rules.is_empty() { - return Err(PluginValidationError::new( - "PLUGIN_INVALID_RUNTIME", - "declarativeRules runtime requires at least one rules file", - )); - } - } - PluginRuntime::Native { engine } => { - if manifest.id != "official.privacy-filter" || engine != "privacyFilter" { - return Err(PluginValidationError::new( - "PLUGIN_UNSUPPORTED_RUNTIME", - "native runtime is reserved for official plugins", - )); - } - } - PluginRuntime::Wasm { abi_version, .. } => { - let Some((major, _, _)) = parse_semver(abi_version) else { - return Err(PluginValidationError::new( - "PLUGIN_INVALID_RUNTIME", - "WASM abiVersion must be SemVer", - )); - }; - if major != SUPPORTED_PLUGIN_API_MAJOR { - return Err(PluginValidationError::new( - "PLUGIN_UNSUPPORTED_RUNTIME", - "WASM ABI major version is not supported", - )); - } - } +fn validate_manifest_api_version(api_version: &str) -> Result<(), PluginValidationError> { + validate_semver(api_version, "PLUGIN_INVALID_API_VERSION")?; + let Some((major, _, _)) = parse_semver(api_version) else { + return Err(PluginValidationError::new( + "PLUGIN_INVALID_API_VERSION", + format!("invalid plugin apiVersion: {api_version}"), + )); + }; + if major != SUPPORTED_PLUGIN_API_MAJOR { + return Err(PluginValidationError::new( + "PLUGIN_INCOMPATIBLE_API", + format!( + "plugin apiVersion {api_version} is not supported; supported major is {}", + SUPPORTED_PLUGIN_API_MAJOR + ), + )); } Ok(()) } -fn validate_hooks(hooks: &[PluginHook]) -> Result<(), PluginValidationError> { - if hooks.is_empty() { +fn validate_extension_host_manifest( + manifest: &PluginManifest, + language: &str, +) -> Result<(), PluginValidationError> { + if manifest + .main + .as_deref() + .is_none_or(|main| main.trim().is_empty()) + { return Err(PluginValidationError::new( - "PLUGIN_MISSING_HOOKS", - "plugin must declare at least one hook", + "PLUGIN_MISSING_MAIN", + "extensionHost runtime requires main", )); } - for hook in hooks { - if is_reserved_gateway_hook(&hook.name) { - return Err(PluginValidationError::new( - "PLUGIN_RESERVED_HOOK", - format!( - "hook is reserved for a future host integration and is not active in plugin API v1: {}", - hook.name - ), - )); + if language != "typescript" { + return Err(PluginValidationError::new( + "PLUGIN_INVALID_RUNTIME", + "extensionHost language must be typescript", + )); + } + if !manifest.hooks.is_empty() { + return Err(PluginValidationError::new( + "PLUGIN_INVALID_MANIFEST", + "extensionHost manifests must not declare top-level hooks", + )); + } + if !manifest.permissions.is_empty() { + return Err(PluginValidationError::new( + "PLUGIN_INVALID_MANIFEST", + "extensionHost manifests must not declare top-level permissions", + )); + } + validate_activation_events(&manifest.activation_events)?; + validate_contributes(manifest.id.as_str(), manifest.contributes.as_ref())?; + validate_capabilities(&manifest.capabilities)?; + validate_capability_dependencies(manifest.contributes.as_ref(), &manifest.capabilities)?; + Ok(()) +} + +fn validate_activation_events(activation_events: &[String]) -> Result<(), PluginValidationError> { + for event in activation_events { + if event == "onStartup" { + continue; } - if !is_known_hook(&hook.name) { + let has_known_prefix = [ + "onCommand:", + "onProviderEditor:", + "onProtocolBridge:", + "onGatewayHook:", + ] + .iter() + .any(|prefix| { + event + .strip_prefix(prefix) + .is_some_and(|value| !value.trim().is_empty()) + }); + if !has_known_prefix { return Err(PluginValidationError::new( - "PLUGIN_UNKNOWN_HOOK", - format!("unknown hook: {}", hook.name), + "PLUGIN_INVALID_ACTIVATION_EVENT", + format!("invalid activation event: {event}"), )); } } Ok(()) } -fn validate_permissions(permissions: &[String]) -> Result<(), PluginValidationError> { - for permission in permissions { - if is_reserved_permission(permission) { +fn validate_contributes( + plugin_id: &str, + contributes: Option<&PluginContributes>, +) -> Result<(), PluginValidationError> { + let Some(contributes) = contributes else { + return Ok(()); + }; + + for provider in &contributes.providers { + if is_blank(&provider.provider_type) + || is_blank(&provider.display_name) + || is_blank(&provider.extension_namespace) + || provider.target_cli_keys.is_empty() + { + return Err(PluginValidationError::new( + "PLUGIN_INVALID_PROVIDER_CONTRIBUTION", + "provider contribution requires providerType, displayName, extensionNamespace, and targetCliKeys", + )); + } + } + + for protocol in &contributes.protocols { + if is_blank(&protocol.protocol_id) { + return Err(PluginValidationError::new( + "PLUGIN_INVALID_PROTOCOL_CONTRIBUTION", + "protocol contribution requires protocolId", + )); + } + } + + for bridge in &contributes.protocol_bridges { + if is_blank(&bridge.bridge_type) + || is_blank(&bridge.inbound_protocol) + || is_blank(&bridge.outbound_protocol) + { + return Err(PluginValidationError::new( + "PLUGIN_INVALID_PROTOCOL_BRIDGE_CONTRIBUTION", + "protocol bridge contribution requires bridgeType, inboundProtocol, and outboundProtocol", + )); + } + if !is_namespaced_contribution_id(plugin_id, &bridge.bridge_type) { return Err(PluginValidationError::new( - "PLUGIN_RESERVED_PERMISSION", - format!( - "permission is reserved for a future host-mediated API and is not active in plugin API v1: {permission}" - ), + "PLUGIN_INVALID_PROTOCOL_BRIDGE_CONTRIBUTION", + "protocol bridge bridgeType must be lower-case and namespaced by plugin id", )); } - if permission_risk(permission).is_none() { + } + + for command in &contributes.commands { + if is_blank(&command.command) || is_blank(&command.title) { return Err(PluginValidationError::new( - "PLUGIN_UNKNOWN_PERMISSION", - format!("unknown permission: {permission}"), + "PLUGIN_INVALID_COMMAND_CONTRIBUTION", + "command contribution requires command and title", )); } } + + for hook in &contributes.gateway_hooks { + validate_hook(hook)?; + } + + for (slot, ui_contributions) in &contributes.ui { + if !is_known_ui_slot(slot) { + return Err(PluginValidationError::new( + "PLUGIN_UNKNOWN_UI_SLOT", + format!("unknown UI contribution slot: {slot}"), + )); + } + for contribution in ui_contributions { + validate_ui_contribution(contribution)?; + } + } Ok(()) } -fn validate_hook_permissions( - hooks: &[PluginHook], - permissions: &[String], +fn validate_ui_contribution( + contribution: &super::plugin_contributions::UiContribution, ) -> Result<(), PluginValidationError> { - let has = |permission: &str| permissions.iter().any(|item| item == permission); - for hook in hooks { - match hook.name.as_str() { - "gateway.request.afterBodyRead" - if has("request.body.write") && !has("request.body.read") => - { + if is_blank(&contribution.id) { + return Err(PluginValidationError::new( + "PLUGIN_INVALID_UI_CONTRIBUTION", + "UI contribution requires id", + )); + } + + match &contribution.schema { + HostRenderedSchema::Section { fields } | HostRenderedSchema::Panel { fields } => { + for field in fields { + validate_host_rendered_field(field)?; + } + } + HostRenderedSchema::Badge { label, .. } => { + if is_blank(label) { return Err(PluginValidationError::new( - "PLUGIN_PERMISSION_MISMATCH", - "request.body.write requires request.body.read", + "PLUGIN_INVALID_UI_CONTRIBUTION", + "badge schema requires label", )); } - "gateway.response.chunk" if has("stream.modify") && !has("stream.inspect") => { + } + } + Ok(()) +} + +fn validate_host_rendered_field(field: &HostRenderedField) -> Result<(), PluginValidationError> { + match field { + HostRenderedField::Text { key, label, .. } + | HostRenderedField::Password { key, label, .. } + | HostRenderedField::Number { key, label, .. } + | HostRenderedField::Boolean { key, label } + | HostRenderedField::Textarea { key, label, .. } + | HostRenderedField::Info { key, label, .. } => validate_ui_field_key_label(key, label), + HostRenderedField::Button { + key, + label, + command, + } => { + validate_ui_field_key_label(key, label)?; + if is_blank(command) { return Err(PluginValidationError::new( - "PLUGIN_PERMISSION_MISMATCH", - "stream.modify requires stream.inspect", + "PLUGIN_INVALID_UI_CONTRIBUTION", + "button field requires command", )); } - "gateway.response.after" - if has("response.body.write") && !has("response.body.read") => + Ok(()) + } + HostRenderedField::Select { + key, + label, + options, + } => { + validate_ui_field_key_label(key, label)?; + if options.is_empty() + || options + .iter() + .any(|option| is_blank(&option.value) || is_blank(&option.label)) { return Err(PluginValidationError::new( - "PLUGIN_PERMISSION_MISMATCH", - "response.body.write requires response.body.read", + "PLUGIN_INVALID_UI_CONTRIBUTION", + "select field requires options", )); } - _ => {} + Ok(()) } } +} + +fn validate_ui_field_key_label(key: &str, label: &str) -> Result<(), PluginValidationError> { + if is_blank(key) || is_blank(label) { + return Err(PluginValidationError::new( + "PLUGIN_INVALID_UI_CONTRIBUTION", + "UI field requires key and label", + )); + } Ok(()) } -fn hook_allows_permission(hook_name: &str, permission: &str) -> bool { - match permission { - "request.meta.read" - | "request.header.read" - | "request.header.readSensitive" - | "request.header.write" - | "request.body.read" - | "request.body.write" => matches!( - hook_name, - "gateway.request.afterBodyRead" | "gateway.request.beforeSend" - ), - "response.header.read" - | "response.header.write" - | "response.body.read" - | "response.body.write" => { - matches!(hook_name, "gateway.response.after" | "gateway.error") +fn validate_capabilities(capabilities: &[String]) -> Result<(), PluginValidationError> { + for capability in capabilities { + if !is_known_capability(capability) { + return Err(PluginValidationError::new( + "PLUGIN_UNKNOWN_CAPABILITY", + format!("unknown capability: {capability}"), + )); } - "stream.inspect" | "stream.modify" => hook_name == "gateway.response.chunk", - "log.redact" => hook_name == "log.beforePersist", - _ => false, } + Ok(()) } -fn validate_permission_scope( - hooks: &[PluginHook], - permissions: &[String], +fn validate_capability_dependencies( + contributes: Option<&PluginContributes>, + capabilities: &[String], ) -> Result<(), PluginValidationError> { - for permission in permissions { - if is_reserved_permission(permission) { - continue; + let Some(contributes) = contributes else { + return Ok(()); + }; + + if !contributes.commands.is_empty() { + require_capability(capabilities, "commands.execute", "commands contribution")?; + } + if !contributes.providers.is_empty() { + require_capability( + capabilities, + "provider.extensionValues", + "provider contribution", + )?; + } + if !contributes.gateway_hooks.is_empty() { + require_capability(capabilities, "gateway.hooks", "gatewayHooks contribution")?; + } + if !contributes.protocol_bridges.is_empty() { + require_capability( + capabilities, + "protocol.bridge", + "protocolBridges contribution", + )?; + } + for slot in ["providers.editor.sections", "providers.editor.fields"] { + if contributes + .ui + .get(slot) + .is_some_and(|items| !items.is_empty()) + { + require_capability( + capabilities, + "provider.extensionValues", + &format!("{slot} UI contribution"), + )?; } - let allowed = hooks + } + if ui_has_button_field(&contributes.ui) { + require_capability(capabilities, "commands.execute", "UI command field")?; + } + + Ok(()) +} + +fn has_capability(capabilities: &[String], capability: &str) -> bool { + capabilities.iter().any(|item| item == capability) +} + +fn require_capability( + capabilities: &[String], + capability: &str, + reason: &str, +) -> Result<(), PluginValidationError> { + if has_capability(capabilities, capability) { + return Ok(()); + } + Err(PluginValidationError::new( + "PLUGIN_MISSING_CAPABILITY", + format!("{reason} requires {capability}"), + )) +} + +fn ui_has_button_field(ui: &BTreeMap>) -> bool { + ui.values().any(|contributions| { + contributions .iter() - .any(|hook| hook_allows_permission(&hook.name, permission)); - if !allowed { - return Err(PluginValidationError::new( - "PLUGIN_PERMISSION_SCOPE_MISMATCH", - format!("permission {permission} does not apply to any declared hook"), - )); + .any(|contribution| schema_has_button_field(&contribution.schema)) + }) +} + +fn schema_has_button_field(schema: &HostRenderedSchema) -> bool { + match schema { + HostRenderedSchema::Section { fields } | HostRenderedSchema::Panel { fields } => { + fields.iter().any(is_button_field) } + HostRenderedSchema::Badge { .. } => false, + } +} + +fn is_button_field(field: &HostRenderedField) -> bool { + matches!(field, HostRenderedField::Button { .. }) +} + +fn is_blank(value: &str) -> bool { + value.trim().is_empty() +} + +fn is_namespaced_contribution_id(plugin_id: &str, value: &str) -> bool { + if !is_valid_contribution_id(value) { + return false; + } + if value == plugin_id { + return true; + } + let Some(suffix) = value.strip_prefix(plugin_id) else { + return false; + }; + suffix.len() > 1 && matches!(suffix.as_bytes().first(), Some(b'.' | b'/' | b':')) +} + +fn is_valid_contribution_id(value: &str) -> bool { + value.split(['.', '/', ':']).all(|segment| { + let mut chars = segment.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first.is_ascii_lowercase() || first.is_ascii_digit()) + && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') + }) +} + +fn validate_hook(hook: &PluginHook) -> Result<(), PluginValidationError> { + validate_hook_name(&hook.name)?; + if hook.timeout_ms == Some(0) { + return Err(PluginValidationError::new( + "PLUGIN_INVALID_HOOK_TIMEOUT", + "hook timeoutMs must be greater than zero", + )); + } + Ok(()) +} + +fn validate_hook_name(hook_name: &str) -> Result<(), PluginValidationError> { + if is_reserved_gateway_hook(hook_name) { + return Err(PluginValidationError::new( + "PLUGIN_RESERVED_HOOK", + format!( + "hook is reserved for a future host integration and is not active in plugin API v1: {}", + hook_name + ), + )); + } + if !is_known_hook(hook_name) { + return Err(PluginValidationError::new( + "PLUGIN_UNKNOWN_HOOK", + format!("unknown hook: {}", hook_name), + )); } Ok(()) } @@ -629,141 +1240,527 @@ mod tests { use super::*; fn valid_manifest() -> serde_json::Value { + valid_extension_host_manifest() + } + + fn valid_extension_host_manifest() -> serde_json::Value { serde_json::json!({ - "id": "community.prompt-helper", - "name": "Community Prompt Helper", + "id": "acme.extension", + "name": "Extension", "version": "1.0.0", "apiVersion": "1.0.0", - "runtime": { - "kind": "declarativeRules", - "rules": ["rules/main.json"] - }, - "hooks": [ - { - "name": "gateway.request.afterBodyRead", - "priority": 100, - "failurePolicy": "fail-open" - } - ], - "permissions": ["request.body.read", "request.body.write"], + "main": "dist/extension.js", + "runtime": { "kind": "extensionHost", "language": "typescript" }, + "activationEvents": ["onStartup"], + "capabilities": [], "hostCompatibility": { - "app": ">=0.56.0 <1.0.0", - "pluginApi": "^1.0.0", - "platforms": ["macos", "windows", "linux"] + "app": ">=0.62.0 <1.0.0", + "pluginApi": "^1.0.0" } }) } + fn assert_manifest_validation_error(raw: serde_json::Value, expected_code: &str) { + let err = manifest_validation_error(raw); + assert_eq!(err.code, expected_code); + } + + fn manifest_validation_error(raw: serde_json::Value) -> PluginValidationError { + let manifest: PluginManifest = serde_json::from_value(raw).unwrap(); + validate_manifest(&manifest, "0.62.0").unwrap_err() + } + + fn assert_unsupported_or_unknown_runtime(raw: serde_json::Value) { + match serde_json::from_value::(raw) { + Ok(manifest) => { + let err = validate_manifest(&manifest, "0.62.0").unwrap_err(); + assert_eq!(err.code, "PLUGIN_UNSUPPORTED_RUNTIME"); + } + Err(err) => assert!(err.to_string().contains("unknown variant")), + } + } + + fn hook(name: &str) -> PluginHook { + PluginHook { + name: name.to_string(), + priority: 100, + failure_policy: Some("fail-open".to_string()), + timeout_ms: None, + } + } + + fn plugin_manifest_with_contributes(contributes: serde_json::Value) -> PluginManifest { + let mut raw = valid_extension_host_manifest(); + raw["contributes"] = contributes; + serde_json::from_value(raw).unwrap() + } + #[test] fn manifest_json_deserializes_and_validates() { - let manifest: PluginManifest = serde_json::from_value(valid_manifest()).unwrap(); - validate_manifest(&manifest, "0.56.0").unwrap(); - assert_eq!(manifest.id.as_str(), "community.prompt-helper"); + let manifest: PluginManifest = + serde_json::from_value(valid_extension_host_manifest()).unwrap(); + validate_manifest(&manifest, "0.62.0").unwrap(); + assert_eq!(manifest.id.as_str(), "acme.extension"); } #[test] - fn manifest_rejects_unknown_permission() { + fn unknown_runtime_is_unsupported() { let mut raw = valid_manifest(); - raw["permissions"] = serde_json::json!(["request.body.read", "unknown.permission"]); - let manifest: PluginManifest = serde_json::from_value(raw).unwrap(); - let err = validate_manifest(&manifest, "0.56.0").unwrap_err(); - assert_eq!(err.code, "PLUGIN_UNKNOWN_PERMISSION"); + raw["runtime"] = serde_json::json!({ + "kind": "legacyRules", + "rules": ["rules/main.json"] + }); + + assert_unsupported_or_unknown_runtime(raw); } #[test] - fn manifest_rejects_unknown_hook() { + fn wasm_runtime_is_unsupported() { + let mut raw = valid_manifest(); + raw["runtime"] = serde_json::json!({ + "kind": "wasm", + "abiVersion": "1.0.0" + }); + + assert_unsupported_or_unknown_runtime(raw); + } + + #[test] + fn third_party_native_runtime_is_unsupported() { let mut raw = valid_manifest(); - raw["hooks"][0]["name"] = serde_json::json!("gateway.request.missing"); + raw["id"] = serde_json::json!("community.privacy-filter"); + raw["runtime"] = serde_json::json!({ + "kind": "native", + "engine": "hostPrivateRedactor" + }); + + assert_unsupported_or_unknown_runtime(raw); + } + + #[test] + fn native_runtime_is_not_a_public_manifest_runtime() { + let mut raw = valid_manifest(); + raw["runtime"] = serde_json::json!({ + "kind": "native", + "engine": "hostPrivateRedactor" + }); + + let manifest = serde_json::from_value::(raw); + assert!(manifest + .expect_err("native must not deserialize as a public PluginRuntime") + .to_string() + .contains("unknown variant"),); + } + + #[test] + fn extension_host_rejects_top_level_hooks() { + let mut raw = valid_extension_host_manifest(); + raw["hooks"] = serde_json::json!([ + { + "name": "gateway.request.afterBodyRead", + "priority": 100, + "failurePolicy": "fail-open" + } + ]); + + assert_manifest_validation_error(raw, "PLUGIN_INVALID_MANIFEST"); + } + + #[test] + fn extension_host_rejects_top_level_permissions() { + let mut raw = valid_extension_host_manifest(); + raw["permissions"] = serde_json::json!(["request.body.read"]); + + assert_manifest_validation_error(raw, "PLUGIN_INVALID_MANIFEST"); + } + + #[test] + fn extension_host_rejects_unknown_contribution_fields() { + let mut raw = valid_extension_host_manifest(); + raw["contributes"] = serde_json::json!({ + "legacyRules": [{ + "id": "acme.extension.gateway-rule", + "rules": ["request.path == '/v1/chat/completions'"], + "hooks": ["gateway.request.afterBodyRead"] + }] + }); + + let err = serde_json::from_value::(raw).unwrap_err(); + assert!(err.to_string().contains("unknown field")); + } + + #[test] + fn extension_host_rejects_unknown_contribution_fields_when_empty_or_non_array() { + for legacy_rules in [ + serde_json::json!([]), + serde_json::json!({}), + serde_json::json!("bad"), + serde_json::json!(null), + ] { + let mut raw = valid_extension_host_manifest(); + raw["contributes"] = serde_json::json!({ "legacyRules": legacy_rules }); + + let err = serde_json::from_value::(raw).unwrap_err(); + assert!(err.to_string().contains("unknown field")); + } + } + + #[test] + fn extension_host_commands_require_execute_capability() { + let mut raw = valid_extension_host_manifest(); + raw["contributes"] = serde_json::json!({ + "commands": [{ + "command": "acme.extension.refresh", + "title": "Refresh" + }] + }); + + assert_manifest_validation_error(raw, "PLUGIN_MISSING_CAPABILITY"); + } + + #[test] + fn extension_host_gateway_hooks_require_gateway_hooks_capability() { + let mut raw = valid_extension_host_manifest(); + raw["contributes"] = serde_json::json!({ + "gatewayHooks": [{ + "name": "gateway.request.afterBodyRead", + "priority": 100, + "failurePolicy": "fail-open" + }] + }); + + assert_manifest_validation_error(raw, "PLUGIN_MISSING_CAPABILITY"); + } + + #[test] + fn extension_host_protocol_bridges_require_protocol_bridge_capability() { + let mut raw = valid_extension_host_manifest(); + raw["contributes"] = serde_json::json!({ + "protocolBridges": [{ + "bridgeType": "acme.extension.bridge", + "inboundProtocol": "claude", + "outboundProtocol": "codex" + }] + }); + + assert_manifest_validation_error(raw, "PLUGIN_MISSING_CAPABILITY"); + } + + #[test] + fn extension_host_rejects_non_namespaced_protocol_bridge_type() { + let mut raw = valid_extension_host_manifest(); + raw["capabilities"] = serde_json::json!(["protocol.bridge"]); + raw["contributes"] = serde_json::json!({ + "protocolBridges": [{ + "bridgeType": "openai-gemini", + "inboundProtocol": "openai.chat", + "outboundProtocol": "gemini.generateContent" + }] + }); + + let err = manifest_validation_error(raw); + assert_eq!(err.code, "PLUGIN_INVALID_PROTOCOL_BRIDGE_CONTRIBUTION"); + assert_eq!( + err.message, + "protocol bridge bridgeType must be lower-case and namespaced by plugin id" + ); + } + + #[test] + fn extension_host_rejects_invalid_protocol_bridge_type() { + let mut raw = valid_extension_host_manifest(); + raw["capabilities"] = serde_json::json!(["protocol.bridge"]); + raw["contributes"] = serde_json::json!({ + "protocolBridges": [{ + "bridgeType": "acme.extension.OpenAI", + "inboundProtocol": "openai.chat", + "outboundProtocol": "gemini.generateContent" + }] + }); + + let err = manifest_validation_error(raw); + assert_eq!(err.code, "PLUGIN_INVALID_PROTOCOL_BRIDGE_CONTRIBUTION"); + assert_eq!( + err.message, + "protocol bridge bridgeType must be lower-case and namespaced by plugin id" + ); + } + + #[test] + fn extension_host_accepts_valid_namespaced_protocol_bridge_type() { + let mut raw = valid_extension_host_manifest(); + raw["capabilities"] = serde_json::json!(["protocol.bridge"]); + raw["contributes"] = serde_json::json!({ + "protocolBridges": [{ + "bridgeType": "acme.extension.openai-gemini", + "inboundProtocol": "openai.chat", + "outboundProtocol": "gemini.generateContent" + }] + }); let manifest: PluginManifest = serde_json::from_value(raw).unwrap(); - let err = validate_manifest(&manifest, "0.56.0").unwrap_err(); + + validate_manifest(&manifest, "0.62.0").unwrap(); + } + + #[test] + fn extension_host_providers_require_extension_values_capability() { + let mut raw = valid_extension_host_manifest(); + raw["contributes"] = serde_json::json!({ + "providers": [{ + "providerType": "acme.extension.openrouter", + "displayName": "OpenRouter", + "targetCliKeys": ["codex"], + "extensionNamespace": "openrouter" + }] + }); + + assert_manifest_validation_error(raw, "PLUGIN_MISSING_CAPABILITY"); + } + + #[test] + fn extension_host_provider_editor_sections_require_extension_values_capability() { + let mut raw = valid_extension_host_manifest(); + raw["contributes"] = serde_json::json!({ + "ui": { + "providers.editor.sections": [{ + "id": "openrouter-routing", + "schema": { + "type": "section", + "fields": [{ "type": "text", "key": "route", "label": "Route" }] + } + }] + } + }); + + assert_manifest_validation_error(raw, "PLUGIN_MISSING_CAPABILITY"); + } + + #[test] + fn extension_host_provider_editor_fields_require_extension_values_capability() { + let mut raw = valid_extension_host_manifest(); + raw["contributes"] = serde_json::json!({ + "ui": { + "providers.editor.fields": [{ + "id": "openrouter-api-key", + "schema": { + "type": "section", + "fields": [{ "type": "password", "key": "apiKey", "label": "API key" }] + } + }] + } + }); + + let err = manifest_validation_error(raw); + assert_eq!(err.code, "PLUGIN_MISSING_CAPABILITY"); + assert!(err + .message + .contains("providers.editor.fields UI contribution requires provider.extensionValues")); + } + + #[test] + fn extension_host_button_fields_require_execute_capability() { + let mut raw = valid_extension_host_manifest(); + raw["contributes"] = serde_json::json!({ + "ui": { + "settings.sections": [{ + "id": "refresh-settings", + "schema": { + "type": "section", + "fields": [{ + "type": "button", + "key": "refresh", + "label": "Refresh", + "command": "acme.extension.refresh" + }] + } + }] + } + }); + + assert_manifest_validation_error(raw, "PLUGIN_MISSING_CAPABILITY"); + } + + #[test] + fn validates_extension_host_provider_manifest() { + let manifest = serde_json::json!({ + "id": "acme.openrouter", + "name": "OpenRouter Provider", + "version": "0.1.0", + "apiVersion": "1.0.0", + "main": "dist/extension.js", + "runtime": { "kind": "extensionHost", "language": "typescript" }, + "activationEvents": ["onStartup", "onProviderEditor:openrouter"], + "contributes": { + "providers": [{ + "providerType": "openrouter", + "displayName": "OpenRouter", + "targetCliKeys": ["claude", "codex"], + "extensionNamespace": "openrouter" + }], + "ui": { + "providers.editor.sections": [{ + "id": "openrouter-routing", + "title": "OpenRouter 路由", + "order": 100, + "schema": { + "type": "section", + "fields": [{ "type": "text", "key": "route", "label": "Route" }] + } + }] + }, + "commands": [{ + "command": "acme.openrouter.refreshModels", + "title": "刷新 OpenRouter 模型" + }] + }, + "capabilities": ["provider.extensionValues", "commands.execute"], + "hostCompatibility": { "app": ">=0.62.0 <1.0.0", "pluginApi": "^1.0.0" } + }); + let manifest: PluginManifest = serde_json::from_value(manifest).unwrap(); + + validate_manifest(&manifest, "0.62.0").unwrap(); + } + + #[test] + fn extension_host_manifest_rejects_unknown_slot() { + let manifest = serde_json::json!({ + "id": "acme.bad-slot", + "name": "Bad Slot", + "version": "0.1.0", + "apiVersion": "1.0.0", + "main": "dist/extension.js", + "runtime": { "kind": "extensionHost", "language": "typescript" }, + "activationEvents": ["onStartup"], + "contributes": { "ui": { "providers.editor.unknown": [] } }, + "capabilities": [], + "hostCompatibility": { "app": ">=0.62.0 <1.0.0", "pluginApi": "^1.0.0" } + }); + let manifest: PluginManifest = serde_json::from_value(manifest).unwrap(); + + let err = validate_manifest(&manifest, "0.62.0").unwrap_err(); + assert_eq!(err.code, "PLUGIN_UNKNOWN_UI_SLOT"); + } + + #[test] + fn extension_host_manifest_rejects_invalid_provider_contribution() { + let manifest = serde_json::json!({ + "id": "acme.bad-provider", + "name": "Bad Provider", + "version": "0.1.0", + "apiVersion": "1.0.0", + "main": "dist/extension.js", + "runtime": { "kind": "extensionHost", "language": "typescript" }, + "activationEvents": ["onStartup"], + "contributes": { + "providers": [{ + "providerType": "", + "displayName": "OpenRouter", + "targetCliKeys": ["claude"], + "extensionNamespace": "openrouter" + }] + }, + "capabilities": [], + "hostCompatibility": { "app": ">=0.62.0 <1.0.0", "pluginApi": "^1.0.0" } + }); + let manifest: PluginManifest = serde_json::from_value(manifest).unwrap(); + + let err = validate_manifest(&manifest, "0.62.0").unwrap_err(); + assert_eq!(err.code, "PLUGIN_INVALID_PROVIDER_CONTRIBUTION"); + } + + #[test] + fn manifest_rejects_unknown_hook() { + let err = validate_hook(&hook("gateway.request.missing")).unwrap_err(); assert_eq!(err.code, "PLUGIN_UNKNOWN_HOOK"); } #[test] fn validate_manifest_rejects_reserved_hook_until_it_is_wired() { - let mut raw = valid_manifest(); - raw["hooks"][0]["name"] = serde_json::json!("gateway.request.received"); - raw["permissions"] = serde_json::json!(["request.meta.read"]); - let manifest: PluginManifest = serde_json::from_value(raw).unwrap(); - let err = validate_manifest(&manifest, "0.56.0").unwrap_err(); + let err = validate_hook(&hook("gateway.request.received")).unwrap_err(); assert_eq!(err.code, "PLUGIN_RESERVED_HOOK"); assert!(err.message.contains("gateway.request.received")); } #[test] - fn validate_manifest_accepts_active_vnext_hooks() { - let cases = [ - ( - "gateway.request.afterBodyRead", - serde_json::json!(["request.body.read", "request.body.write"]), - ), - ( - "gateway.request.beforeSend", - serde_json::json!(["request.body.read", "request.body.write"]), - ), - ( - "gateway.response.chunk", - serde_json::json!(["stream.inspect", "stream.modify"]), - ), - ( - "gateway.response.after", - serde_json::json!(["response.body.read", "response.body.write"]), - ), - ("gateway.error", serde_json::json!(["response.body.read"])), - ("log.beforePersist", serde_json::json!(["log.redact"])), - ]; - - for (hook_name, permissions) in cases { - let mut raw = valid_manifest(); - raw["hooks"][0]["name"] = serde_json::json!(hook_name); - raw["permissions"] = permissions; - let manifest: PluginManifest = serde_json::from_value(raw).unwrap(); - validate_manifest(&manifest, "0.56.0") - .unwrap_or_else(|err| panic!("active hook {hook_name} rejected: {err:?}")); - } + fn manifest_rejects_zero_hook_timeout() { + let mut hook = hook("gateway.request.afterBodyRead"); + hook.timeout_ms = Some(0); + + let err = validate_hook(&hook).unwrap_err(); + + assert_eq!(err.code, "PLUGIN_INVALID_HOOK_TIMEOUT"); } #[test] - fn validate_manifest_rejects_reserved_permissions_until_host_apis_exist() { + fn extension_host_effective_permissions_are_derived_from_gateway_contributions() { + let mut manifest = plugin_manifest_with_contributes(serde_json::json!({ + "gatewayHooks": [ + { "name": "gateway.request.afterBodyRead", "priority": 10 }, + { "name": "log.beforePersist", "priority": 20 } + ] + })); + manifest.capabilities = vec!["gateway.hooks".to_string()]; + + let permissions = manifest_effective_permissions(&manifest); + for permission in [ - "plugin.storage", - "network.fetch", - "file.read", - "file.write", - "secret.read", + "request.meta.read", + "request.header.read", + "request.header.readSensitive", + "request.body.read", + "request.header.write", + "request.body.write", + "log.redact", ] { - let mut raw = valid_manifest(); - raw["permissions"] = - serde_json::json!(["request.body.read", "request.body.write", permission]); - let manifest: PluginManifest = serde_json::from_value(raw).unwrap(); - let err = validate_manifest(&manifest, "0.56.0").unwrap_err(); - assert_eq!(err.code, "PLUGIN_RESERVED_PERMISSION"); - assert!(err.message.contains(permission)); + assert!( + permissions.contains(&permission.to_string()), + "missing derived permission {permission}" + ); } + assert_eq!( + manifest_permission_risk(&manifest), + PluginPermissionRisk::High + ); } #[test] - fn manifest_rejects_permissions_that_do_not_apply_to_declared_hooks() { - let mut raw = valid_manifest(); - raw["hooks"] = serde_json::json!([ - { "name": "log.beforePersist", "priority": 10, "failurePolicy": "fail-open" } - ]); - raw["permissions"] = serde_json::json!(["request.body.read", "log.redact"]); - let manifest: PluginManifest = serde_json::from_value(raw).unwrap(); - let err = validate_manifest(&manifest, "0.56.0").unwrap_err(); - assert_eq!(err.code, "PLUGIN_PERMISSION_SCOPE_MISMATCH"); - assert!(err.message.contains("request.body.read")); + fn extension_host_effective_permissions_require_gateway_capability() { + let manifest = plugin_manifest_with_contributes(serde_json::json!({ + "gatewayHooks": [ + { "name": "gateway.request.afterBodyRead", "priority": 10 } + ] + })); + + assert!(manifest_effective_permissions(&manifest).is_empty()); + assert!( + gateway_hook_effective_permissions(&manifest, "gateway.request.afterBodyRead") + .is_empty() + ); } #[test] fn manifest_rejects_incompatible_host() { - let mut raw = valid_manifest(); + let mut raw = valid_extension_host_manifest(); raw["hostCompatibility"]["app"] = serde_json::json!(">=9.0.0"); let manifest: PluginManifest = serde_json::from_value(raw).unwrap(); - let err = validate_manifest(&manifest, "0.56.0").unwrap_err(); + let err = validate_manifest(&manifest, "0.62.0").unwrap_err(); assert_eq!(err.code, "PLUGIN_INCOMPATIBLE_HOST"); } + #[test] + fn validate_manifest_rejects_future_api_version_major_even_when_compat_range_mentions_v1() { + let mut raw = valid_extension_host_manifest(); + raw["apiVersion"] = serde_json::json!("2.0.0"); + raw["hostCompatibility"]["pluginApi"] = serde_json::json!("^1.0.0"); + let manifest: PluginManifest = serde_json::from_value(raw).unwrap(); + + let err = validate_manifest(&manifest, "0.62.0").unwrap_err(); + + assert_eq!(err.code, "PLUGIN_INCOMPATIBLE_API"); + assert!(err.message.contains("apiVersion")); + assert!(err.message.contains("2.0.0")); + } + #[test] fn manifest_rejects_invalid_runtime() { let mut raw = valid_manifest(); @@ -773,24 +1770,41 @@ mod tests { } #[test] - fn manifest_allows_only_official_privacy_filter_native_runtime() { + fn official_privacy_filter_extension_host_manifest_uses_normal_validation() { + let manifest: PluginManifest = serde_json::from_value(serde_json::json!({ + "id": "official.privacy-filter", + "name": "Privacy Filter", + "version": "1.0.0", + "apiVersion": "1.0.0", + "runtime": { "kind": "extensionHost", "language": "typescript" }, + "main": "dist/extension.js", + "capabilities": ["gateway.hooks", "privacy.redact"], + "contributes": { + "gatewayHooks": [ + { "name": "gateway.request.afterBodyRead", "priority": 5, "failurePolicy": "fail-closed" }, + { "name": "gateway.request.beforeSend", "priority": 5, "failurePolicy": "fail-closed" }, + { "name": "log.beforePersist", "priority": 1, "failurePolicy": "fail-closed" } + ] + }, + "hostCompatibility": { "app": ">=0.60.0 <1.0.0", "pluginApi": "^1.0.0" } + })) + .unwrap(); + + validate_manifest(&manifest, "0.62.0") + .expect("official privacy filter should be a normal extension host manifest"); + validate_manifest_for_official_plugin(&manifest, "0.62.0") + .expect("official validation should also accept normal extension host manifest"); + } + + #[test] + fn official_privacy_filter_native_runtime_is_not_a_public_manifest_runtime() { let mut official = valid_manifest(); official["id"] = serde_json::json!("official.privacy-filter"); official["runtime"] = serde_json::json!({ "kind": "native", - "engine": "privacyFilter" + "engine": "hostPrivateRedactor" }); - let manifest: PluginManifest = serde_json::from_value(official).unwrap(); - validate_manifest(&manifest, "0.56.0").unwrap(); - let mut local = valid_manifest(); - local["id"] = serde_json::json!("local.privacy-filter"); - local["runtime"] = serde_json::json!({ - "kind": "native", - "engine": "privacyFilter" - }); - let manifest: PluginManifest = serde_json::from_value(local).unwrap(); - let err = validate_manifest(&manifest, "0.56.0").unwrap_err(); - assert_eq!(err.code, "PLUGIN_UNSUPPORTED_RUNTIME"); + assert_unsupported_or_unknown_runtime(official); } } diff --git a/src-tauri/src/domain/prompts.rs b/src-tauri/src/domain/prompts.rs index 42a42b33..55fdf5a0 100644 --- a/src-tauri/src/domain/prompts.rs +++ b/src-tauri/src/domain/prompts.rs @@ -404,7 +404,7 @@ fn clear_enabled_for_workspace( fn normalize_prompt_name(name: &str) -> crate::shared::error::AppResult { let normalized = name.trim(); if normalized.is_empty() { - return Err("SEC_INVALID_INPUT: prompt name is required" + return Err("PROMPT_NAME_REQUIRED: prompt name is required" .to_string() .into()); } @@ -476,7 +476,7 @@ INSERT INTO prompts( if err.code == rusqlite::ErrorCode::ConstraintViolation => { crate::shared::error::AppError::new( - "DB_CONSTRAINT", + "PROMPT_NAME_CONFLICT", format!( "prompt already exists for workspace_id={workspace_id}, name={name}" ), @@ -546,7 +546,7 @@ WHERE id = ?5 ) .map_err(|e| match e { rusqlite::Error::SqliteFailure(err, _) if err.code == rusqlite::ErrorCode::ConstraintViolation => { - crate::shared::error::AppError::new("DB_CONSTRAINT", format!("prompt name already exists for workspace_id={workspace_id}, name={name}")) + crate::shared::error::AppError::new("PROMPT_NAME_CONFLICT", format!("prompt name already exists for workspace_id={workspace_id}, name={name}")) } other => db_err!("failed to update prompt: {other}"), })?; diff --git a/src-tauri/src/domain/provider_limit_usage.rs b/src-tauri/src/domain/provider_limit_usage.rs index 5d82ab3f..085835f8 100644 --- a/src-tauri/src/domain/provider_limit_usage.rs +++ b/src-tauri/src/domain/provider_limit_usage.rs @@ -544,6 +544,7 @@ mod tests { source_provider_id: None, bridge_type: None, stream_idle_timeout_seconds: None, + extension_values: None, }, ) .expect("create provider") diff --git a/src-tauri/src/domain/provider_oauth_limits.rs b/src-tauri/src/domain/provider_oauth_limits.rs index 2f325184..0df69cc8 100644 --- a/src-tauri/src/domain/provider_oauth_limits.rs +++ b/src-tauri/src/domain/provider_oauth_limits.rs @@ -395,6 +395,7 @@ INSERT INTO provider_oauth_limit_snapshots( source_provider_id: None, bridge_type: None, stream_idle_timeout_seconds: None, + extension_values: None, }, ) .expect("insert provider") @@ -607,4 +608,41 @@ INSERT INTO provider_oauth_limit_snapshots( assert_eq!(first_count, Some(4)); assert_eq!(second_count, Some(1)); } + + #[test] + fn acceptance_oauth_exhausted_snapshot_is_scoped_to_provider() { + let dir = tempfile::tempdir().expect("tempdir"); + let db = db::init_for_tests(&dir.path().join("oauth-limits-scope.db")).expect("init db"); + let now = now_unix_seconds(); + let exhausted_provider_id = insert_test_provider_named(&db, "OAuth exhausted"); + let healthy_provider_id = insert_test_provider_named(&db, "OAuth healthy"); + + save_exhausted_snapshot(&db, exhausted_provider_id, Some(now + 3_600)) + .expect("save exhausted snapshot"); + save_snapshot( + &db, + OAuthLimitSnapshotInput { + provider_id: healthy_provider_id, + limit_short_label: Some("5h"), + limit_5h_text: Some("25%"), + limit_weekly_text: Some("80%"), + limit_5h_reset_at: None, + limit_weekly_reset_at: None, + reset_credit_available_count: Some(3), + }, + ) + .expect("save healthy snapshot"); + + let conn = db.open_connection().expect("open"); + assert_eq!( + gate_snapshot(&conn, exhausted_provider_id, now).expect("gate exhausted"), + OAuthLimitGate::Limited { + reset_at: Some(now + 3_600) + } + ); + assert_eq!( + gate_snapshot(&conn, healthy_provider_id, now).expect("gate healthy"), + OAuthLimitGate::Allow + ); + } } diff --git a/src-tauri/src/domain/providers/mod.rs b/src-tauri/src/domain/providers/mod.rs index 5922bbee..df54edb6 100644 --- a/src-tauri/src/domain/providers/mod.rs +++ b/src-tauri/src/domain/providers/mod.rs @@ -5,19 +5,19 @@ mod types; mod validation; pub use types::{ - ClaudeModels, DailyResetMode, ProviderAuthMode, ProviderBaseUrlMode, ProviderSummary, - ProviderUpsertParams, + ClaudeModels, DailyResetMode, ProviderAuthMode, ProviderBaseUrlMode, + ProviderExtensionValuesInput, ProviderSummary, ProviderUpsertParams, }; #[allow(unused_imports)] pub(crate) use types::{ - ClaudeTerminalLaunchContext, GatewayProvidersSelection, ProviderForGateway, + is_cx2cc_bridge, ClaudeTerminalLaunchContext, GatewayProvidersSelection, ProviderForGateway, ProviderOAuthDetails, ProviderRouteRow, }; pub use queries::{ - default_route_list, default_route_set_order, delete, get_api_key_plaintext, list_by_cli, - names_by_id, reorder, upsert, + default_route_list, default_route_set_order, delete, duplicate, get_api_key_plaintext, + list_by_cli, names_by_id, reorder, upsert, }; pub(crate) use queries::{ diff --git a/src-tauri/src/domain/providers/queries.rs b/src-tauri/src/domain/providers/queries.rs index 7fa27921..b42deafa 100644 --- a/src-tauri/src/domain/providers/queries.rs +++ b/src-tauri/src/domain/providers/queries.rs @@ -6,7 +6,7 @@ use crate::db; use crate::shared::error::db_err; use crate::shared::sqlite::enabled_to_int; use crate::shared::time::now_unix_seconds; -use rusqlite::{params, params_from_iter, Connection, OptionalExtension}; +use rusqlite::{params, params_from_iter, Connection, OptionalExtension, Transaction}; use std::collections::{HashMap, HashSet}; fn decode_provider_row( @@ -84,6 +84,7 @@ fn row_to_summary(row: &rusqlite::Row<'_>) -> Result>("api_key_configured") .unwrap_or(None) @@ -92,12 +93,305 @@ fn row_to_summary(row: &rusqlite::Row<'_>) -> Result crate::shared::error::AppResult> { + let mut stmt = conn + .prepare_cached( + r#" +SELECT + plugin_id, + namespace, + values_json, + updated_at +FROM provider_extension_values +WHERE provider_id = ?1 +ORDER BY plugin_id ASC, namespace ASC +"#, + ) + .map_err(|e| db_err!("failed to prepare provider extension values query: {e}"))?; + + let rows = stmt + .query_map(params![provider_id], |row| { + let values_json: String = row.get("values_json")?; + let values = serde_json::from_str::(&values_json) + .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())); + + Ok(ProviderExtensionValues { + plugin_id: row.get("plugin_id")?, + namespace: row.get("namespace")?, + values, + updated_at: row.get("updated_at")?, + }) + }) + .map_err(|e| db_err!("failed to list provider extension values: {e}"))?; + + let mut values = Vec::new(); + for row in rows { + values.push(row.map_err(|e| db_err!("failed to read provider extension value: {e}"))?); + } + Ok(values) +} + +fn fill_summary_extension_values( + conn: &Connection, + summary: &mut ProviderSummary, +) -> crate::shared::error::AppResult<()> { + summary.extension_values = list_extension_values(conn, summary.id)?; + Ok(()) +} + +fn fill_gateway_extension_values( + conn: &Connection, + provider: &mut ProviderForGateway, +) -> crate::shared::error::AppResult<()> { + provider.extension_values = list_extension_values(conn, provider.id)?; + Ok(()) +} + +fn replace_extension_values( + conn: &Connection, + provider_id: i64, + values: Option<&[ProviderExtensionValuesInput]>, +) -> crate::shared::error::AppResult { + let Some(values) = values else { + return Ok(false); + }; + + conn.execute( + "DELETE FROM provider_extension_values WHERE provider_id = ?1", + params![provider_id], + ) + .map_err(|e| db_err!("failed to clear provider extension values: {e}"))?; + + let now = now_unix_seconds(); + for value in values { + let plugin_id = value.plugin_id.trim(); + if plugin_id.is_empty() { + return Err("SEC_INVALID_INPUT: extension_values.plugin_id is required" + .to_string() + .into()); + } + + let namespace = value.namespace.trim(); + if namespace.is_empty() { + return Err("SEC_INVALID_INPUT: extension_values.namespace is required" + .to_string() + .into()); + } + + let values_json = + serde_json::to_string(&value.values).map_err(|e| format!("SYSTEM_ERROR: {e}"))?; + + conn.execute( + r#" +INSERT INTO provider_extension_values( + provider_id, + plugin_id, + namespace, + values_json, + updated_at +) VALUES (?1, ?2, ?3, ?4, ?5) +ON CONFLICT(provider_id, plugin_id, namespace) DO UPDATE SET + values_json = excluded.values_json, + updated_at = excluded.updated_at +"#, + params![provider_id, plugin_id, namespace, values_json, now], + ) + .map_err(|e| db_err!("failed to save provider extension values: {e}"))?; + } + + Ok(true) +} + +pub(crate) fn copy_extension_values( + conn: &Connection, + from_provider_id: i64, + to_provider_id: i64, +) -> crate::shared::error::AppResult<()> { + let now = now_unix_seconds(); + conn.execute( + r#" +INSERT INTO provider_extension_values( + provider_id, + plugin_id, + namespace, + values_json, + updated_at +) +SELECT + ?2, + plugin_id, + namespace, + values_json, + ?3 +FROM provider_extension_values +WHERE provider_id = ?1 +ON CONFLICT(provider_id, plugin_id, namespace) DO UPDATE SET + values_json = excluded.values_json, + updated_at = excluded.updated_at +"#, + params![from_provider_id, to_provider_id, now], + ) + .map_err(|e| db_err!("failed to copy provider extension values: {e}"))?; + + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn insert_provider( + tx: &Transaction<'_>, + cli_key: &str, + name: &str, + base_urls: &[String], + base_url_mode: ProviderBaseUrlMode, + requested_auth_mode: ProviderAuthMode, + api_key: Option<&str>, + enabled: bool, + cost_multiplier: f64, + priority: Option, + claude_models: Option, + limit_5h_usd: Option, + limit_daily_usd: Option, + daily_reset_mode: Option, + daily_reset_time: Option, + limit_weekly_usd: Option, + limit_monthly_usd: Option, + limit_total_usd: Option, + tags: Option>, + note: Option, + source_provider_id: Option, + bridge_type: Option, + stream_idle_timeout_seconds: Option, + extension_values: Option<&[ProviderExtensionValuesInput]>, +) -> crate::shared::error::AppResult { + let now = now_unix_seconds(); + let priority = priority.unwrap_or(DEFAULT_PRIORITY); + let is_oauth = requested_auth_mode == ProviderAuthMode::Oauth; + let is_cx2cc = is_cx2cc_bridge(source_provider_id, bridge_type.as_deref()); + let api_key = match is_oauth || is_cx2cc { + true => api_key.unwrap_or(""), + false => api_key.ok_or_else(|| "SEC_INVALID_INPUT: api_key is required".to_string())?, + }; + let sort_order = next_sort_order(tx, cli_key)?; + + let claude_models = if cli_key == "claude" { + let input = claude_models.unwrap_or_default(); + validate_claude_models(&input)?; + input.normalized() + } else { + ClaudeModels::default() + }; + let claude_models_json = + serde_json::to_string(&claude_models).map_err(|e| format!("SYSTEM_ERROR: {e}"))?; + + let limit_5h_usd = validate_limit_usd("limit_5h_usd", limit_5h_usd)?; + let limit_daily_usd = validate_limit_usd("limit_daily_usd", limit_daily_usd)?; + let limit_weekly_usd = validate_limit_usd("limit_weekly_usd", limit_weekly_usd)?; + let limit_monthly_usd = validate_limit_usd("limit_monthly_usd", limit_monthly_usd)?; + let limit_total_usd = validate_limit_usd("limit_total_usd", limit_total_usd)?; + + let daily_reset_mode = daily_reset_mode.unwrap_or(DailyResetMode::Fixed); + let daily_reset_time_raw = daily_reset_time.as_deref().unwrap_or("00:00:00"); + let daily_reset_time = + normalize_reset_time_hms_strict("daily_reset_time", daily_reset_time_raw)?; + + let tags_normalized = normalize_tags(tags.unwrap_or_default()); + let tags_json_value = + serde_json::to_string(&tags_normalized).map_err(|e| format!("SYSTEM_ERROR: {e}"))?; + let note_value = normalize_note(note.as_deref())?; + + let base_url_primary = base_urls.first().cloned().unwrap_or_default(); + let base_urls_json = + serde_json::to_string(base_urls).map_err(|e| format!("SYSTEM_ERROR: {e}"))?; + + tx.execute( + r#" +INSERT INTO providers( + cli_key, + name, + base_url, + base_urls_json, + base_url_mode, + auth_mode, + claude_models_json, + supported_models_json, + model_mapping_json, + api_key_plaintext, + sort_order, + enabled, + priority, + cost_multiplier, + limit_5h_usd, + limit_daily_usd, + daily_reset_mode, + daily_reset_time, + limit_weekly_usd, + limit_monthly_usd, + limit_total_usd, + tags_json, + note, + source_provider_id, + bridge_type, + stream_idle_timeout_seconds, + created_at, + updated_at +) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, '{}', '{}', ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26) +"#, + params![ + cli_key, + name, + base_url_primary, + base_urls_json, + base_url_mode.as_str(), + requested_auth_mode.as_str(), + claude_models_json, + api_key, + sort_order, + enabled_to_int(enabled), + priority, + cost_multiplier, + limit_5h_usd, + limit_daily_usd, + daily_reset_mode.as_str(), + daily_reset_time, + limit_weekly_usd, + limit_monthly_usd, + limit_total_usd, + tags_json_value, + note_value, + source_provider_id, + bridge_type, + stream_idle_timeout_seconds, + now, + now + ], + ) + .map_err(|e| match e { + rusqlite::Error::SqliteFailure(err, _) + if err.code == rusqlite::ErrorCode::ConstraintViolation => + { + crate::shared::error::AppError::new( + "DB_CONSTRAINT", + format!("provider already exists for cli_key={cli_key}, name={name}"), + ) + } + other => db_err!("failed to insert provider: {other}"), + })?; + + let id = tx.last_insert_rowid(); + replace_extension_values(tx, id, extension_values)?; + Ok(id) +} + pub(crate) fn get_by_id( conn: &Connection, provider_id: i64, ) -> crate::shared::error::AppResult { - conn.query_row( - r#" + let mut summary = conn + .query_row( + r#" SELECT id, cli_key, @@ -132,12 +426,15 @@ SELECT FROM providers WHERE id = ?1 "#, - params![provider_id], - row_to_summary, - ) - .optional() - .map_err(|e| db_err!("failed to query provider: {e}"))? - .ok_or_else(|| crate::shared::error::AppError::from("DB_NOT_FOUND: provider not found")) + params![provider_id], + row_to_summary, + ) + .optional() + .map_err(|e| db_err!("failed to query provider: {e}"))? + .ok_or_else(|| crate::shared::error::AppError::from("DB_NOT_FOUND: provider not found"))?; + + fill_summary_extension_values(conn, &mut summary)?; + Ok(summary) } pub(crate) fn claude_terminal_launch_context( @@ -408,7 +705,9 @@ ORDER BY let mut items = Vec::new(); for row in rows { - items.push(row.map_err(|e| db_err!("failed to read provider row: {e}"))?); + let mut item = row.map_err(|e| db_err!("failed to read provider row: {e}"))?; + fill_summary_extension_values(&conn, &mut item)?; + items.push(item); } Ok(items) @@ -443,6 +742,7 @@ fn map_gateway_provider_row( stream_idle_timeout_seconds: parse_positive_optional_u32( row.get("stream_idle_timeout_seconds").unwrap_or(None), ), + extension_values: Vec::new(), }) } @@ -494,7 +794,9 @@ ORDER BY mp.sort_order ASC let mut items = Vec::new(); for row in rows { - items.push(row.map_err(|e| db_err!("failed to read gateway provider row: {e}"))?); + let mut item = row.map_err(|e| db_err!("failed to read gateway provider row: {e}"))?; + fill_gateway_extension_values(conn, &mut item)?; + items.push(item); } Ok(items) } @@ -553,7 +855,9 @@ ORDER BY let mut items = Vec::new(); for row in rows { - items.push(row.map_err(|e| db_err!("failed to read gateway provider row: {e}"))?); + let mut item = row.map_err(|e| db_err!("failed to read gateway provider row: {e}"))?; + fill_gateway_extension_values(conn, &mut item)?; + items.push(item); } Ok(items) } @@ -641,7 +945,7 @@ pub(crate) fn get_source_provider_for_gateway( crate::shared::error::AppError::from("DB_NOT_FOUND: source provider not found") })?; - let provider = conn + let mut provider = conn .query_row( r#" SELECT @@ -675,6 +979,7 @@ WHERE id = ?1 AND enabled = 1 AND source_provider_id IS NULL AND cli_key = 'code .ok_or_else(|| { crate::shared::error::AppError::from("DB_NOT_FOUND: source provider not found") })?; + fill_gateway_extension_values(&conn, &mut provider)?; Ok((provider, cli_key_owned)) } @@ -687,6 +992,64 @@ fn next_sort_order(conn: &Connection, cli_key: &str) -> crate::shared::error::Ap .map_err(|e| db_err!("failed to query next sort_order: {e}")) } +fn validate_source_provider( + db: &db::Db, + provider_id: Option, + source_provider_id: Option, +) -> crate::shared::error::AppResult<()> { + let Some(source_id) = source_provider_id else { + return Ok(()); + }; + + if provider_id == Some(source_id) { + return Err( + "SEC_INVALID_INPUT: source_provider_id cannot reference itself" + .to_string() + .into(), + ); + } + + let source_conn = db.open_connection()?; + let source_row: Option<(String, i64, Option)> = source_conn + .query_row( + "SELECT cli_key, enabled, source_provider_id FROM providers WHERE id = ?1", + params![source_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional() + .map_err(|e| db_err!("failed to validate source provider: {e}"))?; + + match source_row { + None => Err( + "SEC_INVALID_INPUT: source_provider_id references a non-existent provider" + .to_string() + .into(), + ), + Some((ref src_cli, enabled, nested_source)) => { + if src_cli != "codex" { + return Err( + "SEC_INVALID_INPUT: source provider must belong to codex CLI" + .to_string() + .into(), + ); + } + if enabled == 0 { + return Err("SEC_INVALID_INPUT: source provider must be enabled" + .to_string() + .into()); + } + if nested_source.is_some() { + return Err( + "SEC_INVALID_INPUT: source provider cannot itself be a bridge provider" + .to_string() + .into(), + ); + } + Ok(()) + } + } +} + pub fn upsert( db: &db::Db, input: ProviderUpsertParams, @@ -715,6 +1078,7 @@ pub fn upsert( source_provider_id, bridge_type, stream_idle_timeout_seconds, + extension_values, } = input; let cli_key = cli_key.trim(); validate_cli_key(cli_key)?; @@ -742,58 +1106,7 @@ pub fn upsert( None }; - // Validate source_provider_id constraints for CX2CC bridging. - if let Some(source_id) = source_provider_id { - if let Some(pid) = provider_id { - if pid == source_id { - return Err( - "SEC_INVALID_INPUT: source_provider_id cannot reference itself" - .to_string() - .into(), - ); - } - } - let source_conn = db.open_connection()?; - let source_row: Option<(String, i64, Option)> = source_conn - .query_row( - "SELECT cli_key, enabled, source_provider_id FROM providers WHERE id = ?1", - params![source_id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .optional() - .map_err(|e| db_err!("failed to validate source provider: {e}"))?; - - match source_row { - None => { - return Err( - "SEC_INVALID_INPUT: source_provider_id references a non-existent provider" - .to_string() - .into(), - ); - } - Some((ref src_cli, enabled, nested_source)) => { - if src_cli != "codex" { - return Err( - "SEC_INVALID_INPUT: source provider must belong to codex CLI" - .to_string() - .into(), - ); - } - if enabled == 0 { - return Err("SEC_INVALID_INPUT: source provider must be enabled" - .to_string() - .into()); - } - if nested_source.is_some() { - return Err( - "SEC_INVALID_INPUT: source provider cannot itself be a bridge provider" - .to_string() - .into(), - ); - } - } - } - } + validate_source_provider(db, provider_id, source_provider_id)?; if is_cx2cc && cli_key != "claude" { return Err( @@ -811,7 +1124,6 @@ pub fn upsert( normalize_base_urls(base_urls)? }; let base_url_primary = base_urls.first().cloned().unwrap_or_default(); - let base_urls_json = serde_json::to_string(&base_urls).map_err(|e| format!("SYSTEM_ERROR: {e}"))?; let stream_idle_timeout_seconds_specified = stream_idle_timeout_seconds.is_some(); @@ -841,113 +1153,36 @@ pub fn upsert( match provider_id { None => { - let priority = priority.unwrap_or(DEFAULT_PRIORITY); - let api_key = match is_oauth || is_cx2cc { - true => api_key.unwrap_or(""), - false => { - api_key.ok_or_else(|| "SEC_INVALID_INPUT: api_key is required".to_string())? - } - }; - let sort_order = next_sort_order(&conn, cli_key)?; - - let claude_models = if cli_key == "claude" { - claude_models.unwrap_or_default().normalized() - } else { - ClaudeModels::default() - }; - let claude_models_json = - serde_json::to_string(&claude_models).map_err(|e| format!("SYSTEM_ERROR: {e}"))?; - - let limit_5h_usd = validate_limit_usd("limit_5h_usd", limit_5h_usd)?; - let limit_daily_usd = validate_limit_usd("limit_daily_usd", limit_daily_usd)?; - let limit_weekly_usd = validate_limit_usd("limit_weekly_usd", limit_weekly_usd)?; - let limit_monthly_usd = validate_limit_usd("limit_monthly_usd", limit_monthly_usd)?; - let limit_total_usd = validate_limit_usd("limit_total_usd", limit_total_usd)?; - - let daily_reset_mode = daily_reset_mode.unwrap_or(DailyResetMode::Fixed); - let daily_reset_time_raw = daily_reset_time.as_deref().unwrap_or("00:00:00"); - let daily_reset_time = - normalize_reset_time_hms_strict("daily_reset_time", daily_reset_time_raw)?; - - let tags_normalized = normalize_tags(tags.unwrap_or_default()); - let tags_json_value = serde_json::to_string(&tags_normalized) - .map_err(|e| format!("SYSTEM_ERROR: {e}"))?; - let note_value = normalize_note(note.as_deref())?; - - conn.execute( - r#" -INSERT INTO providers( - cli_key, - name, - base_url, - base_urls_json, - base_url_mode, - auth_mode, - claude_models_json, - supported_models_json, - model_mapping_json, - api_key_plaintext, - sort_order, - enabled, - priority, - cost_multiplier, - limit_5h_usd, - limit_daily_usd, - daily_reset_mode, - daily_reset_time, - limit_weekly_usd, - limit_monthly_usd, - limit_total_usd, - tags_json, - note, - source_provider_id, - bridge_type, - stream_idle_timeout_seconds, - created_at, - updated_at -) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, '{}', '{}', ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26) -"#, - params![ - cli_key, - name, - base_url_primary, - base_urls_json, - base_url_mode.as_str(), - requested_auth_mode.as_str(), - claude_models_json, - api_key, - sort_order, - enabled_to_int(enabled), - priority, - cost_multiplier, - limit_5h_usd, - limit_daily_usd, - daily_reset_mode.as_str(), - daily_reset_time, - limit_weekly_usd, - limit_monthly_usd, - limit_total_usd, - tags_json_value, - note_value, - source_provider_id, - bridge_type, - stream_idle_timeout_seconds, - now, - now - ], - ) - .map_err(|e| match e { - rusqlite::Error::SqliteFailure(err, _) - if err.code == rusqlite::ErrorCode::ConstraintViolation => - { - crate::shared::error::AppError::new("DB_CONSTRAINT", format!( - "provider already exists for cli_key={cli_key}, name={name}" - )) - } - other => db_err!("failed to insert provider: {other}"), - })?; - - let id = conn.last_insert_rowid(); + let tx = conn + .transaction() + .map_err(|e| db_err!("failed to start transaction: {e}"))?; + let id = insert_provider( + &tx, + cli_key, + name, + &base_urls, + base_url_mode, + requested_auth_mode, + api_key, + enabled, + cost_multiplier, + priority, + claude_models, + limit_5h_usd, + limit_daily_usd, + daily_reset_mode, + daily_reset_time, + limit_weekly_usd, + limit_monthly_usd, + limit_total_usd, + tags, + note, + source_provider_id, + bridge_type, + stream_idle_timeout_seconds, + extension_values.as_deref(), + )?; + tx.commit().map_err(|e| db_err!("failed to commit: {e}"))?; Ok(get_by_id(&conn, id)?) } Some(id) => { @@ -1015,7 +1250,10 @@ INSERT INTO providers( }; let next_claude_models = match claude_models { - Some(v) if cli_key == "claude" => Some(v.normalized()), + Some(v) if cli_key == "claude" => { + validate_claude_models(&v)?; + Some(v.normalized()) + } _ => None, }; @@ -1135,6 +1373,7 @@ WHERE id = ?24 other => db_err!("failed to update provider: {other}"), })?; + replace_extension_values(&tx, id, extension_values.as_deref())?; tx.commit().map_err(|e| db_err!("failed to commit: {e}"))?; get_by_id(&conn, id) @@ -1142,6 +1381,158 @@ WHERE id = ?24 } } +pub fn duplicate( + db: &db::Db, + duplicate_from_provider_id: i64, + input: ProviderUpsertParams, +) -> crate::shared::error::AppResult { + if duplicate_from_provider_id <= 0 { + return Err( + format!("SEC_INVALID_INPUT: invalid provider_id={duplicate_from_provider_id}").into(), + ); + } + + if input.provider_id.is_some() { + return Err("SEC_INVALID_INPUT: duplicate provider_id must be empty" + .to_string() + .into()); + } + + let ProviderUpsertParams { + provider_id: _, + cli_key, + name, + base_urls, + base_url_mode, + auth_mode, + api_key, + enabled, + cost_multiplier, + priority, + claude_models, + limit_5h_usd, + limit_daily_usd, + daily_reset_mode, + daily_reset_time, + limit_weekly_usd, + limit_monthly_usd, + limit_total_usd, + tags, + note, + source_provider_id, + bridge_type, + stream_idle_timeout_seconds, + extension_values: _, + } = input; + + let cli_key = cli_key.trim(); + validate_cli_key(cli_key)?; + + let name = name.trim(); + if name.is_empty() { + return Err("SEC_INVALID_INPUT: provider name is required" + .to_string() + .into()); + } + + let requested_auth_mode = auth_mode.unwrap_or(ProviderAuthMode::ApiKey); + let is_oauth = requested_auth_mode == ProviderAuthMode::Oauth; + if let Some(ref bt) = bridge_type { + if bt != CX2CC_BRIDGE_TYPE { + return Err(format!("SEC_INVALID_INPUT: unsupported bridge_type: {bt}").into()); + } + } + + let is_cx2cc = is_cx2cc_bridge(source_provider_id, bridge_type.as_deref()); + let bridge_type = if is_cx2cc { + Some(CX2CC_BRIDGE_TYPE.to_string()) + } else { + None + }; + validate_source_provider(db, None, source_provider_id)?; + + if is_cx2cc && cli_key != "claude" { + return Err( + "SEC_INVALID_INPUT: cx2cc bridge is only supported for claude" + .to_string() + .into(), + ); + } + + let base_urls = if is_oauth || is_cx2cc { + Vec::new() + } else { + normalize_base_urls(base_urls)? + }; + let stream_idle_timeout_seconds = + normalize_stream_idle_timeout_seconds(stream_idle_timeout_seconds)?; + let api_key = api_key.as_deref().map(str::trim).filter(|v| !v.is_empty()); + + if !cost_multiplier.is_finite() || !(0.0..=1000.0).contains(&cost_multiplier) { + return Err( + "SEC_INVALID_INPUT: cost_multiplier must be within [0, 1000]" + .to_string() + .into(), + ); + } + + if let Some(priority) = priority { + if !(0..=1000).contains(&priority) { + return Err("SEC_INVALID_INPUT: priority must be within [0, 1000]" + .to_string() + .into()); + } + } + + let mut conn = db.open_connection()?; + let tx = conn + .transaction() + .map_err(|e| db_err!("failed to start transaction: {e}"))?; + let source_exists = tx + .query_row( + "SELECT 1 FROM providers WHERE id = ?1", + params![duplicate_from_provider_id], + |row| row.get::<_, i64>(0), + ) + .optional() + .map_err(|e| db_err!("failed to query source provider: {e}"))? + .is_some(); + if !source_exists { + return Err("DB_NOT_FOUND: provider not found".to_string().into()); + } + + let id = insert_provider( + &tx, + cli_key, + name, + &base_urls, + base_url_mode, + requested_auth_mode, + api_key, + enabled, + cost_multiplier, + priority, + claude_models, + limit_5h_usd, + limit_daily_usd, + daily_reset_mode, + daily_reset_time, + limit_weekly_usd, + limit_monthly_usd, + limit_total_usd, + tags, + note, + source_provider_id, + bridge_type, + stream_idle_timeout_seconds, + None, + )?; + copy_extension_values(&tx, duplicate_from_provider_id, id)?; + tx.commit().map_err(|e| db_err!("failed to commit: {e}"))?; + + get_by_id(&conn, id) +} + pub fn set_enabled( db: &db::Db, provider_id: i64, diff --git a/src-tauri/src/domain/providers/tests.rs b/src-tauri/src/domain/providers/tests.rs index 49961e47..c2c54e9c 100644 --- a/src-tauri/src/domain/providers/tests.rs +++ b/src-tauri/src/domain/providers/tests.rs @@ -437,9 +437,158 @@ fn default_provider_params(name: &str) -> ProviderUpsertParams { source_provider_id: None, bridge_type: None, stream_idle_timeout_seconds: None, + extension_values: None, } } +fn seed_plugin(db: &crate::db::Db, plugin_id: &str) { + let conn = db.open_connection().expect("open db connection"); + conn.execute( + r#" +INSERT INTO plugins( + plugin_id, + name, + install_source, + status, + manifest_json, + config_json, + granted_permissions_json, + created_at, + updated_at +) VALUES (?1, ?1, 'dev', 'enabled', '{}', '{}', '[]', 1, 1) +"#, + rusqlite::params![plugin_id], + ) + .expect("insert plugin"); +} + +#[test] +fn provider_upsert_replaces_extension_values_when_submitted() { + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("provider_extension_values_replace.db"); + let db = crate::db::init_for_tests(&db_path).expect("init db"); + seed_plugin(&db, "plugin.alpha"); + + let mut params = default_provider_params("extension-values-preserve"); + params.extension_values = Some(vec![ + ProviderExtensionValuesInput { + plugin_id: "plugin.alpha".to_string(), + namespace: "first".to_string(), + values: serde_json::json!({ "enabled": true }), + }, + ProviderExtensionValuesInput { + plugin_id: "plugin.alpha".to_string(), + namespace: "second".to_string(), + values: serde_json::json!({ "threshold": 2 }), + }, + ]); + + let saved = upsert(&db, params).expect("save provider extension values"); + assert_eq!(saved.extension_values.len(), 2); + + let mut preserve_update = default_provider_params("extension-values-preserve-updated"); + preserve_update.provider_id = Some(saved.id); + preserve_update.extension_values = None; + + let preserved = upsert(&db, preserve_update).expect("update provider without extension values"); + + assert_eq!(preserved.extension_values.len(), 2); + assert!(preserved.extension_values.iter().any(|value| { + value.plugin_id == "plugin.alpha" + && value.namespace == "first" + && value.values == serde_json::json!({ "enabled": true }) + })); + assert!(preserved.extension_values.iter().any(|value| { + value.plugin_id == "plugin.alpha" + && value.namespace == "second" + && value.values == serde_json::json!({ "threshold": 2 }) + })); + + let mut clear_update = default_provider_params("extension-values-clear"); + clear_update.provider_id = Some(saved.id); + clear_update.extension_values = Some(vec![]); + + let cleared = upsert(&db, clear_update).expect("clear provider extension values"); + assert!(cleared.extension_values.is_empty()); + + let mut replace_update = default_provider_params("extension-values-one"); + replace_update.provider_id = Some(saved.id); + replace_update.extension_values = Some(vec![ProviderExtensionValuesInput { + plugin_id: "plugin.alpha".to_string(), + namespace: "first".to_string(), + values: serde_json::json!({ "enabled": false }), + }]); + + let replaced = upsert(&db, replace_update).expect("replace provider extension values"); + assert_eq!(replaced.extension_values.len(), 1); + assert_eq!(replaced.extension_values[0].plugin_id, "plugin.alpha"); + assert_eq!(replaced.extension_values[0].namespace, "first"); + assert_eq!( + replaced.extension_values[0].values, + serde_json::json!({ "enabled": false }) + ); +} + +#[test] +fn provider_duplicate_copies_extension_values() { + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("provider_extension_values_duplicate.db"); + let db = crate::db::init_for_tests(&db_path).expect("init db"); + seed_plugin(&db, "plugin.alpha"); + + let mut source_params = default_provider_params("extension-values-source"); + source_params.extension_values = Some(vec![ProviderExtensionValuesInput { + plugin_id: "plugin.alpha".to_string(), + namespace: "routing".to_string(), + values: serde_json::json!({ "mode": "sticky" }), + }]); + let source = upsert(&db, source_params).expect("save source provider"); + + let source_summary = { + let conn = db.open_connection().expect("open db connection"); + get_by_id(&conn, source.id).expect("get source provider") + }; + let duplicated = duplicate( + &db, + source.id, + ProviderUpsertParams { + provider_id: None, + cli_key: source_summary.cli_key.clone(), + name: "extension-values-duplicate".to_string(), + base_urls: source_summary.base_urls.clone(), + base_url_mode: source_summary.base_url_mode, + auth_mode: Some(ProviderAuthMode::ApiKey), + api_key: Some("sk-test".to_string()), + enabled: source_summary.enabled, + cost_multiplier: source_summary.cost_multiplier, + priority: None, + claude_models: Some(source_summary.claude_models.clone()), + limit_5h_usd: source_summary.limit_5h_usd, + limit_daily_usd: source_summary.limit_daily_usd, + daily_reset_mode: Some(source_summary.daily_reset_mode), + daily_reset_time: Some(source_summary.daily_reset_time.clone()), + limit_weekly_usd: source_summary.limit_weekly_usd, + limit_monthly_usd: source_summary.limit_monthly_usd, + limit_total_usd: source_summary.limit_total_usd, + tags: Some(source_summary.tags.clone()), + note: Some(source_summary.note.clone()), + source_provider_id: source_summary.source_provider_id, + bridge_type: source_summary.bridge_type.clone(), + stream_idle_timeout_seconds: source_summary.stream_idle_timeout_seconds, + extension_values: None, + }, + ) + .expect("duplicate provider"); + + assert_eq!(duplicated.extension_values.len(), 1); + assert_eq!(duplicated.extension_values[0].plugin_id, "plugin.alpha"); + assert_eq!(duplicated.extension_values[0].namespace, "routing"); + assert_eq!( + duplicated.extension_values[0].values, + serde_json::json!({ "mode": "sticky" }) + ); +} + #[test] fn upsert_accepts_unicode_note_at_character_limit() { let dir = tempfile::tempdir().expect("tempdir"); @@ -466,6 +615,58 @@ fn upsert_rejects_unicode_note_over_character_limit() { assert!(err.to_string().contains("note must be at most")); } +#[test] +fn upsert_accepts_claude_model_name_at_character_limit() { + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("providers_model_name_limit.db"); + let db = crate::db::init_for_tests(&db_path).expect("init db"); + + let mut params = default_provider_params("model-name-at-limit"); + params.claude_models = Some(ClaudeModels { + main_model: Some("m".repeat(MAX_MODEL_NAME_LEN)), + ..ClaudeModels::default() + }); + + let saved = upsert(&db, params).expect("save provider"); + let main_model = saved.claude_models.main_model.expect("main model"); + assert_eq!(main_model.chars().count(), MAX_MODEL_NAME_LEN); +} + +#[test] +fn upsert_rejects_claude_model_name_over_character_limit() { + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("providers_model_name_over_limit.db"); + let db = crate::db::init_for_tests(&db_path).expect("init db"); + + let mut params = default_provider_params("model-name-over-limit"); + params.claude_models = Some(ClaudeModels { + main_model: Some("m".repeat(MAX_MODEL_NAME_LEN + 1)), + ..ClaudeModels::default() + }); + + let err = upsert(&db, params).expect_err("model name over limit"); + assert!(err.to_string().contains("main_model must be at most")); +} + +#[test] +fn upsert_update_rejects_claude_model_name_over_character_limit() { + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("providers_model_name_update_over_limit.db"); + let db = crate::db::init_for_tests(&db_path).expect("init db"); + + let saved = upsert(&db, default_provider_params("model-name-update")).expect("save provider"); + + let mut params = default_provider_params("model-name-update"); + params.provider_id = Some(saved.id); + params.claude_models = Some(ClaudeModels { + reasoning_model: Some("模".repeat(MAX_MODEL_NAME_LEN + 1)), + ..ClaudeModels::default() + }); + + let err = upsert(&db, params).expect_err("model name over limit on update"); + assert!(err.to_string().contains("reasoning_model must be at most")); +} + #[test] fn upsert_oauth_provider_drops_submitted_base_urls() { let dir = tempfile::tempdir().expect("tempdir"); @@ -652,6 +853,7 @@ fn create_oauth_provider_for_cas_test(db: &crate::db::Db, name: &str) -> i64 { source_provider_id: None, bridge_type: None, stream_idle_timeout_seconds: None, + extension_values: None, }, ) .expect("create oauth provider") diff --git a/src-tauri/src/domain/providers/types.rs b/src-tauri/src/domain/providers/types.rs index d06e8e9d..5813fded 100644 --- a/src-tauri/src/domain/providers/types.rs +++ b/src-tauri/src/domain/providers/types.rs @@ -83,6 +83,7 @@ pub struct ProviderUpsertParams { pub source_provider_id: Option, pub bridge_type: Option, pub stream_idle_timeout_seconds: Option, + pub extension_values: Option>, } #[derive(Debug, Clone, Default, Serialize, Deserialize, specta::Type)] @@ -196,6 +197,23 @@ impl ProviderBaseUrlMode { } } +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProviderExtensionValues { + pub plugin_id: String, + pub namespace: String, + pub values: serde_json::Value, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProviderExtensionValuesInput { + pub plugin_id: String, + pub namespace: String, + pub values: serde_json::Value, +} + #[derive(Debug, Clone, Serialize, specta::Type)] pub struct ProviderSummary { pub id: i64, @@ -226,6 +244,7 @@ pub struct ProviderSummary { pub source_provider_id: Option, pub bridge_type: Option, pub stream_idle_timeout_seconds: Option, + pub extension_values: Vec, pub api_key_configured: bool, } @@ -255,6 +274,7 @@ pub(crate) struct ProviderForGateway { #[allow(dead_code)] // Will be read when failover_loop uses bridge_type for dispatch. pub bridge_type: Option, pub stream_idle_timeout_seconds: Option, + pub extension_values: Vec, } #[derive(Debug, Clone)] diff --git a/src-tauri/src/domain/providers/validation.rs b/src-tauri/src/domain/providers/validation.rs index d41453c2..cd9f9f5c 100644 --- a/src-tauri/src/domain/providers/validation.rs +++ b/src-tauri/src/domain/providers/validation.rs @@ -32,6 +32,29 @@ pub(super) fn normalize_note(value: Option<&str>) -> crate::shared::error::AppRe Ok(note) } +/// Write-path validation: rejects over-length model names, matching the frontend +/// editor limit. The read path (`normalize_model_slot`) keeps truncating so +/// legacy/hand-edited rows still load. +pub(super) fn validate_claude_models( + models: &super::types::ClaudeModels, +) -> crate::shared::error::AppResult<()> { + let fields = [ + ("main_model", models.main_model.as_deref()), + ("reasoning_model", models.reasoning_model.as_deref()), + ("haiku_model", models.haiku_model.as_deref()), + ("sonnet_model", models.sonnet_model.as_deref()), + ("opus_model", models.opus_model.as_deref()), + ]; + for (field, value) in fields { + let trimmed = value.unwrap_or("").trim(); + if trimmed.is_empty() { + continue; + } + validate_max_chars(field, trimmed, super::types::MAX_MODEL_NAME_LEN)?; + } + Ok(()) +} + pub(super) fn parse_reset_time_hms(input: &str) -> Option<(u8, u8, u8)> { let trimmed = input.trim(); if trimmed.is_empty() { diff --git a/src-tauri/src/domain/usage.rs b/src-tauri/src/domain/usage.rs index ecd1f412..71aa574e 100644 --- a/src-tauri/src/domain/usage.rs +++ b/src-tauri/src/domain/usage.rs @@ -396,6 +396,7 @@ fn is_completion_event_type(event_type: &str) -> bool { | "response.completed" | "message.done" | "message.completed" + | "message_stop" | "message.stop" ) || normalized.ends_with(".completed") } @@ -419,6 +420,14 @@ fn is_terminal_error_status(status: &str) -> bool { ) } +fn is_non_empty_marker_value(value: &Value) -> bool { + !value.is_null() + && value + .as_str() + .map(|value| !value.trim().is_empty()) + .unwrap_or(true) +} + impl SseUsageTracker { pub fn new(cli_key: &str) -> Self { Self { @@ -649,6 +658,36 @@ impl SseUsageTracker { self.completion_seen = true; } + let finish_fields = [ + data.get("finish_reason"), + data.get("finishReason"), + data.get("response").and_then(|v| v.get("finish_reason")), + data.get("response").and_then(|v| v.get("finishReason")), + ]; + if finish_fields + .into_iter() + .flatten() + .any(is_non_empty_marker_value) + { + self.completion_seen = true; + } + + for array_name in ["choices", "candidates"] { + if data + .get(array_name) + .and_then(|v| v.as_array()) + .is_some_and(|items| { + items.iter().any(|item| { + item.get("finish_reason") + .or_else(|| item.get("finishReason")) + .is_some_and(is_non_empty_marker_value) + }) + }) + { + self.completion_seen = true; + } + } + if let Some(model) = extract_model_from_json_value(data) { self.last_model = Some(model); } diff --git a/src-tauri/src/domain/usage/tests.rs b/src-tauri/src/domain/usage/tests.rs index c0059199..80b69f34 100644 --- a/src-tauri/src/domain/usage/tests.rs +++ b/src-tauri/src/domain/usage/tests.rs @@ -97,6 +97,15 @@ fn parse_sse_done_marker_marks_completion_seen() { assert!(tracker.finalize().is_none()); } +#[test] +fn parse_claude_message_stop_type_marks_completion_seen() { + let sse = b"data: {\"type\":\"message_stop\"}\n\n"; + let mut tracker = SseUsageTracker::new("claude"); + tracker.ingest_chunk(sse); + tracker.finalize(); + assert!(tracker.completion_seen()); +} + #[test] fn parse_codex_response_completed_marks_completion_seen() { let sse = b"data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":2,\"total_tokens\":3}}}\n\n"; @@ -109,6 +118,24 @@ fn parse_codex_response_completed_marks_completion_seen() { assert_eq!(extract.metrics.total_tokens, Some(3)); } +#[test] +fn parse_openai_chat_finish_reason_marks_completion_seen() { + let sse = b"data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n"; + let mut tracker = SseUsageTracker::new("codex"); + tracker.ingest_chunk(sse); + tracker.finalize(); + assert!(tracker.completion_seen()); +} + +#[test] +fn parse_gemini_finish_reason_marks_completion_seen() { + let sse = b"data: {\"candidates\":[{\"finishReason\":\"STOP\"}]}\n\n"; + let mut tracker = SseUsageTracker::new("gemini"); + tracker.ingest_chunk(sse); + tracker.finalize(); + assert!(tracker.completion_seen()); +} + #[test] fn parse_sse_error_event_marks_terminal_error_seen() { let sse = b"event: error\ndata: {\"error\":{\"message\":\"upstream failed\"}}\n\n"; diff --git a/src-tauri/src/domain/usage_stats/bounds.rs b/src-tauri/src/domain/usage_stats/bounds.rs index 233aaaf8..ee6890a8 100644 --- a/src-tauri/src/domain/usage_stats/bounds.rs +++ b/src-tauri/src/domain/usage_stats/bounds.rs @@ -8,11 +8,21 @@ pub(super) fn compute_bounds_v2( period: UsagePeriodV2, start_ts: Option, end_ts: Option, + day_start_hour: i64, ) -> Result<(Option, Option), String> { match period { - UsagePeriodV2::Daily => Ok((compute_start_ts(conn, UsageRange::Today)?, None)), - UsagePeriodV2::Weekly => Ok((compute_start_ts(conn, UsageRange::Last7)?, None)), - UsagePeriodV2::Monthly => Ok((compute_start_ts(conn, UsageRange::Month)?, None)), + UsagePeriodV2::Daily => Ok(( + compute_start_ts_with_day_start(conn, UsageRange::Today, day_start_hour)?, + None, + )), + UsagePeriodV2::Weekly => Ok(( + compute_start_ts_with_day_start(conn, UsageRange::Last7, day_start_hour)?, + None, + )), + UsagePeriodV2::Monthly => Ok(( + compute_start_ts_with_day_start(conn, UsageRange::Month, day_start_hour)?, + None, + )), UsagePeriodV2::AllTime => Ok((None, None)), UsagePeriodV2::Custom => { let start_ts = start_ts @@ -56,6 +66,39 @@ pub(super) fn compute_start_ts( Ok(Some(ts)) } +fn compute_start_ts_with_day_start( + conn: &Connection, + range: UsageRange, + day_start_hour: i64, +) -> Result, String> { + if day_start_hour == 0 { + return compute_start_ts(conn, range); + } + let offset = format!("-{day_start_hour} hours"); + let restore = format!("+{day_start_hour} hours"); + let sql = match range { + UsageRange::All => return Ok(None), + UsageRange::Today => { + "SELECT CAST(strftime('%s','now','localtime', ?1,'start of day', ?2,'utc') AS INTEGER)" + } + UsageRange::Last7 => { + "SELECT CAST(strftime('%s','now','localtime', ?1,'start of day','-6 days', ?2,'utc') AS INTEGER)" + } + UsageRange::Last30 => { + "SELECT CAST(strftime('%s','now','localtime', ?1,'start of day','-29 days', ?2,'utc') AS INTEGER)" + } + UsageRange::Month => { + "SELECT CAST(strftime('%s','now','localtime', ?1,'start of month', ?2,'utc') AS INTEGER)" + } + }; + + let ts = conn + .query_row(sql, params![offset, restore], |row| row.get::<_, i64>(0)) + .map_err(|e| db_err!("failed to compute shifted range start ts: {e}"))?; + + Ok(Some(ts)) +} + pub(super) fn compute_start_ts_last_n_days(conn: &Connection, days: u32) -> Result { if days < 1 { return Err("SEC_INVALID_INPUT: days must be >= 1".to_string()); diff --git a/src-tauri/src/domain/usage_stats/cache_rate_trend_v1.rs b/src-tauri/src/domain/usage_stats/cache_rate_trend_v1.rs index 7debf650..ce59d411 100644 --- a/src-tauri/src/domain/usage_stats/cache_rate_trend_v1.rs +++ b/src-tauri/src/domain/usage_stats/cache_rate_trend_v1.rs @@ -282,7 +282,9 @@ pub fn provider_cache_rate_trend_v1( limit: Option, ) -> crate::shared::error::AppResult> { let conn = db.open_connection()?; - let resolved = resolve_query_params(&conn, params)?; + let mut params = params.clone(); + params.day_start_hour = None; + let resolved = resolve_query_params(&conn, ¶ms)?; Ok(provider_cache_rate_trend_v1_with_conn( &conn, ProviderCacheRateTrendQuery { diff --git a/src-tauri/src/domain/usage_stats/day_detail.rs b/src-tauri/src/domain/usage_stats/day_detail.rs index cd1a0bef..21bd96a9 100644 --- a/src-tauri/src/domain/usage_stats/day_detail.rs +++ b/src-tauri/src/domain/usage_stats/day_detail.rs @@ -8,7 +8,7 @@ use super::folders::{ filter_rows_by_folder_keys, folder_identity_for_row, resolved_folder_map, session_lookup_keys, usage_event_rows, UsageEventAgg, }; -use super::input::{normalize_day, normalize_provider_id_filter}; +use super::input::{normalize_day, normalize_day_start_hour, normalize_provider_id_filter}; use super::{ normalize_cli_filter, normalize_folder_keys, ProviderAgg, UsageDayDetailParams, UsageDayDetailV1, UsageDayFolderRow, UsageDayHourRow, @@ -19,11 +19,12 @@ pub use super::folders::{ UsageSessionLookupKey as UsageDaySessionLookupKey, }; -fn local_start_ts(conn: &Connection, day: &str) -> Result { +fn local_start_ts(conn: &Connection, day: &str, day_start_hour: i64) -> Result { + let time = format!("{day_start_hour:02}:00:00"); let ts = conn .query_row( - "SELECT CAST(strftime('%s', ?1 || ' 00:00:00', 'utc') AS INTEGER)", - params![day], + "SELECT CAST(strftime('%s', ?1 || ' ' || ?2, 'utc') AS INTEGER)", + params![day, time], |row| row.get::<_, Option>(0), ) .map_err(|e| db_err!("failed to compute local day start ts: {e}"))? @@ -31,7 +32,11 @@ fn local_start_ts(conn: &Connection, day: &str) -> Result { Ok(ts) } -fn local_day_bounds(conn: &Connection, day: &str) -> Result<(String, i64, i64), String> { +fn local_day_bounds( + conn: &Connection, + day: &str, + day_start_hour: i64, +) -> Result<(String, i64, i64), String> { let normalized = normalize_day(day)?; let date = NaiveDate::parse_from_str(&normalized, "%Y-%m-%d") .map_err(|_| format!("SEC_INVALID_INPUT: invalid day={normalized}"))?; @@ -40,8 +45,8 @@ fn local_day_bounds(conn: &Connection, day: &str) -> Result<(String, i64, i64), .ok_or_else(|| "SEC_INVALID_INPUT: day out of range".to_string())? .format("%Y-%m-%d") .to_string(); - let start_ts = local_start_ts(conn, &normalized)?; - let end_ts = local_start_ts(conn, &next_day)?; + let start_ts = local_start_ts(conn, &normalized, day_start_hour)?; + let end_ts = local_start_ts(conn, &next_day, day_start_hour)?; if start_ts >= end_ts { return Err("SEC_INVALID_INPUT: invalid local day bounds".to_string()); } @@ -143,7 +148,8 @@ pub(super) fn day_detail_v1_with_conn( where F: FnOnce(&[UsageDaySessionLookupKey]) -> Vec, { - let (day, start_ts, end_ts) = local_day_bounds(conn, ¶ms.day)?; + let day_start_hour = normalize_day_start_hour(params.day_start_hour)?; + let (day, start_ts, end_ts) = local_day_bounds(conn, ¶ms.day, day_start_hour)?; let cli_key = normalize_cli_filter(params.cli_key.as_deref())?; let provider_id = normalize_provider_id_filter(params.provider_id)?; let folder_limit = params.folder_limit.map(|value| value.clamp(1, 50) as usize); diff --git a/src-tauri/src/domain/usage_stats/folders.rs b/src-tauri/src/domain/usage_stats/folders.rs index 99ab478d..63243a4a 100644 --- a/src-tauri/src/domain/usage_stats/folders.rs +++ b/src-tauri/src/domain/usage_stats/folders.rs @@ -129,6 +129,9 @@ pub(super) fn row_to_agg(row: &Row<'_>) -> rusqlite::Result { requests_total: row.get("requests_total")?, requests_success: row.get::<_, Option>("requests_success")?.unwrap_or(0), requests_failed: row.get::<_, Option>("requests_failed")?.unwrap_or(0), + total_duration_ms: row.get::<_, Option>("total_duration_ms")?.unwrap_or(0), + first_request_created_at_ms: row.get("first_request_created_at_ms")?, + last_request_created_at_ms: row.get("last_request_created_at_ms")?, success_duration_ms_sum: row .get::<_, Option>("success_duration_ms_sum")? .unwrap_or(0), @@ -258,6 +261,9 @@ SELECT r.cost_usd_femto IS NOT NULL AND r.cost_usd_femto > 0 ) THEN r.cost_usd_femto ELSE 0 END ) AS total_cost_usd_femto, + SUM(r.duration_ms) AS total_duration_ms, + MIN(CASE WHEN r.created_at_ms > 0 THEN r.created_at_ms ELSE r.created_at * 1000 END) AS first_request_created_at_ms, + MAX(CASE WHEN r.created_at_ms > 0 THEN r.created_at_ms ELSE r.created_at * 1000 END) AS last_request_created_at_ms, SUM(CASE WHEN r.status >= 200 AND r.status < 300 AND r.error_code IS NULL THEN r.duration_ms ELSE 0 END) AS success_duration_ms_sum, SUM( CASE WHEN ( diff --git a/src-tauri/src/domain/usage_stats/input.rs b/src-tauri/src/domain/usage_stats/input.rs index 34dfd4a2..eb016800 100644 --- a/src-tauri/src/domain/usage_stats/input.rs +++ b/src-tauri/src/domain/usage_stats/input.rs @@ -11,6 +11,7 @@ pub struct UsageQueryParams { pub cli_key: Option, pub provider_id: Option, pub folder_keys: Option>, + pub day_start_hour: Option, #[serde( rename = "excludeCx2CcGatewayBridge", alias = "excludeCx2ccGatewayBridge" @@ -27,6 +28,7 @@ pub struct UsageDayDetailParams { pub provider_id: Option, pub folder_limit: Option, pub folder_keys: Option>, + pub day_start_hour: Option, #[serde( rename = "excludeCx2CcGatewayBridge", alias = "excludeCx2ccGatewayBridge" @@ -151,6 +153,16 @@ pub(super) fn normalize_folder_keys( Ok(Some(out)) } +pub(super) fn normalize_day_start_hour(value: Option) -> crate::shared::error::AppResult { + let Some(hour) = value else { + return Ok(0); + }; + if !(0..=9).contains(&hour) { + return Err("SEC_INVALID_INPUT: day_start_hour must be between 0 and 9".into()); + } + Ok(hour) +} + /// Validated and resolved query parameters ready for SQL execution. pub(super) struct ResolvedQueryParams<'a> { pub period: UsagePeriodV2, @@ -159,6 +171,7 @@ pub(super) struct ResolvedQueryParams<'a> { pub cli_key: Option<&'a str>, pub provider_id: Option, pub folder_keys: Option>, + pub day_start_hour: i64, pub exclude_cx2cc_gateway_bridge: bool, } @@ -172,8 +185,9 @@ pub(super) fn resolve_query_params<'a>( params: &'a UsageQueryParams, ) -> crate::shared::error::AppResult> { let period = parse_period_v2(¶ms.period)?; + let day_start_hour = normalize_day_start_hour(params.day_start_hour)?; let (start_ts, end_ts) = - super::compute_bounds_v2(conn, period, params.start_ts, params.end_ts)?; + super::compute_bounds_v2(conn, period, params.start_ts, params.end_ts, day_start_hour)?; let cli_key = normalize_cli_filter(params.cli_key.as_deref())?; let provider_id = normalize_provider_id_filter(params.provider_id)?; let folder_keys = normalize_folder_keys(params.folder_keys.as_deref())?; @@ -185,6 +199,7 @@ pub(super) fn resolve_query_params<'a>( cli_key, provider_id, folder_keys, + day_start_hour, exclude_cx2cc_gateway_bridge, }) } diff --git a/src-tauri/src/domain/usage_stats/leaderboard_range.rs b/src-tauri/src/domain/usage_stats/leaderboard_range.rs index f6276980..bf3cadd4 100644 --- a/src-tauri/src/domain/usage_stats/leaderboard_range.rs +++ b/src-tauri/src/domain/usage_stats/leaderboard_range.rs @@ -22,6 +22,9 @@ pub(super) struct ProviderAgg { pub(super) requests_total: i64, pub(super) requests_success: i64, pub(super) requests_failed: i64, + pub(super) total_duration_ms: i64, + pub(super) first_request_created_at_ms: Option, + pub(super) last_request_created_at_ms: Option, pub(super) success_duration_ms_sum: i64, pub(super) success_ttfb_ms_sum: i64, pub(super) success_ttfb_ms_count: i64, @@ -43,6 +46,23 @@ impl ProviderAgg { self.requests_total = self.requests_total.saturating_add(add.requests_total); self.requests_success = self.requests_success.saturating_add(add.requests_success); self.requests_failed = self.requests_failed.saturating_add(add.requests_failed); + self.total_duration_ms = self.total_duration_ms.saturating_add(add.total_duration_ms); + self.first_request_created_at_ms = match ( + self.first_request_created_at_ms, + add.first_request_created_at_ms, + ) { + (Some(current), Some(next)) => Some(current.min(next)), + (None, Some(next)) => Some(next), + (current, None) => current, + }; + self.last_request_created_at_ms = match ( + self.last_request_created_at_ms, + add.last_request_created_at_ms, + ) { + (Some(current), Some(next)) => Some(current.max(next)), + (None, Some(next)) => Some(next), + (current, None) => current, + }; self.success_duration_ms_sum = self .success_duration_ms_sum .saturating_add(add.success_duration_ms_sum); @@ -118,6 +138,9 @@ impl ProviderAgg { requests_total: self.requests_total, requests_success: self.requests_success, requests_failed: self.requests_failed, + total_duration_ms: self.total_duration_ms, + first_request_created_at_ms: self.first_request_created_at_ms, + last_request_created_at_ms: self.last_request_created_at_ms, total_tokens: self.total_tokens, io_total_tokens: self.input_tokens.saturating_add(self.output_tokens), input_tokens: self.input_tokens, @@ -267,6 +290,9 @@ pub fn leaderboard_provider( requests_total: 1, requests_success: if success { 1 } else { 0 }, requests_failed: if success { 0 } else { 1 }, + total_duration_ms: duration_ms, + first_request_created_at_ms: None, + last_request_created_at_ms: None, success_duration_ms_sum: if success { duration_ms } else { 0 }, success_ttfb_ms_sum: if success { ttfb_ms.unwrap_or(0) } else { 0 }, success_ttfb_ms_count: if success && ttfb_ms.is_some() { 1 } else { 0 }, diff --git a/src-tauri/src/domain/usage_stats/leaderboard_v2.rs b/src-tauri/src/domain/usage_stats/leaderboard_v2.rs index db486454..768a6429 100644 --- a/src-tauri/src/domain/usage_stats/leaderboard_v2.rs +++ b/src-tauri/src/domain/usage_stats/leaderboard_v2.rs @@ -19,7 +19,17 @@ use super::{ SQL_EFFECTIVE_INPUT_TOKENS_EXPR, }; +fn local_day_bucket_sql(timestamp_expr: &str, day_start_hour: i64) -> String { + if day_start_hour == 0 { + return format!("strftime('%Y-%m-%d', {timestamp_expr}, 'unixepoch', 'localtime')"); + } + format!( + "strftime('%Y-%m-%d', {timestamp_expr}, 'unixepoch', 'localtime', '-{day_start_hour} hours')" + ) +} + #[allow(clippy::too_many_arguments)] +#[cfg(test)] pub(super) fn leaderboard_v2_with_conn( conn: &Connection, scope: UsageScopeV2, @@ -29,9 +39,35 @@ pub(super) fn leaderboard_v2_with_conn( provider_id: Option, limit: Option, exclude_cx2cc_gateway_bridge: bool, +) -> Result, String> { + leaderboard_v2_with_conn_day_start( + conn, + scope, + start_ts, + end_ts, + cli_key, + provider_id, + limit, + exclude_cx2cc_gateway_bridge, + 0, + ) +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn leaderboard_v2_with_conn_day_start( + conn: &Connection, + scope: UsageScopeV2, + start_ts: Option, + end_ts: Option, + cli_key: Option<&str>, + provider_id: Option, + limit: Option, + exclude_cx2cc_gateway_bridge: bool, + day_start_hour: i64, ) -> Result, String> { let effective_input_expr = SQL_EFFECTIVE_INPUT_TOKENS_EXPR; let effective_total_expr = sql_effective_total_tokens_expr(); + let day_bucket_sql = local_day_bucket_sql("created_at", day_start_hour); let (where_clause, where_params) = build_optional_range_cli_provider_filters( "created_at", "cli_key", @@ -90,6 +126,7 @@ SELECT cost_usd_femto IS NOT NULL AND cost_usd_femto > 0 ) THEN cost_usd_femto ELSE 0 END ) AS total_cost_usd_femto, + SUM(duration_ms) AS total_duration_ms, SUM(CASE WHEN status >= 200 AND status < 300 AND error_code IS NULL THEN duration_ms ELSE 0 END) AS success_duration_ms_sum, SUM( CASE WHEN ( @@ -145,6 +182,11 @@ GROUP BY cli_key .get::<_, Option>("requests_success")? .unwrap_or(0), requests_failed: row.get::<_, Option>("requests_failed")?.unwrap_or(0), + total_duration_ms: row + .get::<_, Option>("total_duration_ms")? + .unwrap_or(0), + first_request_created_at_ms: None, + last_request_created_at_ms: None, success_duration_ms_sum: row .get::<_, Option>("success_duration_ms_sum")? .unwrap_or(0), @@ -221,6 +263,7 @@ SELECT cost_usd_femto IS NOT NULL AND cost_usd_femto > 0 ) THEN cost_usd_femto ELSE 0 END ) AS total_cost_usd_femto, + SUM(duration_ms) AS total_duration_ms, SUM(CASE WHEN status >= 200 AND status < 300 AND error_code IS NULL THEN duration_ms ELSE 0 END) AS success_duration_ms_sum, SUM( CASE WHEN ( @@ -276,6 +319,11 @@ GROUP BY COALESCE(NULLIF(requested_model, ''), 'Unknown') .get::<_, Option>("requests_success")? .unwrap_or(0), requests_failed: row.get::<_, Option>("requests_failed")?.unwrap_or(0), + total_duration_ms: row + .get::<_, Option>("total_duration_ms")? + .unwrap_or(0), + first_request_created_at_ms: None, + last_request_created_at_ms: None, success_duration_ms_sum: row .get::<_, Option>("success_duration_ms_sum")? .unwrap_or(0), @@ -324,7 +372,7 @@ GROUP BY COALESCE(NULLIF(requested_model, ''), 'Unknown') let sql = format!( r#" SELECT - strftime('%Y-%m-%d', created_at, 'unixepoch', 'localtime') AS key, + {day_bucket_sql} AS key, COUNT(*) AS requests_total, SUM(CASE WHEN status >= 200 AND status < 300 AND error_code IS NULL THEN 1 ELSE 0 END) AS requests_success, SUM( @@ -352,6 +400,9 @@ SELECT cost_usd_femto IS NOT NULL AND cost_usd_femto > 0 ) THEN cost_usd_femto ELSE 0 END ) AS total_cost_usd_femto, + SUM(duration_ms) AS total_duration_ms, + MIN(CASE WHEN created_at_ms > 0 THEN created_at_ms ELSE created_at * 1000 END) AS first_request_created_at_ms, + MAX(CASE WHEN created_at_ms > 0 THEN created_at_ms ELSE created_at * 1000 END) AS last_request_created_at_ms, SUM(CASE WHEN status >= 200 AND status < 300 AND error_code IS NULL THEN duration_ms ELSE 0 END) AS success_duration_ms_sum, SUM( CASE WHEN ( @@ -392,7 +443,8 @@ GROUP BY key effective_input_expr = effective_input_expr, effective_total_expr = effective_total_expr.as_str(), where_clause = where_clause, - cx2cc_filter_clause = cx2cc_filter_clause + cx2cc_filter_clause = cx2cc_filter_clause, + day_bucket_sql = day_bucket_sql ); let mut stmt = conn .prepare(&sql) @@ -407,6 +459,11 @@ GROUP BY key .get::<_, Option>("requests_success")? .unwrap_or(0), requests_failed: row.get::<_, Option>("requests_failed")?.unwrap_or(0), + total_duration_ms: row + .get::<_, Option>("total_duration_ms")? + .unwrap_or(0), + first_request_created_at_ms: row.get("first_request_created_at_ms")?, + last_request_created_at_ms: row.get("last_request_created_at_ms")?, success_duration_ms_sum: row .get::<_, Option>("success_duration_ms_sum")? .unwrap_or(0), @@ -490,6 +547,7 @@ SELECT r.cost_usd_femto IS NOT NULL AND r.cost_usd_femto > 0 ) THEN r.cost_usd_femto ELSE 0 END ) AS total_cost_usd_femto, + SUM(r.duration_ms) AS total_duration_ms, SUM(CASE WHEN r.status >= 200 AND r.status < 300 AND r.error_code IS NULL THEN r.duration_ms ELSE 0 END) AS success_duration_ms_sum, SUM( CASE WHEN ( @@ -552,6 +610,11 @@ GROUP BY r.cli_key, r.final_provider_id .get::<_, Option>("requests_success")? .unwrap_or(0), requests_failed: row.get::<_, Option>("requests_failed")?.unwrap_or(0), + total_duration_ms: row + .get::<_, Option>("total_duration_ms")? + .unwrap_or(0), + first_request_created_at_ms: None, + last_request_created_at_ms: None, success_duration_ms_sum: row .get::<_, Option>("success_duration_ms_sum")? .unwrap_or(0), @@ -734,6 +797,7 @@ pub(super) struct FolderFilteredLeaderboardParams<'a> { pub(super) folder_keys: &'a [String], pub(super) limit: Option, pub(super) exclude_cx2cc_gateway_bridge: bool, + pub(super) day_start_hour: i64, } pub(super) fn leaderboard_v2_folder_filtered_with_conn( @@ -744,15 +808,14 @@ pub(super) fn leaderboard_v2_folder_filtered_with_conn( where F: FnOnce(&[UsageSessionLookupKey]) -> Vec, { + let day_bucket_sql = local_day_bucket_sql("r.created_at", params.day_start_hour); let bucket_sql = match params.scope { UsageScopeV2::Cli => None, UsageScopeV2::Provider => { Some("CASE WHEN r.final_provider_id IS NULL THEN NULL ELSE CAST(r.final_provider_id AS TEXT) END") } UsageScopeV2::Model => Some("COALESCE(NULLIF(r.requested_model, ''), 'Unknown')"), - UsageScopeV2::Day => { - Some("strftime('%Y-%m-%d', r.created_at, 'unixepoch', 'localtime')") - } + UsageScopeV2::Day => Some(day_bucket_sql.as_str()), }; let rows = usage_event_rows( @@ -801,7 +864,12 @@ where let entry = by_key .entry(key) .or_insert_with(|| (name, ProviderAgg::default())); - entry.1.merge(row.agg); + let mut agg = row.agg; + if !matches!(params.scope, UsageScopeV2::Day) { + agg.first_request_created_at_ms = None; + agg.last_request_created_at_ms = None; + } + entry.1.merge(agg); } let mut out: Vec = by_key @@ -853,12 +921,13 @@ where folder_keys, limit, exclude_cx2cc_gateway_bridge: resolved.exclude_cx2cc_gateway_bridge, + day_start_hour: resolved.day_start_hour, }, folder_lookup, )?); } - Ok(leaderboard_v2_with_conn( + Ok(leaderboard_v2_with_conn_day_start( &conn, scope, resolved.start_ts, @@ -867,5 +936,27 @@ where resolved.provider_id, limit, resolved.exclude_cx2cc_gateway_bridge, + resolved.day_start_hour, )?) } + +#[cfg(test)] +mod tests { + use super::local_day_bucket_sql; + + #[test] + fn local_day_bucket_sql_shifts_after_localtime_for_wall_clock_day_boundaries() { + assert_eq!( + local_day_bucket_sql("created_at", 0), + "strftime('%Y-%m-%d', created_at, 'unixepoch', 'localtime')" + ); + assert_eq!( + local_day_bucket_sql("created_at", 5), + "strftime('%Y-%m-%d', created_at, 'unixepoch', 'localtime', '-5 hours')" + ); + assert_eq!( + local_day_bucket_sql("r.created_at", 5), + "strftime('%Y-%m-%d', r.created_at, 'unixepoch', 'localtime', '-5 hours')" + ); + } +} diff --git a/src-tauri/src/domain/usage_stats/mod.rs b/src-tauri/src/domain/usage_stats/mod.rs index 5f767eaf..8c0db13f 100644 --- a/src-tauri/src/domain/usage_stats/mod.rs +++ b/src-tauri/src/domain/usage_stats/mod.rs @@ -14,6 +14,8 @@ mod summary; mod tokens; mod types; +pub(crate) use tokens::{effective_input_tokens_display, is_bridged_input_semantics}; + pub use cache_rate_trend_v1::provider_cache_rate_trend_v1; pub use day_detail::day_detail_v1; pub use folder_options::folder_options_v1; diff --git a/src-tauri/src/domain/usage_stats/summary.rs b/src-tauri/src/domain/usage_stats/summary.rs index b38e6705..0870e3b3 100644 --- a/src-tauri/src/domain/usage_stats/summary.rs +++ b/src-tauri/src/domain/usage_stats/summary.rs @@ -84,6 +84,7 @@ pub(super) fn summary_query( cost_usd_femto IS NOT NULL ) THEN 1 ELSE 0 END ) AS cost_covered_success, + SUM(duration_ms) AS total_duration_ms, SUM(CASE WHEN status >= 200 AND status < 300 AND error_code IS NULL THEN duration_ms ELSE 0 END) AS success_duration_ms_sum, SUM( CASE WHEN ( @@ -181,6 +182,7 @@ pub(super) fn summary_query( cost_covered_success: row .get::<_, Option>("cost_covered_success")? .unwrap_or(0), + total_duration_ms: row.get::<_, Option>("total_duration_ms")?.unwrap_or(0), avg_duration_ms, avg_ttfb_ms, avg_output_tokens_per_second, @@ -251,6 +253,7 @@ fn summary_from_event_rows(rows: &[UsageEventAgg]) -> UsageSummary { requests_success: agg.requests_success, requests_failed: agg.requests_failed, cost_covered_success: agg.cost_covered_success, + total_duration_ms: agg.total_duration_ms, avg_duration_ms, avg_ttfb_ms, avg_output_tokens_per_second, diff --git a/src-tauri/src/domain/usage_stats/tests.rs b/src-tauri/src/domain/usage_stats/tests.rs index 1f3d20c5..9a37143b 100644 --- a/src-tauri/src/domain/usage_stats/tests.rs +++ b/src-tauri/src/domain/usage_stats/tests.rs @@ -5,7 +5,7 @@ use super::day_detail::{day_detail_v1_with_conn, UsageDayResolvedFolder}; use super::folder_options::folder_options_v1_with_conn; use super::leaderboard_v2::{ leaderboard_v2_folder_filtered_with_conn, leaderboard_v2_with_conn, - FolderFilteredLeaderboardParams, + leaderboard_v2_with_conn_day_start, FolderFilteredLeaderboardParams, }; use super::summary::{summary_query, summary_v2_with_conn}; use super::*; @@ -44,7 +44,8 @@ fn setup_conn() -> Connection { usage_json TEXT, excluded_from_stats INTEGER NOT NULL DEFAULT 0, session_id TEXT, - created_at INTEGER NOT NULL + created_at INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL DEFAULT 0 ); "#, ) @@ -77,6 +78,16 @@ fn local_day_start_ts(conn: &Connection, day: &str) -> i64 { .expect("query local day start ts") } +fn local_usage_day_start_ts(conn: &Connection, day: &str, day_start_hour: i64) -> i64 { + let time = format!("{day_start_hour:02}:00:00"); + conn.query_row( + "SELECT CAST(strftime('%s', ?1 || ' ' || ?2, 'utc') AS INTEGER)", + params![day, time], + |row| row.get(0), + ) + .expect("query local usage day start ts") +} + #[derive(Clone)] struct TestUsageLog<'a> { cli_key: &'a str, @@ -185,6 +196,7 @@ fn lifecycle_interruption_rows_are_excluded_from_usage_summary_and_leaderboard() TestUsageLog { provider_id: 1, provider_name: "Included Provider", + duration_ms: 1000, input_tokens: Some(80), output_tokens: Some(20), total_tokens: Some(100), @@ -192,6 +204,21 @@ fn lifecycle_interruption_rows_are_excluded_from_usage_summary_and_leaderboard() ..base_usage_log(1_000) }, ); + insert_usage_log( + &conn, + TestUsageLog { + provider_id: 1, + provider_name: "Included Provider", + status: Some(500), + error_code: Some("UPSTREAM_ERROR"), + duration_ms: 2500, + input_tokens: None, + output_tokens: None, + total_tokens: None, + cost_usd_femto: None, + ..base_usage_log(1_001) + }, + ); insert_usage_log( &conn, TestUsageLog { @@ -199,17 +226,20 @@ fn lifecycle_interruption_rows_are_excluded_from_usage_summary_and_leaderboard() provider_name: "Interrupted Provider", status: Some(499), error_code: Some("GW_REQUEST_INTERRUPTED_BY_RESTART"), + duration_ms: 99_000, input_tokens: Some(8_000), output_tokens: Some(2_000), total_tokens: Some(10_000), cost_usd_femto: Some(99_000_000_000_000_000), excluded_from_stats: 1, - ..base_usage_log(1_001) + ..base_usage_log(1_002) }, ); let summary = summary_query(&conn, None, None, None, None, false).expect("summary"); - assert_eq!(summary.requests_total, 1); + assert_eq!(summary.requests_total, 2); + assert_eq!(summary.requests_failed, 1); + assert_eq!(summary.total_duration_ms, 3500); assert_eq!(summary.total_tokens, 100); let rows = leaderboard_v2_with_conn( @@ -225,6 +255,9 @@ fn lifecycle_interruption_rows_are_excluded_from_usage_summary_and_leaderboard() .expect("leaderboard"); assert_eq!(rows.len(), 1); assert_eq!(rows[0].key, "codex:1"); + assert_eq!(rows[0].requests_total, 2); + assert_eq!(rows[0].requests_failed, 1); + assert_eq!(rows[0].total_duration_ms, 3500); assert_eq!(rows[0].total_tokens, 100); } @@ -373,10 +406,12 @@ fn usage_params_accept_generated_and_legacy_cx2cc_filter_keys() { "cliKey": null, "providerId": null, "folderKeys": null, + "dayStartHour": 5, "excludeCx2CcGatewayBridge": true })) .expect("deserialize usage query params"); assert_eq!(params.exclude_cx2cc_gateway_bridge, Some(true)); + assert_eq!(params.day_start_hour, Some(5)); let legacy_params: UsageQueryParams = serde_json::from_value(serde_json::json!({ "period": "daily", @@ -396,10 +431,12 @@ fn usage_params_accept_generated_and_legacy_cx2cc_filter_keys() { "providerId": null, "folderLimit": 8, "folderKeys": null, + "dayStartHour": 5, "excludeCx2CcGatewayBridge": true })) .expect("deserialize usage day detail params"); assert_eq!(detail_params.exclude_cx2cc_gateway_bridge, Some(true)); + assert_eq!(detail_params.day_start_hour, Some(5)); let legacy_detail_params: UsageDayDetailParams = serde_json::from_value(serde_json::json!({ "day": "2026-04-22", @@ -545,6 +582,7 @@ fn cx2cc_gateway_bridge_filter_covers_overview_and_home_usage_queries() { cli_key: None, provider_id: None, folder_keys: None, + day_start_hour: None, exclude_cx2cc_gateway_bridge: Some(true), }, fixture_folder_lookup, @@ -562,6 +600,7 @@ fn cx2cc_gateway_bridge_filter_covers_overview_and_home_usage_queries() { cli_key: None, provider_id: None, folder_keys: None, + day_start_hour: None, exclude_cx2cc_gateway_bridge: Some(false), }, fixture_folder_lookup, @@ -579,6 +618,7 @@ fn cx2cc_gateway_bridge_filter_covers_overview_and_home_usage_queries() { cli_key: None, provider_id: None, folder_keys: None, + day_start_hour: None, exclude_cx2cc_gateway_bridge: Some(true), }, fixture_folder_lookup, @@ -599,6 +639,7 @@ fn cx2cc_gateway_bridge_filter_covers_overview_and_home_usage_queries() { provider_id: None, folder_limit: None, folder_keys: Some(vec!["/work/alpha".to_string()]), + day_start_hour: None, exclude_cx2cc_gateway_bridge: Some(true), }, fixture_folder_lookup, @@ -1447,6 +1488,8 @@ INSERT INTO request_logs ( assert_eq!(rows[0].output_tokens, 30); assert_eq!(rows[0].total_tokens, 330); assert_eq!(rows[0].cost_usd, Some(3.0)); + assert_eq!(rows[0].first_request_created_at_ms, Some(day_two_ts * 1000)); + assert_eq!(rows[0].last_request_created_at_ms, Some(day_two_ts * 1000)); assert_eq!(rows[1].key, day_one); assert_eq!(rows[1].name, day_one); @@ -1455,6 +1498,11 @@ INSERT INTO request_logs ( assert_eq!(rows[1].output_tokens, 90); assert_eq!(rows[1].total_tokens, 390); assert_eq!(rows[1].cost_usd, Some(3.0)); + assert_eq!(rows[1].first_request_created_at_ms, Some(day_one_ts * 1000)); + assert_eq!( + rows[1].last_request_created_at_ms, + Some((day_one_ts + 3600) * 1000) + ); let cli_filtered = leaderboard_v2_with_conn( &conn, @@ -1470,6 +1518,10 @@ INSERT INTO request_logs ( assert_eq!(cli_filtered.len(), 1); assert_eq!(cli_filtered[0].key, day_one); assert_eq!(cli_filtered[0].requests_total, 2); + assert_eq!( + cli_filtered[0].last_request_created_at_ms, + Some((day_one_ts + 3600) * 1000) + ); let provider_filtered = leaderboard_v2_with_conn( &conn, @@ -1485,6 +1537,200 @@ INSERT INTO request_logs ( assert_eq!(provider_filtered.len(), 1); assert_eq!(provider_filtered[0].key, day_two); assert_eq!(provider_filtered[0].requests_total, 1); + + let model_rows = leaderboard_v2_with_conn( + &conn, + UsageScopeV2::Model, + Some(day_one_ts), + Some(end_ts), + None, + None, + Some(50), + false, + ) + .expect("model leaderboard"); + assert!(model_rows + .iter() + .all(|row| row.first_request_created_at_ms.is_none() + && row.last_request_created_at_ms.is_none())); +} + +#[test] +fn v2_day_leaderboard_respects_usage_day_start_hour() { + let conn = setup_conn(); + let day_one = "2026-04-16"; + let day_two = "2026-04-17"; + let day_start_hour = 5; + let usage_day_one_start = local_usage_day_start_ts(&conn, day_one, day_start_hour); + let usage_day_two_start = local_usage_day_start_ts(&conn, day_two, day_start_hour); + let query_end = local_usage_day_start_ts(&conn, "2026-04-18", day_start_hour); + + for (provider_id, provider_name) in [(123, "OpenAI"), (456, "Gemini Upstream")] { + conn.execute( + "INSERT INTO providers (id, name) VALUES (?1, ?2)", + params![provider_id, provider_name], + ) + .expect("insert provider"); + } + + for (cli_key, provider_id, provider_name, session_id, created_at, input_tokens) in [ + ( + "codex", + 123, + "OpenAI", + "codex-alpha-1", + usage_day_one_start + 4 * 3600, + 100i64, + ), + ( + "codex", + 123, + "OpenAI", + "codex-alpha-2", + usage_day_one_start + 21 * 3600, + 200i64, + ), + ( + "codex", + 456, + "Gemini Upstream", + "codex-beta-1", + usage_day_two_start + 4 * 3600, + 300i64, + ), + ( + "claude", + 123, + "OpenAI", + "claude-alpha-1", + usage_day_two_start + 15 * 3600, + 400i64, + ), + ] { + insert_usage_log( + &conn, + TestUsageLog { + cli_key, + provider_id, + provider_name, + requested_model: "model-test", + input_tokens: Some(input_tokens), + output_tokens: Some(10), + session_id: Some(session_id), + created_at, + ..base_usage_log(created_at) + }, + ); + } + + let usage_day_rows = leaderboard_v2_with_conn_day_start( + &conn, + UsageScopeV2::Day, + Some(usage_day_one_start), + Some(query_end), + None, + None, + Some(50), + false, + day_start_hour, + ) + .expect("usage day leaderboard"); + assert_eq!(usage_day_rows.len(), 2); + assert_eq!(usage_day_rows[0].key, day_two); + assert_eq!(usage_day_rows[0].requests_total, 2); + assert_eq!( + usage_day_rows[0].first_request_created_at_ms, + Some((usage_day_two_start + 4 * 3600) * 1000) + ); + assert_eq!( + usage_day_rows[0].last_request_created_at_ms, + Some((usage_day_two_start + 15 * 3600) * 1000) + ); + assert_eq!(usage_day_rows[1].key, day_one); + assert_eq!(usage_day_rows[1].requests_total, 2); + assert_eq!( + usage_day_rows[1].first_request_created_at_ms, + Some((usage_day_one_start + 4 * 3600) * 1000) + ); + assert_eq!( + usage_day_rows[1].last_request_created_at_ms, + Some((usage_day_one_start + 21 * 3600) * 1000) + ); + + let natural_rows = leaderboard_v2_with_conn( + &conn, + UsageScopeV2::Day, + Some(usage_day_one_start), + Some(query_end), + None, + None, + Some(50), + false, + ) + .expect("natural day leaderboard"); + assert_eq!(natural_rows.len(), 2); + assert_eq!(natural_rows[0].key, day_two); + assert_eq!(natural_rows[0].requests_total, 3); + assert_eq!( + natural_rows[0].first_request_created_at_ms, + Some((usage_day_one_start + 21 * 3600) * 1000) + ); + assert_eq!( + natural_rows[0].last_request_created_at_ms, + Some((usage_day_two_start + 15 * 3600) * 1000) + ); + assert_eq!(natural_rows[1].key, day_one); + assert_eq!(natural_rows[1].requests_total, 1); + + let folder_rows = leaderboard_v2_folder_filtered_with_conn( + &conn, + FolderFilteredLeaderboardParams { + scope: UsageScopeV2::Day, + start_ts: Some(usage_day_one_start), + end_ts: Some(query_end), + cli_key: None, + provider_id: None, + folder_keys: &["/work/alpha".to_string()], + limit: Some(50), + exclude_cx2cc_gateway_bridge: false, + day_start_hour, + }, + fixture_folder_lookup, + ) + .expect("folder filtered usage day leaderboard"); + assert_eq!(folder_rows.len(), 2); + assert_eq!(folder_rows[0].key, day_two); + assert_eq!(folder_rows[0].requests_total, 1); + assert_eq!(folder_rows[1].key, day_one); + assert_eq!(folder_rows[1].requests_total, 2); + + let day_one_detail = day_detail_v1_with_conn( + &conn, + &UsageDayDetailParams { + day: day_one.to_string(), + cli_key: None, + provider_id: None, + folder_limit: None, + folder_keys: Some(vec!["/work/alpha".to_string()]), + day_start_hour: Some(day_start_hour), + exclude_cx2cc_gateway_bridge: None, + }, + fixture_folder_lookup, + ) + .expect("usage day detail"); + assert_eq!( + day_one_detail + .hours + .iter() + .map(|row| row.requests_total) + .sum::(), + 2 + ); + assert_eq!(day_one_detail.hours[2].requests_total, 1); + assert_eq!(day_one_detail.hours[9].requests_total, 1); + assert_eq!(day_one_detail.folders.len(), 1); + assert_eq!(day_one_detail.folders[0].key, "/work/alpha"); + assert_eq!(day_one_detail.folders[0].requests_total, 2); } #[test] @@ -1564,6 +1810,7 @@ fn day_detail_v1_filters_by_local_day_and_returns_hour_buckets() { provider_id: None, folder_limit: None, folder_keys: None, + day_start_hour: None, exclude_cx2cc_gateway_bridge: None, }, |_| Vec::new(), @@ -1594,6 +1841,7 @@ fn day_detail_v1_filters_by_local_day_and_returns_hour_buckets() { provider_id: Some(456), folder_limit: None, folder_keys: None, + day_start_hour: None, exclude_cx2cc_gateway_bridge: None, }, |_| Vec::new(), @@ -1612,6 +1860,7 @@ fn day_detail_v1_filters_by_local_day_and_returns_hour_buckets() { provider_id: None, folder_limit: None, folder_keys: None, + day_start_hour: None, exclude_cx2cc_gateway_bridge: None, }, |_| Vec::new(), @@ -1708,6 +1957,7 @@ fn day_detail_v1_groups_resolved_folders_and_unknown_sessions() { provider_id: None, folder_limit: None, folder_keys: None, + day_start_hour: None, exclude_cx2cc_gateway_bridge: None, }, |keys| { @@ -1838,6 +2088,7 @@ fn folder_options_v1_groups_resolved_folders_and_keeps_unknown_selectable() { cli_key: None, provider_id: None, folder_keys: Some(vec!["/work/alpha".to_string()]), + day_start_hour: None, exclude_cx2cc_gateway_bridge: None, }, fixture_folder_lookup, @@ -1911,6 +2162,7 @@ fn folder_keys_filter_summary_leaderboard_and_day_detail() { cli_key: None, provider_id: None, folder_keys: Some(vec!["/work/alpha".to_string()]), + day_start_hour: None, exclude_cx2cc_gateway_bridge: None, }; let unfiltered_summary = summary_v2_with_conn( @@ -1942,6 +2194,7 @@ fn folder_keys_filter_summary_leaderboard_and_day_detail() { folder_keys: &["/work/alpha".to_string()], limit: Some(50), exclude_cx2cc_gateway_bridge: false, + day_start_hour: 0, }, fixture_folder_lookup, ) @@ -1949,6 +2202,14 @@ fn folder_keys_filter_summary_leaderboard_and_day_detail() { assert_eq!(alpha_day_rows.len(), 1); assert_eq!(alpha_day_rows[0].key, day); assert_eq!(alpha_day_rows[0].total_tokens, 120); + assert_eq!( + alpha_day_rows[0].first_request_created_at_ms, + Some((start_ts + 2 * 3600) * 1000) + ); + assert_eq!( + alpha_day_rows[0].last_request_created_at_ms, + Some((start_ts + 2 * 3600) * 1000) + ); let alpha_model_rows = leaderboard_v2_folder_filtered_with_conn( &conn, @@ -1961,12 +2222,15 @@ fn folder_keys_filter_summary_leaderboard_and_day_detail() { folder_keys: &["/work/alpha".to_string()], limit: Some(50), exclude_cx2cc_gateway_bridge: false, + day_start_hour: 0, }, fixture_folder_lookup, ) .expect("model leaderboard"); assert_eq!(alpha_model_rows.len(), 1); assert_eq!(alpha_model_rows[0].key, "gpt-alpha"); + assert_eq!(alpha_model_rows[0].first_request_created_at_ms, None); + assert_eq!(alpha_model_rows[0].last_request_created_at_ms, None); let unknown_summary = summary_v2_with_conn( &conn, @@ -2003,6 +2267,7 @@ fn folder_keys_filter_summary_leaderboard_and_day_detail() { provider_id: None, folder_limit: None, folder_keys: Some(vec!["/work/alpha".to_string()]), + day_start_hour: None, exclude_cx2cc_gateway_bridge: None, }, fixture_folder_lookup, diff --git a/src-tauri/src/domain/usage_stats/tokens.rs b/src-tauri/src/domain/usage_stats/tokens.rs index 91f4be9a..9bfc7719 100644 --- a/src-tauri/src/domain/usage_stats/tokens.rs +++ b/src-tauri/src/domain/usage_stats/tokens.rs @@ -7,6 +7,51 @@ pub(super) fn token_total(total: Option, input: Option, output: Option pub(super) const SQL_EFFECTIVE_INPUT_TOKENS_EXPR: &str = "CASE WHEN cli_key IN ('codex','gemini') OR EXISTS (SELECT 1 FROM providers p WHERE p.id = final_provider_id AND (p.source_provider_id IS NOT NULL OR p.bridge_type = 'cx2cc')) THEN MAX(COALESCE(input_tokens, 0) - COALESCE(cache_read_input_tokens, 0), 0) ELSE COALESCE(input_tokens, 0) END"; +/// Rust twin of the EXISTS predicate inside [`SQL_EFFECTIVE_INPUT_TOKENS_EXPR`]. +/// Locked to the SQL by `effective_input_tokens_matches_sql_expression`. +pub(crate) fn is_bridged_input_semantics( + source_provider_id: Option, + bridge_type: Option<&str>, +) -> bool { + crate::providers::is_cx2cc_bridge(source_provider_id, bridge_type) +} + +/// Rust twin of [`SQL_EFFECTIVE_INPUT_TOKENS_EXPR`] — the single source of +/// truth for "effective input tokens". codex/gemini responses and bridged +/// (cx2cc) providers report cache reads inside `input_tokens`, so those reads +/// are subtracted; claude reports them separately and keeps raw input. +/// Locked to the SQL by `effective_input_tokens_matches_sql_expression`. +pub(crate) fn effective_input_tokens( + cli_key: &str, + bridged: bool, + input_tokens: Option, + cache_read_input_tokens: Option, +) -> i64 { + let input = input_tokens.unwrap_or(0); + if cli_key == "codex" || cli_key == "gemini" || bridged { + input + .saturating_sub(cache_read_input_tokens.unwrap_or(0)) + .max(0) + } else { + input + } +} + +/// Display variant of [`effective_input_tokens`]: keeps "usage unknown" +/// (`input_tokens: None`) as `None` instead of collapsing it to 0, so rows and +/// events without usage render as "—" rather than "0". Aggregates keep the +/// COALESCE(..., 0) semantics of the SQL expression. +pub(crate) fn effective_input_tokens_display( + cli_key: &str, + bridged: bool, + input_tokens: Option, + cache_read_input_tokens: Option, +) -> Option { + input_tokens + .is_some() + .then(|| effective_input_tokens(cli_key, bridged, input_tokens, cache_read_input_tokens)) +} + pub(super) fn sql_effective_input_tokens_expr_with_alias(alias: &str) -> String { format!( "CASE WHEN {alias}.cli_key IN ('codex','gemini') OR EXISTS (SELECT 1 FROM providers p WHERE p.id = {alias}.final_provider_id AND (p.source_provider_id IS NOT NULL OR p.bridge_type = 'cx2cc')) THEN MAX(COALESCE({alias}.input_tokens, 0) - COALESCE({alias}.cache_read_input_tokens, 0), 0) ELSE COALESCE({alias}.input_tokens, 0) END" @@ -26,3 +71,95 @@ pub(super) fn sql_effective_total_tokens_expr_with_alias(alias: &str) -> String "({effective_input_expr}) + COALESCE({alias}.output_tokens, 0) + COALESCE({alias}.cache_creation_input_tokens, 0) + COALESCE({alias}.cache_read_input_tokens, 0)", ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn effective_input_tokens_display_preserves_unknown_usage() { + // Unknown usage stays unknown (frontend renders "—", not "0"). + assert_eq!( + effective_input_tokens_display("claude", false, None, None), + None + ); + assert_eq!( + effective_input_tokens_display("codex", false, None, Some(500)), + None + ); + // Present usage matches the SSOT formula, including the 0 clamp. + assert_eq!( + effective_input_tokens_display("claude", false, Some(1200), Some(800)), + Some(1200) + ); + assert_eq!( + effective_input_tokens_display("codex", false, Some(800), Some(1200)), + Some(0) + ); + assert_eq!( + effective_input_tokens_display("claude", true, Some(1200), Some(800)), + Some(400) + ); + } + + /// Lock-step guard: the Rust twin must agree with the SQL expression for + /// every cli/bridge/token combination. If this fails, one side changed + /// without the other. + #[test] + fn effective_input_tokens_matches_sql_expression() { + let conn = rusqlite::Connection::open_in_memory().expect("open in-memory db"); + conn.execute_batch( + r#" +CREATE TABLE providers (id INTEGER PRIMARY KEY, source_provider_id INTEGER, bridge_type TEXT); +-- provider 1: plain, 2: bridged via source id, 3: bridged via cx2cc type, 4: both +INSERT INTO providers VALUES (1, NULL, NULL); +INSERT INTO providers VALUES (2, 99, NULL); +INSERT INTO providers VALUES (3, NULL, 'cx2cc'); +INSERT INTO providers VALUES (4, 99, 'cx2cc'); +CREATE TABLE r (cli_key TEXT, final_provider_id INTEGER, input_tokens INTEGER, cache_read_input_tokens INTEGER); +"#, + ) + .expect("create schema"); + + let provider_cases: [(i64, Option, Option<&str>); 5] = [ + (1, None, None), + (2, Some(99), None), + (3, None, Some("cx2cc")), + (4, Some(99), Some("cx2cc")), + // provider id 5 does not exist in the providers table at all + (5, None, None), + ]; + let token_cases: [(Option, Option); 5] = [ + (None, None), + (Some(1200), None), + (Some(1200), Some(800)), + (Some(800), Some(1200)), // read > input clamps to 0 + (None, Some(500)), + ]; + + let sql = format!("SELECT {SQL_EFFECTIVE_INPUT_TOKENS_EXPR} FROM r"); + for cli_key in ["claude", "codex", "gemini"] { + for (provider_id, source_provider_id, bridge_type) in provider_cases { + for (input, cache_read) in token_cases { + conn.execute("DELETE FROM r", []).expect("clear r"); + conn.execute( + "INSERT INTO r VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![cli_key, provider_id, input, cache_read], + ) + .expect("insert row"); + + let sql_value: i64 = conn + .query_row(&sql, [], |row| row.get(0)) + .expect("evaluate sql expression"); + let bridged = is_bridged_input_semantics(source_provider_id, bridge_type); + let rust_value = effective_input_tokens(cli_key, bridged, input, cache_read); + + assert_eq!( + sql_value, rust_value, + "cli_key={cli_key} provider={provider_id} input={input:?} cache_read={cache_read:?}" + ); + } + } + } + } +} diff --git a/src-tauri/src/domain/usage_stats/types.rs b/src-tauri/src/domain/usage_stats/types.rs index 54bf16c1..8805fefe 100644 --- a/src-tauri/src/domain/usage_stats/types.rs +++ b/src-tauri/src/domain/usage_stats/types.rs @@ -7,6 +7,7 @@ pub struct UsageSummary { pub requests_success: i64, pub requests_failed: i64, pub cost_covered_success: i64, + pub total_duration_ms: i64, pub avg_duration_ms: Option, pub avg_ttfb_ms: Option, pub avg_output_tokens_per_second: Option, @@ -126,6 +127,9 @@ pub struct UsageLeaderboardRow { pub requests_total: i64, pub requests_success: i64, pub requests_failed: i64, + pub total_duration_ms: i64, + pub first_request_created_at_ms: Option, + pub last_request_created_at_ms: Option, pub total_tokens: i64, pub io_total_tokens: i64, pub input_tokens: i64, diff --git a/src-tauri/src/gateway.rs b/src-tauri/src/gateway.rs index 770657f2..ae68f61a 100644 --- a/src-tauri/src/gateway.rs +++ b/src-tauri/src/gateway.rs @@ -1,3 +1,4 @@ +pub(crate) mod active_requests; mod background_tasks; mod billing_header_rectifier; mod binder; diff --git a/src-tauri/src/gateway/active_requests.rs b/src-tauri/src/gateway/active_requests.rs new file mode 100644 index 00000000..e1f81e02 --- /dev/null +++ b/src-tauri/src/gateway/active_requests.rs @@ -0,0 +1,306 @@ +use crate::gateway::events::GatewayAttemptEvent; +use serde::Serialize; +use std::collections::HashMap; +use std::sync::RwLock; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ActiveRequestStart { + pub(crate) trace_id: String, + pub(crate) cli_key: String, + pub(crate) method: String, + pub(crate) path: String, + pub(crate) query: Option, + pub(crate) session_id: Option, + pub(crate) requested_model: Option, + pub(crate) created_at_ms: i64, +} + +#[derive(Debug, Clone, Serialize, specta::Type, PartialEq, Eq)] +pub(crate) struct ActiveRequestSnapshotItem { + pub trace_id: String, + pub cli_key: String, + pub method: String, + pub path: String, + pub query: Option, + pub session_id: Option, + pub requested_model: Option, + pub created_at_ms: i64, + pub last_activity_ms: i64, + pub current_attempt: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ActiveRequestFinishReason { + Completed, + Failed, + ClientAborted, + GatewayStopped, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ActiveRequestEntry { + start: ActiveRequestStart, + last_activity_ms: i64, + current_attempt: Option, +} + +#[derive(Debug, Default)] +pub(crate) struct ActiveRequestRegistry { + entries: RwLock>, +} + +impl ActiveRequestRegistry { + pub(crate) fn register(&self, start: ActiveRequestStart) { + let last_activity_ms = start.created_at_ms.max(0); + if let Ok(mut entries) = self.entries.write() { + entries.insert( + start.trace_id.clone(), + ActiveRequestEntry { + start, + last_activity_ms, + current_attempt: None, + }, + ); + } + } + + pub(crate) fn touch(&self, trace_id: &str, last_activity_ms: i64) { + if let Ok(mut entries) = self.entries.write() { + if let Some(entry) = entries.get_mut(trace_id) { + entry.last_activity_ms = entry.last_activity_ms.max(last_activity_ms.max(0)); + } + } + } + + pub(crate) fn record_attempt_start(&self, attempt: GatewayAttemptEvent, last_activity_ms: i64) { + if let Ok(mut entries) = self.entries.write() { + let Some(entry) = entries.get_mut(&attempt.trace_id) else { + return; + }; + if entry + .current_attempt + .as_ref() + .is_some_and(|current| current.attempt_index > attempt.attempt_index) + { + return; + } + entry.last_activity_ms = entry.last_activity_ms.max(last_activity_ms.max(0)); + entry.current_attempt = Some(attempt); + } + } + + pub(crate) fn finish( + &self, + trace_id: &str, + _reason: ActiveRequestFinishReason, + ) -> Option { + self.entries + .write() + .ok() + .and_then(|mut entries| entries.remove(trace_id)) + .map(ActiveRequestEntry::into_snapshot) + } + + pub(crate) fn finish_all( + &self, + _reason: ActiveRequestFinishReason, + ) -> Vec { + let Ok(mut entries) = self.entries.write() else { + return Vec::new(); + }; + let mut rows: Vec<_> = entries + .drain() + .map(|(_, entry)| entry.into_snapshot()) + .collect(); + sort_snapshot_items(&mut rows); + rows + } + + pub(crate) fn snapshot(&self) -> Vec { + let Ok(entries) = self.entries.read() else { + return Vec::new(); + }; + let mut rows: Vec<_> = entries + .values() + .cloned() + .map(ActiveRequestEntry::into_snapshot) + .collect(); + sort_snapshot_items(&mut rows); + rows + } +} + +impl ActiveRequestEntry { + fn into_snapshot(self) -> ActiveRequestSnapshotItem { + ActiveRequestSnapshotItem { + trace_id: self.start.trace_id, + cli_key: self.start.cli_key, + method: self.start.method, + path: self.start.path, + query: self.start.query, + session_id: self.start.session_id, + requested_model: self.start.requested_model, + created_at_ms: self.start.created_at_ms, + last_activity_ms: self.last_activity_ms, + current_attempt: self.current_attempt, + } + } +} + +fn sort_snapshot_items(rows: &mut [ActiveRequestSnapshotItem]) { + rows.sort_by(|a, b| { + b.created_at_ms + .cmp(&a.created_at_ms) + .then_with(|| b.trace_id.cmp(&a.trace_id)) + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::gateway::events::GatewayAttemptEvent; + + fn active_request_start(trace_id: &str) -> ActiveRequestStart { + ActiveRequestStart { + trace_id: trace_id.to_string(), + cli_key: "claude".to_string(), + method: "POST".to_string(), + path: "/v1/messages".to_string(), + query: None, + session_id: None, + requested_model: Some("claude-sonnet-4".to_string()), + created_at_ms: 1_000, + } + } + + fn gateway_attempt( + trace_id: &str, + attempt_index: u32, + provider_name: &str, + ) -> GatewayAttemptEvent { + GatewayAttemptEvent { + trace_id: trace_id.to_string(), + cli_key: "claude".to_string(), + session_id: None, + method: "POST".to_string(), + path: "/v1/messages".to_string(), + query: None, + requested_model: Some("claude-sonnet-4".to_string()), + attempt_index, + provider_id: i64::from(attempt_index), + session_reuse: None, + provider_name: provider_name.to_string(), + base_url: "https://provider.example".to_string(), + outcome: "started".to_string(), + status: None, + attempt_started_ms: u128::from(attempt_index) * 100, + attempt_duration_ms: 0, + circuit_state_before: Some("CLOSED"), + circuit_state_after: None, + circuit_failure_count: Some(0), + circuit_failure_threshold: Some(3), + claude_model_mapping: None, + } + } + + #[test] + fn registry_register_touch_finish_lifecycle() { + let registry = ActiveRequestRegistry::default(); + registry.register(active_request_start("trace-active")); + + assert_eq!(registry.snapshot().len(), 1); + registry.touch("trace-active", 2_000); + assert_eq!(registry.snapshot()[0].last_activity_ms, 2_000); + + assert!(registry + .finish("trace-active", ActiveRequestFinishReason::Completed) + .is_some()); + assert!(registry.snapshot().is_empty()); + } + + #[test] + fn registry_finish_is_idempotent() { + let registry = ActiveRequestRegistry::default(); + registry.register(active_request_start("trace-once")); + + assert!(registry + .finish("trace-once", ActiveRequestFinishReason::Completed) + .is_some()); + assert!(registry + .finish("trace-once", ActiveRequestFinishReason::Completed) + .is_none()); + } + + #[test] + fn registry_finish_all_clears_entries() { + let registry = ActiveRequestRegistry::default(); + registry.register(active_request_start("trace-a")); + registry.register(active_request_start("trace-b")); + registry.record_attempt_start(gateway_attempt("trace-a", 1, "Provider A"), 1_100); + + let removed = registry.finish_all(ActiveRequestFinishReason::GatewayStopped); + + assert_eq!(removed.len(), 2); + assert!(removed.iter().any(|row| row.current_attempt.is_some())); + assert!(registry.snapshot().is_empty()); + } + + #[test] + fn registry_snapshot_replays_latest_attempt() { + let registry = ActiveRequestRegistry::default(); + registry.register(active_request_start("trace-progress")); + let attempt = gateway_attempt("trace-progress", 1, "Provider A"); + + registry.record_attempt_start(attempt.clone(), 1_100); + + let snapshot = registry.snapshot(); + assert_eq!(snapshot[0].current_attempt, Some(attempt)); + assert_eq!(snapshot[0].last_activity_ms, 1_100); + } + + #[test] + fn registry_older_attempt_does_not_replace_newer_progress() { + let registry = ActiveRequestRegistry::default(); + registry.register(active_request_start("trace-monotonic")); + let newer = gateway_attempt("trace-monotonic", 2, "Provider B"); + + registry.record_attempt_start(newer.clone(), 1_200); + registry.record_attempt_start(gateway_attempt("trace-monotonic", 1, "Provider A"), 1_300); + + let snapshot = registry.snapshot(); + assert_eq!(snapshot[0].current_attempt, Some(newer)); + assert_eq!(snapshot[0].last_activity_ms, 1_200); + } + + #[test] + fn registry_same_attempt_index_refreshes_progress() { + let registry = ActiveRequestRegistry::default(); + registry.register(active_request_start("trace-refresh")); + registry.record_attempt_start( + gateway_attempt("trace-refresh", 1, "Provider Before"), + 1_100, + ); + let refreshed = gateway_attempt("trace-refresh", 1, "Provider After"); + + registry.record_attempt_start(refreshed.clone(), 1_200); + + let snapshot = registry.snapshot(); + assert_eq!(snapshot[0].current_attempt, Some(refreshed)); + assert_eq!(snapshot[0].last_activity_ms, 1_200); + } + + #[test] + fn registry_finish_clears_attempt_progress() { + let registry = ActiveRequestRegistry::default(); + registry.register(active_request_start("trace-finished")); + let attempt = gateway_attempt("trace-finished", 1, "Provider A"); + registry.record_attempt_start(attempt.clone(), 1_100); + + let finished = registry + .finish("trace-finished", ActiveRequestFinishReason::Completed) + .expect("active request should be removed"); + + assert_eq!(finished.current_attempt, Some(attempt)); + assert!(registry.snapshot().is_empty()); + } +} diff --git a/src-tauri/src/gateway/control_service.rs b/src-tauri/src/gateway/control_service.rs index 4dfd86a7..3e92c6a1 100644 --- a/src-tauri/src/gateway/control_service.rs +++ b/src-tauri/src/gateway/control_service.rs @@ -8,6 +8,7 @@ use std::net::SocketAddr; use std::sync::{Arc, Mutex}; use tokio::sync::oneshot; +use super::active_requests::ActiveRequestRegistry; use super::background_tasks::GatewayBackgroundTasks; use super::binder::{bind_exact, bind_first_available, resolve_gateway_binding}; use super::codex_session_id::CodexSessionIdCache; @@ -82,6 +83,7 @@ impl GatewayControlService { let session = Arc::new(session_manager::SessionManager::new()); let recent_errors = Arc::new(Mutex::new(RecentErrorCache::default())); let plugin_pipeline = load_gateway_plugin_pipeline(&db); + let active_requests = Arc::new(ActiveRequestRegistry::default()); let state = GatewayAppState { app: app.clone(), @@ -93,6 +95,7 @@ impl GatewayControlService { recent_errors: recent_errors.clone(), latency_cache: Arc::new(Mutex::new(ProviderBaseUrlPingCache::default())), plugin_pipeline: plugin_pipeline.clone(), + active_requests: active_requests.clone(), }; let router = build_router(state); let (shutdown, shutdown_rx) = oneshot::channel::<()>(); @@ -125,6 +128,7 @@ impl GatewayControlService { circuit, session, recent_errors, + active_requests, shutdown, task, background_tasks, @@ -261,18 +265,17 @@ fn load_gateway_plugin_pipeline( db: &db::Db, ) -> Arc { match plugin_service::enabled_plugins_for_gateway(db) { - Ok(plugins) if plugins.is_empty() => { - super::plugins::pipeline::GatewayPluginPipeline::empty_shared() - } Ok(plugins) => { - tracing::info!( - plugin_count = plugins.len(), - "loaded enabled gateway plugins" - ); + if !plugins.is_empty() { + tracing::info!( + plugin_count = plugins.len(), + "loaded enabled gateway plugins" + ); + } Arc::new( super::plugins::pipeline::GatewayPluginPipeline::for_runtime( plugins, - Arc::new(RuntimeGatewayPluginExecutor::default()), + Arc::new(RuntimeGatewayPluginExecutor::with_db(db.clone())), super::plugins::pipeline::GatewayPluginPipelineConfig::default(), ), ) @@ -282,11 +285,31 @@ fn load_gateway_plugin_pipeline( error = %err, "failed to load gateway plugins; continuing with empty plugin pipeline" ); - super::plugins::pipeline::GatewayPluginPipeline::empty_shared() + empty_runtime_gateway_plugin_pipeline(db) } } } +#[cfg(test)] +fn fallback_gateway_plugin_pipeline_for_tests( + db: &db::Db, +) -> Arc { + tracing::warn!("failed to load gateway plugins; continuing with empty plugin pipeline"); + empty_runtime_gateway_plugin_pipeline(db) +} + +fn empty_runtime_gateway_plugin_pipeline( + db: &db::Db, +) -> Arc { + Arc::new( + super::plugins::pipeline::GatewayPluginPipeline::for_runtime( + Vec::new(), + Arc::new(RuntimeGatewayPluginExecutor::with_db(db.clone())), + super::plugins::pipeline::GatewayPluginPipelineConfig::default(), + ), + ) +} + fn provider_ids_for_cli(db: &db::Db, cli_key: &str) -> crate::shared::error::AppResult> { Ok(providers::list_by_cli(db, cli_key)? .into_iter() @@ -365,6 +388,15 @@ fn build_circuit_breaker( #[cfg(test)] mod tests { use super::*; + use crate::domain::plugin_contributions::PluginContributes; + use crate::domain::plugins::{ + PluginDetail, PluginHook, PluginHostCompatibility, PluginInstallSource, PluginManifest, + PluginPermissionRisk, PluginRuntime, PluginStatus, PluginSummary, + }; + use crate::gateway::plugins::context::{GatewayPluginHookName, GatewayRequestHookInput}; + use axum::body::Bytes; + use axum::http::{HeaderMap, Method}; + use std::collections::BTreeMap; #[test] fn configure_http_client_rejects_runtime_self_loop_proxy() { @@ -381,4 +413,103 @@ mod tests { assert!(err.contains(GatewayErrorCode::HttpClientInit.as_str())); assert!(err.contains("self-loop")); } + + #[tokio::test] + async fn fallback_gateway_plugin_pipeline_retains_runtime_executor_for_refresh() { + let temp = tempfile::tempdir().expect("tempdir"); + let db = + crate::db::init_for_tests(&temp.path().join("gateway-fallback.db")).expect("init db"); + let pipeline = fallback_gateway_plugin_pipeline_for_tests(&db); + + pipeline.replace_plugins(vec![extension_host_plugin_without_root()]); + + let err = pipeline + .run_request_hook(GatewayRequestHookInput { + hook_name: GatewayPluginHookName::RequestAfterBodyRead, + trace_id: "trace-fallback-executor".to_string(), + cli_key: "codex".to_string(), + method: Method::POST, + path: "/v1/responses".to_string(), + query: None, + headers: HeaderMap::new(), + body: Bytes::from_static(b"hello"), + requested_model: None, + }) + .await + .expect_err("fallback pipeline should keep runtime executor after refresh"); + + assert_eq!(err.code(), "PLUGIN_EXTENSION_HOST_GATEWAY_FAILED"); + assert!(err + .to_string() + .contains("PLUGIN_EXTENSION_HOST_ROOT_UNAVAILABLE")); + } + + fn extension_host_plugin_without_root() -> PluginDetail { + PluginDetail { + summary: PluginSummary { + id: 1, + plugin_id: "example.extension".to_string(), + name: "Example Extension".to_string(), + current_version: Some("1.0.0".to_string()), + status: PluginStatus::Enabled, + runtime: "extensionHost".to_string(), + permission_risk: PluginPermissionRisk::High, + update_available: false, + last_error: None, + created_at: 1, + updated_at: 1, + }, + manifest: PluginManifest { + id: "example.extension".to_string(), + name: "Example Extension".to_string(), + version: "1.0.0".to_string(), + api_version: "1.0.0".to_string(), + runtime: PluginRuntime::ExtensionHost { + language: "typescript".to_string(), + }, + hooks: Vec::new(), + permissions: Vec::new(), + main: Some("dist/index.js".to_string()), + activation_events: Vec::new(), + contributes: Some(PluginContributes { + providers: Vec::new(), + protocols: Vec::new(), + protocol_bridges: Vec::new(), + commands: Vec::new(), + gateway_hooks: vec![PluginHook { + name: "gateway.request.afterBodyRead".to_string(), + priority: 10, + failure_policy: Some("fail-closed".to_string()), + timeout_ms: None, + }], + ui: BTreeMap::new(), + }), + capabilities: vec!["gateway.hooks".to_string()], + host_compatibility: PluginHostCompatibility { + app: ">=0.56.0 <1.0.0".to_string(), + plugin_api: "^1.0.0".to_string(), + platforms: Vec::new(), + }, + entry: None, + config_schema: None, + config_version: None, + description: None, + author: None, + homepage: None, + repository: None, + license: None, + checksum: None, + signature: None, + category: None, + }, + install_source: PluginInstallSource::Local, + installed_dir: None, + config: serde_json::json!({}), + granted_permissions: vec!["request.body.read".to_string()], + pending_permissions: Vec::new(), + audit_logs: Vec::new(), + runtime_failures: Vec::new(), + rollback_versions: Vec::new(), + } + } } diff --git a/src-tauri/src/gateway/events.rs b/src-tauri/src/gateway/events.rs index c4719fa0..9a866840 100644 --- a/src-tauri/src/gateway/events.rs +++ b/src-tauri/src/gateway/events.rs @@ -1,4 +1,4 @@ -use crate::{circuit_breaker, notice, settings, usage}; +use crate::{circuit_breaker, settings, usage}; use serde::Serialize; use tauri::Manager; @@ -69,7 +69,7 @@ pub(in crate::gateway) mod decision_chain { } } -#[derive(Debug, Serialize, Clone)] +#[derive(Debug, Serialize, Clone, specta::Type)] pub(super) struct FailoverAttempt { pub(super) provider_id: i64, pub(super) provider_name: String, @@ -91,9 +91,26 @@ pub(super) struct FailoverAttempt { pub(super) circuit_state_after: Option<&'static str>, pub(super) circuit_failure_count: Option, pub(super) circuit_failure_threshold: Option, + // Circuit attribution for circuit-gate skip attempts (recovery point and + // the error code that triggered the breaker). Serialized only when set so + // success attempts and non-circuit paths gain zero bytes in attempts_json. + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) circuit_recover_at_unix: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) circuit_trigger_error_code: Option<&'static str>, + // Whether the attempted provider has bridged (cx2cc) input semantics; None + // for synthetic attempts without a concrete provider. Feeds the request + // event's effective_input_tokens. + pub(super) provider_bridged: Option, + // Effective first-byte timeout (seconds); Some only for failures recorded + // under an active first-byte timeout window (GW_UPSTREAM_TIMEOUT plus the + // first-chunk stream-error branches). Structured contract for the frontend + // (never parsed out of `outcome`); serializes as explicit null per the + // gateway event contract. + pub(super) timeout_secs: Option, } -#[derive(Debug, Serialize, Clone, PartialEq, Eq)] +#[derive(Debug, Serialize, Clone, PartialEq, Eq, specta::Type)] #[serde(rename_all = "camelCase")] pub(super) struct ClaudeModelMapping { pub(super) requested_model: String, @@ -104,8 +121,8 @@ pub(super) struct ClaudeModelMapping { pub(super) applied: bool, } -#[derive(Debug, Serialize, Clone)] -struct GatewayRequestEvent { +#[derive(Debug, Serialize, Clone, specta::Type)] +pub(crate) struct GatewayRequestEvent { trace_id: String, cli_key: String, session_id: Option, @@ -126,11 +143,14 @@ struct GatewayRequestEvent { cache_creation_input_tokens: Option, cache_creation_5m_input_tokens: Option, cache_creation_1h_input_tokens: Option, + // Backend-computed via domain::usage_stats::effective_input_tokens so the + // frontend never re-derives the formula (single source of truth). + effective_input_tokens: Option, claude_model_mapping: Option, } -#[derive(Debug, Serialize, Clone)] -struct GatewayRequestStartEvent { +#[derive(Debug, Serialize, Clone, specta::Type)] +pub(crate) struct GatewayRequestStartEvent { trace_id: String, cli_key: String, session_id: Option, @@ -141,8 +161,8 @@ struct GatewayRequestStartEvent { ts: i64, } -#[derive(Debug, Serialize, Clone)] -struct GatewayRequestSignalEvent { +#[derive(Debug, Serialize, Clone, specta::Type)] +pub(crate) struct GatewayRequestSignalEvent { trace_id: String, cli_key: String, session_id: Option, @@ -151,8 +171,8 @@ struct GatewayRequestSignalEvent { ts: i64, } -#[derive(Debug, Serialize, Clone)] -pub(super) struct GatewayAttemptEvent { +#[derive(Debug, Serialize, Clone, PartialEq, Eq, specta::Type)] +pub(crate) struct GatewayAttemptEvent { pub(super) trace_id: String, pub(super) cli_key: String, pub(super) session_id: Option, @@ -176,8 +196,8 @@ pub(super) struct GatewayAttemptEvent { pub(super) claude_model_mapping: Option, } -#[derive(Debug, Serialize, Clone)] -pub(super) struct GatewayCircuitEvent { +#[derive(Debug, Serialize, Clone, specta::Type)] +pub(crate) struct GatewayCircuitEvent { pub(super) trace_id: String, pub(super) cli_key: String, pub(super) provider_id: i64, @@ -191,10 +211,16 @@ pub(super) struct GatewayCircuitEvent { pub(super) cooldown_until: Option, pub(super) reason: &'static str, pub(super) ts: i64, + // Trigger-failure attribution (error code that tripped the breaker and the + // effective first-byte timeout in seconds). The frontend builds the + // circuit-breaker notice body from these; None outside failure-recording + // transitions. Serialized as explicit null per the gateway event contract. + pub(super) trigger_error_code: Option, + pub(super) first_byte_timeout_secs: Option, } -#[derive(Debug, Serialize, Clone)] -pub(super) struct GatewayLogEvent { +#[derive(Debug, Serialize, Clone, specta::Type)] +pub(crate) struct GatewayLogEvent { pub(super) level: &'static str, pub(super) error_code: &'static str, pub(super) message: String, @@ -351,7 +377,7 @@ fn bound_request_signal_event(mut payload: GatewayRequestSignalEvent) -> Gateway payload } -fn bound_attempt_event(mut payload: GatewayAttemptEvent) -> GatewayAttemptEvent { +pub(super) fn bound_attempt_event(mut payload: GatewayAttemptEvent) -> GatewayAttemptEvent { payload.method = truncate_chars(payload.method, EVENT_METHOD_MAX_CHARS); payload.path = truncate_chars(payload.path, EVENT_PATH_MAX_CHARS); truncate_optional_chars(&mut payload.query, EVENT_QUERY_MAX_CHARS); @@ -367,6 +393,7 @@ fn bound_attempt_event(mut payload: GatewayAttemptEvent) -> GatewayAttemptEvent fn bound_circuit_event(mut payload: GatewayCircuitEvent) -> GatewayCircuitEvent { payload.provider_name = truncate_chars(payload.provider_name, EVENT_SHORT_TEXT_MAX_CHARS); payload.base_url = truncate_chars(payload.base_url, EVENT_URL_MAX_CHARS); + truncate_optional_chars(&mut payload.trigger_error_code, EVENT_SHORT_TEXT_MAX_CHARS); payload } @@ -410,6 +437,21 @@ pub(super) fn emit_request_event( } let usage = usage.unwrap_or_default(); + // The last attempt with a concrete provider decides the input semantics + // (skipped/synthetic attempts carry None), matching final-provider + // resolution in the persisted log. + let final_provider_bridged = attempts + .iter() + .rev() + .find_map(|attempt| attempt.provider_bridged) + .unwrap_or(false); + // None when usage is unknown (no input_tokens) so the frontend renders "—". + let effective_input_tokens = crate::usage_stats::effective_input_tokens_display( + &cli_key, + final_provider_bridged, + usage.input_tokens, + usage.cache_read_input_tokens, + ); let payload = GatewayRequestEvent { trace_id, cli_key, @@ -431,6 +473,7 @@ pub(super) fn emit_request_event( cache_creation_input_tokens: usage.cache_creation_input_tokens, cache_creation_5m_input_tokens: usage.cache_creation_5m_input_tokens, cache_creation_1h_input_tokens: usage.cache_creation_1h_input_tokens, + effective_input_tokens, claude_model_mapping, }; @@ -519,6 +562,8 @@ pub(super) fn emit_circuit_transition( base_url: &str, transition: &circuit_breaker::CircuitTransition, now_unix: i64, + trigger_error_code: Option<&'static str>, + first_byte_timeout_secs: Option, ) { let payload = GatewayCircuitEvent { trace_id: trace_id.to_string(), @@ -534,88 +579,11 @@ pub(super) fn emit_circuit_transition( cooldown_until: transition.snapshot.cooldown_until, reason: transition.reason, ts: now_unix, + trigger_error_code: trigger_error_code.map(str::to_string), + first_byte_timeout_secs, }; emit_circuit_event(app, payload); - - let enable_notice = match settings::read(app) { - Ok(cfg) => cfg.enable_circuit_breaker_notice, - Err(err) => { - tracing::warn!("skip circuit notice because settings read failed: {err}"); - return; - } - }; - if !enable_notice { - return; - } - - let prev_state_text = match transition.prev_state { - circuit_breaker::CircuitState::Closed => "正常", - circuit_breaker::CircuitState::Open => "熔断", - circuit_breaker::CircuitState::HalfOpen => "半开", - }; - let next_state_text = match transition.next_state { - circuit_breaker::CircuitState::Closed => "正常", - circuit_breaker::CircuitState::Open => "熔断", - circuit_breaker::CircuitState::HalfOpen => "半开", - }; - - let (level, title) = match transition.next_state { - circuit_breaker::CircuitState::Open => ( - notice::NoticeLevel::Warning, - format!("熔断触发:{provider_name}"), - ), - circuit_breaker::CircuitState::HalfOpen => ( - notice::NoticeLevel::Info, - format!("熔断试探:{provider_name}"), - ), - circuit_breaker::CircuitState::Closed => ( - notice::NoticeLevel::Success, - format!("熔断恢复:{provider_name}"), - ), - }; - - let reason_text = match transition.reason { - "FAILURE_THRESHOLD_REACHED" => "失败次数达到阈值", - "OPEN_EXPIRED" => "熔断到期,进入半开试探", - "HALF_OPEN_SUCCESS" => "半开试探成功,恢复正常", - "HALF_OPEN_FAILURE" => "半开试探失败,重新熔断", - other => other, - }; - - let mut lines: Vec = Vec::with_capacity(10); - lines.push(format!("CLI:{cli_key}")); - lines.push(format!("Provider:{provider_name} (id={provider_id})")); - lines.push(format!("Base URL:{base_url}")); - lines.push(format!("状态:{prev_state_text} → {next_state_text}")); - lines.push(format!( - "失败:{} / {}", - transition.snapshot.failure_count, transition.snapshot.failure_threshold - )); - lines.push(format!("原因:{reason_text}({})", transition.reason)); - - match transition.snapshot.open_until { - Some(open_until) => { - let remaining_secs = open_until.saturating_sub(now_unix); - let remaining_minutes = remaining_secs.saturating_add(59) / 60; - if remaining_secs > 0 { - lines.push(format!( - "熔断至:{open_until}(约 {remaining_minutes} 分钟后)" - )); - } else { - lines.push(format!("熔断至:{open_until}(已到期)")); - } - } - None => lines.push("熔断至:—".to_string()), - } - - lines.push(format!("Trace:{trace_id}")); - - if let Err(err) = notice::build(level, Some(title), lines.join("\n")) - .and_then(|payload| notice::emit(app, payload)) - { - tracing::warn!("failed to emit circuit breaker notice: {}", err); - } } #[cfg(test)] @@ -656,6 +624,10 @@ mod tests { circuit_state_after: None, circuit_failure_count: None, circuit_failure_threshold: None, + circuit_recover_at_unix: None, + circuit_trigger_error_code: None, + provider_bridged: Some(false), + timeout_secs: None, } } @@ -663,6 +635,233 @@ mod tests { value.chars().count() } + // --- Shared payload fixtures --- + // These JSON files are the wire contract with the frontend runtime guards + // (src/services/gateway/__tests__/gatewayEvents.contract.test.ts validates + // the same files). A failing test here means the payload shape changed: + // update the fixture AND the frontend types/normalizers together. + + fn fixture_mapping() -> ClaudeModelMapping { + ClaudeModelMapping { + requested_model: "claude-sonnet-4-5".to_string(), + effective_model: "gpt-5.4".to_string(), + mapping_kind: "sonnet".to_string(), + provider_id: 7, + provider_name: "Provider A".to_string(), + applied: true, + } + } + + fn assert_matches_fixture(event: &T, fixture: &str) { + let expected: serde_json::Value = + serde_json::from_str(fixture).expect("parse shared event fixture"); + let actual = serde_json::to_value(event).expect("serialize event payload"); + assert_eq!( + actual, expected, + "event payload no longer matches the shared frontend fixture; \ + update the fixture and the frontend guards together" + ); + } + + #[test] + fn request_event_payload_matches_shared_fixture() { + let event = GatewayRequestEvent { + trace_id: "trace-fixture-001".to_string(), + cli_key: "claude".to_string(), + session_id: Some("sess-fixture-001".to_string()), + method: "POST".to_string(), + path: "/v1/messages".to_string(), + query: Some("beta=true".to_string()), + requested_model: Some("claude-sonnet-4-5".to_string()), + status: Some(200), + error_category: None, + error_code: None, + duration_ms: 2350, + ttfb_ms: Some(420), + attempts: vec![FailoverAttempt { + provider_id: 7, + provider_name: "Provider A".to_string(), + base_url: "https://provider-a.example".to_string(), + outcome: "success".to_string(), + status: Some(200), + provider_index: Some(1), + retry_index: Some(1), + session_reuse: Some(false), + error_category: None, + error_code: None, + decision: None, + reason: None, + selection_method: Some(decision_chain::SELECTION_METHOD_ORDERED), + reason_code: Some(decision_chain::REASON_REQUEST_SUCCESS), + attempt_started_ms: Some(1_750_000_000_123), + attempt_duration_ms: Some(458), + circuit_state_before: Some("CLOSED"), + circuit_state_after: Some("CLOSED"), + circuit_failure_count: Some(0), + circuit_failure_threshold: Some(5), + circuit_recover_at_unix: None, + circuit_trigger_error_code: None, + provider_bridged: Some(false), + timeout_secs: None, + }], + input_tokens: Some(1200), + output_tokens: Some(350), + total_tokens: Some(1550), + cache_read_input_tokens: Some(800), + cache_creation_input_tokens: Some(100), + cache_creation_5m_input_tokens: Some(60), + cache_creation_1h_input_tokens: Some(40), + // claude + non-bridged provider: effective input == raw input. + effective_input_tokens: Some(1200), + claude_model_mapping: Some(fixture_mapping()), + }; + + assert_matches_fixture( + &event, + include_str!("../../../src/services/gateway/__fixtures__/gatewayEvents/request.json"), + ); + } + + #[test] + fn request_start_event_payload_matches_shared_fixture() { + let event = GatewayRequestStartEvent { + trace_id: "trace-fixture-001".to_string(), + cli_key: "claude".to_string(), + session_id: Some("sess-fixture-001".to_string()), + method: "POST".to_string(), + path: "/v1/messages".to_string(), + query: Some("beta=true".to_string()), + requested_model: Some("claude-sonnet-4-5".to_string()), + ts: 1_750_000_000, + }; + + assert_matches_fixture( + &event, + include_str!( + "../../../src/services/gateway/__fixtures__/gatewayEvents/request_start.json" + ), + ); + } + + #[test] + fn request_signal_event_payload_matches_shared_fixture() { + let event = GatewayRequestSignalEvent { + trace_id: "trace-fixture-001".to_string(), + cli_key: "claude".to_string(), + session_id: Some("sess-fixture-001".to_string()), + requested_model: Some("claude-sonnet-4-5".to_string()), + phase: "complete", + ts: 1_750_000_001, + }; + + assert_matches_fixture( + &event, + include_str!( + "../../../src/services/gateway/__fixtures__/gatewayEvents/request_signal.json" + ), + ); + } + + #[test] + fn attempt_event_payload_matches_shared_fixture() { + let event = GatewayAttemptEvent { + trace_id: "trace-fixture-001".to_string(), + cli_key: "claude".to_string(), + session_id: Some("sess-fixture-001".to_string()), + method: "POST".to_string(), + path: "/v1/messages".to_string(), + query: Some("beta=true".to_string()), + requested_model: Some("claude-sonnet-4-5".to_string()), + attempt_index: 1, + provider_id: 7, + session_reuse: Some(false), + provider_name: "Provider A".to_string(), + base_url: "https://provider-a.example".to_string(), + outcome: "success".to_string(), + status: Some(200), + attempt_started_ms: 1_750_000_000_123, + attempt_duration_ms: 458, + circuit_state_before: Some("CLOSED"), + circuit_state_after: Some("CLOSED"), + circuit_failure_count: Some(0), + circuit_failure_threshold: Some(5), + claude_model_mapping: Some(fixture_mapping()), + }; + + assert_matches_fixture( + &event, + include_str!("../../../src/services/gateway/__fixtures__/gatewayEvents/attempt.json"), + ); + } + + #[test] + fn circuit_event_payload_matches_shared_fixture() { + let event = GatewayCircuitEvent { + trace_id: "trace-fixture-001".to_string(), + cli_key: "claude".to_string(), + provider_id: 7, + provider_name: "Provider A".to_string(), + base_url: "https://provider-a.example".to_string(), + prev_state: "CLOSED", + next_state: "OPEN", + failure_count: 5, + failure_threshold: 5, + open_until: Some(1_750_001_800), + cooldown_until: None, + reason: "FAILURE_THRESHOLD_REACHED", + ts: 1_750_000_000, + trigger_error_code: Some("GW_UPSTREAM_TIMEOUT".to_string()), + first_byte_timeout_secs: Some(300), + }; + + assert_matches_fixture( + &event, + include_str!("../../../src/services/gateway/__fixtures__/gatewayEvents/circuit.json"), + ); + } + + #[test] + fn circuit_event_serializes_missing_trigger_fields_as_null() { + let event = GatewayCircuitEvent { + trace_id: "trace-1".to_string(), + cli_key: "claude".to_string(), + provider_id: 7, + provider_name: "Provider A".to_string(), + base_url: "https://provider-a.example".to_string(), + prev_state: "OPEN", + next_state: "HALF_OPEN", + failure_count: 5, + failure_threshold: 5, + open_until: None, + cooldown_until: None, + reason: "OPEN_EXPIRED", + ts: 1_750_000_000, + trigger_error_code: None, + first_byte_timeout_secs: None, + }; + + let value = serde_json::to_value(event).expect("serializable circuit event"); + assert_eq!(value.get("trigger_error_code"), Some(&json!(null))); + assert_eq!(value.get("first_byte_timeout_secs"), Some(&json!(null))); + } + + #[test] + fn log_event_payload_matches_shared_fixture() { + let event = GatewayLogEvent { + level: "warn", + error_code: "GW_PORT_IN_USE", + message: "port 37123 already in use".to_string(), + requested_port: 37123, + bound_port: 37124, + base_url: "http://127.0.0.1:37124".to_string(), + }; + + assert_matches_fixture( + &event, + include_str!("../../../src/services/gateway/__fixtures__/gatewayEvents/log.json"), + ); + } + fn repeated_ascii(count: usize) -> String { "a".repeat(count) } @@ -862,6 +1061,7 @@ mod tests { cache_creation_input_tokens: None, cache_creation_5m_input_tokens: None, cache_creation_1h_input_tokens: None, + effective_input_tokens: None, claude_model_mapping: None, }; diff --git a/src-tauri/src/gateway/manager.rs b/src-tauri/src/gateway/manager.rs index 75e0b860..159f45ee 100644 --- a/src-tauri/src/gateway/manager.rs +++ b/src-tauri/src/gateway/manager.rs @@ -248,6 +248,7 @@ mod tests { source_provider_id: None, bridge_type: None, stream_idle_timeout_seconds: None, + extension_values: None, }, ) .expect("insert provider") diff --git a/src-tauri/src/gateway/plugins/audit.rs b/src-tauri/src/gateway/plugins/audit.rs index aedd01e6..7b1e09b8 100644 --- a/src-tauri/src/gateway/plugins/audit.rs +++ b/src-tauri/src/gateway/plugins/audit.rs @@ -1,10 +1,11 @@ //! Usage: Best-effort persistence for gateway plugin hook audit events. use super::permissions::GatewayPluginError; -use super::pipeline::GatewayPluginAuditEvent; +use super::pipeline::{GatewayPluginAuditEvent, GatewayPluginHookExecutionReport}; use crate::infra::plugins::repository::{ self, AppendPluginAuditLogInput, RecordPluginRuntimeFailureInput, }; +use crate::infra::plugins::runtime_reports::{self, RecordPluginHookExecutionReportInput}; pub(crate) fn persist_gateway_plugin_error_audit_events( db: &crate::db::Db, @@ -12,16 +13,39 @@ pub(crate) fn persist_gateway_plugin_error_audit_events( err: &mut GatewayPluginError, ) { let events = err.take_audit_events(); - if events.is_empty() { + let reports = err.take_execution_reports(); + persist_gateway_plugin_diagnostics(db, trace_id, events, reports); +} + +pub(crate) fn persist_gateway_plugin_diagnostics( + db: &crate::db::Db, + trace_id: &str, + events: Vec, + reports: Vec, +) { + if events.is_empty() && reports.is_empty() { return; } - persist_gateway_plugin_audit_events(db, trace_id, events); + + // Callers sit on the request/stream hot path; run the rusqlite writes on the + // bounded blocking pool instead of the async worker. Best-effort: failures are + // logged inside the blocking body, join errors by blocking::run itself. + let db = db.clone(); + let trace_id = trace_id.to_string(); + tauri::async_runtime::spawn(async move { + let _ = crate::blocking::run("gateway_plugin_audit_persist", move || { + persist_gateway_plugin_diagnostics_blocking(&db, &trace_id, events, reports); + Ok::<_, crate::shared::error::AppError>(()) + }) + .await; + }); } -pub(crate) fn persist_gateway_plugin_audit_events( +fn persist_gateway_plugin_diagnostics_blocking( db: &crate::db::Db, trace_id: &str, events: Vec, + reports: Vec, ) { for event in events { if let Err(err) = repository::append_audit_log( @@ -69,4 +93,35 @@ pub(crate) fn persist_gateway_plugin_audit_events( } } } + + for report in reports { + if let Err(err) = runtime_reports::record_hook_execution_report( + db, + RecordPluginHookExecutionReportInput { + plugin_id: report.plugin_id.clone(), + trace_id: Some(trace_id.to_string()), + hook_name: report.hook_name.clone(), + runtime_kind: report.runtime_kind, + status: report.status, + started_at_ms: report.started_at_ms, + duration_ms: report.duration_ms, + failure_kind: report.failure_kind, + error_code: report.error_code, + failure_policy: report.failure_policy, + circuit_state: report.circuit_state, + context_budget_json: report.context_budget, + output_budget_json: report.output_budget, + mutation_summary_json: report.mutation_summary, + replayable: report.replayable, + replay_export_reason: report.replay_export_reason, + }, + ) { + tracing::warn!( + plugin_id = %report.plugin_id, + hook_name = %report.hook_name, + error = %err, + "failed to persist gateway plugin hook execution report" + ); + } + } } diff --git a/src-tauri/src/gateway/plugins/context.rs b/src-tauri/src/gateway/plugins/context.rs index 92700680..40f194ab 100644 --- a/src-tauri/src/gateway/plugins/context.rs +++ b/src-tauri/src/gateway/plugins/context.rs @@ -5,6 +5,32 @@ use axum::http::{HeaderMap, Method}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; +pub(crate) const DEFAULT_PLUGIN_CONTEXT_STREAM_BYTES: usize = 64 * 1024; +pub(crate) const DEFAULT_PLUGIN_CONTEXT_LOG_BYTES: usize = 64 * 1024; +pub(crate) const DEFAULT_PLUGIN_NORMALIZED_MESSAGE_LIMIT: usize = 64; +pub(crate) const DEFAULT_PLUGIN_NORMALIZED_MESSAGE_TEXT_BYTES: usize = 8 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct GatewayPluginContextBudget { + pub(crate) body_bytes: usize, + pub(crate) stream_bytes: usize, + pub(crate) log_bytes: usize, + pub(crate) normalized_messages: usize, + pub(crate) normalized_message_text_bytes: usize, +} + +impl Default for GatewayPluginContextBudget { + fn default() -> Self { + Self { + body_bytes: crate::gateway::util::max_request_body_bytes(), + stream_bytes: DEFAULT_PLUGIN_CONTEXT_STREAM_BYTES, + log_bytes: DEFAULT_PLUGIN_CONTEXT_LOG_BYTES, + normalized_messages: DEFAULT_PLUGIN_NORMALIZED_MESSAGE_LIMIT, + normalized_message_text_bytes: DEFAULT_PLUGIN_NORMALIZED_MESSAGE_TEXT_BYTES, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub(crate) enum GatewayPluginHookName { RequestReceived, @@ -33,6 +59,24 @@ impl GatewayPluginHookName { } } + #[allow(dead_code)] + pub(crate) fn from_str(raw: &str) -> Option { + match raw { + "gateway.request.received" => Some(Self::RequestReceived), + "gateway.request.afterBodyRead" => Some(Self::RequestAfterBodyRead), + "gateway.request.beforeProviderResolution" => { + Some(Self::RequestBeforeProviderResolution) + } + "gateway.request.beforeSend" => Some(Self::RequestBeforeSend), + "gateway.response.headers" => Some(Self::ResponseHeaders), + "gateway.response.chunk" => Some(Self::ResponseChunk), + "gateway.response.after" => Some(Self::ResponseAfter), + "gateway.error" => Some(Self::Error), + "log.beforePersist" => Some(Self::LogBeforePersist), + _ => None, + } + } + pub(crate) fn is_request_hook(self) -> bool { matches!( self, @@ -65,7 +109,16 @@ pub(crate) struct GatewayRequestHookInput { } impl GatewayRequestHookInput { + #[allow(dead_code)] pub(crate) fn visible_context(&self, permissions: &[String]) -> GatewayVisibleHookContext { + self.visible_context_with_budget(permissions, GatewayPluginContextBudget::default()) + } + + pub(crate) fn visible_context_with_budget( + &self, + permissions: &[String], + budget: GatewayPluginContextBudget, + ) -> GatewayVisibleHookContext { let mut ctx = GatewayVisibleHookContext::new(self.hook_name, self.trace_id.clone()); if has_permission(permissions, "request.meta.read") { @@ -84,9 +137,16 @@ impl GatewayRequestHookInput { )); } if has_permission(permissions, "request.body.read") { - let body = bytes_to_visible_string(&self.body); - ctx.request.normalized_messages = normalized_messages_from_body(&body); + let (body, body_truncated) = visible_string_with_limit(&self.body, budget.body_bytes); + let (messages, messages_truncated) = normalized_messages_from_body_with_budget( + &body, + budget.normalized_messages, + budget.normalized_message_text_bytes, + ); + ctx.request.normalized_messages = messages; + ctx.request.normalized_messages_truncated = messages_truncated || body_truncated; ctx.request.body = Some(body); + ctx.request.body_truncated = body_truncated; } ctx @@ -103,7 +163,16 @@ pub(crate) struct GatewayResponseHookInput { } impl GatewayResponseHookInput { + #[allow(dead_code)] pub(crate) fn visible_context(&self, permissions: &[String]) -> GatewayVisibleHookContext { + self.visible_context_with_budget(permissions, GatewayPluginContextBudget::default()) + } + + pub(crate) fn visible_context_with_budget( + &self, + permissions: &[String], + budget: GatewayPluginContextBudget, + ) -> GatewayVisibleHookContext { let mut ctx = GatewayVisibleHookContext::new(self.hook_name, self.trace_id.clone()); if has_permission(permissions, "response.header.read") || has_permission(permissions, "response.body.read") @@ -114,7 +183,9 @@ impl GatewayResponseHookInput { ctx.response.headers = Some(headers_to_json_map(&self.headers, true)); } if has_permission(permissions, "response.body.read") { - ctx.response.body = Some(bytes_to_visible_string(&self.body)); + let (body, body_truncated) = visible_string_with_limit(&self.body, budget.body_bytes); + ctx.response.body = Some(body); + ctx.response.body_truncated = body_truncated; } ctx } @@ -128,14 +199,26 @@ pub(crate) struct GatewayStreamHookInput { } impl GatewayStreamHookInput { + #[allow(dead_code)] pub(crate) fn visible_context(&self, permissions: &[String]) -> GatewayVisibleHookContext { + self.visible_context_with_budget(permissions, GatewayPluginContextBudget::default()) + } + + pub(crate) fn visible_context_with_budget( + &self, + permissions: &[String], + budget: GatewayPluginContextBudget, + ) -> GatewayVisibleHookContext { let mut ctx = GatewayVisibleHookContext::new( GatewayPluginHookName::ResponseChunk, self.trace_id.clone(), ); if has_permission(permissions, "stream.inspect") { ctx.stream.sequence = Some(self.sequence); - ctx.stream.chunk = Some(bytes_to_visible_string(&self.chunk)); + let (chunk, chunk_truncated) = + visible_string_with_limit(&self.chunk, budget.stream_bytes); + ctx.stream.chunk = Some(chunk); + ctx.stream.chunk_truncated = chunk_truncated; } ctx } @@ -148,13 +231,24 @@ pub(crate) struct GatewayLogHookInput { } impl GatewayLogHookInput { + #[allow(dead_code)] pub(crate) fn visible_context(&self, permissions: &[String]) -> GatewayVisibleHookContext { + self.visible_context_with_budget(permissions, GatewayPluginContextBudget::default()) + } + + pub(crate) fn visible_context_with_budget( + &self, + permissions: &[String], + budget: GatewayPluginContextBudget, + ) -> GatewayVisibleHookContext { let mut ctx = GatewayVisibleHookContext::new( GatewayPluginHookName::LogBeforePersist, self.trace_id.clone(), ); if has_permission(permissions, "log.redact") { - ctx.log.message = Some(self.message.clone()); + let (message, message_truncated) = text_with_limit(&self.message, budget.log_bytes); + ctx.log.message = Some(message); + ctx.log.message_truncated = message_truncated; } ctx } @@ -199,7 +293,9 @@ pub(crate) struct GatewayVisibleRequestContext { pub(crate) query: Option, pub(crate) headers: Option>, pub(crate) body: Option, + pub(crate) body_truncated: bool, pub(crate) normalized_messages: Vec, + pub(crate) normalized_messages_truncated: bool, pub(crate) requested_model: Option, } @@ -208,17 +304,20 @@ pub(crate) struct GatewayVisibleResponseContext { pub(crate) status: Option, pub(crate) headers: Option>, pub(crate) body: Option, + pub(crate) body_truncated: bool, } #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub(crate) struct GatewayVisibleStreamContext { pub(crate) sequence: Option, pub(crate) chunk: Option, + pub(crate) chunk_truncated: bool, } #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub(crate) struct GatewayVisibleLogContext { pub(crate) message: Option, + pub(crate) message_truncated: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -260,49 +359,117 @@ fn bytes_to_visible_string(bytes: &Bytes) -> String { String::from_utf8_lossy(bytes.as_ref()).into_owned() } -fn normalized_messages_from_body(body: &str) -> Vec { +fn visible_string_with_limit(bytes: &Bytes, limit: usize) -> (String, bool) { + if bytes.len() <= limit { + return (bytes_to_visible_string(bytes), false); + } + let capped = &bytes.as_ref()[..limit]; + let boundary = std::str::from_utf8(capped) + .map(|_| limit) + .unwrap_or_else(|err| err.valid_up_to()); + ( + String::from_utf8_lossy(&bytes.as_ref()[..boundary]).into_owned(), + true, + ) +} + +fn text_with_limit(text: &str, limit: usize) -> (String, bool) { + if text.len() <= limit { + return (text.to_string(), false); + } + let boundary = (0..=limit) + .rev() + .find(|index| text.is_char_boundary(*index)) + .unwrap_or(0); + (text[..boundary].to_string(), true) +} + +fn normalized_messages_from_body_with_budget( + body: &str, + message_limit: usize, + text_limit: usize, +) -> (Vec, bool) { let Ok(root) = serde_json::from_str::(body) else { - return Vec::new(); + return (Vec::new(), false); }; let mut out = Vec::new(); + let mut truncated = false; if let Some(messages) = root.get("messages").and_then(serde_json::Value::as_array) { for message in messages { - collect_message_content(message, "messages.content", &mut out); + collect_message_content( + message, + "messages.content", + &mut out, + message_limit, + text_limit, + &mut truncated, + ); } } if let Some(input) = root.get("input") { match input { - serde_json::Value::String(text) => { - push_normalized_message(&mut out, "user", text, "openai.responses.input") - } + serde_json::Value::String(text) => push_normalized_message( + &mut out, + "user", + text, + "openai.responses.input", + message_limit, + text_limit, + &mut truncated, + ), serde_json::Value::Array(items) => { for item in items { - collect_responses_input_item(item, &mut out); + collect_responses_input_item( + item, + &mut out, + message_limit, + text_limit, + &mut truncated, + ); } } _ => {} } } - out + (out, truncated) } fn collect_message_content( message: &serde_json::Value, source_prefix: &'static str, out: &mut Vec, + message_limit: usize, + text_limit: usize, + truncated: &mut bool, ) { let role = message_role(message, "user"); match message.get("content") { Some(serde_json::Value::String(text)) => { - push_normalized_message(out, &role, text, source_prefix); + push_normalized_message( + out, + &role, + text, + source_prefix, + message_limit, + text_limit, + truncated, + ); } Some(serde_json::Value::Array(parts)) => { for part in parts { if let Some(text) = part.get("text").and_then(serde_json::Value::as_str) { - push_normalized_message(out, &role, text, "messages.content.text"); + push_normalized_message( + out, + &role, + text, + "messages.content.text", + message_limit, + text_limit, + truncated, + ); } } } @@ -310,11 +477,25 @@ fn collect_message_content( } } -fn collect_responses_input_item(item: &serde_json::Value, out: &mut Vec) { +fn collect_responses_input_item( + item: &serde_json::Value, + out: &mut Vec, + message_limit: usize, + text_limit: usize, + truncated: &mut bool, +) { let role = message_role(item, "user"); match item.get("content") { Some(serde_json::Value::String(text)) => { - push_normalized_message(out, &role, text, "openai.responses.content"); + push_normalized_message( + out, + &role, + text, + "openai.responses.content", + message_limit, + text_limit, + truncated, + ); } Some(serde_json::Value::Array(parts)) => { for part in parts { @@ -330,7 +511,15 @@ fn collect_responses_input_item(item: &serde_json::Value, out: &mut Vec {} @@ -351,13 +540,22 @@ fn push_normalized_message( role: &str, text: &str, source: &'static str, + message_limit: usize, + text_limit: usize, + truncated: &mut bool, ) { if text.is_empty() { return; } + if out.len() >= message_limit { + *truncated = true; + return; + } + let (text, text_truncated) = text_with_limit(text, text_limit); + *truncated |= text_truncated; out.push(GatewayNormalizedMessage { role: role.to_string(), - text: text.to_string(), + text, source: source.to_string(), }); } @@ -399,6 +597,26 @@ mod tests { use axum::body::Bytes; use axum::http::{HeaderMap, HeaderValue, Method}; + #[test] + fn hook_name_from_str_maps_all_known_hook_names() { + let hooks = [ + GatewayPluginHookName::RequestReceived, + GatewayPluginHookName::RequestAfterBodyRead, + GatewayPluginHookName::RequestBeforeProviderResolution, + GatewayPluginHookName::RequestBeforeSend, + GatewayPluginHookName::ResponseHeaders, + GatewayPluginHookName::ResponseChunk, + GatewayPluginHookName::ResponseAfter, + GatewayPluginHookName::Error, + GatewayPluginHookName::LogBeforePersist, + ]; + + for hook in hooks { + assert_eq!(GatewayPluginHookName::from_str(hook.as_str()), Some(hook)); + } + assert_eq!(GatewayPluginHookName::from_str("gateway.unknown"), None); + } + fn headers() -> HeaderMap { let mut headers = HeaderMap::new(); headers.insert("authorization", HeaderValue::from_static("Bearer secret")); @@ -454,6 +672,172 @@ mod tests { ); } + #[test] + fn gateway_plugin_context_truncates_request_body_and_normalized_messages_by_budget() { + let body = format!( + "{{\"messages\":[{}]}}", + (0..5) + .map(|index| format!( + "{{\"role\":\"user\",\"content\":\"message-{index}-{}\"}}", + "x".repeat(64) + )) + .collect::>() + .join(",") + ); + let input = GatewayRequestHookInput { + hook_name: GatewayPluginHookName::RequestAfterBodyRead, + trace_id: "trace-budget".to_string(), + cli_key: "codex".to_string(), + method: Method::POST, + path: "/v1/messages".to_string(), + query: None, + headers: HeaderMap::new(), + body: Bytes::from(body), + requested_model: None, + }; + let budget = GatewayPluginContextBudget { + body_bytes: 48, + normalized_messages: 2, + normalized_message_text_bytes: 16, + ..GatewayPluginContextBudget::default() + }; + + let visible = input.visible_context_with_budget(&["request.body.read".to_string()], budget); + + assert!(visible.request.body.as_deref().unwrap().len() <= 48 + 64); + assert!(visible.request.body_truncated); + assert!(visible.request.normalized_messages.len() <= 2); + assert!(visible + .request + .normalized_messages + .iter() + .all(|message| message.text.len() <= 16 + 32)); + } + + #[test] + fn gateway_plugin_context_truncates_stream_and_log_by_budget() { + let stream = GatewayStreamHookInput { + trace_id: "trace-stream-budget".to_string(), + chunk: Bytes::from("s".repeat(128)), + sequence: 1, + }; + let log = GatewayLogHookInput { + trace_id: "trace-log-budget".to_string(), + message: "l".repeat(128), + }; + let budget = GatewayPluginContextBudget { + stream_bytes: 16, + log_bytes: 24, + ..GatewayPluginContextBudget::default() + }; + + let visible_stream = + stream.visible_context_with_budget(&["stream.inspect".to_string()], budget); + let visible_log = log.visible_context_with_budget(&["log.redact".to_string()], budget); + + assert!(visible_stream.stream.chunk.as_deref().unwrap().len() <= 80); + assert!(visible_stream.stream.chunk_truncated); + assert!(visible_log.log.message.as_deref().unwrap().len() <= 88); + assert!(visible_log.log.message_truncated); + } + + #[test] + fn gateway_plugin_context_truncates_response_body_by_budget() { + let response = GatewayResponseHookInput { + hook_name: GatewayPluginHookName::ResponseAfter, + trace_id: "trace-response-budget".to_string(), + status: 200, + headers: HeaderMap::new(), + body: Bytes::from_static(b"abcdefghij"), + }; + let budget = GatewayPluginContextBudget { + body_bytes: 4, + ..GatewayPluginContextBudget::default() + }; + + let visible = + response.visible_context_with_budget(&["response.body.read".to_string()], budget); + + assert_eq!(visible.response.status, Some(200)); + assert_eq!(visible.response.body.as_deref(), Some("abcd")); + assert!(visible.response.body_truncated); + } + + #[test] + fn gateway_plugin_context_truncates_multibyte_text_without_replacement_characters() { + let body = "你好🙂abc"; + let normalized_body = "{\"messages\":[{\"role\":\"user\",\"content\":\"你好🙂abc\"}]}"; + let input = GatewayRequestHookInput { + hook_name: GatewayPluginHookName::RequestAfterBodyRead, + trace_id: "trace-multibyte-budget".to_string(), + cli_key: "codex".to_string(), + method: Method::POST, + path: "/v1/messages".to_string(), + query: None, + headers: HeaderMap::new(), + body: Bytes::from(body), + requested_model: None, + }; + let normalized_input = GatewayRequestHookInput { + body: Bytes::from(normalized_body), + ..input.clone() + }; + let stream = GatewayStreamHookInput { + trace_id: "trace-stream-multibyte-budget".to_string(), + chunk: Bytes::from("你好🙂abc"), + sequence: 1, + }; + let log = GatewayLogHookInput { + trace_id: "trace-log-multibyte-budget".to_string(), + message: "你好🙂abc".to_string(), + }; + let budget = GatewayPluginContextBudget { + body_bytes: 11, + stream_bytes: 5, + log_bytes: 5, + normalized_messages: 1, + normalized_message_text_bytes: 8, + }; + + let visible_request = + input.visible_context_with_budget(&["request.body.read".to_string()], budget); + let visible_normalized_request = normalized_input.visible_context_with_budget( + &["request.body.read".to_string()], + GatewayPluginContextBudget { + body_bytes: normalized_body.len(), + ..budget + }, + ); + let visible_stream = + stream.visible_context_with_budget(&["stream.inspect".to_string()], budget); + let visible_log = log.visible_context_with_budget(&["log.redact".to_string()], budget); + + let request_body = visible_request.request.body.as_deref().unwrap(); + let stream_chunk = visible_stream.stream.chunk.as_deref().unwrap(); + let log_message = visible_log.log.message.as_deref().unwrap(); + assert!(visible_request.request.body_truncated); + assert!(visible_stream.stream.chunk_truncated); + assert!(visible_log.log.message_truncated); + assert!(!request_body.contains('\u{FFFD}')); + assert!(!stream_chunk.contains('\u{FFFD}')); + assert!(!log_message.contains('\u{FFFD}')); + assert!(request_body.len() <= 11); + assert!(stream_chunk.len() <= 5); + assert!(log_message.len() <= 5); + let normalized_message = visible_normalized_request + .request + .normalized_messages + .first() + .unwrap(); + assert!( + visible_normalized_request + .request + .normalized_messages_truncated + ); + assert!(!normalized_message.text.contains('\u{FFFD}')); + assert!(normalized_message.text.len() <= 8); + } + #[test] fn visible_request_context_extracts_codex_input_text_messages() { let input = GatewayRequestHookInput { diff --git a/src-tauri/src/gateway/plugins/contract.rs b/src-tauri/src/gateway/plugins/contract.rs new file mode 100644 index 00000000..37c0712c --- /dev/null +++ b/src-tauri/src/gateway/plugins/contract.rs @@ -0,0 +1,525 @@ +//! Usage: Rust metadata for the Plugin API v1 gateway contract. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HookKind { + Request, + Response, + Stream, + Log, +} + +impl HookKind { + #[cfg(test)] + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Request => "request", + Self::Response => "response", + Self::Stream => "stream", + Self::Log => "log", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HookStatus { + Active, + Reserved, +} + +impl HookStatus { + #[cfg(test)] + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Reserved => "reserved", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct HookContract { + pub(crate) id: &'static str, + pub(crate) phase: &'static str, + pub(crate) kind: HookKind, + pub(crate) status: HookStatus, + pub(crate) default_failure_policy: &'static str, + pub(crate) timeout_ms: u64, + pub(crate) reserved_header_policy: &'static str, + pub(crate) read_permissions: &'static [&'static str], + pub(crate) write_permissions: &'static [&'static str], + pub(crate) permission_dependencies: &'static [PermissionDependency], + pub(crate) mutation_fields: &'static [&'static str], + pub(crate) context_fields: &'static [&'static str], +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PermissionDependency { + pub(crate) permission: &'static str, + pub(crate) requires: &'static [&'static str], +} + +pub(crate) const DEFAULT_HOOK_TIMEOUT_MS: u64 = 5_000; +pub(crate) const DEFAULT_FAILURE_POLICY: &str = "fail-open"; +const RESERVED_HEADER_POLICY: &str = "block-gateway-owned"; + +pub(crate) const ACTIVE_HOOKS: &[HookContract] = &[ + HookContract { + id: "gateway.request.afterBodyRead", + phase: "after request body read and before upstream provider send", + kind: HookKind::Request, + status: HookStatus::Active, + default_failure_policy: DEFAULT_FAILURE_POLICY, + timeout_ms: DEFAULT_HOOK_TIMEOUT_MS, + reserved_header_policy: RESERVED_HEADER_POLICY, + read_permissions: &[ + "request.meta.read", + "request.header.read", + "request.header.readSensitive", + "request.body.read", + ], + write_permissions: &["request.header.write", "request.body.write"], + permission_dependencies: &[PermissionDependency { + permission: "request.body.write", + requires: &["request.body.read"], + }], + mutation_fields: &["headers", "requestBody"], + context_fields: &[ + "traceId", + "request.cliKey", + "request.method", + "request.path", + "request.query", + "request.headers", + "request.body", + "request.requestedModel", + "request.normalizedMessages", + ], + }, + HookContract { + id: "gateway.request.beforeSend", + phase: "after provider resolution and before upstream provider send", + kind: HookKind::Request, + status: HookStatus::Active, + default_failure_policy: DEFAULT_FAILURE_POLICY, + timeout_ms: DEFAULT_HOOK_TIMEOUT_MS, + reserved_header_policy: RESERVED_HEADER_POLICY, + read_permissions: &[ + "request.meta.read", + "request.header.read", + "request.header.readSensitive", + "request.body.read", + ], + write_permissions: &["request.header.write", "request.body.write"], + permission_dependencies: &[], + mutation_fields: &["headers", "requestBody"], + context_fields: &[ + "traceId", + "request.cliKey", + "request.method", + "request.path", + "request.query", + "request.headers", + "request.body", + "request.requestedModel", + "request.normalizedMessages", + ], + }, + HookContract { + id: "gateway.response.chunk", + phase: "for each bounded streaming response chunk", + kind: HookKind::Stream, + status: HookStatus::Active, + default_failure_policy: DEFAULT_FAILURE_POLICY, + timeout_ms: DEFAULT_HOOK_TIMEOUT_MS, + reserved_header_policy: RESERVED_HEADER_POLICY, + read_permissions: &["stream.inspect"], + write_permissions: &["stream.modify"], + permission_dependencies: &[PermissionDependency { + permission: "stream.modify", + requires: &["stream.inspect"], + }], + mutation_fields: &["streamChunk"], + context_fields: &["traceId", "stream.sequence", "stream.chunk"], + }, + HookContract { + id: "gateway.response.after", + phase: "after a complete non-streaming upstream response body is available", + kind: HookKind::Response, + status: HookStatus::Active, + default_failure_policy: DEFAULT_FAILURE_POLICY, + timeout_ms: DEFAULT_HOOK_TIMEOUT_MS, + reserved_header_policy: RESERVED_HEADER_POLICY, + read_permissions: &["response.header.read", "response.body.read"], + write_permissions: &["response.header.write", "response.body.write"], + permission_dependencies: &[PermissionDependency { + permission: "response.body.write", + requires: &["response.body.read"], + }], + mutation_fields: &["headers", "responseBody"], + context_fields: &[ + "traceId", + "response.status", + "response.headers", + "response.body", + ], + }, + HookContract { + id: "gateway.error", + phase: "after gateway error response materialization and before it is sent", + kind: HookKind::Response, + status: HookStatus::Active, + default_failure_policy: DEFAULT_FAILURE_POLICY, + timeout_ms: DEFAULT_HOOK_TIMEOUT_MS, + reserved_header_policy: RESERVED_HEADER_POLICY, + read_permissions: &["response.header.read", "response.body.read"], + write_permissions: &["response.header.write", "response.body.write"], + permission_dependencies: &[], + mutation_fields: &["headers", "responseBody"], + context_fields: &[ + "traceId", + "response.status", + "response.headers", + "response.body", + ], + }, + HookContract { + id: "log.beforePersist", + phase: "before gateway request log persistence", + kind: HookKind::Log, + status: HookStatus::Active, + default_failure_policy: DEFAULT_FAILURE_POLICY, + timeout_ms: DEFAULT_HOOK_TIMEOUT_MS, + reserved_header_policy: RESERVED_HEADER_POLICY, + read_permissions: &["log.redact"], + write_permissions: &["log.redact"], + permission_dependencies: &[], + mutation_fields: &["logMessage"], + context_fields: &["traceId", "log.message"], + }, +]; + +pub(crate) const RESERVED_HOOKS: &[HookContract] = &[ + HookContract { + id: "gateway.request.received", + phase: "reserved for a future host integration", + kind: HookKind::Request, + status: HookStatus::Reserved, + default_failure_policy: DEFAULT_FAILURE_POLICY, + timeout_ms: DEFAULT_HOOK_TIMEOUT_MS, + reserved_header_policy: RESERVED_HEADER_POLICY, + read_permissions: &[], + write_permissions: &[], + permission_dependencies: &[], + mutation_fields: &[], + context_fields: &[], + }, + HookContract { + id: "gateway.request.beforeProviderResolution", + phase: "reserved for a future host integration", + kind: HookKind::Request, + status: HookStatus::Reserved, + default_failure_policy: DEFAULT_FAILURE_POLICY, + timeout_ms: DEFAULT_HOOK_TIMEOUT_MS, + reserved_header_policy: RESERVED_HEADER_POLICY, + read_permissions: &[], + write_permissions: &[], + permission_dependencies: &[], + mutation_fields: &[], + context_fields: &[], + }, + HookContract { + id: "gateway.response.headers", + phase: "reserved for a future host integration", + kind: HookKind::Response, + status: HookStatus::Reserved, + default_failure_policy: DEFAULT_FAILURE_POLICY, + timeout_ms: DEFAULT_HOOK_TIMEOUT_MS, + reserved_header_policy: RESERVED_HEADER_POLICY, + read_permissions: &[], + write_permissions: &[], + permission_dependencies: &[], + mutation_fields: &[], + context_fields: &[], + }, +]; + +#[cfg(test)] +pub(crate) const RESERVED_PERMISSIONS: &[&str] = &[ + "plugin.storage", + "network.fetch", + "file.read", + "file.write", + "secret.read", +]; + +pub(crate) fn hook_contract(hook: &str) -> Option<&'static HookContract> { + ACTIVE_HOOKS + .iter() + .chain(RESERVED_HOOKS.iter()) + .find(|contract| contract.id == hook) +} + +pub(crate) fn is_active_hook(hook: &str) -> bool { + ACTIVE_HOOKS.iter().any(|contract| contract.id == hook) +} + +pub(crate) fn is_reserved_hook(hook: &str) -> bool { + RESERVED_HOOKS.iter().any(|contract| contract.id == hook) +} + +#[allow(dead_code)] +pub(crate) fn is_known_hook(hook: &str) -> bool { + hook_contract(hook).is_some() +} + +#[cfg(test)] +pub(crate) fn is_reserved_permission(permission: &str) -> bool { + RESERVED_PERMISSIONS.contains(&permission) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plugin_api_v1_contract() -> serde_json::Value { + serde_json::from_str(include_str!( + "../../../../docs/plugins/plugin-api-v1-contract.json" + )) + .expect("plugin API v1 contract JSON should parse") + } + + fn contract_string_array(contract: &serde_json::Value, key: &str) -> Vec { + contract[key] + .as_array() + .unwrap_or_else(|| panic!("{key} should be an array")) + .iter() + .map(|value| { + value + .as_str() + .unwrap_or_else(|| panic!("{key} entries should be strings")) + .to_string() + }) + .collect() + } + + fn hook_entry_string_array(entry: &serde_json::Value, key: &str) -> Vec { + entry[key] + .as_array() + .unwrap_or_else(|| panic!("hookMatrix.{key} should be an array")) + .iter() + .map(|value| { + value + .as_str() + .unwrap_or_else(|| panic!("hookMatrix.{key} entries should be strings")) + .to_string() + }) + .collect() + } + + fn hook_names(contracts: &[HookContract]) -> Vec { + contracts + .iter() + .map(|contract| contract.id.to_string()) + .collect() + } + + fn string_slice(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() + } + + fn dependency_pairs(dependencies: &[PermissionDependency]) -> Vec<(String, Vec)> { + dependencies + .iter() + .map(|dependency| { + ( + dependency.permission.to_string(), + string_slice(dependency.requires), + ) + }) + .collect() + } + + fn assert_no_duplicate_values(hook_id: &str, field_name: &str, values: &[&str]) { + let mut seen = std::collections::HashSet::new(); + for value in values { + assert!( + seen.insert(*value), + "{hook_id} {field_name} contains duplicate value {value}" + ); + } + } + + fn contract_dependency_pairs(entry: &serde_json::Value) -> Vec<(String, Vec)> { + let dependencies = entry["permissionDependencies"] + .as_object() + .expect("hookMatrix permissionDependencies should be an object"); + dependencies + .iter() + .map(|(permission, requires)| { + ( + permission.clone(), + requires + .as_array() + .expect("permission dependency entries should be arrays") + .iter() + .map(|value| { + value + .as_str() + .expect("permission dependency entries should be strings") + .to_string() + }) + .collect::>(), + ) + }) + .collect() + } + + #[test] + fn hook_contract_arrays_do_not_contain_duplicates() { + for hook in ACTIVE_HOOKS.iter().chain(RESERVED_HOOKS.iter()) { + assert_no_duplicate_values(hook.id, "read_permissions", hook.read_permissions); + assert_no_duplicate_values(hook.id, "write_permissions", hook.write_permissions); + assert_no_duplicate_values(hook.id, "mutation_fields", hook.mutation_fields); + assert_no_duplicate_values(hook.id, "context_fields", hook.context_fields); + } + } + + #[test] + fn active_hook_metadata_matches_plugin_api_v1() { + let contract = plugin_api_v1_contract(); + assert_eq!( + DEFAULT_HOOK_TIMEOUT_MS, + contract["defaultHookTimeoutMs"] + .as_u64() + .expect("defaultHookTimeoutMs should be a number") + ); + assert_eq!( + DEFAULT_FAILURE_POLICY, + contract["defaultFailurePolicy"] + .as_str() + .expect("defaultFailurePolicy should be a string") + ); + assert_eq!( + hook_names(ACTIVE_HOOKS), + contract_string_array(&contract, "activeHooks") + ); + + let matrix = contract["hookMatrix"] + .as_object() + .expect("hookMatrix should be an object"); + for hook in ACTIVE_HOOKS { + let entry = matrix + .get(hook.id) + .unwrap_or_else(|| panic!("hookMatrix entry missing for {}", hook.id)); + assert_eq!(hook_contract(hook.id), Some(hook)); + assert!(is_active_hook(hook.id)); + assert!(is_known_hook(hook.id)); + assert!(!is_reserved_hook(hook.id)); + assert_eq!( + hook.phase, + entry["phase"] + .as_str() + .expect("hookMatrix phase should be a string") + ); + assert_eq!( + hook.kind.as_str(), + entry["kind"] + .as_str() + .expect("hookMatrix kind should be a string") + ); + assert_eq!( + hook.status.as_str(), + entry["status"] + .as_str() + .expect("hookMatrix status should be a string") + ); + assert_eq!( + hook.default_failure_policy, + entry["defaultFailurePolicy"] + .as_str() + .expect("hookMatrix defaultFailurePolicy should be a string") + ); + assert_eq!( + hook.timeout_ms, + entry["timeoutMs"] + .as_u64() + .expect("hookMatrix timeoutMs should be a number") + ); + assert_eq!( + hook.reserved_header_policy, + entry["reservedHeaderPolicy"] + .as_str() + .expect("hookMatrix reservedHeaderPolicy should be a string") + ); + assert_eq!( + string_slice(hook.read_permissions), + hook_entry_string_array(entry, "readPermissions") + ); + assert_eq!( + string_slice(hook.write_permissions), + hook_entry_string_array(entry, "writePermissions") + ); + assert_eq!( + string_slice(hook.mutation_fields), + hook_entry_string_array(entry, "mutationFields") + ); + assert_eq!( + string_slice(hook.context_fields), + hook_entry_string_array(entry, "contextFields") + ); + } + } + + #[test] + fn reserved_hook_metadata_matches_plugin_api_v1() { + let contract = plugin_api_v1_contract(); + assert_eq!( + hook_names(RESERVED_HOOKS), + contract_string_array(&contract, "reservedHooks") + ); + + for hook in RESERVED_HOOKS { + assert_eq!(hook_contract(hook.id), Some(hook)); + assert_eq!(hook.status, HookStatus::Reserved); + assert!(!is_active_hook(hook.id)); + assert!(is_reserved_hook(hook.id)); + assert!(is_known_hook(hook.id)); + } + assert!(!is_known_hook("gateway.request.missing")); + } + + #[test] + fn permission_metadata_marks_reserved_permissions() { + let contract = plugin_api_v1_contract(); + assert_eq!( + string_slice(RESERVED_PERMISSIONS), + contract_string_array(&contract, "reservedPermissions") + ); + + for permission in RESERVED_PERMISSIONS { + assert!(is_reserved_permission(permission)); + } + for permission in contract_string_array(&contract, "activePermissions") { + assert!(!is_reserved_permission(&permission)); + } + assert!(!is_reserved_permission("unknown.permission")); + } + + #[test] + fn permission_dependency_metadata_matches_plugin_api_v1() { + let contract = plugin_api_v1_contract(); + let matrix = contract["hookMatrix"] + .as_object() + .expect("hookMatrix should be an object"); + + for hook in ACTIVE_HOOKS { + let entry = matrix + .get(hook.id) + .unwrap_or_else(|| panic!("hookMatrix entry missing for {}", hook.id)); + assert_eq!( + dependency_pairs(hook.permission_dependencies), + contract_dependency_pairs(entry) + ); + } + } +} diff --git a/src-tauri/src/gateway/plugins/mod.rs b/src-tauri/src/gateway/plugins/mod.rs index b6532e29..c02f3434 100644 --- a/src-tauri/src/gateway/plugins/mod.rs +++ b/src-tauri/src/gateway/plugins/mod.rs @@ -2,5 +2,8 @@ pub(crate) mod audit; pub(crate) mod context; +pub(crate) mod contract; +pub(crate) mod mutation; pub(crate) mod permissions; pub(crate) mod pipeline; +pub(crate) mod registry; diff --git a/src-tauri/src/gateway/plugins/mutation.rs b/src-tauri/src/gateway/plugins/mutation.rs new file mode 100644 index 00000000..e63fd5dd --- /dev/null +++ b/src-tauri/src/gateway/plugins/mutation.rs @@ -0,0 +1,274 @@ +//! Usage: Descriptor-driven gateway plugin mutation enforcement. + +use super::context::{GatewayHookResult, GatewayPluginHookName}; +use super::permissions::GatewayPluginError; +use super::registry::HookDescriptor; + +pub(crate) const DEFAULT_PLUGIN_MUTATION_STREAM_BYTES: usize = 64 * 1024; +pub(crate) const DEFAULT_PLUGIN_MUTATION_LOG_BYTES: usize = 64 * 1024; +pub(crate) const DEFAULT_PLUGIN_MUTATION_HEADER_COUNT: usize = 64; +pub(crate) const DEFAULT_PLUGIN_MUTATION_HEADER_VALUE_BYTES: usize = 8 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct GatewayPluginMutationBudget { + pub(crate) body_bytes: usize, + pub(crate) stream_bytes: usize, + pub(crate) log_bytes: usize, + pub(crate) header_count: usize, + pub(crate) header_value_bytes: usize, +} + +impl Default for GatewayPluginMutationBudget { + fn default() -> Self { + Self { + body_bytes: crate::gateway::util::max_request_body_bytes(), + stream_bytes: DEFAULT_PLUGIN_MUTATION_STREAM_BYTES, + log_bytes: DEFAULT_PLUGIN_MUTATION_LOG_BYTES, + header_count: DEFAULT_PLUGIN_MUTATION_HEADER_COUNT, + header_value_bytes: DEFAULT_PLUGIN_MUTATION_HEADER_VALUE_BYTES, + } + } +} + +pub(crate) fn enforce_descriptor_permissions( + descriptor: HookDescriptor, + permissions: &[String], + result: &GatewayHookResult, +) -> Result<(), GatewayPluginError> { + enforce_descriptor_permissions_with_budget( + descriptor, + permissions, + result, + GatewayPluginMutationBudget::default(), + ) +} + +pub(crate) fn enforce_descriptor_permissions_with_budget( + descriptor: HookDescriptor, + permissions: &[String], + result: &GatewayHookResult, + budget: GatewayPluginMutationBudget, +) -> Result<(), GatewayPluginError> { + if result.request_body.is_some() { + require_mutation_field(descriptor, "requestBody", "request body mutation")?; + require_permission(permissions, "request.body.write")?; + } + if result.response_body.is_some() { + require_mutation_field(descriptor, "responseBody", "response body mutation")?; + require_permission(permissions, "response.body.write")?; + } + if result.stream_chunk.is_some() { + require_mutation_field(descriptor, "streamChunk", "stream chunk mutation")?; + require_permission(permissions, "stream.modify")?; + } + if result.log_message.is_some() { + require_mutation_field(descriptor, "logMessage", "log mutation")?; + require_permission(permissions, "log.redact")?; + } + if !result.headers.is_empty() { + require_header_mutation(descriptor, permissions)?; + } + enforce_output_budget(result, budget)?; + Ok(()) +} + +fn enforce_output_budget( + result: &GatewayHookResult, + budget: GatewayPluginMutationBudget, +) -> Result<(), GatewayPluginError> { + if result + .request_body + .as_ref() + .is_some_and(|body| body.len() > budget.body_bytes) + || result + .response_body + .as_ref() + .is_some_and(|body| body.len() > budget.body_bytes) + { + return Err(output_too_large( + "body mutation exceeds plugin output budget", + )); + } + if result + .stream_chunk + .as_ref() + .is_some_and(|chunk| chunk.len() > budget.stream_bytes) + { + return Err(output_too_large( + "stream mutation exceeds plugin output budget", + )); + } + if result + .log_message + .as_ref() + .is_some_and(|message| message.len() > budget.log_bytes) + { + return Err(output_too_large( + "log mutation exceeds plugin output budget", + )); + } + if result.headers.len() > budget.header_count + || result + .headers + .values() + .any(|value| value.len() > budget.header_value_bytes) + { + return Err(output_too_large( + "header mutation exceeds plugin output budget", + )); + } + Ok(()) +} + +fn output_too_large(message: &'static str) -> GatewayPluginError { + GatewayPluginError::new("PLUGIN_OUTPUT_TOO_LARGE", message) +} + +fn require_mutation_field( + descriptor: HookDescriptor, + field: &'static str, + operation: &'static str, +) -> Result<(), GatewayPluginError> { + if descriptor.allows_mutation_field(field) { + Ok(()) + } else { + Err(GatewayPluginError::new( + "PLUGIN_PERMISSION_DENIED", + format!( + "{operation} is not allowed in {}", + descriptor.hook_name.as_str() + ), + )) + } +} + +fn require_header_mutation( + descriptor: HookDescriptor, + permissions: &[String], +) -> Result<(), GatewayPluginError> { + if !descriptor.allows_mutation_field("headers") { + if descriptor.hook_name == GatewayPluginHookName::ResponseChunk { + // Pre-descriptor enforcement accepted response header writes on stream chunks + // even though stream outputs do not apply header patches. + return require_permission(permissions, "response.header.write"); + } + return Err(GatewayPluginError::new( + "PLUGIN_PERMISSION_DENIED", + format!( + "headers cannot be mutated in {}", + descriptor.hook_name.as_str() + ), + )); + } + debug_assert!( + descriptor.hook_name.is_request_hook() || descriptor.hook_name.is_response_hook() + ); + + let Some(permission) = header_write_permission(descriptor) else { + return Err(GatewayPluginError::new( + "PLUGIN_PERMISSION_DENIED", + format!( + "headers cannot be mutated in {}", + descriptor.hook_name.as_str() + ), + )); + }; + + require_permission(permissions, permission) +} + +fn header_write_permission(descriptor: HookDescriptor) -> Option<&'static str> { + if descriptor.allows_write_permission("request.header.write") { + Some("request.header.write") + } else if descriptor.allows_write_permission("response.header.write") { + Some("response.header.write") + } else { + None + } +} + +fn require_permission( + permissions: &[String], + permission: &'static str, +) -> Result<(), GatewayPluginError> { + if permissions.iter().any(|item| item == permission) { + Ok(()) + } else { + Err(GatewayPluginError::new( + "PLUGIN_PERMISSION_DENIED", + format!("missing plugin permission: {permission}"), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::gateway::plugins::context::GatewayPluginHookName; + use crate::gateway::plugins::registry::HookRegistry; + + #[test] + fn request_body_mutation_requires_descriptor_permission() { + let descriptor = HookRegistry::new() + .descriptor(GatewayPluginHookName::RequestBeforeSend) + .expect("request before send descriptor should resolve"); + let result = GatewayHookResult { + request_body: Some("changed".to_string()), + ..GatewayHookResult::continue_unchanged() + }; + + let err = enforce_descriptor_permissions(descriptor, &[], &result) + .expect_err("request body write should require permission"); + assert_eq!(err.code_for_logging(), "PLUGIN_PERMISSION_DENIED"); + + enforce_descriptor_permissions(descriptor, &["request.body.write".to_string()], &result) + .expect("request body write permission should allow mutation"); + } + + #[test] + fn stream_chunk_header_mutation_preserves_legacy_permission_behavior() { + let descriptor = HookRegistry::new() + .descriptor(GatewayPluginHookName::ResponseChunk) + .expect("response chunk descriptor should resolve"); + let mut result = GatewayHookResult::continue_unchanged(); + result + .headers + .insert("x-plugin".to_string(), "1".to_string()); + + let err = enforce_descriptor_permissions(descriptor, &[], &result) + .expect_err("legacy response header write should still require permission"); + assert_eq!(err.code_for_logging(), "PLUGIN_PERMISSION_DENIED"); + assert!(err.to_string().contains("response.header.write")); + + enforce_descriptor_permissions(descriptor, &["response.header.write".to_string()], &result) + .expect("legacy response header write permission should allow stream hook result"); + } + + #[test] + fn mutation_budget_rejects_oversized_hook_outputs() { + let descriptor = HookRegistry::new() + .descriptor(GatewayPluginHookName::RequestBeforeSend) + .expect("request before send descriptor should resolve"); + let result = GatewayHookResult { + request_body: Some("x".repeat(128)), + ..GatewayHookResult::continue_unchanged() + }; + let budget = GatewayPluginMutationBudget { + body_bytes: 16, + stream_bytes: 16, + log_bytes: 16, + header_count: 8, + header_value_bytes: 64, + }; + + let err = enforce_descriptor_permissions_with_budget( + descriptor, + &["request.body.write".to_string()], + &result, + budget, + ) + .expect_err("oversized body mutation should be rejected"); + + assert_eq!(err.code_for_logging(), "PLUGIN_OUTPUT_TOO_LARGE"); + } +} diff --git a/src-tauri/src/gateway/plugins/permissions.rs b/src-tauri/src/gateway/plugins/permissions.rs index 318b800a..d0fe1e0b 100644 --- a/src-tauri/src/gateway/plugins/permissions.rs +++ b/src-tauri/src/gateway/plugins/permissions.rs @@ -1,7 +1,9 @@ //! Usage: Gateway plugin permission trimming and result enforcement. use super::context::{GatewayHookResult, GatewayPluginHookName}; -use super::pipeline::GatewayPluginAuditEvent; +use super::mutation; +use super::pipeline::{GatewayPluginAuditEvent, GatewayPluginHookExecutionReport}; +use super::registry::HookRegistry; use std::fmt; #[derive(Debug, Clone, PartialEq)] @@ -9,6 +11,7 @@ pub(crate) struct GatewayPluginError { code: &'static str, message: String, audit_events: Vec, + execution_reports: Vec, } impl GatewayPluginError { @@ -17,11 +20,16 @@ impl GatewayPluginError { code, message: message.into(), audit_events: Vec::new(), + execution_reports: Vec::new(), } } #[cfg(test)] pub(crate) fn code(&self) -> &'static str { + self.code_for_logging() + } + + pub(crate) fn code_for_logging(&self) -> &'static str { self.code } @@ -34,19 +42,37 @@ impl GatewayPluginError { self } + pub(crate) fn with_execution_reports( + mut self, + mut execution_reports: Vec, + ) -> Self { + execution_reports.extend(self.execution_reports); + self.execution_reports = execution_reports; + self + } + #[cfg(test)] pub(crate) fn audit_events(&self) -> &[GatewayPluginAuditEvent] { &self.audit_events } + #[cfg(test)] + pub(crate) fn execution_reports(&self) -> &[GatewayPluginHookExecutionReport] { + &self.execution_reports + } + pub(crate) fn take_audit_events(&mut self) -> Vec { std::mem::take(&mut self.audit_events) } + + pub(crate) fn take_execution_reports(&mut self) -> Vec { + std::mem::take(&mut self.execution_reports) + } } impl fmt::Display for GatewayPluginError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}: {}", self.code, self.message) + write!(f, "{}: {}", self.code_for_logging(), self.message) } } @@ -90,83 +116,17 @@ pub(crate) fn enforce_hook_result_permissions( permissions: &[String], result: &GatewayHookResult, ) -> Result<(), GatewayPluginError> { - if result.request_body.is_some() { - require_hook( - hook_name.is_request_hook(), - "request body mutation", - hook_name, - )?; - require_permission(permissions, "request.body.write")?; - } - if result.response_body.is_some() { - require_hook( - matches!( - hook_name, - GatewayPluginHookName::ResponseAfter | GatewayPluginHookName::Error - ), - "response body mutation", - hook_name, - )?; - require_permission(permissions, "response.body.write")?; - } - if result.stream_chunk.is_some() { - require_hook( - matches!(hook_name, GatewayPluginHookName::ResponseChunk), - "stream chunk mutation", - hook_name, - )?; - require_permission(permissions, "stream.modify")?; - } - if !result.headers.is_empty() { - if hook_name.is_request_hook() { - require_permission(permissions, "request.header.write")?; - } else if hook_name.is_response_hook() { - require_permission(permissions, "response.header.write")?; - } else { - return Err(GatewayPluginError::new( - "PLUGIN_PERMISSION_DENIED", - format!("headers cannot be mutated in {}", hook_name.as_str()), - )); - } - } - if result.log_message.is_some() { - require_hook( - matches!(hook_name, GatewayPluginHookName::LogBeforePersist), - "log mutation", - hook_name, - )?; - require_permission(permissions, "log.redact")?; - } - Ok(()) -} - -fn require_permission( - permissions: &[String], - permission: &'static str, -) -> Result<(), GatewayPluginError> { - if permissions.iter().any(|item| item == permission) { - Ok(()) - } else { - Err(GatewayPluginError::new( - "PLUGIN_PERMISSION_DENIED", - format!("missing plugin permission: {permission}"), - )) - } -} - -fn require_hook( - allowed: bool, - operation: &'static str, - hook_name: GatewayPluginHookName, -) -> Result<(), GatewayPluginError> { - if allowed { - Ok(()) - } else { - Err(GatewayPluginError::new( - "PLUGIN_PERMISSION_DENIED", - format!("{operation} is not allowed in {}", hook_name.as_str()), - )) - } + let descriptor = HookRegistry::new().descriptor(hook_name).ok_or_else(|| { + GatewayPluginError::new( + "PLUGIN_UNKNOWN_HOOK", + format!("unknown hook: {}", hook_name.as_str()), + ) + })?; + debug_assert!(descriptor + .read_permissions + .iter() + .all(|permission| descriptor.allows_read_permission(permission))); + mutation::enforce_descriptor_permissions(descriptor, permissions, result) } #[cfg(test)] diff --git a/src-tauri/src/gateway/plugins/pipeline.rs b/src-tauri/src/gateway/plugins/pipeline.rs index 42be0b64..beaa1e19 100644 --- a/src-tauri/src/gateway/plugins/pipeline.rs +++ b/src-tauri/src/gateway/plugins/pipeline.rs @@ -1,12 +1,19 @@ //! Usage: Ordered, timeout-bounded gateway plugin hook pipeline. use super::context::{ - GatewayHookAction, GatewayHookResult, GatewayLogHookInput, GatewayPluginHookName, - GatewayRequestHookInput, GatewayResponseHookInput, GatewayStreamHookInput, - GatewayVisibleHookContext, + GatewayHookAction, GatewayHookResult, GatewayLogHookInput, GatewayPluginContextBudget, + GatewayPluginHookName, GatewayRequestHookInput, GatewayResponseHookInput, + GatewayStreamHookInput, GatewayVisibleHookContext, }; -use super::permissions::{enforce_hook_result_permissions, GatewayPluginError}; -use crate::domain::plugins::{PluginDetail, PluginStatus}; +use super::contract::DEFAULT_HOOK_TIMEOUT_MS; +use super::mutation::{enforce_descriptor_permissions_with_budget, GatewayPluginMutationBudget}; +use super::permissions::{ + enforce_hook_result_permissions as enforce_descriptor_result_permissions, GatewayPluginError, +}; +use super::registry::HookRegistry; +use crate::app::plugins::access_policy::effective_hook_permissions; +use crate::domain::plugins::{PluginDetail, PluginHook, PluginStatus}; +use crate::shared::time::now_unix_millis; use axum::body::Bytes; use axum::http::{HeaderMap, HeaderName, HeaderValue}; use std::collections::HashMap; @@ -21,38 +28,49 @@ pub(crate) type GatewayHookFuture = pub(crate) trait GatewayPluginExecutor: Send + Sync { fn retain_runtime_caches_for_plugins(&self, _plugins: &[PluginDetail]) {} + /// Execute the hook within the invocation budget selected by the pipeline. + /// + /// Implementations own runtime-specific cancellation and cleanup so the + /// pipeline can audit the result without racing worker teardown. fn execute_request_hook( &self, plugin: &PluginDetail, context: GatewayVisibleHookContext, + hook_timeout: Duration, ) -> GatewayHookFuture; fn execute_response_hook( &self, plugin: &PluginDetail, context: GatewayVisibleHookContext, + hook_timeout: Duration, ) -> GatewayHookFuture; fn execute_stream_hook( &self, plugin: &PluginDetail, context: GatewayVisibleHookContext, + hook_timeout: Duration, ) -> GatewayHookFuture; fn execute_log_hook( &self, plugin: &PluginDetail, context: GatewayVisibleHookContext, + hook_timeout: Duration, ) -> GatewayHookFuture; } +#[cfg(test)] struct NoopGatewayPluginExecutor; +#[cfg(test)] impl GatewayPluginExecutor for NoopGatewayPluginExecutor { fn execute_request_hook( &self, _plugin: &PluginDetail, _context: GatewayVisibleHookContext, + _hook_timeout: Duration, ) -> GatewayHookFuture { Box::pin(async { Ok(GatewayHookResult::continue_unchanged()) }) } @@ -61,6 +79,7 @@ impl GatewayPluginExecutor for NoopGatewayPluginExecutor { &self, _plugin: &PluginDetail, _context: GatewayVisibleHookContext, + _hook_timeout: Duration, ) -> GatewayHookFuture { Box::pin(async { Ok(GatewayHookResult::continue_unchanged()) }) } @@ -69,6 +88,7 @@ impl GatewayPluginExecutor for NoopGatewayPluginExecutor { &self, _plugin: &PluginDetail, _context: GatewayVisibleHookContext, + _hook_timeout: Duration, ) -> GatewayHookFuture { Box::pin(async { Ok(GatewayHookResult::continue_unchanged()) }) } @@ -77,6 +97,7 @@ impl GatewayPluginExecutor for NoopGatewayPluginExecutor { &self, _plugin: &PluginDetail, _context: GatewayVisibleHookContext, + _hook_timeout: Duration, ) -> GatewayHookFuture { Box::pin(async { Ok(GatewayHookResult::continue_unchanged()) }) } @@ -87,14 +108,18 @@ pub(crate) struct GatewayPluginPipelineConfig { pub(crate) hook_timeout: Duration, pub(crate) circuit_failure_threshold: u32, pub(crate) circuit_cooldown: Duration, + pub(crate) context_budget: GatewayPluginContextBudget, + pub(crate) mutation_budget: GatewayPluginMutationBudget, } impl Default for GatewayPluginPipelineConfig { fn default() -> Self { Self { - hook_timeout: Duration::from_millis(150), + hook_timeout: Duration::from_millis(DEFAULT_HOOK_TIMEOUT_MS), circuit_failure_threshold: 3, circuit_cooldown: Duration::from_secs(30), + context_budget: GatewayPluginContextBudget::default(), + mutation_budget: GatewayPluginMutationBudget::default(), } } } @@ -117,6 +142,26 @@ pub(crate) struct GatewayPluginAuditEvent { pub(crate) details: serde_json::Value, } +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct GatewayPluginHookExecutionReport { + pub(crate) plugin_id: String, + pub(crate) trace_id: String, + pub(crate) hook_name: String, + pub(crate) runtime_kind: String, + pub(crate) status: String, + pub(crate) started_at_ms: i64, + pub(crate) duration_ms: i64, + pub(crate) failure_kind: Option, + pub(crate) error_code: Option, + pub(crate) failure_policy: Option, + pub(crate) circuit_state: Option, + pub(crate) context_budget: serde_json::Value, + pub(crate) output_budget: serde_json::Value, + pub(crate) mutation_summary: serde_json::Value, + pub(crate) replayable: bool, + pub(crate) replay_export_reason: Option, +} + #[derive(Debug, Clone, PartialEq)] pub(crate) struct GatewayBlockResponse { pub(crate) status: u16, @@ -129,6 +174,7 @@ pub(crate) struct GatewayRequestHookOutput { pub(crate) body: Bytes, pub(crate) blocked: Option, pub(crate) audit_events: Vec, + pub(crate) execution_reports: Vec, } #[derive(Debug, Clone, PartialEq)] @@ -137,6 +183,7 @@ pub(crate) struct GatewayResponseHookOutput { pub(crate) body: Bytes, pub(crate) blocked: Option, pub(crate) audit_events: Vec, + pub(crate) execution_reports: Vec, } #[derive(Debug, Clone, PartialEq)] @@ -144,12 +191,14 @@ pub(crate) struct GatewayStreamHookOutput { pub(crate) chunk: Bytes, pub(crate) blocked: Option, pub(crate) audit_events: Vec, + pub(crate) execution_reports: Vec, } #[derive(Debug, Clone, PartialEq)] pub(crate) struct GatewayLogHookOutput { pub(crate) message: String, pub(crate) audit_events: Vec, + pub(crate) execution_reports: Vec, } #[derive(Clone, Default)] @@ -165,6 +214,7 @@ pub(crate) struct GatewayPluginPipeline { } impl GatewayPluginPipeline { + #[cfg(test)] pub(crate) fn empty_shared() -> Arc { Arc::new(Self { plugins: RwLock::new(Arc::new(GatewayPluginSnapshot::default())), @@ -180,6 +230,7 @@ impl GatewayPluginPipeline { executor: Arc, config: GatewayPluginPipelineConfig, ) -> Self { + executor.retain_runtime_caches_for_plugins(&plugins); Self { plugins: RwLock::new(Arc::new(build_plugin_snapshot(plugins))), executor, @@ -193,6 +244,7 @@ impl GatewayPluginPipeline { executor: Arc, config: GatewayPluginPipelineConfig, ) -> Self { + executor.retain_runtime_caches_for_plugins(&plugins); Self { plugins: RwLock::new(Arc::new(build_plugin_snapshot(plugins))), executor, @@ -217,6 +269,7 @@ impl GatewayPluginPipeline { let mut headers = input.headers.clone(); let mut body = input.body.clone(); let mut audit_events = Vec::new(); + let mut execution_reports = Vec::new(); let plugins = self.plugins_for_hook(input.hook_name); for plugin in plugins.iter() { @@ -229,6 +282,21 @@ impl GatewayPluginPipeline { "Plugin hook skipped because its circuit is open", serde_json::json!({ "reason": "circuit_open" }), )); + execution_reports.push(self.hook_execution_report( + plugin, + input.hook_name, + input.trace_id.as_str(), + HookReportOutcome { + started_at_ms: now_unix_millis(), + duration_ms: 0, + status: "circuitOpen", + failure_kind: Some("circuit_open"), + error_code: None, + mutation_summary: serde_json::json!({ "changed": false }), + replayable: false, + replay_export_reason: Some("hook skipped because plugin circuit is open"), + }, + )); continue; } @@ -237,56 +305,75 @@ impl GatewayPluginPipeline { body: body.clone(), ..input.clone() }; - let visible = current_input.visible_context(&plugin.granted_permissions); - let future = self.executor.execute_request_hook(plugin, visible); - let result = match tokio::time::timeout(self.config.hook_timeout, future).await { - Ok(Ok(result)) => result, - Ok(Err(err)) => { + let permissions = effective_hook_permissions(plugin, input.hook_name); + let visible = + current_input.visible_context_with_budget(&permissions, self.config.context_budget); + let truncation = VisibleTruncationState::from_context(&visible); + let started_at_ms = now_unix_millis(); + let started = Instant::now(); + let hook_timeout = self.hook_timeout(plugin, input.hook_name); + let future = self + .executor + .execute_request_hook(plugin, visible, hook_timeout); + let result = match future.await { + Ok(result) => result, + Err(err) => { self.record_failure(&plugin.summary.plugin_id); - audit_events.push(audit_event( - plugin, - input.hook_name, - "plugin.hook.failed", - "high", - "Plugin hook failed", - serde_json::json!({ "error": err.to_string() }), - )); - if failure_policy(plugin, input.hook_name) == FailurePolicy::FailClosed { - return Err(err.with_audit_events(audit_events)); + let error_code = err.code_for_logging(); + if is_hook_timeout_error(error_code) { + audit_events.push(timeout_event(plugin, input.hook_name)); + } else { + audit_events.push(audit_event( + plugin, + input.hook_name, + "plugin.hook.failed", + "high", + "Plugin hook failed", + serde_json::json!({ "error": err.to_string() }), + )); } - continue; - } - Err(_) => { - self.record_failure(&plugin.summary.plugin_id); - tracing::warn!( - plugin_id = %plugin.summary.plugin_id, - hook_name = input.hook_name.as_str(), - timeout_ms = self.config.hook_timeout.as_millis(), - "plugin hook timed out" - ); - audit_events.push(audit_event( + let fail_closed = + failure_policy(plugin, input.hook_name) == FailurePolicy::FailClosed; + execution_reports.push(self.hook_execution_report( plugin, input.hook_name, - "plugin.hook.failed", - "high", - "Plugin hook timed out", - serde_json::json!({ "failureKind": "timeout" }), + input.trace_id.as_str(), + HookReportOutcome { + started_at_ms, + duration_ms: duration_ms_i64(started), + status: if fail_closed { + "failedClosed" + } else { + "failedOpen" + }, + failure_kind: Some(if is_hook_timeout_error(error_code) { + "timeout" + } else { + "hook_error" + }), + error_code: Some(error_code), + mutation_summary: serde_json::json!({ "changed": false }), + replayable: true, + replay_export_reason: None, + }, )); - if failure_policy(plugin, input.hook_name) == FailurePolicy::FailClosed { - return Err(GatewayPluginError::new( - "PLUGIN_HOOK_TIMEOUT", - format!("plugin hook timed out: {}", plugin.summary.plugin_id), - ) - .with_audit_events(audit_events)); + if fail_closed { + return Err(attach_plugin_diagnostics( + err, + audit_events, + execution_reports, + )); } continue; } }; - if let Err(err) = enforce_hook_result_permissions( + if let Err(err) = enforce_hook_result_with_budget( input.hook_name, - &plugin.granted_permissions, + &permissions, &result, + self.config.mutation_budget, + &truncation, ) { self.record_failure(&plugin.summary.plugin_id); audit_events.push(audit_event( @@ -297,22 +384,73 @@ impl GatewayPluginPipeline { "Plugin hook returned unauthorized mutations", serde_json::json!({ "error": err.to_string() }), )); - if failure_policy(plugin, input.hook_name) == FailurePolicy::FailClosed { - return Err(err.with_audit_events(audit_events)); + let fail_closed = + failure_policy(plugin, input.hook_name) == FailurePolicy::FailClosed; + execution_reports.push(self.hook_execution_report( + plugin, + input.hook_name, + input.trace_id.as_str(), + HookReportOutcome { + started_at_ms, + duration_ms: duration_ms_i64(started), + status: budget_or_policy_status(err.code_for_logging()), + failure_kind: Some(failure_kind_for_error_code(err.code_for_logging())), + error_code: Some(err.code_for_logging()), + mutation_summary: mutation_summary(&result), + replayable: true, + replay_export_reason: None, + }, + )); + if fail_closed { + return Err(attach_plugin_diagnostics( + err, + audit_events, + execution_reports, + )); } continue; } - self.record_success(&plugin.summary.plugin_id); - apply_header_patch(&mut headers, &result.headers) - .map_err(|err| err.with_audit_events(audit_events.clone()))?; - if let Some(next_body) = result.request_body { - body = Bytes::from(next_body); + if let Err(err) = apply_header_patch(&mut headers, &result.headers) { + self.record_failure(&plugin.summary.plugin_id); + audit_events.push(audit_event( + plugin, + input.hook_name, + "plugin.hook.failed", + "high", + "Plugin hook returned rejected header mutations", + serde_json::json!({ "error": err.to_string() }), + )); + execution_reports.push(self.hook_execution_report( + plugin, + input.hook_name, + input.trace_id.as_str(), + HookReportOutcome { + started_at_ms, + duration_ms: duration_ms_i64(started), + status: "policyRejected", + failure_kind: Some(failure_kind_for_error_code(err.code_for_logging())), + error_code: Some(err.code_for_logging()), + mutation_summary: mutation_summary(&result), + replayable: true, + replay_export_reason: None, + }, + )); + return Err(attach_plugin_diagnostics( + err, + audit_events, + execution_reports, + )); + } + if let Some(next_body) = result.request_body.as_ref() { + body = Bytes::from(next_body.clone()); } if result.action == GatewayHookAction::Block { + let mutation_summary = mutation_summary(&result); let reason = result .reason .unwrap_or_else(|| "Plugin blocked gateway request".to_string()); + self.record_success(&plugin.summary.plugin_id); audit_events.push(audit_event( plugin, input.hook_name, @@ -321,6 +459,21 @@ impl GatewayPluginPipeline { "Plugin blocked gateway request", serde_json::json!({ "reason": reason }), )); + execution_reports.push(self.hook_execution_report( + plugin, + input.hook_name, + input.trace_id.as_str(), + HookReportOutcome { + started_at_ms, + duration_ms: duration_ms_i64(started), + status: "blocked", + failure_kind: None, + error_code: None, + mutation_summary, + replayable: true, + replay_export_reason: None, + }, + )); return Ok(GatewayRequestHookOutput { headers, body, @@ -329,8 +482,26 @@ impl GatewayPluginPipeline { reason, }), audit_events, + execution_reports, }); } + self.record_success(&plugin.summary.plugin_id); + push_warning_event(&mut audit_events, plugin, input.hook_name, &result); + execution_reports.push(self.hook_execution_report( + plugin, + input.hook_name, + input.trace_id.as_str(), + HookReportOutcome { + started_at_ms, + duration_ms: duration_ms_i64(started), + status: "completed", + failure_kind: None, + error_code: None, + mutation_summary: mutation_summary(&result), + replayable: true, + replay_export_reason: None, + }, + )); audit_events.push(audit_event( plugin, input.hook_name, @@ -346,6 +517,7 @@ impl GatewayPluginPipeline { body, blocked: None, audit_events, + execution_reports, }) } @@ -356,6 +528,7 @@ impl GatewayPluginPipeline { let mut headers = input.headers.clone(); let mut body = input.body.clone(); let mut audit_events = Vec::new(); + let mut execution_reports = Vec::new(); let plugins = self.plugins_for_hook(input.hook_name); for plugin in plugins.iter() { @@ -368,6 +541,21 @@ impl GatewayPluginPipeline { "Plugin hook skipped because its circuit is open", serde_json::json!({ "reason": "circuit_open" }), )); + execution_reports.push(self.hook_execution_report( + plugin, + input.hook_name, + input.trace_id.as_str(), + HookReportOutcome { + started_at_ms: now_unix_millis(), + duration_ms: 0, + status: "circuitOpen", + failure_kind: Some("circuit_open"), + error_code: None, + mutation_summary: serde_json::json!({ "changed": false }), + replayable: false, + replay_export_reason: Some("hook skipped because plugin circuit is open"), + }, + )); continue; } @@ -376,56 +564,132 @@ impl GatewayPluginPipeline { body: body.clone(), ..input.clone() }; - let visible = current_input.visible_context(&plugin.granted_permissions); - let result = match tokio::time::timeout( - self.config.hook_timeout, - self.executor.execute_response_hook(plugin, visible), - ) - .await + let permissions = effective_hook_permissions(plugin, input.hook_name); + let visible = + current_input.visible_context_with_budget(&permissions, self.config.context_budget); + let truncation = VisibleTruncationState::from_context(&visible); + let started_at_ms = now_unix_millis(); + let started = Instant::now(); + let hook_timeout = self.hook_timeout(plugin, input.hook_name); + let result = match self + .executor + .execute_response_hook(plugin, visible, hook_timeout) + .await { - Ok(Ok(result)) => result, - Ok(Err(err)) => { + Ok(result) => result, + Err(err) => { self.record_failure(&plugin.summary.plugin_id); - audit_events.push(failed_event(plugin, input.hook_name, &err.to_string())); - if failure_policy(plugin, input.hook_name) == FailurePolicy::FailClosed { - return Err(err.with_audit_events(audit_events)); + let error_code = err.code_for_logging(); + if is_hook_timeout_error(error_code) { + audit_events.push(timeout_event(plugin, input.hook_name)); + } else { + audit_events.push(failed_event(plugin, input.hook_name, &err.to_string())); } - continue; - } - Err(_) => { - self.record_failure(&plugin.summary.plugin_id); - audit_events.push(timeout_event(plugin, input.hook_name)); - if failure_policy(plugin, input.hook_name) == FailurePolicy::FailClosed { - return Err(timeout_error(&plugin.summary.plugin_id) - .with_audit_events(audit_events)); + let fail_closed = + failure_policy(plugin, input.hook_name) == FailurePolicy::FailClosed; + execution_reports.push(self.hook_execution_report( + plugin, + input.hook_name, + input.trace_id.as_str(), + HookReportOutcome { + started_at_ms, + duration_ms: duration_ms_i64(started), + status: if fail_closed { + "failedClosed" + } else { + "failedOpen" + }, + failure_kind: Some(if is_hook_timeout_error(error_code) { + "timeout" + } else { + "hook_error" + }), + error_code: Some(error_code), + mutation_summary: serde_json::json!({ "changed": false }), + replayable: true, + replay_export_reason: None, + }, + )); + if fail_closed { + return Err(attach_plugin_diagnostics( + err, + audit_events, + execution_reports, + )); } continue; } }; - if let Err(err) = enforce_hook_result_permissions( + if let Err(err) = enforce_hook_result_with_budget( input.hook_name, - &plugin.granted_permissions, + &permissions, &result, + self.config.mutation_budget, + &truncation, ) { self.record_failure(&plugin.summary.plugin_id); audit_events.push(failed_event(plugin, input.hook_name, &err.to_string())); - if failure_policy(plugin, input.hook_name) == FailurePolicy::FailClosed { - return Err(err.with_audit_events(audit_events)); + let fail_closed = + failure_policy(plugin, input.hook_name) == FailurePolicy::FailClosed; + execution_reports.push(self.hook_execution_report( + plugin, + input.hook_name, + input.trace_id.as_str(), + HookReportOutcome { + started_at_ms, + duration_ms: duration_ms_i64(started), + status: budget_or_policy_status(err.code_for_logging()), + failure_kind: Some(failure_kind_for_error_code(err.code_for_logging())), + error_code: Some(err.code_for_logging()), + mutation_summary: mutation_summary(&result), + replayable: true, + replay_export_reason: None, + }, + )); + if fail_closed { + return Err(attach_plugin_diagnostics( + err, + audit_events, + execution_reports, + )); } continue; } - self.record_success(&plugin.summary.plugin_id); - apply_header_patch(&mut headers, &result.headers) - .map_err(|err| err.with_audit_events(audit_events.clone()))?; - if let Some(next_body) = result.response_body { - body = Bytes::from(next_body); + if let Err(err) = apply_header_patch(&mut headers, &result.headers) { + self.record_failure(&plugin.summary.plugin_id); + audit_events.push(failed_event(plugin, input.hook_name, &err.to_string())); + execution_reports.push(self.hook_execution_report( + plugin, + input.hook_name, + input.trace_id.as_str(), + HookReportOutcome { + started_at_ms, + duration_ms: duration_ms_i64(started), + status: "policyRejected", + failure_kind: Some(failure_kind_for_error_code(err.code_for_logging())), + error_code: Some(err.code_for_logging()), + mutation_summary: mutation_summary(&result), + replayable: true, + replay_export_reason: None, + }, + )); + return Err(attach_plugin_diagnostics( + err, + audit_events, + execution_reports, + )); + } + if let Some(next_body) = result.response_body.as_ref() { + body = Bytes::from(next_body.clone()); } if result.action == GatewayHookAction::Block { + let mutation_summary = mutation_summary(&result); let reason = result .reason .unwrap_or_else(|| "Plugin blocked gateway response".to_string()); + self.record_success(&plugin.summary.plugin_id); audit_events.push(audit_event( plugin, input.hook_name, @@ -434,6 +698,21 @@ impl GatewayPluginPipeline { "Plugin blocked gateway response", serde_json::json!({ "reason": reason }), )); + execution_reports.push(self.hook_execution_report( + plugin, + input.hook_name, + input.trace_id.as_str(), + HookReportOutcome { + started_at_ms, + duration_ms: duration_ms_i64(started), + status: "blocked", + failure_kind: None, + error_code: None, + mutation_summary, + replayable: true, + replay_export_reason: None, + }, + )); return Ok(GatewayResponseHookOutput { headers, body, @@ -442,8 +721,26 @@ impl GatewayPluginPipeline { reason, }), audit_events, + execution_reports, }); } + self.record_success(&plugin.summary.plugin_id); + push_warning_event(&mut audit_events, plugin, input.hook_name, &result); + execution_reports.push(self.hook_execution_report( + plugin, + input.hook_name, + input.trace_id.as_str(), + HookReportOutcome { + started_at_ms, + duration_ms: duration_ms_i64(started), + status: "completed", + failure_kind: None, + error_code: None, + mutation_summary: mutation_summary(&result), + replayable: true, + replay_export_reason: None, + }, + )); audit_events.push(completed_event(plugin, input.hook_name)); } @@ -452,6 +749,7 @@ impl GatewayPluginPipeline { body, blocked: None, audit_events, + execution_reports, }) } @@ -462,6 +760,7 @@ impl GatewayPluginPipeline { let hook_name = GatewayPluginHookName::ResponseChunk; let mut chunk = input.chunk.clone(); let mut audit_events = Vec::new(); + let mut execution_reports = Vec::new(); let plugins = self.plugins_for_hook(hook_name); for plugin in plugins.iter() { @@ -474,6 +773,21 @@ impl GatewayPluginPipeline { "Plugin hook skipped because its circuit is open", serde_json::json!({ "reason": "circuit_open" }), )); + execution_reports.push(self.hook_execution_report( + plugin, + hook_name, + input.trace_id.as_str(), + HookReportOutcome { + started_at_ms: now_unix_millis(), + duration_ms: 0, + status: "circuitOpen", + failure_kind: Some("circuit_open"), + error_code: None, + mutation_summary: serde_json::json!({ "changed": false }), + replayable: false, + replay_export_reason: Some("hook skipped because plugin circuit is open"), + }, + )); continue; } @@ -481,49 +795,104 @@ impl GatewayPluginPipeline { chunk: chunk.clone(), ..input.clone() }; - let visible = current_input.visible_context(&plugin.granted_permissions); - let result = match tokio::time::timeout( - self.config.hook_timeout, - self.executor.execute_stream_hook(plugin, visible), - ) - .await + let permissions = effective_hook_permissions(plugin, hook_name); + let visible = + current_input.visible_context_with_budget(&permissions, self.config.context_budget); + let truncation = VisibleTruncationState::from_context(&visible); + let started_at_ms = now_unix_millis(); + let started = Instant::now(); + let hook_timeout = self.hook_timeout(plugin, hook_name); + let result = match self + .executor + .execute_stream_hook(plugin, visible, hook_timeout) + .await { - Ok(Ok(result)) => result, - Ok(Err(err)) => { + Ok(result) => result, + Err(err) => { self.record_failure(&plugin.summary.plugin_id); - audit_events.push(failed_event(plugin, hook_name, &err.to_string())); - if failure_policy(plugin, hook_name) == FailurePolicy::FailClosed { - return Err(err.with_audit_events(audit_events)); + let error_code = err.code_for_logging(); + if is_hook_timeout_error(error_code) { + audit_events.push(timeout_event(plugin, hook_name)); + } else { + audit_events.push(failed_event(plugin, hook_name, &err.to_string())); } - continue; - } - Err(_) => { - self.record_failure(&plugin.summary.plugin_id); - audit_events.push(timeout_event(plugin, hook_name)); - if failure_policy(plugin, hook_name) == FailurePolicy::FailClosed { - return Err(timeout_error(&plugin.summary.plugin_id) - .with_audit_events(audit_events)); + let fail_closed = + failure_policy(plugin, hook_name) == FailurePolicy::FailClosed; + execution_reports.push(self.hook_execution_report( + plugin, + hook_name, + input.trace_id.as_str(), + HookReportOutcome { + started_at_ms, + duration_ms: duration_ms_i64(started), + status: if fail_closed { + "failedClosed" + } else { + "failedOpen" + }, + failure_kind: Some(if is_hook_timeout_error(error_code) { + "timeout" + } else { + "hook_error" + }), + error_code: Some(error_code), + mutation_summary: serde_json::json!({ "changed": false }), + replayable: true, + replay_export_reason: None, + }, + )); + if fail_closed { + return Err(attach_plugin_diagnostics( + err, + audit_events, + execution_reports, + )); } continue; } }; - if let Err(err) = - enforce_hook_result_permissions(hook_name, &plugin.granted_permissions, &result) - { + if let Err(err) = enforce_hook_result_with_budget( + hook_name, + &permissions, + &result, + self.config.mutation_budget, + &truncation, + ) { self.record_failure(&plugin.summary.plugin_id); audit_events.push(failed_event(plugin, hook_name, &err.to_string())); - if failure_policy(plugin, hook_name) == FailurePolicy::FailClosed { - return Err(err.with_audit_events(audit_events)); + let fail_closed = failure_policy(plugin, hook_name) == FailurePolicy::FailClosed; + execution_reports.push(self.hook_execution_report( + plugin, + hook_name, + input.trace_id.as_str(), + HookReportOutcome { + started_at_ms, + duration_ms: duration_ms_i64(started), + status: budget_or_policy_status(err.code_for_logging()), + failure_kind: Some(failure_kind_for_error_code(err.code_for_logging())), + error_code: Some(err.code_for_logging()), + mutation_summary: mutation_summary(&result), + replayable: true, + replay_export_reason: None, + }, + )); + if fail_closed { + return Err(attach_plugin_diagnostics( + err, + audit_events, + execution_reports, + )); } continue; } self.record_success(&plugin.summary.plugin_id); - if let Some(next_chunk) = result.stream_chunk { - chunk = Bytes::from(next_chunk); + if let Some(next_chunk) = result.stream_chunk.as_ref() { + chunk = Bytes::from(next_chunk.clone()); } if result.action == GatewayHookAction::Block { + let mutation_summary = mutation_summary(&result); let reason = result .reason .unwrap_or_else(|| "Plugin blocked gateway stream".to_string()); @@ -535,6 +904,21 @@ impl GatewayPluginPipeline { "Plugin blocked gateway stream", serde_json::json!({ "reason": reason }), )); + execution_reports.push(self.hook_execution_report( + plugin, + hook_name, + input.trace_id.as_str(), + HookReportOutcome { + started_at_ms, + duration_ms: duration_ms_i64(started), + status: "blocked", + failure_kind: None, + error_code: None, + mutation_summary, + replayable: true, + replay_export_reason: None, + }, + )); return Ok(GatewayStreamHookOutput { chunk, blocked: Some(GatewayBlockResponse { @@ -542,14 +926,32 @@ impl GatewayPluginPipeline { reason, }), audit_events, + execution_reports, }); } + push_warning_event(&mut audit_events, plugin, hook_name, &result); + execution_reports.push(self.hook_execution_report( + plugin, + hook_name, + input.trace_id.as_str(), + HookReportOutcome { + started_at_ms, + duration_ms: duration_ms_i64(started), + status: "completed", + failure_kind: None, + error_code: None, + mutation_summary: mutation_summary(&result), + replayable: true, + replay_export_reason: None, + }, + )); } Ok(GatewayStreamHookOutput { chunk, blocked: None, audit_events, + execution_reports, }) } @@ -560,6 +962,7 @@ impl GatewayPluginPipeline { let hook_name = GatewayPluginHookName::LogBeforePersist; let mut message = input.message.clone(); let mut audit_events = Vec::new(); + let mut execution_reports = Vec::new(); let plugins = self.plugins_for_hook(hook_name); for plugin in plugins.iter() { @@ -572,6 +975,21 @@ impl GatewayPluginPipeline { "Plugin hook skipped because its circuit is open", serde_json::json!({ "reason": "circuit_open" }), )); + execution_reports.push(self.hook_execution_report( + plugin, + hook_name, + input.trace_id.as_str(), + HookReportOutcome { + started_at_ms: now_unix_millis(), + duration_ms: 0, + status: "circuitOpen", + failure_kind: Some("circuit_open"), + error_code: None, + mutation_summary: serde_json::json!({ "changed": false }), + replayable: false, + replay_export_reason: Some("hook skipped because plugin circuit is open"), + }, + )); continue; } @@ -579,54 +997,125 @@ impl GatewayPluginPipeline { message: message.clone(), ..input.clone() }; - let visible = current_input.visible_context(&plugin.granted_permissions); - let result = match tokio::time::timeout( - self.config.hook_timeout, - self.executor.execute_log_hook(plugin, visible), - ) - .await + let permissions = effective_hook_permissions(plugin, hook_name); + let visible = + current_input.visible_context_with_budget(&permissions, self.config.context_budget); + let truncation = VisibleTruncationState::from_context(&visible); + let started_at_ms = now_unix_millis(); + let started = Instant::now(); + let hook_timeout = self.hook_timeout(plugin, hook_name); + let result = match self + .executor + .execute_log_hook(plugin, visible, hook_timeout) + .await { - Ok(Ok(result)) => result, - Ok(Err(err)) => { + Ok(result) => result, + Err(err) => { self.record_failure(&plugin.summary.plugin_id); - audit_events.push(failed_event(plugin, hook_name, &err.to_string())); - if failure_policy(plugin, hook_name) == FailurePolicy::FailClosed { - return Err(err.with_audit_events(audit_events)); + let error_code = err.code_for_logging(); + if is_hook_timeout_error(error_code) { + audit_events.push(timeout_event(plugin, hook_name)); + } else { + audit_events.push(failed_event(plugin, hook_name, &err.to_string())); } - continue; - } - Err(_) => { - self.record_failure(&plugin.summary.plugin_id); - audit_events.push(timeout_event(plugin, hook_name)); - if failure_policy(plugin, hook_name) == FailurePolicy::FailClosed { - return Err(timeout_error(&plugin.summary.plugin_id) - .with_audit_events(audit_events)); + let fail_closed = + failure_policy(plugin, hook_name) == FailurePolicy::FailClosed; + execution_reports.push(self.hook_execution_report( + plugin, + hook_name, + input.trace_id.as_str(), + HookReportOutcome { + started_at_ms, + duration_ms: duration_ms_i64(started), + status: if fail_closed { + "failedClosed" + } else { + "failedOpen" + }, + failure_kind: Some(if is_hook_timeout_error(error_code) { + "timeout" + } else { + "hook_error" + }), + error_code: Some(error_code), + mutation_summary: serde_json::json!({ "changed": false }), + replayable: true, + replay_export_reason: None, + }, + )); + if fail_closed { + return Err(attach_plugin_diagnostics( + err, + audit_events, + execution_reports, + )); } continue; } }; - if let Err(err) = - enforce_hook_result_permissions(hook_name, &plugin.granted_permissions, &result) - { + if let Err(err) = enforce_hook_result_with_budget( + hook_name, + &permissions, + &result, + self.config.mutation_budget, + &truncation, + ) { self.record_failure(&plugin.summary.plugin_id); audit_events.push(failed_event(plugin, hook_name, &err.to_string())); - if failure_policy(plugin, hook_name) == FailurePolicy::FailClosed { - return Err(err.with_audit_events(audit_events)); + let fail_closed = failure_policy(plugin, hook_name) == FailurePolicy::FailClosed; + execution_reports.push(self.hook_execution_report( + plugin, + hook_name, + input.trace_id.as_str(), + HookReportOutcome { + started_at_ms, + duration_ms: duration_ms_i64(started), + status: budget_or_policy_status(err.code_for_logging()), + failure_kind: Some(failure_kind_for_error_code(err.code_for_logging())), + error_code: Some(err.code_for_logging()), + mutation_summary: mutation_summary(&result), + replayable: true, + replay_export_reason: None, + }, + )); + if fail_closed { + return Err(attach_plugin_diagnostics( + err, + audit_events, + execution_reports, + )); } continue; } self.record_success(&plugin.summary.plugin_id); - if let Some(next_message) = result.log_message { - message = next_message; + if let Some(next_message) = result.log_message.as_ref() { + message = next_message.clone(); } + push_warning_event(&mut audit_events, plugin, hook_name, &result); + execution_reports.push(self.hook_execution_report( + plugin, + hook_name, + input.trace_id.as_str(), + HookReportOutcome { + started_at_ms, + duration_ms: duration_ms_i64(started), + status: "completed", + failure_kind: None, + error_code: None, + mutation_summary: mutation_summary(&result), + replayable: true, + replay_export_reason: None, + }, + )); audit_events.push(completed_event(plugin, hook_name)); } Ok(GatewayLogHookOutput { message, audit_events, + execution_reports, }) } @@ -671,6 +1160,13 @@ impl GatewayPluginPipeline { !self.plugins_for_hook(hook_name).is_empty() } + fn hook_timeout(&self, plugin: &PluginDetail, hook_name: GatewayPluginHookName) -> Duration { + if let Some(timeout_ms) = plugin_hook(plugin, hook_name).and_then(|hook| hook.timeout_ms) { + return Duration::from_millis(timeout_ms); + } + self.config.hook_timeout + } + #[cfg(test)] pub(crate) fn plugins_for_hook_count_for_tests( &self, @@ -741,6 +1237,56 @@ impl GatewayPluginPipeline { GatewayPluginCircuitSnapshot::default(), ); } + + fn hook_execution_report( + &self, + plugin: &PluginDetail, + hook_name: GatewayPluginHookName, + trace_id: &str, + outcome: HookReportOutcome, + ) -> GatewayPluginHookExecutionReport { + GatewayPluginHookExecutionReport { + plugin_id: plugin.summary.plugin_id.clone(), + trace_id: trace_id.to_string(), + hook_name: hook_name.as_str().to_string(), + runtime_kind: runtime_kind(plugin), + status: outcome.status.to_string(), + started_at_ms: outcome.started_at_ms, + duration_ms: outcome.duration_ms, + failure_kind: outcome.failure_kind.map(str::to_string), + error_code: outcome.error_code.map(str::to_string), + failure_policy: Some(failure_policy(plugin, hook_name).as_str().to_string()), + circuit_state: Some(self.circuit_state_for_report(&plugin.summary.plugin_id)), + context_budget: context_budget_summary(self.config.context_budget), + output_budget: mutation_budget_summary(self.config.mutation_budget), + mutation_summary: outcome.mutation_summary, + replayable: outcome.replayable, + replay_export_reason: outcome.replay_export_reason.map(str::to_string), + } + } + + fn circuit_state_for_report(&self, plugin_id: &str) -> String { + let circuits = self + .circuits + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match circuits.get(plugin_id) { + Some(snapshot) if snapshot.open && snapshot.half_open => "halfOpen".to_string(), + Some(snapshot) if snapshot.open => "open".to_string(), + _ => "closed".to_string(), + } + } +} + +struct HookReportOutcome { + started_at_ms: i64, + duration_ms: i64, + status: &'static str, + failure_kind: Option<&'static str>, + error_code: Option<&'static str>, + mutation_summary: serde_json::Value, + replayable: bool, + replay_export_reason: Option<&'static str>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -749,6 +1295,94 @@ enum FailurePolicy { FailClosed, } +impl FailurePolicy { + fn as_str(self) -> &'static str { + match self { + Self::FailOpen => "fail-open", + Self::FailClosed => "fail-closed", + } + } +} + +fn enforce_hook_result_with_budget( + hook_name: GatewayPluginHookName, + permissions: &[String], + result: &GatewayHookResult, + budget: GatewayPluginMutationBudget, + truncation: &VisibleTruncationState, +) -> Result<(), GatewayPluginError> { + enforce_untruncated_context_mutations(result, truncation)?; + if budget == GatewayPluginMutationBudget::default() { + return enforce_descriptor_result_permissions(hook_name, permissions, result); + } + + let descriptor = HookRegistry::new().descriptor(hook_name).ok_or_else(|| { + GatewayPluginError::new( + "PLUGIN_UNKNOWN_HOOK", + format!("unknown hook: {}", hook_name.as_str()), + ) + })?; + debug_assert!(descriptor + .read_permissions + .iter() + .all(|permission| descriptor.allows_read_permission(permission))); + enforce_descriptor_permissions_with_budget(descriptor, permissions, result, budget) +} + +fn attach_plugin_diagnostics( + err: GatewayPluginError, + audit_events: Vec, + execution_reports: Vec, +) -> GatewayPluginError { + err.with_audit_events(audit_events) + .with_execution_reports(execution_reports) +} + +fn enforce_untruncated_context_mutations( + result: &GatewayHookResult, + truncation: &VisibleTruncationState, +) -> Result<(), GatewayPluginError> { + if result.request_body.is_some() && truncation.request_body { + return Err(truncated_context_mutation_error("request body")); + } + if result.response_body.is_some() && truncation.response_body { + return Err(truncated_context_mutation_error("response body")); + } + if result.stream_chunk.is_some() && truncation.stream_chunk { + return Err(truncated_context_mutation_error("stream chunk")); + } + if result.log_message.is_some() && truncation.log_message { + return Err(truncated_context_mutation_error("log message")); + } + Ok(()) +} + +#[derive(Debug, Clone, Copy)] +struct VisibleTruncationState { + request_body: bool, + response_body: bool, + stream_chunk: bool, + log_message: bool, +} + +impl VisibleTruncationState { + fn from_context(visible: &GatewayVisibleHookContext) -> Self { + Self { + request_body: visible.request.body_truncated, + response_body: visible.response.body_truncated, + stream_chunk: visible.stream.chunk_truncated, + log_message: visible.log.message_truncated, + } + } +} + +fn truncated_context_mutation_error(field: &'static str) -> GatewayPluginError { + GatewayPluginError::new( + "PLUGIN_CONTEXT_TRUNCATED", + format!("plugin cannot mutate {field} because visible context was truncated"), + ) +} + fn failure_policy(plugin: &PluginDetail, hook_name: GatewayPluginHookName) -> FailurePolicy { plugin_hook(plugin, hook_name) .and_then(|hook| hook.failure_policy.as_deref()) @@ -762,15 +1396,91 @@ fn failure_policy(plugin: &PluginDetail, hook_name: GatewayPluginHookName) -> Fa .unwrap_or(FailurePolicy::FailOpen) } -fn plugin_hook( - plugin: &PluginDetail, - hook_name: GatewayPluginHookName, -) -> Option<&crate::domain::plugins::PluginHook> { - plugin - .manifest - .hooks - .iter() - .find(|hook| hook.name == hook_name.as_str()) +fn plugin_hook(plugin: &PluginDetail, hook_name: GatewayPluginHookName) -> Option<&PluginHook> { + active_plugin_hooks(plugin).find(|hook| hook.name == hook_name.as_str()) +} + +fn runtime_kind(plugin: &PluginDetail) -> String { + let _ = plugin; + "extensionHost".to_string() +} + +fn duration_ms_i64(started: Instant) -> i64 { + started.elapsed().as_millis().min(i64::MAX as u128) as i64 +} + +fn budget_or_policy_status(error_code: &str) -> &'static str { + match error_code { + "PLUGIN_OUTPUT_TOO_LARGE" | "PLUGIN_CONTEXT_TRUNCATED" => "budgetRejected", + "PLUGIN_PERMISSION_DENIED" | "PLUGIN_RESERVED_HEADER" | "PLUGIN_UNKNOWN_HOOK" => { + "policyRejected" + } + _ => "failedOpen", + } +} + +fn failure_kind_for_error_code(error_code: &str) -> &'static str { + match error_code { + "PLUGIN_OUTPUT_TOO_LARGE" => "output_budget", + "PLUGIN_CONTEXT_TRUNCATED" => "context_budget", + "PLUGIN_PERMISSION_DENIED" => "permission_denied", + "PLUGIN_RESERVED_HEADER" => "reserved_header", + "PLUGIN_UNKNOWN_HOOK" => "unknown_hook", + _ => "hook_error", + } +} + +fn context_budget_summary(budget: GatewayPluginContextBudget) -> serde_json::Value { + serde_json::json!({ + "bodyBytes": budget.body_bytes, + "streamBytes": budget.stream_bytes, + "logBytes": budget.log_bytes, + "normalizedMessages": budget.normalized_messages, + "normalizedMessageTextBytes": budget.normalized_message_text_bytes, + }) +} + +fn mutation_budget_summary(budget: GatewayPluginMutationBudget) -> serde_json::Value { + serde_json::json!({ + "bodyBytes": budget.body_bytes, + "streamBytes": budget.stream_bytes, + "logBytes": budget.log_bytes, + "headerCount": budget.header_count, + "headerValueBytes": budget.header_value_bytes, + }) +} + +fn mutation_summary(result: &GatewayHookResult) -> serde_json::Value { + let fields = [ + ( + "requestBody", + result.request_body.as_ref().map(|value| value.len()), + ), + ( + "responseBody", + result.response_body.as_ref().map(|value| value.len()), + ), + ( + "streamChunk", + result.stream_chunk.as_ref().map(|value| value.len()), + ), + ( + "logMessage", + result.log_message.as_ref().map(|value| value.len()), + ), + ] + .into_iter() + .filter_map(|(field, bytes)| { + bytes.map(|bytes| serde_json::json!({ "field": field, "bytes": bytes })) + }) + .collect::>(); + + serde_json::json!({ + "changed": !fields.is_empty() || !result.headers.is_empty() || result.action == GatewayHookAction::Block, + "fields": fields, + "headersChanged": result.headers.len(), + "blocked": result.action == GatewayHookAction::Block, + }) } fn build_plugin_snapshot(plugins: Vec) -> GatewayPluginSnapshot { @@ -779,7 +1489,7 @@ fn build_plugin_snapshot(plugins: Vec) -> GatewayPluginSnapshot { if plugin.summary.status != PluginStatus::Enabled { continue; } - for hook in &plugin.manifest.hooks { + for hook in active_plugin_hooks(plugin) { let Some(hook_name) = hook_name_from_str(&hook.name) else { continue; }; @@ -805,6 +1515,18 @@ fn build_plugin_snapshot(plugins: Vec) -> GatewayPluginSnapshot { GatewayPluginSnapshot { by_hook } } +fn active_plugin_hooks(plugin: &PluginDetail) -> impl Iterator { + plugin.manifest.hooks.iter().chain( + plugin + .manifest + .contributes + .as_ref() + .map(|contributes| contributes.gateway_hooks.as_slice()) + .unwrap_or(&[]) + .iter(), + ) +} + fn hook_name_from_str(raw: &str) -> Option { match raw { "gateway.request.received" => Some(GatewayPluginHookName::RequestReceived), @@ -883,6 +1605,26 @@ fn timeout_event( ) } +fn push_warning_event( + audit_events: &mut Vec, + plugin: &PluginDetail, + hook_name: GatewayPluginHookName, + result: &GatewayHookResult, +) { + let Some(message) = result.reason.as_deref() else { + return; + }; + audit_events.push(audit_event( + plugin, + hook_name, + "plugin.hook.warned", + "medium", + "Plugin hook warning", + serde_json::json!({ "message": message }), + )); +} + +#[cfg(test)] fn timeout_error(plugin_id: &str) -> GatewayPluginError { GatewayPluginError::new( "PLUGIN_HOOK_TIMEOUT", @@ -890,6 +1632,13 @@ fn timeout_error(plugin_id: &str) -> GatewayPluginError { ) } +fn is_hook_timeout_error(error_code: &str) -> bool { + matches!( + error_code, + "PLUGIN_HOOK_TIMEOUT" | "PLUGIN_EXTENSION_HOST_TIMEOUT" + ) +} + fn apply_header_patch( headers: &mut HeaderMap, patch: &std::collections::BTreeMap, @@ -927,7 +1676,8 @@ fn is_reserved_gateway_header(name: &str) -> bool { } #[cfg(test)] -type TestRequestHandler = Arc GatewayHookFuture + Send + Sync>; +type TestRequestHandler = + Arc GatewayHookFuture + Send + Sync>; #[cfg(test)] pub(crate) struct InMemoryGatewayPluginExecutor { @@ -935,6 +1685,7 @@ pub(crate) struct InMemoryGatewayPluginExecutor { response_handlers: HashMap, stream_handlers: HashMap, log_handlers: HashMap, + observed_timeouts: Arc>>, } #[cfg(test)] @@ -945,16 +1696,21 @@ impl InMemoryGatewayPluginExecutor { response_handlers: HashMap::new(), stream_handlers: HashMap::new(), log_handlers: HashMap::new(), + observed_timeouts: Arc::new(Mutex::new(Vec::new())), } } + pub(crate) fn observed_timeouts(&self) -> Arc>> { + self.observed_timeouts.clone() + } + pub(crate) fn with_request_handler(mut self, plugin_id: &str, handler: F) -> Self where F: Fn(GatewayVisibleHookContext) -> GatewayHookResult + Send + Sync + 'static, { self.request_handlers.insert( plugin_id.to_string(), - Arc::new(move |ctx| { + Arc::new(move |ctx, _timeout| { let result = handler(ctx); Box::pin(async move { Ok(result) }) }), @@ -969,7 +1725,7 @@ impl InMemoryGatewayPluginExecutor { { self.request_handlers.insert( plugin_id.to_string(), - Arc::new(move |ctx| { + Arc::new(move |ctx, _timeout| { let future = handler(ctx); Box::pin(async move { Ok(future.await) }) }), @@ -983,7 +1739,7 @@ impl InMemoryGatewayPluginExecutor { { self.response_handlers.insert( plugin_id.to_string(), - Arc::new(move |ctx| { + Arc::new(move |ctx, _timeout| { let result = handler(ctx); Box::pin(async move { Ok(result) }) }), @@ -997,7 +1753,7 @@ impl InMemoryGatewayPluginExecutor { { self.stream_handlers.insert( plugin_id.to_string(), - Arc::new(move |ctx| { + Arc::new(move |ctx, _timeout| { let result = handler(ctx); Box::pin(async move { Ok(result) }) }), @@ -1011,7 +1767,7 @@ impl InMemoryGatewayPluginExecutor { { self.log_handlers.insert( plugin_id.to_string(), - Arc::new(move |ctx| { + Arc::new(move |ctx, _timeout| { let result = handler(ctx); Box::pin(async move { Ok(result) }) }), @@ -1026,59 +1782,112 @@ impl GatewayPluginExecutor for InMemoryGatewayPluginExecutor { &self, plugin: &PluginDetail, context: GatewayVisibleHookContext, + hook_timeout: Duration, ) -> GatewayHookFuture { - match self.request_handlers.get(&plugin.summary.plugin_id) { - Some(handler) => handler(context), + self.observed_timeouts + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(hook_timeout); + let plugin_id = plugin.summary.plugin_id.clone(); + let future = match self.request_handlers.get(&plugin.summary.plugin_id) { + Some(handler) => handler(context, hook_timeout), None => Box::pin(async { Ok(GatewayHookResult::continue_unchanged()) }), - } + }; + enforce_test_hook_timeout(plugin_id, hook_timeout, future) } fn execute_response_hook( &self, plugin: &PluginDetail, context: GatewayVisibleHookContext, + hook_timeout: Duration, ) -> GatewayHookFuture { - match self.response_handlers.get(&plugin.summary.plugin_id) { - Some(handler) => handler(context), + self.observed_timeouts + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(hook_timeout); + let plugin_id = plugin.summary.plugin_id.clone(); + let future = match self.response_handlers.get(&plugin.summary.plugin_id) { + Some(handler) => handler(context, hook_timeout), None => Box::pin(async { Ok(GatewayHookResult::continue_unchanged()) }), - } + }; + enforce_test_hook_timeout(plugin_id, hook_timeout, future) } fn execute_stream_hook( &self, plugin: &PluginDetail, context: GatewayVisibleHookContext, + hook_timeout: Duration, ) -> GatewayHookFuture { - match self.stream_handlers.get(&plugin.summary.plugin_id) { - Some(handler) => handler(context), + self.observed_timeouts + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(hook_timeout); + let plugin_id = plugin.summary.plugin_id.clone(); + let future = match self.stream_handlers.get(&plugin.summary.plugin_id) { + Some(handler) => handler(context, hook_timeout), None => Box::pin(async { Ok(GatewayHookResult::continue_unchanged()) }), - } + }; + enforce_test_hook_timeout(plugin_id, hook_timeout, future) } fn execute_log_hook( &self, plugin: &PluginDetail, context: GatewayVisibleHookContext, + hook_timeout: Duration, ) -> GatewayHookFuture { - match self.log_handlers.get(&plugin.summary.plugin_id) { - Some(handler) => handler(context), + self.observed_timeouts + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(hook_timeout); + let plugin_id = plugin.summary.plugin_id.clone(); + let future = match self.log_handlers.get(&plugin.summary.plugin_id) { + Some(handler) => handler(context, hook_timeout), None => Box::pin(async { Ok(GatewayHookResult::continue_unchanged()) }), - } + }; + enforce_test_hook_timeout(plugin_id, hook_timeout, future) } } +#[cfg(test)] +fn enforce_test_hook_timeout( + plugin_id: String, + hook_timeout: Duration, + future: GatewayHookFuture, +) -> GatewayHookFuture { + Box::pin(async move { + match tokio::time::timeout(hook_timeout, future).await { + Ok(result) => result, + Err(_) => Err(timeout_error(&plugin_id)), + } + }) +} + #[cfg(test)] mod tests { use super::*; + use crate::domain::plugin_contributions::PluginContributes; use crate::domain::plugins::{ PluginDetail, PluginHook, PluginInstallSource, PluginManifest, PluginRuntime, PluginStatus, PluginSummary, }; use axum::body::Bytes; use axum::http::{HeaderMap, Method}; + use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; use std::time::Duration; + #[test] + fn default_pipeline_timeout_matches_plugin_contract() { + assert_eq!( + GatewayPluginPipelineConfig::default().hook_timeout, + Duration::from_secs(5) + ); + assert_eq!(DEFAULT_HOOK_TIMEOUT_MS, 5_000); + } + fn plugin(plugin_id: &str, priority: i32, permissions: Vec<&str>) -> PluginDetail { PluginDetail { summary: PluginSummary { @@ -1087,7 +1896,7 @@ mod tests { name: plugin_id.to_string(), current_version: Some("1.0.0".to_string()), status: PluginStatus::Enabled, - runtime: "declarativeRules".to_string(), + runtime: "extensionHost".to_string(), permission_risk: crate::domain::plugins::PluginPermissionRisk::High, update_available: false, last_error: None, @@ -1099,15 +1908,27 @@ mod tests { name: plugin_id.to_string(), version: "1.0.0".to_string(), api_version: "1.0.0".to_string(), - runtime: PluginRuntime::DeclarativeRules { - rules: vec!["rules/main.json".to_string()], + runtime: PluginRuntime::ExtensionHost { + language: "typescript".to_string(), }, - hooks: vec![PluginHook { - name: "gateway.request.afterBodyRead".to_string(), - priority, - failure_policy: Some("fail-open".to_string()), - }], - permissions: permissions.iter().map(|item| item.to_string()).collect(), + hooks: vec![], + permissions: vec![], + main: Some("dist/index.js".to_string()), + activation_events: vec![], + contributes: Some(PluginContributes { + providers: vec![], + protocols: vec![], + protocol_bridges: vec![], + commands: vec![], + gateway_hooks: vec![PluginHook { + name: "gateway.request.afterBodyRead".to_string(), + priority, + failure_policy: Some("fail-open".to_string()), + timeout_ms: None, + }], + ui: BTreeMap::new(), + }), + capabilities: vec!["gateway.hooks".to_string()], host_compatibility: crate::domain::plugins::PluginHostCompatibility { app: ">=0.56.0 <1.0.0".to_string(), plugin_api: "^1.0.0".to_string(), @@ -1132,9 +1953,21 @@ mod tests { pending_permissions: vec![], audit_logs: vec![], runtime_failures: vec![], + rollback_versions: vec![], } } + fn gateway_hook_mut(plugin: &mut PluginDetail) -> &mut PluginHook { + plugin + .manifest + .contributes + .as_mut() + .expect("extension host gateway hook contributions") + .gateway_hooks + .first_mut() + .expect("gateway hook") + } + fn request_input() -> GatewayRequestHookInput { GatewayRequestHookInput { hook_name: GatewayPluginHookName::RequestAfterBodyRead, @@ -1209,6 +2042,7 @@ mod tests { &self, _plugin: &PluginDetail, _context: GatewayVisibleHookContext, + _hook_timeout: Duration, ) -> GatewayHookFuture { Box::pin(async { Ok(GatewayHookResult::continue_unchanged()) }) } @@ -1217,6 +2051,7 @@ mod tests { &self, _plugin: &PluginDetail, _context: GatewayVisibleHookContext, + _hook_timeout: Duration, ) -> GatewayHookFuture { Box::pin(async { Ok(GatewayHookResult::continue_unchanged()) }) } @@ -1225,6 +2060,7 @@ mod tests { &self, _plugin: &PluginDetail, _context: GatewayVisibleHookContext, + _hook_timeout: Duration, ) -> GatewayHookFuture { Box::pin(async { Ok(GatewayHookResult::continue_unchanged()) }) } @@ -1233,11 +2069,24 @@ mod tests { &self, _plugin: &PluginDetail, _context: GatewayVisibleHookContext, + _hook_timeout: Duration, ) -> GatewayHookFuture { Box::pin(async { Ok(GatewayHookResult::continue_unchanged()) }) } } + #[test] + fn for_tests_notifies_executor_to_retain_initial_runtime_caches() { + let executor = Arc::new(PruneRecordingExecutor::default()); + let _pipeline = GatewayPluginPipeline::for_tests_shared( + vec![plugin("acme.a", 1, vec!["request.body.read"])], + executor.clone(), + GatewayPluginPipelineConfig::default(), + ); + + assert_eq!(executor.last_retain_ids(), vec!["acme.a"]); + } + #[test] fn replace_plugins_notifies_executor_to_prune_runtime_caches() { let executor = Arc::new(PruneRecordingExecutor::default()); @@ -1275,20 +2124,39 @@ mod tests { } #[tokio::test(flavor = "current_thread")] - #[ignore = "performance smoke: run manually before plugin API releases"] - async fn perf_one_noop_plugin_request_hook_budget() { - let pipeline = GatewayPluginPipeline::for_tests_shared( - vec![plugin("plugin.noop", 10, vec!["request.body.read"])], - Arc::new(InMemoryGatewayPluginExecutor::new()), - GatewayPluginPipelineConfig::default(), - ); - let iterations = 5_000_u32; + async fn plugin_pipeline_lightweight_request_hook_budget_guard() { + let pipeline = GatewayPluginPipeline::empty_shared(); + let iterations = 500_u32; let start = std::time::Instant::now(); for _ in 0..iterations { let output = pipeline .run_request_hook(request_input()) .await - .expect("one-plugin pipeline should pass"); + .expect("empty pipeline should pass"); + assert_eq!(output.body.as_ref(), b"hello"); + } + let avg_nanos = start.elapsed().as_nanos() / u128::from(iterations); + assert!( + avg_nanos < 200_000, + "empty plugin pipeline exceeded lightweight 200us budget: {avg_nanos}ns" + ); + } + + #[tokio::test(flavor = "current_thread")] + #[ignore = "performance smoke: run manually before plugin API releases"] + async fn perf_one_noop_plugin_request_hook_budget() { + let pipeline = GatewayPluginPipeline::for_tests_shared( + vec![plugin("plugin.noop", 10, vec!["request.body.read"])], + Arc::new(InMemoryGatewayPluginExecutor::new()), + GatewayPluginPipelineConfig::default(), + ); + let iterations = 5_000_u32; + let start = std::time::Instant::now(); + for _ in 0..iterations { + let output = pipeline + .run_request_hook(request_input()) + .await + .expect("one-plugin pipeline should pass"); assert_eq!(output.body.as_ref(), b"hello"); } let avg_nanos = start.elapsed().as_nanos() / u128::from(iterations); @@ -1355,6 +2223,7 @@ mod tests { hook_timeout: Duration::from_secs(1), circuit_failure_threshold: 2, circuit_cooldown: Duration::from_secs(30), + ..GatewayPluginPipelineConfig::default() }, ); @@ -1370,6 +2239,245 @@ mod tests { })); } + #[tokio::test(flavor = "current_thread")] + async fn extension_host_request_hook_continue_leaves_body_unchanged() { + let executor = + InMemoryGatewayPluginExecutor::new().with_request_handler("plugin.extension", |ctx| { + assert_eq!(ctx.hook_name, "gateway.request.afterBodyRead"); + assert_eq!(ctx.request.body.as_deref(), Some("hello")); + GatewayHookResult::continue_unchanged() + }); + let pipeline = GatewayPluginPipeline::for_tests( + vec![plugin("plugin.extension", 10, vec!["request.body.read"])], + Arc::new(executor), + GatewayPluginPipelineConfig::default(), + ); + + let output = pipeline + .run_request_hook(request_input()) + .await + .expect("extension host continue should pass request through"); + + assert_eq!(output.body.as_ref(), b"hello"); + assert_eq!(output.execution_reports.len(), 1); + assert_eq!(output.execution_reports[0].runtime_kind, "extensionHost"); + assert_eq!(output.execution_reports[0].status, "completed"); + assert_eq!( + output.execution_reports[0].mutation_summary["changed"], + serde_json::json!(false) + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn extension_host_request_hook_replace_changes_body() { + let executor = + InMemoryGatewayPluginExecutor::new().with_request_handler("plugin.extension", |_ctx| { + GatewayHookResult { + request_body: Some(r#"{"messages":[]}"#.to_string()), + ..GatewayHookResult::continue_unchanged() + } + }); + let pipeline = GatewayPluginPipeline::for_tests( + vec![plugin( + "plugin.extension", + 10, + vec!["request.body.read", "request.body.write"], + )], + Arc::new(executor), + GatewayPluginPipelineConfig::default(), + ); + + let output = pipeline + .run_request_hook(request_input()) + .await + .expect("extension host replace should mutate request body"); + + assert_eq!(output.body.as_ref(), br#"{"messages":[]}"#); + assert_eq!( + output.execution_reports[0].mutation_summary["fields"][0]["field"], + serde_json::json!("requestBody") + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn extension_host_request_hook_uses_contribution_access_without_legacy_permissions() { + let executor = + InMemoryGatewayPluginExecutor::new().with_request_handler("plugin.extension", |ctx| { + assert_eq!(ctx.request.body.as_deref(), Some("hello")); + GatewayHookResult { + request_body: Some(r#"{"messages":[]}"#.to_string()), + ..GatewayHookResult::continue_unchanged() + } + }); + let pipeline = GatewayPluginPipeline::for_tests( + vec![plugin("plugin.extension", 10, vec![])], + Arc::new(executor), + GatewayPluginPipelineConfig::default(), + ); + + let output = pipeline + .run_request_hook(request_input()) + .await + .expect("extension host contribution access should allow request body mutation"); + + assert_eq!(output.body.as_ref(), br#"{"messages":[]}"#); + } + + #[tokio::test(flavor = "current_thread")] + async fn extension_host_response_hook_warn_records_audit_and_report() { + let executor = InMemoryGatewayPluginExecutor::new().with_response_handler( + "plugin.extension", + |_ctx| GatewayHookResult { + reason: Some("response looked risky".to_string()), + ..GatewayHookResult::continue_unchanged() + }, + ); + let mut plugin = plugin( + "plugin.extension", + 10, + vec!["response.body.read", "response.header.read"], + ); + gateway_hook_mut(&mut plugin).name = "gateway.response.after".to_string(); + let pipeline = GatewayPluginPipeline::for_tests( + vec![plugin], + Arc::new(executor), + GatewayPluginPipelineConfig::default(), + ); + + let output = pipeline + .run_response_hook(response_input()) + .await + .expect("extension host warn should not fail response"); + + assert_eq!(output.body.as_ref(), b"secret response"); + assert!(output.audit_events.iter().any(|event| { + event.plugin_id == "plugin.extension" + && event.hook_name == "gateway.response.after" + && event.event_type == "plugin.hook.warned" + && event.risk_level == "medium" + && event.details.get("message") == Some(&serde_json::json!("response looked risky")) + })); + assert!(output.execution_reports.iter().any(|report| { + report.plugin_id == "plugin.extension" + && report.runtime_kind == "extensionHost" + && report.hook_name == "gateway.response.after" + && report.status == "completed" + && report.error_code.is_none() + && report.replayable + })); + } + + #[tokio::test(flavor = "current_thread")] + async fn gateway_plugin_pipeline_rejects_oversized_request_output_fail_open_before_applying() { + let executor = + InMemoryGatewayPluginExecutor::new().with_request_handler("plugin.large", |_ctx| { + GatewayHookResult { + request_body: Some("x".repeat(32)), + ..GatewayHookResult::continue_unchanged() + } + }); + let pipeline = GatewayPluginPipeline::for_tests( + vec![plugin( + "plugin.large", + 10, + vec!["request.body.read", "request.body.write"], + )], + Arc::new(executor), + GatewayPluginPipelineConfig { + hook_timeout: Duration::from_secs(1), + circuit_failure_threshold: 1, + circuit_cooldown: Duration::from_secs(60), + mutation_budget: GatewayPluginMutationBudget { + body_bytes: 16, + ..GatewayPluginMutationBudget::default() + }, + ..GatewayPluginPipelineConfig::default() + }, + ); + + let first = pipeline + .run_request_hook(request_input()) + .await + .expect("fail-open oversized output should preserve request"); + + assert_eq!(first.body.as_ref(), b"hello"); + assert!(first.audit_events.iter().any(|event| { + event.plugin_id == "plugin.large" + && event.event_type == "plugin.hook.failed" + && event + .details + .get("error") + .and_then(serde_json::Value::as_str) + .is_some_and(|error| error.contains("PLUGIN_OUTPUT_TOO_LARGE")) + })); + assert!(first.audit_events.iter().all(|event| { + !(event.plugin_id == "plugin.large" && event.event_type == "plugin.hook.completed") + })); + let snapshot = pipeline.circuit_snapshot("plugin.large"); + assert_eq!(snapshot.failure_count, 1); + assert!(snapshot.open); + + let second = pipeline + .run_request_hook(request_input()) + .await + .expect("open circuit should skip oversized plugin"); + + assert_eq!(second.body.as_ref(), b"hello"); + assert!(second.audit_events.iter().any(|event| { + event.plugin_id == "plugin.large" && event.event_type == "plugin.hook.skipped" + })); + } + + #[tokio::test(flavor = "current_thread")] + async fn gateway_plugin_pipeline_rejects_truncated_context_body_mutation_before_applying() { + let executor = + InMemoryGatewayPluginExecutor::new().with_request_handler("plugin.truncated", |ctx| { + GatewayHookResult { + request_body: ctx.request.body, + ..GatewayHookResult::continue_unchanged() + } + }); + let pipeline = GatewayPluginPipeline::for_tests( + vec![plugin( + "plugin.truncated", + 10, + vec!["request.body.read", "request.body.write"], + )], + Arc::new(executor), + GatewayPluginPipelineConfig { + hook_timeout: Duration::from_secs(1), + circuit_failure_threshold: 1, + circuit_cooldown: Duration::from_secs(60), + context_budget: GatewayPluginContextBudget { + body_bytes: 4, + ..GatewayPluginContextBudget::default() + }, + ..GatewayPluginPipelineConfig::default() + }, + ); + let input = GatewayRequestHookInput { + body: Bytes::from_static(b"hello world"), + ..request_input() + }; + + let output = pipeline + .run_request_hook(input) + .await + .expect("fail-open truncated mutation should preserve request"); + + assert_eq!(output.body.as_ref(), b"hello world"); + assert!(output.audit_events.iter().any(|event| { + event.plugin_id == "plugin.truncated" + && event.event_type == "plugin.hook.failed" + && event + .details + .get("error") + .and_then(serde_json::Value::as_str) + .is_some_and(|error| error.contains("PLUGIN_CONTEXT_TRUNCATED")) + })); + assert!(pipeline.circuit_snapshot("plugin.truncated").open); + } + #[tokio::test(flavor = "current_thread")] async fn gateway_plugin_pipeline_times_out_and_opens_circuit_fail_open() { let executor = InMemoryGatewayPluginExecutor::new().with_request_async_handler( @@ -1393,6 +2501,7 @@ mod tests { hook_timeout: Duration::from_millis(1), circuit_failure_threshold: 1, circuit_cooldown: Duration::from_secs(60), + ..GatewayPluginPipelineConfig::default() }, ); @@ -1414,6 +2523,61 @@ mod tests { })); } + #[tokio::test(flavor = "current_thread")] + async fn extension_host_timeout_records_failure_and_fail_open_preserves_body() { + let executor = InMemoryGatewayPluginExecutor::new().with_request_async_handler( + "plugin.extension", + |_ctx| async { + tokio::time::sleep(Duration::from_millis(50)).await; + GatewayHookResult { + request_body: Some("late mutation".to_string()), + ..GatewayHookResult::continue_unchanged() + } + }, + ); + let pipeline = GatewayPluginPipeline::for_tests( + vec![plugin( + "plugin.extension", + 10, + vec!["request.body.read", "request.body.write"], + )], + Arc::new(executor), + GatewayPluginPipelineConfig { + hook_timeout: Duration::from_millis(1), + circuit_failure_threshold: 1, + circuit_cooldown: Duration::from_secs(60), + ..GatewayPluginPipelineConfig::default() + }, + ); + + let output = pipeline + .run_request_hook(request_input()) + .await + .expect("extension host timeout should fail open"); + + assert_eq!(output.body.as_ref(), b"hello"); + assert!(output.audit_events.iter().any(|event| { + event.plugin_id == "plugin.extension" + && event.event_type == "plugin.hook.failed" + && event.details.get("failureKind") == Some(&serde_json::json!("timeout")) + })); + assert_eq!(output.execution_reports.len(), 1); + assert_eq!(output.execution_reports[0].runtime_kind, "extensionHost"); + assert_eq!(output.execution_reports[0].status, "failedOpen"); + assert_eq!( + output.execution_reports[0].failure_kind.as_deref(), + Some("timeout") + ); + assert_eq!( + output.execution_reports[0].error_code.as_deref(), + Some("PLUGIN_HOOK_TIMEOUT") + ); + assert_eq!( + output.execution_reports[0].mutation_summary["changed"], + serde_json::json!(false) + ); + } + #[tokio::test(flavor = "current_thread")] async fn gateway_plugin_pipeline_returns_fail_closed_audit_events_on_error() { let executor = InMemoryGatewayPluginExecutor::new().with_request_async_handler( @@ -1424,7 +2588,7 @@ mod tests { }, ); let mut plugin = plugin("plugin.slow", 10, vec!["request.body.read"]); - plugin.manifest.hooks[0].failure_policy = Some("fail-closed".to_string()); + gateway_hook_mut(&mut plugin).failure_policy = Some("fail-closed".to_string()); let pipeline = GatewayPluginPipeline::for_tests( vec![plugin], Arc::new(executor), @@ -1432,6 +2596,7 @@ mod tests { hook_timeout: Duration::from_millis(1), circuit_failure_threshold: 1, circuit_cooldown: Duration::from_secs(60), + ..GatewayPluginPipelineConfig::default() }, ); @@ -1475,6 +2640,7 @@ mod tests { hook_timeout: Duration::from_secs(1), circuit_failure_threshold: 1, circuit_cooldown: Duration::from_millis(1), + ..GatewayPluginPipelineConfig::default() }, ); @@ -1512,6 +2678,7 @@ mod tests { hook_timeout: Duration::from_secs(1), circuit_failure_threshold: 2, circuit_cooldown: Duration::from_secs(30), + ..GatewayPluginPipelineConfig::default() }, ); @@ -1590,7 +2757,11 @@ mod tests { vec!["request.header.read", "request.header.write"], )], Arc::new(executor), - GatewayPluginPipelineConfig::default(), + GatewayPluginPipelineConfig { + circuit_failure_threshold: 1, + circuit_cooldown: Duration::from_secs(60), + ..GatewayPluginPipelineConfig::default() + }, ); let err = pipeline @@ -1599,6 +2770,13 @@ mod tests { .unwrap_err(); assert!(err.to_string().starts_with("PLUGIN_RESERVED_HEADER:")); + assert!(pipeline.circuit_snapshot("plugin.headers").open); + assert!(err.execution_reports().iter().any(|report| { + report.plugin_id == "plugin.headers" + && report.status == "policyRejected" + && report.error_code.as_deref() == Some("PLUGIN_RESERVED_HEADER") + && report.failure_kind.as_deref() == Some("reserved_header") + })); } #[tokio::test(flavor = "current_thread")] @@ -1638,6 +2816,47 @@ mod tests { } } + #[tokio::test(flavor = "current_thread")] + async fn gateway_plugin_response_pipeline_rejects_reserved_header_without_resetting_circuit() { + let executor = + InMemoryGatewayPluginExecutor::new().with_response_handler("plugin.response", |_ctx| { + let mut result = GatewayHookResult::continue_unchanged(); + result + .headers + .insert("x-trace-id".to_string(), "spoofed".to_string()); + result + }); + let mut plugin = plugin( + "plugin.response", + 10, + vec!["response.header.read", "response.header.write"], + ); + gateway_hook_mut(&mut plugin).name = "gateway.response.after".to_string(); + let pipeline = GatewayPluginPipeline::for_tests( + vec![plugin], + Arc::new(executor), + GatewayPluginPipelineConfig { + circuit_failure_threshold: 1, + circuit_cooldown: Duration::from_secs(60), + ..GatewayPluginPipelineConfig::default() + }, + ); + + let err = pipeline + .run_response_hook(response_input()) + .await + .expect_err("reserved response header should be rejected"); + + assert_eq!(err.code(), "PLUGIN_RESERVED_HEADER"); + assert!(pipeline.circuit_snapshot("plugin.response").open); + assert!(err.execution_reports().iter().any(|report| { + report.plugin_id == "plugin.response" + && report.status == "policyRejected" + && report.error_code.as_deref() == Some("PLUGIN_RESERVED_HEADER") + && report.failure_kind.as_deref() == Some("reserved_header") + })); + } + #[tokio::test(flavor = "current_thread")] async fn gateway_plugin_response_pipeline_applies_body_and_header_changes() { let executor = @@ -1662,7 +2881,7 @@ mod tests { "response.body.write", ], ); - plugin.manifest.hooks[0].name = "gateway.response.after".to_string(); + gateway_hook_mut(&mut plugin).name = "gateway.response.after".to_string(); let pipeline = GatewayPluginPipeline::for_tests( vec![plugin], @@ -1720,7 +2939,7 @@ mod tests { "response.body.write", ], ); - plugin.manifest.hooks[0].name = "gateway.error".to_string(); + gateway_hook_mut(&mut plugin).name = "gateway.error".to_string(); let pipeline = GatewayPluginPipeline::for_tests( vec![plugin], @@ -1757,7 +2976,7 @@ mod tests { } }); let mut plugin = plugin("plugin.stream", 10, vec!["stream.inspect", "stream.modify"]); - plugin.manifest.hooks[0].name = "gateway.response.chunk".to_string(); + gateway_hook_mut(&mut plugin).name = "gateway.response.chunk".to_string(); let pipeline = GatewayPluginPipeline::for_tests( vec![plugin], @@ -1783,7 +3002,7 @@ mod tests { } }); let mut plugin = plugin("plugin.stream", 10, vec!["stream.inspect", "stream.modify"]); - plugin.manifest.hooks[0].name = "gateway.response.chunk".to_string(); + gateway_hook_mut(&mut plugin).name = "gateway.response.chunk".to_string(); let pipeline = GatewayPluginPipeline::for_tests( vec![plugin], @@ -1800,6 +3019,113 @@ mod tests { !(event.hook_name == "gateway.response.chunk" && event.event_type == "plugin.hook.completed") })); + assert!(output.execution_reports.iter().any(|report| { + report.plugin_id == "plugin.stream" + && report.hook_name == "gateway.response.chunk" + && report.status == "completed" + && report.mutation_summary["changed"] == serde_json::json!(true) + })); + } + + #[tokio::test(flavor = "current_thread")] + async fn gateway_plugin_pipeline_records_runtime_report_for_fail_closed_timeout() { + let executor = InMemoryGatewayPluginExecutor::new().with_request_async_handler( + "plugin.slow", + |_ctx| async { + tokio::time::sleep(Duration::from_millis(50)).await; + GatewayHookResult::continue_unchanged() + }, + ); + let mut plugin = plugin("plugin.slow", 10, vec!["request.body.read"]); + gateway_hook_mut(&mut plugin).failure_policy = Some("fail-closed".to_string()); + let pipeline = GatewayPluginPipeline::for_tests( + vec![plugin], + Arc::new(executor), + GatewayPluginPipelineConfig { + hook_timeout: Duration::from_millis(1), + circuit_failure_threshold: 1, + circuit_cooldown: Duration::from_secs(60), + ..GatewayPluginPipelineConfig::default() + }, + ); + + let err = pipeline + .run_request_hook(request_input()) + .await + .expect_err("fail-closed timeout should fail the request"); + + assert_eq!(err.code(), "PLUGIN_HOOK_TIMEOUT"); + assert!(err.audit_events().iter().any(|event| { + event.event_type == "plugin.hook.failed" + && event.details.get("failureKind") == Some(&serde_json::json!("timeout")) + })); + let reports = err.execution_reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].plugin_id, "plugin.slow"); + assert_eq!(reports[0].status, "failedClosed"); + assert_eq!(reports[0].failure_kind.as_deref(), Some("timeout")); + assert_eq!( + reports[0].error_code.as_deref(), + Some("PLUGIN_HOOK_TIMEOUT") + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn gateway_plugin_pipeline_passes_invocation_timeout_to_executor() { + let executor = InMemoryGatewayPluginExecutor::new() + .with_request_handler("plugin.timeout", |_ctx| { + GatewayHookResult::continue_unchanged() + }); + let observed_timeouts = executor.observed_timeouts(); + let plugin = plugin("plugin.timeout", 10, vec!["request.body.read"]); + let pipeline = GatewayPluginPipeline::for_tests( + vec![plugin], + Arc::new(executor), + GatewayPluginPipelineConfig { + hook_timeout: Duration::from_millis(37), + ..GatewayPluginPipelineConfig::default() + }, + ); + + pipeline + .run_request_hook(request_input()) + .await + .expect("request hook should complete"); + + assert_eq!( + *observed_timeouts + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()), + vec![Duration::from_millis(37)] + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn gateway_plugin_pipeline_uses_hook_declared_timeout() { + let executor = InMemoryGatewayPluginExecutor::new() + .with_request_handler("plugin.timeout", |_ctx| { + GatewayHookResult::continue_unchanged() + }); + let observed_timeouts = executor.observed_timeouts(); + let mut plugin = plugin("plugin.timeout", 10, vec!["request.body.read"]); + gateway_hook_mut(&mut plugin).timeout_ms = Some(5_000); + let pipeline = GatewayPluginPipeline::for_tests( + vec![plugin], + Arc::new(executor), + GatewayPluginPipelineConfig::default(), + ); + + pipeline + .run_request_hook(request_input()) + .await + .expect("request hook should complete"); + + assert_eq!( + *observed_timeouts + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()), + vec![Duration::from_secs(5)] + ); } #[tokio::test(flavor = "current_thread")] @@ -1812,7 +3138,7 @@ mod tests { } }); let mut plugin = plugin("plugin.log", 10, vec!["log.redact"]); - plugin.manifest.hooks[0].name = "log.beforePersist".to_string(); + gateway_hook_mut(&mut plugin).name = "log.beforePersist".to_string(); let pipeline = GatewayPluginPipeline::for_tests( vec![plugin], diff --git a/src-tauri/src/gateway/plugins/registry.rs b/src-tauri/src/gateway/plugins/registry.rs new file mode 100644 index 00000000..e28c0eee --- /dev/null +++ b/src-tauri/src/gateway/plugins/registry.rs @@ -0,0 +1,132 @@ +//! Usage: Internal descriptors for gateway plugin hook metadata. + +use super::context::GatewayPluginHookName; +use super::contract::{hook_contract, HookKind}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct HookDescriptor { + pub(crate) hook_name: GatewayPluginHookName, + pub(crate) id: &'static str, + pub(crate) kind: HookKind, + pub(crate) read_permissions: &'static [&'static str], + pub(crate) write_permissions: &'static [&'static str], + pub(crate) mutation_fields: &'static [&'static str], + pub(crate) timeout_ms: u64, + pub(crate) default_failure_policy: &'static str, +} + +impl HookDescriptor { + pub(crate) fn allows_read_permission(self, permission: &str) -> bool { + self.read_permissions.contains(&permission) + } + + pub(crate) fn allows_write_permission(self, permission: &str) -> bool { + self.write_permissions.contains(&permission) + } + + pub(crate) fn allows_mutation_field(self, field: &str) -> bool { + self.mutation_fields.contains(&field) + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct HookRegistry; + +impl HookRegistry { + pub(crate) fn new() -> Self { + Self + } + + pub(crate) fn descriptor(self, hook_name: GatewayPluginHookName) -> Option { + let contract = hook_contract(hook_name.as_str())?; + Some(HookDescriptor { + hook_name, + id: contract.id, + kind: contract.kind, + read_permissions: contract.read_permissions, + write_permissions: contract.write_permissions, + mutation_fields: contract.mutation_fields, + timeout_ms: contract.timeout_ms, + default_failure_policy: contract.default_failure_policy, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::gateway::plugins::contract::{ + HookContract, HookKind, ACTIVE_HOOKS, DEFAULT_FAILURE_POLICY, DEFAULT_HOOK_TIMEOUT_MS, + RESERVED_HOOKS, + }; + + fn assert_contracts_have_registry_descriptors(contracts: &[HookContract]) { + let registry = HookRegistry::new(); + + for contract in contracts { + let hook_name = GatewayPluginHookName::from_str(contract.id).unwrap_or_else(|| { + panic!("hook contract {} should parse as hook name", contract.id) + }); + let descriptor = registry + .descriptor(hook_name) + .unwrap_or_else(|| panic!("descriptor missing for hook {}", contract.id)); + + assert_eq!(descriptor.hook_name, hook_name); + assert_eq!(descriptor.id, contract.id); + assert_eq!(descriptor.kind, contract.kind); + assert_eq!(descriptor.read_permissions, contract.read_permissions); + assert_eq!(descriptor.write_permissions, contract.write_permissions); + assert_eq!(descriptor.mutation_fields, contract.mutation_fields); + assert_eq!(descriptor.timeout_ms, contract.timeout_ms); + assert_eq!( + descriptor.default_failure_policy, + contract.default_failure_policy + ); + } + } + + #[test] + fn registry_descriptors_mirror_every_hook_contract() { + assert_contracts_have_registry_descriptors(ACTIVE_HOOKS); + assert_contracts_have_registry_descriptors(RESERVED_HOOKS); + } + + #[test] + fn registry_resolves_active_request_hook() { + let registry = HookRegistry::new(); + + let descriptor = registry + .descriptor(GatewayPluginHookName::RequestAfterBodyRead) + .expect("active request hook should resolve"); + + assert_eq!( + descriptor.hook_name, + GatewayPluginHookName::RequestAfterBodyRead + ); + assert_eq!(descriptor.id, "gateway.request.afterBodyRead"); + assert_eq!(descriptor.kind, HookKind::Request); + assert_eq!(descriptor.timeout_ms, DEFAULT_HOOK_TIMEOUT_MS); + assert_eq!(descriptor.default_failure_policy, DEFAULT_FAILURE_POLICY); + assert!(descriptor.allows_read_permission("request.body.read")); + assert!(descriptor.allows_write_permission("request.body.write")); + assert!(descriptor.allows_mutation_field("requestBody")); + assert!(!descriptor.allows_read_permission("stream.inspect")); + } + + #[test] + fn registry_marks_stream_chunk_as_stream_kind() { + let registry = HookRegistry::new(); + + let descriptor = registry + .descriptor(GatewayPluginHookName::ResponseChunk) + .expect("stream chunk hook should resolve"); + + assert_eq!(descriptor.hook_name, GatewayPluginHookName::ResponseChunk); + assert_eq!(descriptor.id, "gateway.response.chunk"); + assert_eq!(descriptor.kind, HookKind::Stream); + assert!(descriptor.allows_read_permission("stream.inspect")); + assert!(descriptor.allows_write_permission("stream.modify")); + assert!(descriptor.allows_mutation_field("streamChunk")); + assert!(!descriptor.allows_mutation_field("responseBody")); + } +} diff --git a/src-tauri/src/gateway/proxy/abort_guard.rs b/src-tauri/src/gateway/proxy/abort_guard.rs index 16f5e89d..bff15e4a 100644 --- a/src-tauri/src/gateway/proxy/abort_guard.rs +++ b/src-tauri/src/gateway/proxy/abort_guard.rs @@ -1,5 +1,6 @@ //! Usage: Best-effort drop guard to log client-aborted requests. +use crate::gateway::active_requests::ActiveRequestRegistry; use crate::gateway::events::FailoverAttempt; use crate::gateway::plugins::pipeline::GatewayPluginPipeline; use crate::{db, request_logs}; @@ -16,6 +17,7 @@ pub(super) struct RequestAbortGuard { db: db::Db, log_tx: tokio::sync::mpsc::Sender, plugin_pipeline: Arc, + active_requests: Arc, trace_id: String, cli_key: String, method: String, @@ -38,6 +40,7 @@ impl RequestAbortGuard { db: db::Db, log_tx: tokio::sync::mpsc::Sender, plugin_pipeline: Arc, + active_requests: Arc, trace_id: String, cli_key: String, method: String, @@ -55,6 +58,7 @@ impl RequestAbortGuard { db, log_tx, plugin_pipeline, + active_requests, trace_id, cli_key, method, @@ -84,6 +88,7 @@ impl RequestAbortGuard { db: self.db.clone(), log_tx: self.log_tx.clone(), plugin_pipeline: self.plugin_pipeline.clone(), + active_requests: self.active_requests.clone(), trace_id: std::mem::take(&mut self.trace_id), cli_key: std::mem::take(&mut self.cli_key), method: std::mem::take(&mut self.method), @@ -120,7 +125,13 @@ impl Drop for RequestAbortGuard { let abort_attempts: Vec = self.in_flight_attempt.iter().cloned().collect(); emit_request_event_and_spawn_request_log( RequestEndArgs::from_context(RequestEndContextArgs { - deps: RequestEndDeps::new(&self.app, &self.db, &self.log_tx, &self.plugin_pipeline), + deps: RequestEndDeps::new( + &self.app, + &self.db, + &self.log_tx, + &self.plugin_pipeline, + &self.active_requests, + ), trace_id: self.trace_id.as_str(), cli_key: self.cli_key.as_str(), method: self.method.as_str(), @@ -168,6 +179,10 @@ mod tests { circuit_state_after: None, circuit_failure_count: Some(0), circuit_failure_threshold: Some(5), + circuit_recover_at_unix: None, + circuit_trigger_error_code: None, + provider_bridged: Some(true), + timeout_secs: None, }; let logged_attempts: Vec = Some(attempt.clone()).iter().cloned().collect(); diff --git a/src-tauri/src/gateway/proxy/errors.rs b/src-tauri/src/gateway/proxy/errors.rs index a0b59988..c6c3b7b6 100644 --- a/src-tauri/src/gateway/proxy/errors.rs +++ b/src-tauri/src/gateway/proxy/errors.rs @@ -15,6 +15,8 @@ use crate::gateway::plugins::context::{GatewayPluginHookName, GatewayResponseHoo use crate::gateway::plugins::pipeline::GatewayPluginPipeline; use std::sync::Arc; +const MAX_PLUGIN_ERROR_BODY_BYTES: usize = 256 * 1024; + #[derive(Debug, Serialize)] struct GatewayErrorResponse { trace_id: String, @@ -51,7 +53,7 @@ pub(super) fn classify_upstream_status( return ( ErrorCategory::ProviderError, GatewayErrorCode::Upstream5xx.as_str(), - FailoverDecision::SwitchProvider, + FailoverDecision::RetrySameProvider, ); } @@ -144,7 +146,7 @@ pub(super) async fn apply_gateway_error_hook( ) -> Response { let status = response.status(); let mut headers = response.headers().clone(); - let body = match to_bytes(response.into_body(), usize::MAX).await { + let body = match to_bytes(response.into_body(), MAX_PLUGIN_ERROR_BODY_BYTES).await { Ok(body) => body, Err(err) => { tracing::warn!( @@ -156,7 +158,8 @@ pub(super) async fn apply_gateway_error_hook( StatusCode::INTERNAL_SERVER_ERROR, trace_id, GatewayErrorCode::ResponseBuildError.as_str(), - "failed to read gateway error response body".to_string(), + "failed to read gateway error response body within plugin error body limit" + .to_string(), vec![], ); } @@ -172,10 +175,11 @@ pub(super) async fn apply_gateway_error_hook( let output = match pipeline.run_response_hook(input).await { Ok(output) => { - crate::gateway::plugins::audit::persist_gateway_plugin_audit_events( + crate::gateway::plugins::audit::persist_gateway_plugin_diagnostics( db, &trace_id, output.audit_events.clone(), + output.execution_reports.clone(), ); output } @@ -275,23 +279,23 @@ mod tests { } #[test] - fn upstream_5xx_switches_provider() { + fn upstream_5xx_retries_same_provider() { let (category, code, decision) = classify_upstream_status(reqwest::StatusCode::INTERNAL_SERVER_ERROR); assert!(matches!(category, ErrorCategory::ProviderError)); assert_eq!(code, GatewayErrorCode::Upstream5xx.as_str()); - assert!(matches!(decision, FailoverDecision::SwitchProvider)); + assert!(matches!(decision, FailoverDecision::RetrySameProvider)); let (category, code, decision) = classify_upstream_status(reqwest::StatusCode::BAD_GATEWAY); assert!(matches!(category, ErrorCategory::ProviderError)); assert_eq!(code, GatewayErrorCode::Upstream5xx.as_str()); - assert!(matches!(decision, FailoverDecision::SwitchProvider)); + assert!(matches!(decision, FailoverDecision::RetrySameProvider)); let (category, code, decision) = classify_upstream_status(reqwest::StatusCode::SERVICE_UNAVAILABLE); assert!(matches!(category, ErrorCategory::ProviderError)); assert_eq!(code, GatewayErrorCode::Upstream5xx.as_str()); - assert!(matches!(decision, FailoverDecision::SwitchProvider)); + assert!(matches!(decision, FailoverDecision::RetrySameProvider)); } #[test] diff --git a/src-tauri/src/gateway/proxy/failover/tests.rs b/src-tauri/src/gateway/proxy/failover/tests.rs index 8e4ba59f..db6b5adf 100644 --- a/src-tauri/src/gateway/proxy/failover/tests.rs +++ b/src-tauri/src/gateway/proxy/failover/tests.rs @@ -35,6 +35,7 @@ fn provider_for_base_url_test( source_provider_id: None, bridge_type: None, stream_idle_timeout_seconds: None, + extension_values: vec![], } } diff --git a/src-tauri/src/gateway/proxy/handler/early_error.rs b/src-tauri/src/gateway/proxy/handler/early_error.rs index f56aab29..9d6329e2 100644 --- a/src-tauri/src/gateway/proxy/handler/early_error.rs +++ b/src-tauri/src/gateway/proxy/handler/early_error.rs @@ -27,6 +27,9 @@ pub(super) enum EarlyErrorKind { LargeBodyMissingModel, InvalidCliKey, NoEnabledProvider, + // Provider selection failed for infrastructure reasons (DB / blocking + // pool), not because of anything the client sent. + ProviderSelectionFailed, } #[derive(Debug, Clone, Copy)] @@ -69,6 +72,15 @@ pub(super) fn early_error_contract(kind: EarlyErrorKind) -> EarlyErrorContract { error_category: None, excluded_from_stats: false, }, + // 500 (matches status_override_for_error_code's mapping for + // GW_INTERNAL_ERROR) rather than the 400/invalid-cli-key class these + // errors used to be misfiled under. + EarlyErrorKind::ProviderSelectionFailed => EarlyErrorContract { + status: StatusCode::INTERNAL_SERVER_ERROR, + error_code: GatewayErrorCode::InternalError.as_str(), + error_category: Some(ErrorCategory::SystemError.as_str()), + excluded_from_stats: false, + }, } } @@ -200,6 +212,7 @@ fn early_error_request_end_args<'a, R: tauri::Runtime>( &ctx.state.db, &ctx.state.log_tx, &ctx.state.plugin_pipeline, + &ctx.state.active_requests, ), trace_id: ctx.trace_id, cli_key: ctx.cli_key, @@ -271,3 +284,13 @@ pub(super) fn respond_invalid_cli_key_with_spawn( let contract = early_error_contract(EarlyErrorKind::InvalidCliKey); respond_early_error_with_spawn(ctx, contract, err, None, session_id, requested_model) } + +pub(super) fn respond_provider_selection_failed_with_spawn( + ctx: &EarlyErrorLogCtx<'_, R>, + session_id: Option, + requested_model: Option, + err: String, +) -> Response { + let contract = early_error_contract(EarlyErrorKind::ProviderSelectionFailed); + respond_early_error_with_spawn(ctx, contract, err, None, session_id, requested_model) +} diff --git a/src-tauri/src/gateway/proxy/handler/failover_loop/attempt/attempt_auth.rs b/src-tauri/src/gateway/proxy/handler/failover_loop/attempt/attempt_auth.rs index d0a323a0..0d225ab9 100644 --- a/src-tauri/src/gateway/proxy/handler/failover_loop/attempt/attempt_auth.rs +++ b/src-tauri/src/gateway/proxy/handler/failover_loop/attempt/attempt_auth.rs @@ -110,6 +110,10 @@ fn inject_oauth_auth( circuit_state_after: None, circuit_failure_count: Some(error_ctx.circuit_before.failure_count), circuit_failure_threshold: Some(error_ctx.circuit_before.failure_threshold), + circuit_recover_at_unix: None, + circuit_trigger_error_code: None, + provider_bridged: Some(prepared.provider_bridged), + timeout_secs: None, })); } Ok(()) @@ -140,6 +144,10 @@ fn inject_oauth_auth( circuit_state_after: None, circuit_failure_count: Some(error_ctx.circuit_before.failure_count), circuit_failure_threshold: Some(error_ctx.circuit_before.failure_threshold), + circuit_recover_at_unix: None, + circuit_trigger_error_code: None, + provider_bridged: Some(prepared.provider_bridged), + timeout_secs: None, })) } } diff --git a/src-tauri/src/gateway/proxy/handler/failover_loop/attempt/attempt_executor.rs b/src-tauri/src/gateway/proxy/handler/failover_loop/attempt/attempt_executor.rs index bedeb526..147364a0 100644 --- a/src-tauri/src/gateway/proxy/handler/failover_loop/attempt/attempt_executor.rs +++ b/src-tauri/src/gateway/proxy/handler/failover_loop/attempt/attempt_executor.rs @@ -155,10 +155,11 @@ where }; match ctx.state.plugin_pipeline.run_request_hook(hook_input).await { Ok(output) => { - crate::gateway::plugins::audit::persist_gateway_plugin_audit_events( + crate::gateway::plugins::audit::persist_gateway_plugin_diagnostics( &ctx.state.db, &input.trace_id, output.audit_events.clone(), + output.execution_reports.clone(), ); if let Some(blocked) = output.blocked { tracing::warn!( @@ -343,6 +344,7 @@ async fn handle_url_build_failure( decision, outcome, reason: format!("invalid base_url: {err}"), + timeout_secs: None, }) .await } @@ -357,6 +359,7 @@ fn build_attempt_ctx<'a>( AttemptCtx { attempt_index, retry_index, + provider_max_attempts: prepared.provider_max_attempts, attempt_started_ms, attempt_started: Instant::now(), circuit_before, @@ -373,6 +376,7 @@ fn build_provider_ctx(prepared: &PreparedProvider) -> ProviderCtx<'_> { provider_base_url_base: &prepared.provider_base_url_base, auth_mode: prepared.auth_mode.as_str(), provider_index: prepared.provider_index, + provider_bridged: prepared.provider_bridged, session_reuse: prepared.session_reuse, stream_idle_timeout_seconds: prepared.stream_idle_timeout_seconds, claude_model_mapping: prepared.claude_model_mapping.as_ref(), @@ -413,36 +417,47 @@ fn emit_started_event( circuit_state_after: None, circuit_failure_count: Some(circuit_before.failure_count), circuit_failure_threshold: Some(circuit_before.failure_threshold), + circuit_recover_at_unix: None, + circuit_trigger_error_code: None, + provider_bridged: Some(prepared.provider_bridged), + timeout_secs: None, }; - abort_guard.capture_in_flight_attempt(&started_attempt); - if input.observe_request { - emit_attempt_event( - &input.state.app, - GatewayAttemptEvent { - trace_id: input.trace_id.clone(), - cli_key: input.cli_key.clone(), - session_id: input.session_id.clone(), - method: input.method_hint.clone(), - path: input.forwarded_path.clone(), - query: input.query.clone(), - requested_model: input.requested_model.clone(), - attempt_index, - provider_id: prepared.provider_id, - session_reuse: prepared.session_reuse, - provider_name: prepared.provider_name_base.clone(), - base_url: prepared.provider_base_url_base.clone(), - outcome: "started".to_string(), - status: None, - attempt_started_ms, - attempt_duration_ms: 0, - circuit_state_before: Some(circuit_before.state.as_str()), - circuit_state_after: None, - circuit_failure_count: Some(circuit_before.failure_count), - circuit_failure_threshold: Some(circuit_before.failure_threshold), - claude_model_mapping: prepared.claude_model_mapping.clone(), - }, + let started_event = input.observe_request.then(|| { + bound_attempt_event(GatewayAttemptEvent { + trace_id: input.trace_id.clone(), + cli_key: input.cli_key.clone(), + session_id: input.session_id.clone(), + method: input.method_hint.clone(), + path: input.forwarded_path.clone(), + query: input.query.clone(), + requested_model: input.requested_model.clone(), + attempt_index, + provider_id: prepared.provider_id, + session_reuse: prepared.session_reuse, + provider_name: prepared.provider_name_base.clone(), + base_url: prepared.provider_base_url_base.clone(), + outcome: "started".to_string(), + status: None, + attempt_started_ms, + attempt_duration_ms: 0, + circuit_state_before: Some(circuit_before.state.as_str()), + circuit_state_after: None, + circuit_failure_count: Some(circuit_before.failure_count), + circuit_failure_threshold: Some(circuit_before.failure_threshold), + claude_model_mapping: prepared.claude_model_mapping.clone(), + }) + }); + if let Some(started_event) = started_event.as_ref() { + let elapsed_ms = i64::try_from(attempt_started_ms).unwrap_or(i64::MAX); + input.state.active_requests.record_attempt_start( + started_event.clone(), + input.created_at_ms.saturating_add(elapsed_ms), ); } + abort_guard.capture_in_flight_attempt(&started_attempt); + if let Some(started_event) = started_event { + emit_attempt_event(&input.state.app, started_event); + } } #[cfg(test)] diff --git a/src-tauri/src/gateway/proxy/handler/failover_loop/attempt/attempt_record.rs b/src-tauri/src/gateway/proxy/handler/failover_loop/attempt/attempt_record.rs index 1c54d86f..d9d28c2c 100644 --- a/src-tauri/src/gateway/proxy/handler/failover_loop/attempt/attempt_record.rs +++ b/src-tauri/src/gateway/proxy/handler/failover_loop/attempt/attempt_record.rs @@ -16,6 +16,8 @@ pub(super) struct RecordSystemFailureArgs<'a, R: tauri::Runtime = tauri::Wry> { pub(super) decision: FailoverDecision, pub(super) outcome: String, pub(super) reason: String, + /// First-byte timeout seconds in effect; `Some` only for timeout-class failures. + pub(super) timeout_secs: Option, } pub(super) async fn record_system_failure_and_decide( @@ -50,6 +52,7 @@ async fn record_system_failure_and_decide_impl( mut decision, mut outcome, reason, + timeout_secs, } = args; let ProviderCtx { provider_id, @@ -99,7 +102,11 @@ async fn record_system_failure_and_decide_impl( provider_name_base.as_str(), provider_base_url_base.as_str(), now_unix, - ), + ) + // Attribute the circuit-open notice to this failure (D3): always + // pass the effective first-byte timeout; the notice builder only + // uses it when the trigger code is GW_UPSTREAM_TIMEOUT. + .with_trigger(Some(error_code), Some(ctx.upstream_first_byte_timeout_secs)), ); *circuit_snapshot = change.after.clone(); circuit_state_before = Some(change.before.state.as_str()); @@ -134,6 +141,10 @@ async fn record_system_failure_and_decide_impl( circuit_state_after, circuit_failure_count, circuit_failure_threshold, + circuit_recover_at_unix: None, + circuit_trigger_error_code: None, + provider_bridged: Some(provider_ctx.provider_bridged), + timeout_secs, }); emit_attempt_event_and_log_with_circuit_before( diff --git a/src-tauri/src/gateway/proxy/handler/failover_loop/attempt/retry_engine.rs b/src-tauri/src/gateway/proxy/handler/failover_loop/attempt/retry_engine.rs index 437e4a02..bb2c5ada 100644 --- a/src-tauri/src/gateway/proxy/handler/failover_loop/attempt/retry_engine.rs +++ b/src-tauri/src/gateway/proxy/handler/failover_loop/attempt/retry_engine.rs @@ -150,6 +150,7 @@ fn build_error_contexts<'a, R: tauri::Runtime>( let attempt_ctx = AttemptCtx { attempt_index, retry_index, + provider_max_attempts: prepared.provider_max_attempts, attempt_started_ms: timing.attempt_started_ms, attempt_started: timing.attempt_started, circuit_before: &prepared.circuit_snapshot, @@ -163,6 +164,7 @@ fn build_error_contexts<'a, R: tauri::Runtime>( provider_base_url_base: &prepared.provider_base_url_base, auth_mode: prepared.auth_mode.as_str(), provider_index: prepared.provider_index, + provider_bridged: prepared.provider_bridged, session_reuse: prepared.session_reuse, stream_idle_timeout_seconds: prepared.stream_idle_timeout_seconds, claude_model_mapping: prepared.claude_model_mapping.as_ref(), diff --git a/src-tauri/src/gateway/proxy/handler/failover_loop/attempt/send_timeout.rs b/src-tauri/src/gateway/proxy/handler/failover_loop/attempt/send_timeout.rs index 3657df19..7bd9f96f 100644 --- a/src-tauri/src/gateway/proxy/handler/failover_loop/attempt/send_timeout.rs +++ b/src-tauri/src/gateway/proxy/handler/failover_loop/attempt/send_timeout.rs @@ -3,12 +3,20 @@ use super::*; use crate::gateway::proxy::is_claude_count_tokens_request; -fn timeout_decision(is_count_tokens: bool) -> FailoverDecision { +fn timeout_decision( + is_count_tokens: bool, + retry_index: u32, + provider_max_attempts: u32, +) -> FailoverDecision { if is_count_tokens { return FailoverDecision::Abort; } - FailoverDecision::SwitchProvider + if retry_index < provider_max_attempts { + FailoverDecision::RetrySameProvider + } else { + FailoverDecision::SwitchProvider + } } pub(super) async fn handle_timeout( @@ -20,14 +28,19 @@ pub(super) async fn handle_timeout( let is_count_tokens = is_claude_count_tokens_request(ctx.cli_key.as_str(), ctx.forwarded_path.as_str()); let error_code = GatewayErrorCode::UpstreamTimeout.as_str(); - let decision = timeout_decision(is_count_tokens); + let decision = timeout_decision( + is_count_tokens, + attempt_ctx.retry_index, + attempt_ctx.provider_max_attempts, + ); + let timeout_secs = ctx.upstream_first_byte_timeout_secs; let outcome = format!( "request_timeout: category={} code={} decision={} timeout_secs={}", ErrorCategory::SystemError.as_str(), error_code, decision.as_str(), - ctx.upstream_first_byte_timeout_secs, + timeout_secs, ); if is_count_tokens { @@ -41,6 +54,7 @@ pub(super) async fn handle_timeout( decision, outcome, reason: "request timeout".to_string(), + timeout_secs: Some(timeout_secs), }) .await; } @@ -55,6 +69,7 @@ pub(super) async fn handle_timeout( decision, outcome, reason: "request timeout".to_string(), + timeout_secs: Some(timeout_secs), }) .await } @@ -65,19 +80,19 @@ mod tests { #[test] fn timeout_decision_aborts_for_count_tokens() { - let decision = timeout_decision(true); + let decision = timeout_decision(true, 1, 5); assert!(matches!(decision, FailoverDecision::Abort)); } #[test] - fn timeout_decision_switches_for_regular_requests_before_retry_limit() { - let decision = timeout_decision(false); - assert!(matches!(decision, FailoverDecision::SwitchProvider)); + fn timeout_decision_retries_regular_requests() { + let decision = timeout_decision(false, 1, 5); + assert!(matches!(decision, FailoverDecision::RetrySameProvider)); } #[test] - fn timeout_decision_switches_for_regular_requests_after_retry_limit() { - let decision = timeout_decision(false); + fn timeout_decision_switches_regular_requests_at_retry_limit() { + let decision = timeout_decision(false, 5, 5); assert!(matches!(decision, FailoverDecision::SwitchProvider)); } } diff --git a/src-tauri/src/gateway/proxy/handler/failover_loop/context.rs b/src-tauri/src/gateway/proxy/handler/failover_loop/context.rs index f7939af6..0671b848 100644 --- a/src-tauri/src/gateway/proxy/handler/failover_loop/context.rs +++ b/src-tauri/src/gateway/proxy/handler/failover_loop/context.rs @@ -187,6 +187,7 @@ pub(super) struct ProviderCtx<'a> { pub(super) provider_base_url_base: &'a String, pub(super) auth_mode: &'a str, pub(super) provider_index: u32, + pub(super) provider_bridged: bool, pub(super) session_reuse: Option, pub(super) stream_idle_timeout_seconds: Option, pub(super) claude_model_mapping: Option<&'a ClaudeModelMapping>, @@ -198,6 +199,7 @@ pub(super) struct ProviderCtxOwned { pub(super) provider_base_url_base: String, pub(super) auth_mode: String, pub(super) provider_index: u32, + pub(super) provider_bridged: bool, pub(super) session_reuse: Option, pub(super) stream_idle_timeout_seconds: Option, } @@ -210,6 +212,7 @@ impl<'a> From> for ProviderCtxOwned { provider_base_url_base: ctx.provider_base_url_base.clone(), auth_mode: ctx.auth_mode.to_string(), provider_index: ctx.provider_index, + provider_bridged: ctx.provider_bridged, session_reuse: ctx.session_reuse, stream_idle_timeout_seconds: ctx.stream_idle_timeout_seconds, } @@ -259,6 +262,14 @@ pub(super) fn build_stream_finalize_ctx( auth_mode: provider_ctx.auth_mode.clone(), fake_200_detected: false, fake_200_quota_exhausted: false, + activity: Arc::new(Mutex::new( + crate::gateway::streams::StreamActivityTracker::new( + &ctx.trace_id, + &ctx.cli_key, + ctx.created_at_ms, + ), + )), + active_requests: ctx.state.active_requests.clone(), } } @@ -266,6 +277,7 @@ pub(super) fn build_stream_finalize_ctx( pub(super) struct AttemptCtx<'a> { pub(super) attempt_index: u32, pub(super) retry_index: u32, + pub(super) provider_max_attempts: u32, pub(super) attempt_started_ms: u128, pub(super) attempt_started: Instant, pub(super) circuit_before: &'a circuit_breaker::CircuitSnapshot, diff --git a/src-tauri/src/gateway/proxy/handler/failover_loop/loop_helpers.rs b/src-tauri/src/gateway/proxy/handler/failover_loop/loop_helpers.rs index 7e22d4ed..e0d74a94 100644 --- a/src-tauri/src/gateway/proxy/handler/failover_loop/loop_helpers.rs +++ b/src-tauri/src/gateway/proxy/handler/failover_loop/loop_helpers.rs @@ -40,12 +40,16 @@ pub(super) struct SkippedProviderAttempt<'a> { pub(super) reason: String, pub(super) reason_code: Option<&'static str>, pub(super) attempt_started_ms: u128, + /// Circuit snapshot at gate-deny time; `Some` only for circuit-gate skips + /// so non-circuit skip paths keep their serialized shape unchanged. + pub(super) circuit: Option, } pub(super) fn push_skipped_provider_attempt( attempts: &mut Vec, skipped: SkippedProviderAttempt<'_>, ) { + let circuit = skipped.circuit.as_ref(); attempts.push(FailoverAttempt { provider_id: skipped.provider_id, provider_name: skipped.provider_name.to_string(), @@ -63,10 +67,15 @@ pub(super) fn push_skipped_provider_attempt( reason_code: skipped.reason_code, attempt_started_ms: Some(skipped.attempt_started_ms), attempt_duration_ms: Some(0), - circuit_state_before: None, - circuit_state_after: None, - circuit_failure_count: None, - circuit_failure_threshold: None, + // Gate skip did not change the circuit state; before == after. + circuit_state_before: circuit.map(|s| s.state.as_str()), + circuit_state_after: circuit.map(|s| s.state.as_str()), + circuit_failure_count: circuit.map(|s| s.failure_count), + circuit_failure_threshold: circuit.map(|s| s.failure_threshold), + circuit_recover_at_unix: circuit.and_then(|s| s.open_until.or(s.cooldown_until)), + circuit_trigger_error_code: circuit.and_then(|s| s.last_trigger_error_code), + provider_bridged: None, + timeout_secs: None, }); } diff --git a/src-tauri/src/gateway/proxy/handler/failover_loop/mod.rs b/src-tauri/src/gateway/proxy/handler/failover_loop/mod.rs index 9147a64e..899dd04f 100644 --- a/src-tauri/src/gateway/proxy/handler/failover_loop/mod.rs +++ b/src-tauri/src/gateway/proxy/handler/failover_loop/mod.rs @@ -118,8 +118,8 @@ use std::sync::{Arc, Mutex}; use std::time::Instant; use crate::gateway::events::{ - decision_chain as dc, emit_attempt_event, emit_gateway_debug_log_lazy, emit_gateway_log, - FailoverAttempt, GatewayAttemptEvent, + bound_attempt_event, decision_chain as dc, emit_attempt_event, emit_gateway_debug_log_lazy, + emit_gateway_log, FailoverAttempt, GatewayAttemptEvent, }; use crate::gateway::response_fixer; use crate::gateway::streams::{ diff --git a/src-tauri/src/gateway/proxy/handler/failover_loop/prepare/provider_checks.rs b/src-tauri/src/gateway/proxy/handler/failover_loop/prepare/provider_checks.rs index e394a69b..937da039 100644 --- a/src-tauri/src/gateway/proxy/handler/failover_loop/prepare/provider_checks.rs +++ b/src-tauri/src/gateway/proxy/handler/failover_loop/prepare/provider_checks.rs @@ -26,6 +26,7 @@ pub(super) fn skip_with_reason( reason: reason.reason, reason_code: None, attempt_started_ms, + circuit: None, }, ); } @@ -47,6 +48,7 @@ pub(super) fn run_gates( ) -> Option { let skipped_open_before = counters.skipped_open; let skipped_cooldown_before = counters.skipped_cooldown; + let mut deny_snapshot = None; let gate_allow = provider_gate::gate_provider(provider_gate::ProviderGateInput { ctx, provider_id: identity.provider_id, @@ -55,6 +57,7 @@ pub(super) fn run_gates( earliest_available_unix: &mut counters.earliest_available_unix, skipped_open: &mut counters.skipped_open, skipped_cooldown: &mut counters.skipped_cooldown, + deny_snapshot: &mut deny_snapshot, }); if gate_allow.is_none() { let (reason_code, reason_label) = if counters.skipped_open > skipped_open_before { @@ -75,6 +78,7 @@ pub(super) fn run_gates( reason: format!("provider skipped by circuit breaker ({reason_label})"), reason_code, attempt_started_ms: input.started.elapsed().as_millis(), + circuit: deny_snapshot, }, ); return None; @@ -97,6 +101,7 @@ pub(super) fn run_gates( reason: "provider skipped by rate limit".to_string(), reason_code: Some(dc::REASON_RATE_LIMITED), attempt_started_ms: input.started.elapsed().as_millis(), + circuit: None, }, ); return None; diff --git a/src-tauri/src/gateway/proxy/handler/failover_loop/prepare/provider_gate.rs b/src-tauri/src/gateway/proxy/handler/failover_loop/prepare/provider_gate.rs index 5659c295..5bad4cb8 100644 --- a/src-tauri/src/gateway/proxy/handler/failover_loop/prepare/provider_gate.rs +++ b/src-tauri/src/gateway/proxy/handler/failover_loop/prepare/provider_gate.rs @@ -13,6 +13,9 @@ pub(super) struct ProviderGateInput<'a, R: tauri::Runtime = tauri::Wry> { pub(super) earliest_available_unix: &'a mut Option, pub(super) skipped_open: &'a mut usize, pub(super) skipped_cooldown: &'a mut usize, + /// Filled with the circuit snapshot when the gate denies (see + /// `provider_router::GateProviderArgs::deny_snapshot`). + pub(super) deny_snapshot: &'a mut Option, } pub(super) struct ProviderGateAllow { @@ -30,6 +33,7 @@ pub(super) fn gate_provider( earliest_available_unix, skipped_open, skipped_cooldown, + deny_snapshot, } = input; let now_unix = now_unix_seconds() as i64; @@ -45,6 +49,7 @@ pub(super) fn gate_provider( earliest_available_unix, skipped_open, skipped_cooldown, + deny_snapshot, }) .map(|circuit_after| ProviderGateAllow { circuit_after }) } diff --git a/src-tauri/src/gateway/proxy/handler/failover_loop/prepare/provider_iterator.rs b/src-tauri/src/gateway/proxy/handler/failover_loop/prepare/provider_iterator.rs index f25c83de..4ad618a1 100644 --- a/src-tauri/src/gateway/proxy/handler/failover_loop/prepare/provider_iterator.rs +++ b/src-tauri/src/gateway/proxy/handler/failover_loop/prepare/provider_iterator.rs @@ -17,6 +17,9 @@ pub(super) struct PreparedProvider { pub(super) provider_base_url_display: String, pub(super) auth_mode: String, pub(super) provider_index: u32, + // Bridged (cx2cc) input semantics for this provider; threaded into + // FailoverAttempt so the request event can compute effective_input_tokens. + pub(super) provider_bridged: bool, pub(super) session_reuse: Option, pub(super) effective_credential: String, pub(super) provider_max_attempts: u32, @@ -136,6 +139,7 @@ pub(super) async fn prepare_provider( let provider_max_attempts = provider_max_attempts_for_request( input.max_attempts_per_provider, + gate_allow.circuit_after.failure_threshold, provider.auth_mode == "oauth", codex_request_has_previous_response_id(input), ); @@ -274,6 +278,7 @@ pub(super) async fn prepare_provider( provider_base_url_base: &provider_base_url_base, auth_mode: provider.auth_mode.as_str(), provider_index, + provider_bridged: is_cx2cc_bridge, session_reuse, stream_idle_timeout_seconds: provider.stream_idle_timeout_seconds, claude_model_mapping: None, @@ -328,6 +333,7 @@ pub(super) async fn prepare_provider( provider_base_url_display, auth_mode: provider.auth_mode.clone(), provider_index, + provider_bridged: is_cx2cc_bridge, session_reuse, effective_credential, provider_max_attempts, @@ -372,12 +378,15 @@ fn codex_body_has_previous_response_id(cli_key: &str, body: &[u8]) -> bool { fn provider_max_attempts_for_request( configured_max_attempts: u32, + circuit_failure_threshold: u32, needs_oauth_reactive_refresh_retry: bool, needs_codex_previous_response_id_retry: bool, ) -> u32 { let required_internal_retries = u32::from(needs_oauth_reactive_refresh_retry) + u32::from(needs_codex_previous_response_id_retry); - configured_max_attempts.max(1 + required_internal_retries) + configured_max_attempts + .max(circuit_failure_threshold.max(1)) + .max(1 + required_internal_retries) } #[cfg(test)] @@ -416,10 +425,17 @@ mod tests { #[test] fn provider_max_attempts_reserves_budget_for_internal_retries() { - assert_eq!(provider_max_attempts_for_request(1, false, false), 1); - assert_eq!(provider_max_attempts_for_request(1, true, false), 2); - assert_eq!(provider_max_attempts_for_request(1, false, true), 2); - assert_eq!(provider_max_attempts_for_request(1, true, true), 3); - assert_eq!(provider_max_attempts_for_request(5, true, true), 5); + assert_eq!(provider_max_attempts_for_request(1, 1, false, false), 1); + assert_eq!(provider_max_attempts_for_request(1, 1, true, false), 2); + assert_eq!(provider_max_attempts_for_request(1, 1, false, true), 2); + assert_eq!(provider_max_attempts_for_request(1, 1, true, true), 3); + assert_eq!(provider_max_attempts_for_request(5, 1, true, true), 5); + } + + #[test] + fn provider_max_attempts_respects_circuit_failure_threshold() { + assert_eq!(provider_max_attempts_for_request(1, 5, false, false), 5); + assert_eq!(provider_max_attempts_for_request(3, 5, true, true), 5); + assert_eq!(provider_max_attempts_for_request(10, 5, false, false), 10); } } diff --git a/src-tauri/src/gateway/proxy/handler/failover_loop/response/finalize.rs b/src-tauri/src/gateway/proxy/handler/failover_loop/response/finalize.rs index 5f70cc11..e6bb945a 100644 --- a/src-tauri/src/gateway/proxy/handler/failover_loop/response/finalize.rs +++ b/src-tauri/src/gateway/proxy/handler/failover_loop/response/finalize.rs @@ -121,7 +121,13 @@ pub(super) async fn all_providers_unavailable( let duration_ms = started.elapsed().as_millis(); emit_request_event_and_enqueue_request_log( RequestEndArgs::from_context(RequestEndContextArgs { - deps: RequestEndDeps::new(&state.app, &state.db, &state.log_tx, &state.plugin_pipeline), + deps: RequestEndDeps::new( + &state.app, + &state.db, + &state.log_tx, + &state.plugin_pipeline, + &state.active_requests, + ), trace_id: trace_id.as_str(), cli_key: cli_key.as_str(), method: method_hint.as_str(), @@ -252,7 +258,13 @@ pub(super) async fn all_providers_failed( let duration_ms = started.elapsed().as_millis(); emit_request_event_and_enqueue_request_log( RequestEndArgs::from_context(RequestEndContextArgs { - deps: RequestEndDeps::new(&state.app, &state.db, &state.log_tx, &state.plugin_pipeline), + deps: RequestEndDeps::new( + &state.app, + &state.db, + &state.log_tx, + &state.plugin_pipeline, + &state.active_requests, + ), trace_id: trace_id.as_str(), cli_key: cli_key.as_str(), method: method_hint.as_str(), diff --git a/src-tauri/src/gateway/proxy/handler/failover_loop/response/response_router.rs b/src-tauri/src/gateway/proxy/handler/failover_loop/response/response_router.rs index d1803e6f..587abe99 100644 --- a/src-tauri/src/gateway/proxy/handler/failover_loop/response/response_router.rs +++ b/src-tauri/src/gateway/proxy/handler/failover_loop/response/response_router.rs @@ -60,6 +60,7 @@ where let attempt_ctx = AttemptCtx { attempt_index: indices.attempt_index, retry_index: indices.retry_index, + provider_max_attempts: prepared.provider_max_attempts, attempt_started_ms: timing.attempt_started_ms, attempt_started: timing.attempt_started, circuit_before: &circuit_before, @@ -73,6 +74,7 @@ where provider_base_url_base: &prepared.provider_base_url_base, auth_mode: prepared.auth_mode.as_str(), provider_index: prepared.provider_index, + provider_bridged: prepared.provider_bridged, session_reuse: prepared.session_reuse, stream_idle_timeout_seconds: prepared.stream_idle_timeout_seconds, claude_model_mapping: prepared.claude_model_mapping.as_ref(), @@ -159,6 +161,7 @@ where let attempt_ctx = AttemptCtx { attempt_index: indices.attempt_index, retry_index: indices.retry_index, + provider_max_attempts: prepared.provider_max_attempts, attempt_started_ms: timing.attempt_started_ms, attempt_started: timing.attempt_started, circuit_before: &circuit_before, @@ -172,6 +175,7 @@ where provider_base_url_base: &prepared.provider_base_url_base, auth_mode: prepared.auth_mode.as_str(), provider_index: prepared.provider_index, + provider_bridged: prepared.provider_bridged, session_reuse: prepared.session_reuse, stream_idle_timeout_seconds: prepared.stream_idle_timeout_seconds, claude_model_mapping: prepared.claude_model_mapping.as_ref(), diff --git a/src-tauri/src/gateway/proxy/handler/failover_loop/response/success_event_stream.rs b/src-tauri/src/gateway/proxy/handler/failover_loop/response/success_event_stream.rs index cb6b8de4..8c3d23c7 100644 --- a/src-tauri/src/gateway/proxy/handler/failover_loop/response/success_event_stream.rs +++ b/src-tauri/src/gateway/proxy/handler/failover_loop/response/success_event_stream.rs @@ -52,6 +52,7 @@ where gemini_oauth_response_mode, cx2cc_active, anthropic_stream_requested: _, + .. } = attempt_ctx; let selection_method = dc::selection_method(provider_index, retry_index, session_reuse); let reason_code = dc::success_reason_code(provider_index, retry_index); @@ -154,6 +155,7 @@ where decision, outcome, reason: format!("first chunk read error (event-stream): {err}"), + timeout_secs: Some(upstream_first_byte_timeout_secs), }) .await; } @@ -185,6 +187,7 @@ where decision, outcome, reason: "first byte timeout (event-stream)".to_string(), + timeout_secs: Some(upstream_first_byte_timeout_secs), }) .await; } @@ -227,6 +230,7 @@ where decision, outcome, reason: "upstream returned empty event-stream".to_string(), + timeout_secs: Some(upstream_first_byte_timeout_secs), }) .await; } @@ -254,6 +258,10 @@ where circuit_state_after: None, circuit_failure_count: Some(circuit_before.failure_count), circuit_failure_threshold: Some(circuit_before.failure_threshold), + circuit_recover_at_unix: None, + circuit_trigger_error_code: None, + provider_bridged: Some(provider_ctx_owned.provider_bridged), + timeout_secs: None, }); emit_attempt_event_and_log_with_circuit_before( diff --git a/src-tauri/src/gateway/proxy/handler/failover_loop/response/success_non_stream.rs b/src-tauri/src/gateway/proxy/handler/failover_loop/response/success_non_stream.rs index 23790c06..ec455fb9 100644 --- a/src-tauri/src/gateway/proxy/handler/failover_loop/response/success_non_stream.rs +++ b/src-tauri/src/gateway/proxy/handler/failover_loop/response/success_non_stream.rs @@ -387,6 +387,7 @@ where gemini_oauth_response_mode, cx2cc_active, anthropic_stream_requested, + .. } = attempt_ctx; let selection_method = dc::selection_method(provider_index, retry_index, session_reuse); let reason_code = dc::success_reason_code(provider_index, retry_index); @@ -433,6 +434,10 @@ where circuit_state_after: None, circuit_failure_count: Some(circuit_before.failure_count), circuit_failure_threshold: Some(circuit_before.failure_threshold), + circuit_recover_at_unix: None, + circuit_trigger_error_code: None, + provider_bridged: Some(provider_ctx_owned.provider_bridged), + timeout_secs: None, }); emit_attempt_event_and_log_with_circuit_before( @@ -521,6 +526,10 @@ where circuit_state_after: None, circuit_failure_count: Some(circuit_before.failure_count), circuit_failure_threshold: Some(circuit_before.failure_threshold), + circuit_recover_at_unix: None, + circuit_trigger_error_code: None, + provider_bridged: Some(provider_ctx_owned.provider_bridged), + timeout_secs: None, }); emit_attempt_event_and_log_with_circuit_before( @@ -649,6 +658,7 @@ where decision, outcome, reason: kind.reason(MAX_NON_SSE_BODY_BYTES), + timeout_secs: None, }) .await; } @@ -677,6 +687,10 @@ where circuit_state_after: None, circuit_failure_count: Some(circuit_before.failure_count), circuit_failure_threshold: Some(circuit_before.failure_threshold), + circuit_recover_at_unix: None, + circuit_trigger_error_code: None, + provider_bridged: Some(provider_ctx_owned.provider_bridged), + timeout_secs: None, }); emit_attempt_event_and_log_with_circuit_before( @@ -731,6 +745,7 @@ where decision, outcome, reason: format!("cx2cc event-stream aggregation failed: {err}"), + timeout_secs: None, }) .await; } @@ -886,6 +901,7 @@ where decision, outcome, reason: format!("cx2cc response translation failed: {err}"), + timeout_secs: None, }) .await; } @@ -990,6 +1006,7 @@ where &state.db, &state.log_tx, &state.plugin_pipeline, + &state.active_requests, ), trace_id: common.trace_id.as_str(), cli_key: common.cli_key.as_str(), @@ -1042,10 +1059,11 @@ where }; match state.plugin_pipeline.run_response_hook(hook_input).await { Ok(output) => { - crate::gateway::plugins::audit::persist_gateway_plugin_audit_events( + crate::gateway::plugins::audit::persist_gateway_plugin_diagnostics( &state.db, &common.trace_id, output.audit_events.clone(), + output.execution_reports.clone(), ); if let Some(blocked) = output.blocked { tracing::warn!( @@ -1159,7 +1177,13 @@ where let duration_ms = started.elapsed().as_millis(); emit_request_event_and_enqueue_request_log( RequestEndArgs::from_context(RequestEndContextArgs { - deps: RequestEndDeps::new(&state.app, &state.db, &state.log_tx, &state.plugin_pipeline), + deps: RequestEndDeps::new( + &state.app, + &state.db, + &state.log_tx, + &state.plugin_pipeline, + &state.active_requests, + ), trace_id: common.trace_id.as_str(), cli_key: common.cli_key.as_str(), method: common.method_hint.as_str(), diff --git a/src-tauri/src/gateway/proxy/handler/failover_loop/response/thinking_signature_rectifier_400.rs b/src-tauri/src/gateway/proxy/handler/failover_loop/response/thinking_signature_rectifier_400.rs index 933a6c9f..7b6dae9e 100644 --- a/src-tauri/src/gateway/proxy/handler/failover_loop/response/thinking_signature_rectifier_400.rs +++ b/src-tauri/src/gateway/proxy/handler/failover_loop/response/thinking_signature_rectifier_400.rs @@ -113,6 +113,7 @@ pub(super) async fn handle_thinking_rectifiers_400( &state.db, &state.log_tx, &state.plugin_pipeline, + &state.active_requests, ), trace_id: trace_id.as_str(), cli_key: cli_key.as_str(), @@ -402,6 +403,10 @@ pub(super) async fn handle_thinking_rectifiers_400( circuit_state_after, circuit_failure_count, circuit_failure_threshold, + circuit_recover_at_unix: None, + circuit_trigger_error_code: None, + provider_bridged: Some(provider_ctx.provider_bridged), + timeout_secs: None, }); emit_attempt_event_and_log( @@ -471,6 +476,7 @@ pub(super) async fn handle_thinking_rectifiers_400( &state.db, &state.log_tx, &state.plugin_pipeline, + &state.active_requests, ), trace_id: trace_id.as_str(), cli_key: cli_key.as_str(), diff --git a/src-tauri/src/gateway/proxy/handler/failover_loop/response/upstream_error.rs b/src-tauri/src/gateway/proxy/handler/failover_loop/response/upstream_error.rs index 110db755..29c9c3a4 100644 --- a/src-tauri/src/gateway/proxy/handler/failover_loop/response/upstream_error.rs +++ b/src-tauri/src/gateway/proxy/handler/failover_loop/response/upstream_error.rs @@ -58,7 +58,7 @@ fn upstream_error_decision( fn reqwest_error_decision( is_count_tokens: bool, - is_connect: bool, + _is_connect: bool, retry_index: u32, max_attempts_per_provider: u32, ) -> FailoverDecision { @@ -66,10 +66,6 @@ fn reqwest_error_decision( return FailoverDecision::Abort; } - if is_connect { - return FailoverDecision::SwitchProvider; - } - if retry_index < max_attempts_per_provider { FailoverDecision::RetrySameProvider } else { @@ -289,7 +285,6 @@ pub(super) async fn handle_non_success_response( let mut resp = Some(resp); let state = ctx.state; - let max_attempts_per_provider = ctx.max_attempts_per_provider; let provider_cooldown_secs = ctx.provider_cooldown_secs; let ProviderCtx { @@ -305,6 +300,7 @@ pub(super) async fn handle_non_success_response( let AttemptCtx { attempt_index: _, retry_index, + provider_max_attempts, attempt_started_ms, attempt_started, circuit_before, @@ -326,7 +322,7 @@ pub(super) async fn handle_non_success_response( is_count_tokens, base_decision, retry_index, - max_attempts_per_provider, + provider_max_attempts, ); let mut abort_body_bytes: Option = None; @@ -567,6 +563,10 @@ pub(super) async fn handle_non_success_response( circuit_state_after, circuit_failure_count, circuit_failure_threshold, + circuit_recover_at_unix: None, + circuit_trigger_error_code: None, + provider_bridged: Some(provider_ctx.provider_bridged), + timeout_secs: None, }); emit_attempt_event_and_log( @@ -649,6 +649,7 @@ pub(super) async fn handle_non_success_response( &state.db, &state.log_tx, &state.plugin_pipeline, + &state.active_requests, ), trace_id: trace_id.as_str(), cli_key: cli_key.as_str(), @@ -694,6 +695,7 @@ pub(super) async fn handle_non_success_response( &state.db, &state.log_tx, &state.plugin_pipeline, + &state.active_requests, ), trace_id: trace_id.as_str(), cli_key: cli_key.as_str(), @@ -787,7 +789,7 @@ pub(super) async fn handle_reqwest_error( is_count_tokens, is_connect, attempt_ctx.retry_index, - ctx.max_attempts_per_provider, + attempt_ctx.provider_max_attempts, ); let outcome = format!( "request_error: category={} code={} decision={} err={err}", @@ -812,6 +814,7 @@ pub(super) async fn handle_reqwest_error( decision, outcome, reason: reason.to_string(), + timeout_secs: None, }) .await; } @@ -826,6 +829,7 @@ pub(super) async fn handle_reqwest_error( decision, outcome, reason: reason.to_string(), + timeout_secs: None, }) .await } @@ -1011,9 +1015,9 @@ mod tests { } #[test] - fn reqwest_error_decision_switches_non_count_tokens_connect_errors() { + fn reqwest_error_decision_retries_connect_errors_before_limit() { let decision = reqwest_error_decision(false, true, 1, 5); - assert!(matches!(decision, FailoverDecision::SwitchProvider)); + assert!(matches!(decision, FailoverDecision::RetrySameProvider)); } #[test] diff --git a/src-tauri/src/gateway/proxy/handler/failover_loop/tests.rs b/src-tauri/src/gateway/proxy/handler/failover_loop/tests.rs index bf07b939..65ab6f92 100644 --- a/src-tauri/src/gateway/proxy/handler/failover_loop/tests.rs +++ b/src-tauri/src/gateway/proxy/handler/failover_loop/tests.rs @@ -1,5 +1,9 @@ use super::context::{AttemptOutcome, FailoverRunState}; -use super::loop_helpers::should_finalize_as_all_providers_unavailable; +use super::loop_helpers::{ + push_skipped_provider_attempt, should_finalize_as_all_providers_unavailable, + SkippedProviderAttempt, +}; +use crate::circuit_breaker; use crate::gateway::events::{decision_chain as dc, FailoverAttempt}; use crate::gateway::proxy::GatewayErrorCode; @@ -25,6 +29,10 @@ fn skipped_attempt(reason_code: Option<&'static str>) -> FailoverAttempt { circuit_state_after: None, circuit_failure_count: None, circuit_failure_threshold: None, + circuit_recover_at_unix: None, + circuit_trigger_error_code: None, + provider_bridged: None, + timeout_secs: None, } } @@ -50,6 +58,10 @@ fn real_attempt() -> FailoverAttempt { circuit_state_after: None, circuit_failure_count: Some(0), circuit_failure_threshold: Some(5), + circuit_recover_at_unix: None, + circuit_trigger_error_code: None, + provider_bridged: Some(false), + timeout_secs: None, } } @@ -79,6 +91,10 @@ fn timeout_attempt( circuit_state_after: Some("OPEN"), circuit_failure_count: Some(5), circuit_failure_threshold: Some(5), + circuit_recover_at_unix: None, + circuit_trigger_error_code: None, + provider_bridged: Some(false), + timeout_secs: None, } } @@ -183,3 +199,136 @@ fn stream_flag_from_raw_body_only_scans_first_two_kb() { fn stream_flag_from_raw_body_ignores_non_utf8_payloads() { assert!(!super::stream_flag_from_raw_body(&[0xff, 0xfe, b'{'])); } + +// --- Circuit attribution on gate-skip attempts (attempts_json contract) --- + +fn gate_skip_attempt_json(circuit: Option) -> serde_json::Value { + let mut attempts = Vec::new(); + push_skipped_provider_attempt( + &mut attempts, + SkippedProviderAttempt { + provider_id: 7, + provider_name: "Provider A", + base_url: "https://provider-a.example", + error_category: "circuit_breaker", + error_code: GatewayErrorCode::ProviderCircuitOpen.as_str(), + reason: "provider skipped by circuit breaker (open)".to_string(), + reason_code: Some(dc::REASON_CIRCUIT_OPEN), + attempt_started_ms: 1, + circuit, + }, + ); + serde_json::to_value(&attempts[0]).expect("serialize skip attempt") +} + +#[test] +fn gate_skip_attempt_carries_circuit_attribution() { + let value = gate_skip_attempt_json(Some(circuit_breaker::CircuitSnapshot { + state: circuit_breaker::CircuitState::Open, + failure_count: 5, + failure_threshold: 5, + open_until: Some(1_750_001_800), + cooldown_until: None, + last_trigger_error_code: Some("GW_UPSTREAM_TIMEOUT"), + })); + + assert_eq!(value["circuit_state_before"], serde_json::json!("OPEN")); + assert_eq!(value["circuit_state_after"], serde_json::json!("OPEN")); + assert_eq!(value["circuit_failure_count"], serde_json::json!(5)); + assert_eq!(value["circuit_failure_threshold"], serde_json::json!(5)); + assert_eq!( + value["circuit_recover_at_unix"], + serde_json::json!(1_750_001_800i64) + ); + assert_eq!( + value["circuit_trigger_error_code"], + serde_json::json!("GW_UPSTREAM_TIMEOUT") + ); +} + +#[test] +fn gate_skip_attempt_without_trigger_omits_trigger_key_but_keeps_state() { + let value = gate_skip_attempt_json(Some(circuit_breaker::CircuitSnapshot { + state: circuit_breaker::CircuitState::Closed, + failure_count: 2, + failure_threshold: 5, + open_until: None, + cooldown_until: Some(1_750_000_060), + last_trigger_error_code: None, + })); + + let obj = value.as_object().expect("attempt object"); + assert!(!obj.contains_key("circuit_trigger_error_code")); + assert_eq!(value["circuit_state_before"], serde_json::json!("CLOSED")); + assert_eq!(value["circuit_failure_count"], serde_json::json!(2)); + assert_eq!(value["circuit_failure_threshold"], serde_json::json!(5)); + assert_eq!( + value["circuit_recover_at_unix"], + serde_json::json!(1_750_000_060i64) + ); +} + +#[test] +fn non_circuit_attempts_serialize_without_new_attribution_keys() { + // Baseline key set before this feature: the two new keys must be absent + // when None so successful requests' attempts_json gains zero bytes. + let expected_keys = [ + "provider_id", + "provider_name", + "base_url", + "outcome", + "status", + "provider_index", + "retry_index", + "session_reuse", + "error_category", + "error_code", + "decision", + "reason", + "selection_method", + "reason_code", + "attempt_started_ms", + "attempt_duration_ms", + "circuit_state_before", + "circuit_state_after", + "circuit_failure_count", + "circuit_failure_threshold", + "provider_bridged", + "timeout_secs", + ]; + + let mut success = real_attempt(); + success.outcome = "success".to_string(); + for attempt in [success, gate_skip_attempt_json_input_none()] { + let value = serde_json::to_value(&attempt).expect("serialize attempt"); + let mut keys: Vec<&str> = value + .as_object() + .expect("attempt object") + .keys() + .map(String::as_str) + .collect(); + keys.sort_unstable(); + let mut expected = expected_keys.to_vec(); + expected.sort_unstable(); + assert_eq!(keys, expected); + } +} + +fn gate_skip_attempt_json_input_none() -> FailoverAttempt { + let mut attempts = Vec::new(); + push_skipped_provider_attempt( + &mut attempts, + SkippedProviderAttempt { + provider_id: 7, + provider_name: "Provider A", + base_url: "https://provider-a.example", + error_category: "auth", + error_code: GatewayErrorCode::InternalError.as_str(), + reason: "provider skipped by credential resolution".to_string(), + reason_code: None, + attempt_started_ms: 1, + circuit: None, + }, + ); + attempts.remove(0) +} diff --git a/src-tauri/src/gateway/proxy/handler/middleware/body_reader.rs b/src-tauri/src/gateway/proxy/handler/middleware/body_reader.rs index 8179f9aa..c2a3d4aa 100644 --- a/src-tauri/src/gateway/proxy/handler/middleware/body_reader.rs +++ b/src-tauri/src/gateway/proxy/handler/middleware/body_reader.rs @@ -75,10 +75,11 @@ impl BodyReaderMiddleware { }; match ctx.state.plugin_pipeline.run_request_hook(hook_input).await { Ok(output) => { - crate::gateway::plugins::audit::persist_gateway_plugin_audit_events( + crate::gateway::plugins::audit::persist_gateway_plugin_diagnostics( &ctx.state.db, &ctx.trace_id, output.audit_events.clone(), + output.execution_reports.clone(), ); if let Some(blocked) = output.blocked { tracing::warn!( diff --git a/src-tauri/src/gateway/proxy/handler/middleware/cx2cc_count_tokens_interceptor.rs b/src-tauri/src/gateway/proxy/handler/middleware/cx2cc_count_tokens_interceptor.rs index 4f7900e1..c299cab6 100644 --- a/src-tauri/src/gateway/proxy/handler/middleware/cx2cc_count_tokens_interceptor.rs +++ b/src-tauri/src/gateway/proxy/handler/middleware/cx2cc_count_tokens_interceptor.rs @@ -2,6 +2,7 @@ use super::{MiddlewareAction, ProxyContext}; use crate::gateway::events::emit_gateway_debug_log_lazy; +use crate::gateway::proxy::provider_adapters; use crate::providers; use axum::http::{header, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; @@ -40,10 +41,12 @@ pub(in crate::gateway::proxy::handler) fn should_intercept_cx2cc_count_tokens( is_claude_count_tokens: bool, providers: &[providers::ProviderForGateway], ) -> bool { - is_claude_count_tokens - && providers - .first() - .is_some_and(providers::ProviderForGateway::is_cx2cc_bridge) + providers.first().is_some_and(|provider| { + provider_adapters::cx2cc::is_count_tokens_intercept_supported( + is_claude_count_tokens, + provider_adapters::cx2cc::capabilities_for_provider(provider), + ) + }) } pub(in crate::gateway::proxy::handler) fn build_cx2cc_count_tokens_response_body( @@ -91,6 +94,7 @@ fn build_cx2cc_count_tokens_response(body: serde_json::Value, trace_id: &str) -> #[cfg(test)] mod tests { use super::*; + use crate::gateway::proxy::provider_adapters::{self, ProviderCapabilities}; fn provider(id: i64) -> providers::ProviderForGateway { providers::ProviderForGateway { @@ -112,6 +116,7 @@ mod tests { source_provider_id: None, bridge_type: None, stream_idle_timeout_seconds: None, + extension_values: vec![], } } @@ -123,6 +128,25 @@ mod tests { } } + #[test] + fn cx2cc_count_tokens_uses_adapter_capability() { + assert!( + provider_adapters::cx2cc::is_count_tokens_intercept_supported( + true, + provider_adapters::cx2cc::capabilities_for_provider(&cx2cc_provider(1)) + ) + ); + assert!( + provider_adapters::cx2cc::is_count_tokens_intercept_supported( + true, + ProviderCapabilities { + cx2cc_bridge: true, + ..Default::default() + } + ) + ); + } + #[test] fn intercepts_count_tokens_only_when_first_provider_is_cx2cc() { assert!(should_intercept_cx2cc_count_tokens( diff --git a/src-tauri/src/gateway/proxy/handler/middleware/mod.rs b/src-tauri/src/gateway/proxy/handler/middleware/mod.rs index 2a942c52..b184eb87 100644 --- a/src-tauri/src/gateway/proxy/handler/middleware/mod.rs +++ b/src-tauri/src/gateway/proxy/handler/middleware/mod.rs @@ -31,7 +31,9 @@ pub(super) use runtime_settings_reader::RuntimeSettingsMiddleware; pub(super) use warmup_interceptor::WarmupInterceptorMiddleware; use crate::gateway::proxy::request_body::GatewayRequestBody; -use crate::gateway::proxy::request_context::RequestContextParts; +use crate::gateway::proxy::request_context::{ + effective_first_byte_timeout_secs, RequestContextParts, +}; use crate::gateway::runtime::GatewayAppState; use crate::gateway::util::RequestedModelLocation; use crate::providers; @@ -80,6 +82,9 @@ pub(super) struct ProxyContext { pub(super) requested_model: Option, pub(super) requested_model_location: Option, + // -- request kind classification -- + pub(super) is_compact_request: bool, + // -- runtime settings (populated after settings read) -- pub(super) runtime_settings: Option, @@ -142,7 +147,13 @@ impl ProxyContext { max_attempts_per_provider: rs.max_attempts_per_provider, max_providers_to_try: rs.max_providers_to_try, provider_cooldown_secs: rs.provider_cooldown_secs, - upstream_first_byte_timeout_secs: rs.upstream_first_byte_timeout_secs, + // Compact requests get a widened first-byte timeout: the whole + // prompt cache is invalidated upstream, so the first byte can + // legitimately take minutes. See `effective_first_byte_timeout_secs`. + upstream_first_byte_timeout_secs: effective_first_byte_timeout_secs( + rs.upstream_first_byte_timeout_secs, + self.is_compact_request, + ), upstream_stream_idle_timeout_secs: rs.upstream_stream_idle_timeout_secs, upstream_request_timeout_non_streaming_secs: rs .upstream_request_timeout_non_streaming_secs, diff --git a/src-tauri/src/gateway/proxy/handler/middleware/model_inference.rs b/src-tauri/src/gateway/proxy/handler/middleware/model_inference.rs index ba42f983..46ba5420 100644 --- a/src-tauri/src/gateway/proxy/handler/middleware/model_inference.rs +++ b/src-tauri/src/gateway/proxy/handler/middleware/model_inference.rs @@ -1,5 +1,5 @@ -//! Middleware: infers the requested model from path/query/JSON body and computes -//! observe_request flag. +//! Middleware: infers the requested model from path/query/JSON body, computes +//! observe_request flag, and classifies the request kind (Claude `/compact`). //! //! Also applies the "large body + missing model" diagnostic heuristic aligned //! with claude-code-hub's `LARGE_REQUEST_BODY_BYTES`: if the body exceeds @@ -11,9 +11,19 @@ use super::{MiddlewareAction, ProxyContext}; use crate::gateway::proxy::compute_observe_request; use crate::gateway::proxy::handler::early_error::{ - build_early_error_log_ctx, early_error_contract, respond_early_error_with_spawn, EarlyErrorKind, + build_early_error_log_ctx, early_error_contract, push_special_setting, + respond_early_error_with_spawn, EarlyErrorKind, }; +use crate::gateway::proxy::CLAUDE_LOGGED_MESSAGES_PATH; use crate::gateway::util::{infer_requested_model_info, LARGE_REQUEST_BODY_BYTES}; +use axum::http::Method; + +/// Claude Code `/compact` replaces the whole system prompt with this marker +/// (verified verbatim against claude-cli 2.1.198). Detection is best-effort: +/// if a future CLI version changes the wording, requests silently fall back to +/// the normal timeout policy. +const COMPACT_SYSTEM_PROMPT_PREFIX: &str = + "You are a helpful AI assistant tasked with summarizing conversations."; pub(in crate::gateway::proxy::handler) struct ModelInferenceMiddleware; @@ -36,6 +46,19 @@ impl ModelInferenceMiddleware { ctx.introspection_json.as_ref(), ); + ctx.is_compact_request = is_compact_request( + &ctx.cli_key, + &ctx.req_method, + &ctx.forwarded_path, + ctx.introspection_json.as_ref(), + ); + if ctx.is_compact_request { + push_special_setting( + &ctx.special_settings, + serde_json::json!({ "type": "request_kind", "kind": "compact" }), + ); + } + if is_large_body_missing_model(ctx.body_bytes.len(), ctx.requested_model.as_deref()) { let contract = early_error_contract(EarlyErrorKind::LargeBodyMissingModel); let message = large_body_missing_model_message(ctx.body_bytes.len()); @@ -49,6 +72,33 @@ impl ModelInferenceMiddleware { } } +/// Detects a Claude Code `/compact` request. +/// +/// Only inspects the parsed `system` field (array form, first block's `text`). +/// Never searches the raw body: conversation content may legitimately contain +/// the marker text and must not cause a false positive. +pub(in crate::gateway::proxy::handler) fn is_compact_request( + cli_key: &str, + method: &Method, + forwarded_path: &str, + introspection_json: Option<&serde_json::Value>, +) -> bool { + if cli_key != "claude" + || *method != Method::POST + || forwarded_path != CLAUDE_LOGGED_MESSAGES_PATH + { + return false; + } + + introspection_json + .and_then(|root| root.get("system")) + .and_then(|system| system.as_array()) + .and_then(|blocks| blocks.first()) + .and_then(|block| block.get("text")) + .and_then(|text| text.as_str()) + .is_some_and(|text| text.starts_with(COMPACT_SYSTEM_PROMPT_PREFIX)) +} + pub(in crate::gateway::proxy::handler) fn is_large_body_missing_model( body_len: usize, requested_model: Option<&str>, @@ -114,4 +164,96 @@ mod tests { assert!(message.contains(&format!("{} MB", LARGE_REQUEST_BODY_BYTES / (1024 * 1024)))); assert!(message.contains("truncated")); } + + fn compact_body() -> serde_json::Value { + serde_json::json!({ + "model": "claude-3-5-sonnet", + "system": [ + { + "type": "text", + "text": format!("{COMPACT_SYSTEM_PROMPT_PREFIX} Follow the instructions."), + } + ], + "messages": [{"role": "user", "content": "summarize"}] + }) + } + + #[test] + fn compact_request_detected_for_claude_messages_post() { + assert!(is_compact_request( + "claude", + &Method::POST, + "/v1/messages", + Some(&compact_body()), + )); + } + + #[test] + fn compact_request_rejects_string_form_system() { + let body = serde_json::json!({ + "system": COMPACT_SYSTEM_PROMPT_PREFIX, + "messages": [{"role": "user", "content": "summarize"}] + }); + assert!(!is_compact_request( + "claude", + &Method::POST, + "/v1/messages", + Some(&body), + )); + } + + #[test] + fn compact_request_ignores_marker_text_inside_messages() { + let body = serde_json::json!({ + "system": [{"type": "text", "text": "You are Claude Code, Anthropic's official CLI for Claude."}], + "messages": [ + {"role": "user", "content": COMPACT_SYSTEM_PROMPT_PREFIX} + ] + }); + assert!(!is_compact_request( + "claude", + &Method::POST, + "/v1/messages", + Some(&body), + )); + } + + #[test] + fn compact_request_rejects_other_cli_key_path_or_method() { + let body = compact_body(); + assert!(!is_compact_request( + "codex", + &Method::POST, + "/v1/messages", + Some(&body), + )); + assert!(!is_compact_request( + "claude", + &Method::POST, + "/v1/messages/count_tokens", + Some(&body), + )); + assert!(!is_compact_request( + "claude", + &Method::GET, + "/v1/messages", + Some(&body), + )); + } + + #[test] + fn compact_request_falls_back_false_without_json_or_system() { + assert!(!is_compact_request( + "claude", + &Method::POST, + "/v1/messages", + None, + )); + assert!(!is_compact_request( + "claude", + &Method::POST, + "/v1/messages", + Some(&serde_json::json!({"messages": []})), + )); + } } diff --git a/src-tauri/src/gateway/proxy/handler/middleware/provider_resolution.rs b/src-tauri/src/gateway/proxy/handler/middleware/provider_resolution.rs index bf03046d..7c32a5a5 100644 --- a/src-tauri/src/gateway/proxy/handler/middleware/provider_resolution.rs +++ b/src-tauri/src/gateway/proxy/handler/middleware/provider_resolution.rs @@ -4,7 +4,7 @@ use super::{MiddlewareAction, ProxyContext}; use crate::gateway::proxy::handler::early_error::{ build_early_error_log_ctx, early_error_contract, force_provider_if_requested, push_special_setting, respond_early_error_with_enqueue, respond_invalid_cli_key_with_spawn, - EarlyErrorKind, + respond_provider_selection_failed_with_spawn, EarlyErrorKind, }; use crate::gateway::proxy::handler::provider_selection::{ resolve_session_bound_provider_id, resolve_session_routing_decision, @@ -30,21 +30,45 @@ impl ProviderResolutionMiddleware { ctx.allow_session_reuse = decision.allow_session_reuse; // --- provider selection --- - let selection = match select_providers_with_session_binding( - &ctx.state, - &ctx.cli_key, - ctx.session_id.as_deref(), - ctx.created_at, - ) { + // Runs rusqlite queries; keep them off the async worker via the bounded + // blocking pool (pool.get can block up to 5s under DB contention). + let selection_result = { + let state = ctx.state.clone(); + let cli_key = ctx.cli_key.clone(); + let session_id = ctx.session_id.clone(); + let created_at = ctx.created_at; + crate::blocking::run("gateway_provider_selection", move || { + select_providers_with_session_binding( + &state, + &cli_key, + session_id.as_deref(), + created_at, + ) + }) + .await + }; + let selection = match selection_result { Ok(s) => s, Err(err) => { let log_ctx = build_early_error_log_ctx(&ctx); - let resp = respond_invalid_cli_key_with_spawn( - &log_ctx, - ctx.session_id.clone(), - ctx.requested_model.clone(), - err.to_string(), - ); + // A rejected cli key is the caller's fault (400); everything + // else here is infrastructure (DB pool / blocking pool) and + // must not be misfiled as a client error. + let resp = if err.code() == "SEC_INVALID_INPUT" { + respond_invalid_cli_key_with_spawn( + &log_ctx, + ctx.session_id.clone(), + ctx.requested_model.clone(), + err.to_string(), + ) + } else { + respond_provider_selection_failed_with_spawn( + &log_ctx, + ctx.session_id.clone(), + ctx.requested_model.clone(), + err.to_string(), + ) + }; return MiddlewareAction::ShortCircuit(resp); } }; diff --git a/src-tauri/src/gateway/proxy/handler/middleware/warmup_interceptor.rs b/src-tauri/src/gateway/proxy/handler/middleware/warmup_interceptor.rs index cc1cd866..4423b261 100644 --- a/src-tauri/src/gateway/proxy/handler/middleware/warmup_interceptor.rs +++ b/src-tauri/src/gateway/proxy/handler/middleware/warmup_interceptor.rs @@ -99,6 +99,10 @@ fn respond_warmup_intercept( circuit_state_after: None, circuit_failure_count: None, circuit_failure_threshold: None, + circuit_recover_at_unix: None, + circuit_trigger_error_code: None, + provider_bridged: None, + timeout_secs: None, }]; emit_request_event_and_spawn_request_log( @@ -108,6 +112,7 @@ fn respond_warmup_intercept( &ctx.state.db, &ctx.state.log_tx, &ctx.state.plugin_pipeline, + &ctx.state.active_requests, ), trace_id: &ctx.trace_id, cli_key: &ctx.cli_key, diff --git a/src-tauri/src/gateway/proxy/handler/mod.rs b/src-tauri/src/gateway/proxy/handler/mod.rs index 1112bae4..3a6b2dd7 100644 --- a/src-tauri/src/gateway/proxy/handler/mod.rs +++ b/src-tauri/src/gateway/proxy/handler/mod.rs @@ -4,10 +4,12 @@ //! directory processes a `ProxyContext` and either continues to the next step or //! short-circuits with a Response. +use super::abort_guard::RequestAbortGuard; use super::is_claude_count_tokens_request; use super::logging::enqueue_request_log_placeholder; use super::request_context::RequestContext; +use crate::gateway::active_requests::ActiveRequestStart; use crate::gateway::events::{emit_gateway_debug_log_lazy, emit_request_start_event}; use crate::gateway::proxy::should_seed_in_progress_request_log; use crate::gateway::response_fixer; @@ -73,6 +75,8 @@ fn build_in_progress_request_log_args( attempts_json: "[]".to_string(), requested_model: ctx.requested_model.as_deref().map(str::to_string), created_at_ms: ctx.created_at_ms, + last_activity_ms: None, + activity_details_json: None, created_at: ctx.created_at, usage_metrics: None, usage: None, @@ -81,6 +85,52 @@ fn build_in_progress_request_log_args( }) } +fn register_active_request_from_proxy_context( + ctx: &middleware::ProxyContext, +) { + if !ctx.observe_request { + return; + } + + ctx.state.active_requests.register(ActiveRequestStart { + trace_id: ctx.trace_id.clone(), + cli_key: ctx.cli_key.clone(), + method: ctx.method_hint.clone(), + path: ctx.forwarded_path.clone(), + query: ctx.query.clone(), + session_id: ctx.session_id.clone(), + requested_model: ctx.requested_model.clone(), + created_at_ms: ctx.created_at_ms, + }); +} + +// Armed guard for the post-chain stretch: it must exist BEFORE the active +// request is registered and before any await, so that cancellation at any +// later await point (e.g. the placeholder enqueue) still finishes the +// registry entry and persists a client_abort terminal row via Drop. +fn abort_guard_from_proxy_context( + ctx: &middleware::ProxyContext, +) -> RequestAbortGuard { + RequestAbortGuard::new( + ctx.state.app.clone(), + ctx.state.db.clone(), + ctx.state.log_tx.clone(), + ctx.state.plugin_pipeline.clone(), + ctx.state.active_requests.clone(), + ctx.trace_id.clone(), + ctx.cli_key.clone(), + ctx.method_hint.clone(), + ctx.forwarded_path.clone(), + ctx.observe_request, + ctx.query.clone(), + ctx.session_id.clone(), + ctx.requested_model.clone(), + ctx.created_at_ms, + ctx.created_at, + ctx.started, + ) +} + // --------------------------------------------------------------------------- // Main entry point: middleware chain orchestrator // --------------------------------------------------------------------------- @@ -134,6 +184,7 @@ where special_settings: new_special_settings(), requested_model: None, requested_model_location: None, + is_compact_request: false, runtime_settings: None, session_id: None, allow_session_reuse: false, @@ -221,7 +272,12 @@ where }; // --- Post-chain: emit start event, seed in-progress log, then forward --- + // 顺序契约:先武装 abort guard,再登记活跃注册表,之后才允许出现 await。 + // guard 未武装时登记后被取消(future 被丢弃)会让注册表条目永久泄漏, + // 前端将永远显示一张"进行中"合成卡片。 + let abort_guard = abort_guard_from_proxy_context(&ctx); if ctx.observe_request { + register_active_request_from_proxy_context(&ctx); emit_request_start_event( &ctx.state.app, ctx.trace_id.clone(), @@ -237,12 +293,17 @@ where emit_gateway_debug_log_lazy(&ctx.state.app, || { format!( - "[REQ] trace_id={} cli_key={} method={} path={} model={}\n headers={}\n body({} bytes)={}", + "[REQ] trace_id={} cli_key={} method={} path={} model={}{}\n headers={}\n body({} bytes)={}", ctx.trace_id, ctx.cli_key, ctx.method_hint, ctx.forwarded_path, ctx.requested_model.as_deref().unwrap_or("-"), + if ctx.is_compact_request { + " kind=compact" + } else { + "" + }, redacted_headers_for_debug(&ctx.headers), ctx.body_bytes.len(), lossy_utf8_preview(&ctx.body_bytes, MAX_DEBUG_BODY_PREVIEW_BYTES), @@ -256,6 +317,7 @@ where super::forwarder::forward(RequestContext::from_handler_parts( ctx.into_request_context_parts(), + abort_guard, )) .await } @@ -266,7 +328,9 @@ where #[cfg(test)] mod tests { + use super::abort_guard_from_proxy_context; use super::early_error::early_error_contract; + use super::middleware; use super::middleware::body_reader::body_too_large_message; use super::middleware::cli_proxy_guard::{ cli_proxy_disabled_message, cli_proxy_guard_special_settings_json, @@ -277,12 +341,21 @@ mod tests { warmup_log_usage_metrics, }; use super::provider_selection::resolve_session_routing_decision; + use super::register_active_request_from_proxy_context; use super::request_fingerprint::build_request_fingerprints; use super::runtime_settings::handler_runtime_settings; + use crate::gateway::active_requests::ActiveRequestRegistry; + use crate::gateway::codex_session_id::CodexSessionIdCache; + use crate::gateway::plugins::pipeline::GatewayPluginPipeline; use crate::gateway::proxy::{ErrorCategory, GatewayErrorCode}; - use crate::settings; - use axum::body::Bytes; - use axum::http::{HeaderMap, HeaderValue, StatusCode}; + use crate::gateway::proxy::{ProviderBaseUrlPingCache, RecentErrorCache}; + use crate::gateway::runtime::GatewayAppState; + use crate::{circuit_breaker, db, session_manager, settings}; + use axum::body::{Body, Bytes}; + use axum::http::{HeaderMap, HeaderValue, Method, StatusCode}; + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + use std::time::Instant; fn provider(id: i64) -> crate::providers::ProviderForGateway { crate::providers::ProviderForGateway { @@ -304,6 +377,7 @@ mod tests { source_provider_id: None, bridge_type: None, stream_idle_timeout_seconds: None, + extension_values: vec![], } } @@ -311,6 +385,247 @@ mod tests { items.iter().map(|item| item.id).collect() } + fn active_request_test_state( + app: tauri::AppHandle, + db: db::Db, + log_tx: tokio::sync::mpsc::Sender, + active_requests: Arc, + ) -> GatewayAppState { + GatewayAppState { + app, + db, + log_tx, + circuit: Arc::new(circuit_breaker::CircuitBreaker::new( + circuit_breaker::CircuitBreakerConfig::default(), + HashMap::new(), + None, + )), + session: Arc::new(session_manager::SessionManager::new()), + codex_session_cache: Arc::new(Mutex::new(CodexSessionIdCache::default())), + recent_errors: Arc::new(Mutex::new(RecentErrorCache::default())), + latency_cache: Arc::new(Mutex::new(ProviderBaseUrlPingCache::default())), + plugin_pipeline: GatewayPluginPipeline::empty_shared(), + active_requests, + } + } + + #[test] + fn observed_proxy_context_registers_active_request() { + let app = tauri::test::mock_app(); + let db_dir = tempfile::tempdir().expect("db dir"); + let db = + crate::db::init_for_tests(&db_dir.path().join("handler-active.db")).expect("init db"); + let (log_tx, _log_rx) = tokio::sync::mpsc::channel(1); + let active_requests = Arc::new(ActiveRequestRegistry::default()); + let ctx = middleware::ProxyContext { + state: active_request_test_state( + app.handle().clone(), + db, + log_tx, + active_requests.clone(), + ), + cli_key: "claude".to_string(), + forwarded_path: "/v1/messages".to_string(), + req_method: Method::POST, + method_hint: "POST".to_string(), + query: Some("beta=1".to_string()), + trace_id: "trace-start-active".to_string(), + started: Instant::now(), + created_at_ms: 1_700_000_000_000, + created_at: 1_700_000_000, + is_claude_count_tokens: false, + request_body: Some(Body::empty()), + headers: HeaderMap::new(), + body_bytes: Bytes::new(), + request_body_state: None, + introspection_json: None, + observe_request: true, + strip_request_content_encoding_seed: false, + special_settings: Arc::new(Mutex::new(Vec::new())), + requested_model: Some("claude-sonnet-4".to_string()), + requested_model_location: None, + is_compact_request: false, + runtime_settings: None, + session_id: Some("session-start".to_string()), + allow_session_reuse: false, + effective_sort_mode_id: None, + providers: vec![], + session_bound_provider_id: None, + forced_provider_id: None, + fingerprint_key: 0, + fingerprint_debug: String::new(), + unavailable_fingerprint_key: 0, + unavailable_fingerprint_debug: String::new(), + }; + + register_active_request_from_proxy_context(&ctx); + + let snapshot = active_requests.snapshot(); + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].trace_id, "trace-start-active"); + assert_eq!(snapshot[0].session_id.as_deref(), Some("session-start")); + assert_eq!( + snapshot[0].requested_model.as_deref(), + Some("claude-sonnet-4") + ); + } + + #[test] + fn unobserved_proxy_context_does_not_register_active_request() { + let app = tauri::test::mock_app(); + let db_dir = tempfile::tempdir().expect("db dir"); + let db = crate::db::init_for_tests(&db_dir.path().join("handler-unobserved.db")) + .expect("init db"); + let (log_tx, _log_rx) = tokio::sync::mpsc::channel(1); + let active_requests = Arc::new(ActiveRequestRegistry::default()); + let mut ctx = middleware::ProxyContext { + state: active_request_test_state( + app.handle().clone(), + db, + log_tx, + active_requests.clone(), + ), + cli_key: "claude".to_string(), + forwarded_path: "/v1/messages".to_string(), + req_method: Method::POST, + method_hint: "POST".to_string(), + query: None, + trace_id: "trace-unobserved".to_string(), + started: Instant::now(), + created_at_ms: 1_700_000_000_000, + created_at: 1_700_000_000, + is_claude_count_tokens: false, + request_body: Some(Body::empty()), + headers: HeaderMap::new(), + body_bytes: Bytes::new(), + request_body_state: None, + introspection_json: None, + observe_request: false, + strip_request_content_encoding_seed: false, + special_settings: Arc::new(Mutex::new(Vec::new())), + requested_model: Some("claude-sonnet-4".to_string()), + requested_model_location: None, + is_compact_request: false, + runtime_settings: None, + session_id: None, + allow_session_reuse: false, + effective_sort_mode_id: None, + providers: vec![], + session_bound_provider_id: None, + forced_provider_id: None, + fingerprint_key: 0, + fingerprint_debug: String::new(), + unavailable_fingerprint_key: 0, + unavailable_fingerprint_debug: String::new(), + }; + + register_active_request_from_proxy_context(&ctx); + assert!(active_requests.snapshot().is_empty()); + + ctx.observe_request = true; + register_active_request_from_proxy_context(&ctx); + + assert_eq!(active_requests.snapshot().len(), 1); + } + + fn observed_guard_test_proxy_context( + app: tauri::AppHandle, + db: crate::db::Db, + log_tx: tokio::sync::mpsc::Sender, + active_requests: Arc, + trace_id: &str, + ) -> middleware::ProxyContext { + middleware::ProxyContext { + state: active_request_test_state(app, db, log_tx, active_requests), + cli_key: "claude".to_string(), + forwarded_path: "/v1/messages".to_string(), + req_method: Method::POST, + method_hint: "POST".to_string(), + query: None, + trace_id: trace_id.to_string(), + started: Instant::now(), + created_at_ms: 1_700_000_000_000, + created_at: 1_700_000_000, + is_claude_count_tokens: false, + request_body: Some(Body::empty()), + headers: HeaderMap::new(), + body_bytes: Bytes::new(), + request_body_state: None, + introspection_json: None, + observe_request: true, + strip_request_content_encoding_seed: false, + special_settings: Arc::new(Mutex::new(Vec::new())), + requested_model: Some("claude-sonnet-4".to_string()), + requested_model_location: None, + is_compact_request: false, + runtime_settings: None, + session_id: Some("session-guard".to_string()), + allow_session_reuse: false, + effective_sort_mode_id: None, + providers: vec![], + session_bound_provider_id: None, + forced_provider_id: None, + fingerprint_key: 0, + fingerprint_debug: String::new(), + unavailable_fingerprint_key: 0, + unavailable_fingerprint_debug: String::new(), + } + } + + // 取消安全契约:guard 在登记前武装。handler future 在之后任意 await 点被 + // 取消(drop)时,guard 的 Drop 必须终结注册表条目,否则条目永久泄漏、 + // 前端永远显示"进行中"合成卡片。 + #[test] + fn dropping_armed_abort_guard_finishes_registered_active_request() { + let app = tauri::test::mock_app(); + let db_dir = tempfile::tempdir().expect("db dir"); + let db = crate::db::init_for_tests(&db_dir.path().join("handler-guard-drop.db")) + .expect("init db"); + let (log_tx, _log_rx) = tokio::sync::mpsc::channel(4); + let active_requests = Arc::new(ActiveRequestRegistry::default()); + let ctx = observed_guard_test_proxy_context( + app.handle().clone(), + db, + log_tx, + active_requests.clone(), + "trace-guard-drop", + ); + + let guard = abort_guard_from_proxy_context(&ctx); + register_active_request_from_proxy_context(&ctx); + assert_eq!(active_requests.snapshot().len(), 1); + + drop(guard); + + assert!(active_requests.snapshot().is_empty()); + } + + #[test] + fn dropping_disarmed_abort_guard_keeps_active_request_for_request_end() { + let app = tauri::test::mock_app(); + let db_dir = tempfile::tempdir().expect("db dir"); + let db = crate::db::init_for_tests(&db_dir.path().join("handler-guard-disarm.db")) + .expect("init db"); + let (log_tx, _log_rx) = tokio::sync::mpsc::channel(4); + let active_requests = Arc::new(ActiveRequestRegistry::default()); + let ctx = observed_guard_test_proxy_context( + app.handle().clone(), + db, + log_tx, + active_requests.clone(), + "trace-guard-disarm", + ); + + let mut guard = abort_guard_from_proxy_context(&ctx); + register_active_request_from_proxy_context(&ctx); + guard.disarm(); + + drop(guard); + + // 正常完成路径由 request_end 负责终结,disarm 后的 guard 不得抢先移除。 + assert_eq!(active_requests.snapshot().len(), 1); + } + #[test] fn cli_proxy_disabled_message_without_error_is_actionable() { let message = cli_proxy_disabled_message("claude", None); @@ -401,6 +716,18 @@ mod tests { ); assert_eq!(no_provider.error_category, None); assert!(!no_provider.excluded_from_stats); + + let selection_failed = early_error_contract(EarlyErrorKind::ProviderSelectionFailed); + assert_eq!(selection_failed.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!( + selection_failed.error_code, + GatewayErrorCode::InternalError.as_str() + ); + assert_eq!( + selection_failed.error_category, + Some(ErrorCategory::SystemError.as_str()) + ); + assert!(!selection_failed.excluded_from_stats); } #[test] diff --git a/src-tauri/src/gateway/proxy/handler/provider_order.rs b/src-tauri/src/gateway/proxy/handler/provider_order.rs index e4004490..fb8ec795 100644 --- a/src-tauri/src/gateway/proxy/handler/provider_order.rs +++ b/src-tauri/src/gateway/proxy/handler/provider_order.rs @@ -92,6 +92,7 @@ mod tests { source_provider_id: None, bridge_type: None, stream_idle_timeout_seconds: None, + extension_values: vec![], } } @@ -106,6 +107,15 @@ mod tests { assert_eq!(ids(&providers), vec![3, 1, 2, 4]); } + #[test] + fn acceptance_bound_order_ignores_unknown_and_duplicate_provider_ids() { + let mut providers = vec![provider(1), provider(2), provider(3), provider(4)]; + + reorder_providers_by_bound_order(&mut providers, &[3, 99, 3, 1]); + + assert_eq!(ids(&providers), vec![3, 1, 2, 4]); + } + #[test] fn apply_session_preference_rotates_from_bound_provider_when_present() { let mut providers = vec![provider(11), provider(22), provider(33)]; diff --git a/src-tauri/src/gateway/proxy/handler/provider_selection.rs b/src-tauri/src/gateway/proxy/handler/provider_selection.rs index 0e2a4d56..adc55265 100644 --- a/src-tauri/src/gateway/proxy/handler/provider_selection.rs +++ b/src-tauri/src/gateway/proxy/handler/provider_selection.rs @@ -125,7 +125,7 @@ pub(super) fn resolve_session_bound_provider_id( created_at: i64, allow_session_reuse: bool, forced_provider_id: Option, - providers: &mut [providers::ProviderForGateway], + providers: &mut Vec, bound_provider_order: Option<&[i64]>, ) -> Option { let bound_provider_id = @@ -141,6 +141,7 @@ pub(super) fn resolve_session_bound_provider_id( } else { let allow = circuit.should_allow(bound_provider_id, created_at).allow; if !allow { + providers.retain(|provider| provider.id != bound_provider_id); return None; } } diff --git a/src-tauri/src/gateway/proxy/handler/provider_selection/tests.rs b/src-tauri/src/gateway/proxy/handler/provider_selection/tests.rs index 606cda2b..51bd5ba9 100644 --- a/src-tauri/src/gateway/proxy/handler/provider_selection/tests.rs +++ b/src-tauri/src/gateway/proxy/handler/provider_selection/tests.rs @@ -34,6 +34,7 @@ fn insert_provider(db: &crate::db::Db, name: &str, enabled: bool) -> providers:: source_provider_id: None, bridge_type: None, stream_idle_timeout_seconds: None, + extension_values: None, }, ) .expect("insert provider"); @@ -87,7 +88,7 @@ fn open_circuit_for_provider(provider_id: i64, now: i64) -> circuit_breaker::Cir HashMap::new(), None, ); - circuit.record_failure(provider_id, now); + circuit.record_failure(provider_id, now, None); assert!(!circuit.should_allow(provider_id, now).allow); circuit } @@ -304,7 +305,7 @@ fn default_mode_switches_to_enabled_provider_after_bound_provider_disabled_and_c } #[test] -fn sort_mode_ignores_global_provider_enabled_but_open_circuit_prevents_session_reuse() { +fn sort_mode_ignores_global_provider_enabled_but_open_circuit_falls_back() { let dir = tempfile::tempdir().expect("tempdir"); let db_path = dir.path().join("test.db"); let db = crate::db::init_for_tests(&db_path).expect("init db"); @@ -335,7 +336,7 @@ fn sort_mode_ignores_global_provider_enabled_but_open_circuit_prevents_session_r Some(&[p1.id, p2.id]), ); - assert_eq!(ids(&enabled), vec![p1.id, p2.id]); + assert_eq!(ids(&enabled), vec![p2.id]); assert_eq!(selected, None); assert_eq!( session.get_bound_provider("claude", "sess_1", now), @@ -344,3 +345,37 @@ fn sort_mode_ignores_global_provider_enabled_but_open_circuit_prevents_session_r assert!(!circuit.should_allow(p1.id, now).allow); assert!(circuit.should_allow(p2.id, now).allow); } + +#[test] +fn acceptance_session_bound_provider_falls_back_when_bound_provider_circuit_is_open() { + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("test.db"); + let db = crate::db::init_for_tests(&db_path).expect("init db"); + + let p1 = insert_provider(&db, "P1", true); + let p2 = insert_provider(&db, "P2", true); + let id1 = p1.id; + let id2 = p2.id; + + let session = session_manager::SessionManager::new(); + let now = 1000; + session.bind_success("claude", "sess_1", id1, None, now); + let circuit = open_circuit_for_provider(id1, now); + + let mut enabled = + providers::list_enabled_for_gateway_in_mode(&db, "claude", None).expect("list enabled"); + let selected = resolve_session_bound_provider_id( + &session, + &circuit, + "claude", + Some("sess_1"), + now, + true, + None, + &mut enabled, + Some(&[id1, id2]), + ); + + assert_eq!(selected, None); + assert_eq!(ids(&enabled), vec![id2]); +} diff --git a/src-tauri/src/gateway/proxy/logging.rs b/src-tauri/src/gateway/proxy/logging.rs index 9f7d7b3b..91dfdd2b 100644 --- a/src-tauri/src/gateway/proxy/logging.rs +++ b/src-tauri/src/gateway/proxy/logging.rs @@ -130,6 +130,8 @@ fn request_log_insert_from_args( attempts_json, requested_model, created_at_ms, + last_activity_ms, + activity_details_json, created_at, usage_metrics, usage, @@ -181,6 +183,11 @@ fn request_log_insert_from_args( usage_json: bound_optional_json_object(usage_json, "usage_json"), requested_model: bound_optional_chars(requested_model, REQUEST_LOG_SHORT_TEXT_MAX_CHARS), created_at_ms, + last_activity_ms, + activity_details_json: bound_optional_json_object( + activity_details_json, + "activity_details_json", + ), created_at, provider_chain_json: bound_optional_json_array(provider_chain_json, "provider_chain_json"), error_details_json: bound_optional_json_object(error_details_json, "error_details_json"), @@ -267,10 +274,11 @@ async fn apply_log_before_persist_hook( }; match plugin_pipeline.run_log_hook(input).await { Ok(output) => { - crate::gateway::plugins::audit::persist_gateway_plugin_audit_events( + crate::gateway::plugins::audit::persist_gateway_plugin_diagnostics( db, &args.trace_id, output.audit_events.clone(), + output.execution_reports.clone(), ); if !apply_log_hook_message_to_args(args, output.message.as_str()) { tracing::warn!( @@ -647,6 +655,8 @@ WHERE trace_id = ?1 attempts_json: "[]".to_string(), requested_model: None, created_at_ms: 0, + last_activity_ms: None, + activity_details_json: None, created_at: 0, usage_metrics: None, usage: None, diff --git a/src-tauri/src/gateway/proxy/mod.rs b/src-tauri/src/gateway/proxy/mod.rs index e3a13e7a..ad97923a 100644 --- a/src-tauri/src/gateway/proxy/mod.rs +++ b/src-tauri/src/gateway/proxy/mod.rs @@ -17,6 +17,7 @@ mod http_util; mod logging; mod model_rewrite; pub(in crate::gateway) mod protocol_bridge; +pub(crate) mod provider_adapters; pub(in crate::gateway) mod provider_router; mod request_body; mod request_context; @@ -149,6 +150,8 @@ pub(super) struct RequestLogEnqueueArgs { pub(super) attempts_json: String, pub(super) requested_model: Option, pub(super) created_at_ms: i64, + pub(super) last_activity_ms: Option, + pub(super) activity_details_json: Option, pub(super) created_at: i64, pub(super) usage_metrics: Option, pub(super) usage: Option, diff --git a/src-tauri/src/gateway/proxy/protocol_bridge/e2e_tests.rs b/src-tauri/src/gateway/proxy/protocol_bridge/e2e_tests.rs index 1ddd200c..4400a4cd 100644 --- a/src-tauri/src/gateway/proxy/protocol_bridge/e2e_tests.rs +++ b/src-tauri/src/gateway/proxy/protocol_bridge/e2e_tests.rs @@ -539,6 +539,42 @@ mod tests { assert_eq!(anthropic_resp["usage"]["output_tokens"], 2); } + #[test] + fn acceptance_cx2cc_round_trip_preserves_requested_model_and_usage() { + let bridge = get_bridge("cx2cc").unwrap(); + let ctx = cx2cc_ctx(); + + let anthropic_req = json!({ + "model": "claude-sonnet-4-20250514", + "max_tokens": 1024, + "messages": [ + {"role": "user", "content": "Hello"} + ] + }); + + let translated_req = bridge.translate_request(anthropic_req, &ctx).unwrap(); + assert_eq!(translated_req.target_path, "/v1/responses"); + + let openai_resp = json!({ + "id": "resp_acceptance", + "model": translated_req.body["model"], + "status": "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hi"}] + } + ], + "usage": {"input_tokens": 13, "output_tokens": 5} + }); + + let anthropic_resp = bridge.translate_response(openai_resp, &ctx).unwrap(); + assert_eq!(anthropic_resp["model"], "claude-sonnet-4-20250514"); + assert_eq!(anthropic_resp["usage"]["input_tokens"], 13); + assert_eq!(anthropic_resp["usage"]["output_tokens"], 5); + } + // ── Model mapping ─────────────────────────────────────────────────── #[test] diff --git a/src-tauri/src/gateway/proxy/provider_adapters/claude.rs b/src-tauri/src/gateway/proxy/provider_adapters/claude.rs new file mode 100644 index 00000000..441b7984 --- /dev/null +++ b/src-tauri/src/gateway/proxy/provider_adapters/claude.rs @@ -0,0 +1,6 @@ +use super::ProviderCapabilities; + +#[allow(dead_code)] +pub(crate) fn is_anthropic_compatible(capabilities: ProviderCapabilities) -> bool { + capabilities.anthropic_compatible +} diff --git a/src-tauri/src/gateway/proxy/provider_adapters/codex_chatgpt.rs b/src-tauri/src/gateway/proxy/provider_adapters/codex_chatgpt.rs new file mode 100644 index 00000000..7cf0fa03 --- /dev/null +++ b/src-tauri/src/gateway/proxy/provider_adapters/codex_chatgpt.rs @@ -0,0 +1,6 @@ +use super::ProviderCapabilities; + +#[allow(dead_code)] +pub(crate) fn is_codex_chatgpt_backend(capabilities: ProviderCapabilities) -> bool { + capabilities.codex_chatgpt_backend +} diff --git a/src-tauri/src/gateway/proxy/provider_adapters/cx2cc.rs b/src-tauri/src/gateway/proxy/provider_adapters/cx2cc.rs new file mode 100644 index 00000000..d4114126 --- /dev/null +++ b/src-tauri/src/gateway/proxy/provider_adapters/cx2cc.rs @@ -0,0 +1,17 @@ +use super::ProviderCapabilities; + +pub(crate) fn capabilities_for_provider( + provider: &crate::providers::ProviderForGateway, +) -> ProviderCapabilities { + ProviderCapabilities { + cx2cc_bridge: provider.is_cx2cc_bridge(), + ..ProviderCapabilities::default() + } +} + +pub(crate) fn is_count_tokens_intercept_supported( + is_claude_count_tokens: bool, + capabilities: ProviderCapabilities, +) -> bool { + is_claude_count_tokens && capabilities.supports_count_tokens_local_intercept() +} diff --git a/src-tauri/src/gateway/proxy/provider_adapters/gemini_oauth.rs b/src-tauri/src/gateway/proxy/provider_adapters/gemini_oauth.rs new file mode 100644 index 00000000..cc176e16 --- /dev/null +++ b/src-tauri/src/gateway/proxy/provider_adapters/gemini_oauth.rs @@ -0,0 +1,6 @@ +use super::ProviderCapabilities; + +#[allow(dead_code)] +pub(crate) fn is_gemini_oauth(capabilities: ProviderCapabilities) -> bool { + capabilities.gemini_oauth +} diff --git a/src-tauri/src/gateway/proxy/provider_adapters/mod.rs b/src-tauri/src/gateway/proxy/provider_adapters/mod.rs new file mode 100644 index 00000000..42aedce7 --- /dev/null +++ b/src-tauri/src/gateway/proxy/provider_adapters/mod.rs @@ -0,0 +1,63 @@ +pub(crate) mod claude; +pub(crate) mod codex_chatgpt; +pub(crate) mod cx2cc; +pub(crate) mod gemini_oauth; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct ProviderCapabilities { + pub(crate) anthropic_compatible: bool, + pub(crate) openai_responses_compatible: bool, + pub(crate) codex_chatgpt_backend: bool, + pub(crate) gemini_oauth: bool, + pub(crate) cx2cc_bridge: bool, + pub(crate) service_tier_adjustment: bool, + pub(crate) stream_idle_timeout_override: bool, +} + +impl ProviderCapabilities { + pub(crate) fn supports_count_tokens_local_intercept(self) -> bool { + self.cx2cc_bridge + } +} + +#[cfg(test)] +mod tests { + use super::{cx2cc, ProviderCapabilities}; + + #[test] + fn registry_identifies_cx2cc_bridge_capability() { + let capabilities = ProviderCapabilities { + cx2cc_bridge: true, + ..ProviderCapabilities::default() + }; + + assert!(capabilities.supports_count_tokens_local_intercept()); + assert!(cx2cc::is_count_tokens_intercept_supported( + true, + capabilities + )); + assert!(!cx2cc::is_count_tokens_intercept_supported( + false, + capabilities + )); + } + + #[test] + fn registry_default_capabilities_are_plain_provider() { + let capabilities = ProviderCapabilities::default(); + + assert_eq!( + capabilities, + ProviderCapabilities { + anthropic_compatible: false, + openai_responses_compatible: false, + codex_chatgpt_backend: false, + gemini_oauth: false, + cx2cc_bridge: false, + service_tier_adjustment: false, + stream_idle_timeout_override: false, + } + ); + assert!(!capabilities.supports_count_tokens_local_intercept()); + } +} diff --git a/src-tauri/src/gateway/proxy/provider_router.rs b/src-tauri/src/gateway/proxy/provider_router.rs index a14ed08d..ef4df04c 100644 --- a/src-tauri/src/gateway/proxy/provider_router.rs +++ b/src-tauri/src/gateway/proxy/provider_router.rs @@ -15,6 +15,9 @@ pub(super) struct GateProviderArgs<'a, R: tauri::Runtime = tauri::Wry> { pub(super) earliest_available_unix: &'a mut Option, pub(super) skipped_open: &'a mut usize, pub(super) skipped_cooldown: &'a mut usize, + /// Filled with the circuit snapshot when the gate denies, so callers can + /// attach circuit attribution to the skipped attempt. + pub(super) deny_snapshot: &'a mut Option, } pub(super) fn gate_provider( @@ -32,6 +35,7 @@ pub(super) fn gate_provider( earliest_available_unix, skipped_open, skipped_cooldown, + deny_snapshot, } = args; let allow = circuit.should_allow(provider_id, now_unix); @@ -45,6 +49,8 @@ pub(super) fn gate_provider( provider_base_url_display, t, now_unix, + None, + None, ); } @@ -53,6 +59,7 @@ pub(super) fn gate_provider( } let snap = allow.after; + *deny_snapshot = Some(snap.clone()); let reason = match snap.state { circuit_breaker::CircuitState::Open => { *skipped_open = skipped_open.saturating_add(1); @@ -91,6 +98,9 @@ pub(super) fn gate_provider( cooldown_until: snap.cooldown_until, reason, ts: now_unix, + // Non-transition skip event: no trigger-failure attribution. + trigger_error_code: None, + first_byte_timeout_secs: None, }, ); } @@ -107,6 +117,12 @@ pub(in crate::gateway) struct RecordCircuitArgs<'a, R: tauri::Runtime = tauri::W pub(in crate::gateway) provider_name: &'a str, pub(in crate::gateway) provider_base_url: &'a str, pub(in crate::gateway) now_unix: i64, + /// Error code of the failure being recorded; feeds the "触发失败" line of + /// the circuit-open notice. `None` for call sites without attribution. + pub(in crate::gateway) trigger_error_code: Option<&'static str>, + /// Effective first-byte timeout (seconds); the notice builder only uses it + /// when `trigger_error_code` is `GW_UPSTREAM_TIMEOUT`. + pub(in crate::gateway) first_byte_timeout_secs: Option, } impl<'a, R: tauri::Runtime> RecordCircuitArgs<'a, R> { @@ -130,8 +146,20 @@ impl<'a, R: tauri::Runtime> RecordCircuitArgs<'a, R> { provider_name, provider_base_url, now_unix, + trigger_error_code: None, + first_byte_timeout_secs: None, } } + + pub(in crate::gateway) fn with_trigger( + mut self, + trigger_error_code: Option<&'static str>, + first_byte_timeout_secs: Option, + ) -> Self { + self.trigger_error_code = trigger_error_code; + self.first_byte_timeout_secs = first_byte_timeout_secs; + self + } } impl<'a, R: tauri::Runtime> RecordCircuitArgs<'a, R> { @@ -185,6 +213,7 @@ pub(in crate::gateway) fn record_success_and_emit_transition( provider_name, provider_base_url, now_unix, + .. } = args; let change = circuit.record_success(provider_id, now_unix); @@ -198,6 +227,8 @@ pub(in crate::gateway) fn record_success_and_emit_transition( provider_base_url, t, now_unix, + None, + None, ); } change @@ -215,9 +246,11 @@ pub(in crate::gateway) fn record_failure_and_emit_transition( provider_name, provider_base_url, now_unix, + trigger_error_code, + first_byte_timeout_secs, } = args; - let change = circuit.record_failure(provider_id, now_unix); + let change = circuit.record_failure(provider_id, now_unix, trigger_error_code); if let (Some(app), Some(t)) = (app, change.transition.as_ref()) { emit_circuit_transition( app, @@ -228,6 +261,8 @@ pub(in crate::gateway) fn record_failure_and_emit_transition( provider_base_url, t, now_unix, + trigger_error_code, + first_byte_timeout_secs, ); } change @@ -266,6 +301,7 @@ mod tests { let mut earliest: Option = None; let mut skipped_open = 0usize; let mut skipped_cooldown = 0usize; + let mut deny_snapshot = None; let snap = gate_provider(TestGateProviderArgs { app: None, @@ -279,6 +315,7 @@ mod tests { earliest_available_unix: &mut earliest, skipped_open: &mut skipped_open, skipped_cooldown: &mut skipped_cooldown, + deny_snapshot: &mut deny_snapshot, }) .expect("should allow"); @@ -297,7 +334,7 @@ mod tests { let pid = 1; let now = 1_000; - cb.record_failure(pid, now); + cb.record_failure(pid, now, None); let open = cb.snapshot(pid, now); assert_eq!(open.state, circuit_breaker::CircuitState::Open); let open_until = open.open_until.expect("open_until"); @@ -305,6 +342,7 @@ mod tests { let mut earliest: Option = None; let mut skipped_open = 0usize; let mut skipped_cooldown = 0usize; + let mut deny_snapshot = None; let allowed = gate_provider(TestGateProviderArgs { app: None, @@ -318,6 +356,7 @@ mod tests { earliest_available_unix: &mut earliest, skipped_open: &mut skipped_open, skipped_cooldown: &mut skipped_cooldown, + deny_snapshot: &mut deny_snapshot, }); assert!(allowed.is_none()); @@ -342,6 +381,7 @@ mod tests { let mut earliest: Option = None; let mut skipped_open = 0usize; let mut skipped_cooldown = 0usize; + let mut deny_snapshot = None; let allowed = gate_provider(TestGateProviderArgs { app: None, @@ -355,6 +395,7 @@ mod tests { earliest_available_unix: &mut earliest, skipped_open: &mut skipped_open, skipped_cooldown: &mut skipped_cooldown, + deny_snapshot: &mut deny_snapshot, }); assert!(allowed.is_none()); @@ -371,12 +412,13 @@ mod tests { }); let pid = 1; let now = 1_000; - cb.record_failure(pid, now); + cb.record_failure(pid, now, None); let open_until = cb.snapshot(pid, now).open_until.expect("open_until"); let mut earliest: Option = None; let mut skipped_open = 0usize; let mut skipped_cooldown = 0usize; + let mut deny_snapshot = None; let snap = gate_provider(TestGateProviderArgs { app: None, @@ -390,6 +432,7 @@ mod tests { earliest_available_unix: &mut earliest, skipped_open: &mut skipped_open, skipped_cooldown: &mut skipped_cooldown, + deny_snapshot: &mut deny_snapshot, }) .expect("should allow after expiry"); @@ -424,6 +467,31 @@ mod tests { assert!(change.transition.is_some()); } + #[test] + fn record_circuit_args_default_to_no_trigger_and_with_trigger_sets_fields() { + let cb = breaker(circuit_breaker::CircuitBreakerConfig { + failure_threshold: 5, + open_duration_secs: 60, + }); + + let args = TestRecordCircuitArgs::new( + None, + &cb, + "t", + "claude", + 1, + "p1", + "https://example.invalid", + 1_000, + ); + assert_eq!(args.trigger_error_code, None); + assert_eq!(args.first_byte_timeout_secs, None); + + let args = args.with_trigger(Some("GW_UPSTREAM_TIMEOUT"), Some(300)); + assert_eq!(args.trigger_error_code, Some("GW_UPSTREAM_TIMEOUT")); + assert_eq!(args.first_byte_timeout_secs, Some(300)); + } + #[test] fn record_success_clears_failure_count() { let cb = breaker(circuit_breaker::CircuitBreakerConfig { @@ -432,7 +500,7 @@ mod tests { }); let pid = 1; let now = 1_000; - cb.record_failure(pid, now); + cb.record_failure(pid, now, None); assert!(cb.snapshot(pid, now).failure_count > 0); let change = record_success_and_emit_transition(TestRecordCircuitArgs::new( diff --git a/src-tauri/src/gateway/proxy/request_context.rs b/src-tauri/src/gateway/proxy/request_context.rs index e09a3903..d0338b40 100644 --- a/src-tauri/src/gateway/proxy/request_context.rs +++ b/src-tauri/src/gateway/proxy/request_context.rs @@ -62,7 +62,13 @@ pub(super) struct RequestContext { } impl RequestContext { - pub(super) fn from_handler_parts(parts: RequestContextParts) -> Self { + // abort_guard 由调用方在任何 await 之前构造并武装(见 handler/mod.rs + // post-chain 注释):登记到活跃注册表的请求必须已有武装的 guard 兜底, + // 否则 handler future 在中间的 await 点被取消时注册表条目会永久泄漏。 + pub(super) fn from_handler_parts( + parts: RequestContextParts, + abort_guard: RequestAbortGuard, + ) -> Self { let RequestContextParts { state, cli_key, @@ -125,24 +131,6 @@ impl RequestContext { upstream_request_timeout_non_streaming_secs, ); - let abort_guard = RequestAbortGuard::new( - state.app.clone(), - state.db.clone(), - state.log_tx.clone(), - state.plugin_pipeline.clone(), - trace_id.clone(), - cli_key.clone(), - method_hint.clone(), - forwarded_path.clone(), - observe_request, - query.clone(), - session_id.clone(), - requested_model.clone(), - created_at_ms, - created_at, - started, - ); - let base_headers = build_base_headers(headers); Self { @@ -247,6 +235,28 @@ fn duration_from_secs(secs: u32) -> Option { } } +/// Claude Code `/compact` requests invalidate the whole prompt cache upstream, +/// so the provider must re-process a near-megabyte prompt before the first +/// byte arrives. The global default (30s) times out far too early, and the +/// resulting same-provider retries can trip the circuit breaker (2026-07-04 +/// incident). Fixed floor for now; promote to a user-facing setting if one +/// size turns out not to fit all providers. +pub(super) const COMPACT_FIRST_BYTE_TIMEOUT_SECS: u32 = 300; + +/// Effective first-byte timeout in seconds for a request. Compact requests are +/// widened to at least `COMPACT_FIRST_BYTE_TIMEOUT_SECS`; `0` (timeout +/// disabled) is preserved as-is. +pub(super) fn effective_first_byte_timeout_secs( + configured_secs: u32, + is_compact_request: bool, +) -> u32 { + if is_compact_request && configured_secs > 0 { + configured_secs.max(COMPACT_FIRST_BYTE_TIMEOUT_SECS) + } else { + configured_secs + } +} + pub(super) struct RequestContextParts { pub(super) state: GatewayAppState, pub(super) cli_key: String, @@ -292,3 +302,33 @@ pub(super) struct RequestContextParts { pub(super) response_fixer_stream_config: response_fixer::ResponseFixerConfig, pub(super) response_fixer_non_stream_config: response_fixer::ResponseFixerConfig, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compact_request_widens_first_byte_timeout_to_floor() { + assert_eq!(effective_first_byte_timeout_secs(30, true), 300); + assert_eq!( + effective_first_byte_timeout_secs(COMPACT_FIRST_BYTE_TIMEOUT_SECS - 1, true), + COMPACT_FIRST_BYTE_TIMEOUT_SECS + ); + } + + #[test] + fn compact_request_keeps_configured_timeout_above_floor() { + assert_eq!(effective_first_byte_timeout_secs(400, true), 400); + } + + #[test] + fn non_compact_request_keeps_configured_timeout() { + assert_eq!(effective_first_byte_timeout_secs(30, false), 30); + assert_eq!(effective_first_byte_timeout_secs(400, false), 400); + } + + #[test] + fn disabled_timeout_stays_disabled_for_compact_request() { + assert_eq!(effective_first_byte_timeout_secs(0, true), 0); + } +} diff --git a/src-tauri/src/gateway/proxy/request_end.rs b/src-tauri/src/gateway/proxy/request_end.rs index 55885a66..c710a01b 100644 --- a/src-tauri/src/gateway/proxy/request_end.rs +++ b/src-tauri/src/gateway/proxy/request_end.rs @@ -3,6 +3,7 @@ use super::logging::enqueue_request_log_with_backpressure_and_plugins; use super::status_override; use super::{spawn_enqueue_request_log_with_backpressure, RequestLogEnqueueArgs}; +use crate::gateway::active_requests::{ActiveRequestFinishReason, ActiveRequestRegistry}; use crate::gateway::events::{emit_request_event, ClaudeModelMapping, FailoverAttempt}; use crate::gateway::plugins::pipeline::GatewayPluginPipeline; use crate::{db, request_logs}; @@ -19,6 +20,7 @@ pub(super) struct RequestEndDeps<'a, R: tauri::Runtime = tauri::Wry> { pub(super) db: &'a db::Db, pub(super) log_tx: &'a tokio::sync::mpsc::Sender, pub(super) plugin_pipeline: &'a Arc, + pub(super) active_requests: &'a Arc, } impl<'a, R: tauri::Runtime> RequestEndDeps<'a, R> { @@ -27,12 +29,14 @@ impl<'a, R: tauri::Runtime> RequestEndDeps<'a, R> { db: &'a db::Db, log_tx: &'a tokio::sync::mpsc::Sender, plugin_pipeline: &'a Arc, + active_requests: &'a Arc, ) -> Self { Self { app, db, log_tx, plugin_pipeline, + active_requests, } } } @@ -224,6 +228,8 @@ struct RequestEndPayloadParts { attempts_json: Option, requested_model: Option, created_at_ms: i64, + last_activity_ms: Option, + activity_details_json: Option, created_at: i64, usage_metrics: Option, usage: Option, @@ -592,6 +598,8 @@ fn build_request_end_payload( attempts_json, requested_model, created_at_ms, + last_activity_ms, + activity_details_json, created_at, usage_metrics, usage, @@ -619,6 +627,8 @@ fn build_request_end_payload( attempts_json, requested_model, created_at_ms, + last_activity_ms, + activity_details_json, created_at, usage_metrics, usage, @@ -673,6 +683,8 @@ impl RequestLogEnqueueArgs { attempts_json: None, requested_model, created_at_ms, + last_activity_ms: None, + activity_details_json: None, created_at, usage_metrics, usage, @@ -699,6 +711,8 @@ impl RequestLogEnqueueArgs { attempts_json: String, requested_model: Option, created_at_ms: i64, + last_activity_ms: Option, + activity_details_json: Option, created_at: i64, usage: Option, ) -> (Self, Vec) { @@ -720,6 +734,8 @@ impl RequestLogEnqueueArgs { attempts_json: Some(attempts_json), requested_model, created_at_ms, + last_activity_ms, + activity_details_json, created_at, usage_metrics: None, usage, @@ -793,6 +809,17 @@ fn prepare_request_end( } } +fn active_request_finish_reason( + status: Option, + error_code: Option<&'static str>, +) -> ActiveRequestFinishReason { + if error_code.is_some() || status.is_some_and(|value| value >= 400) { + ActiveRequestFinishReason::Failed + } else { + ActiveRequestFinishReason::Completed + } +} + pub(super) async fn emit_request_event_and_enqueue_request_log( args: RequestEndArgs<'_, R>, ) { @@ -821,6 +848,11 @@ pub(super) async fn emit_request_event_and_enqueue_request_log( log_args, } = prepare_request_end(args); + deps.active_requests.finish( + log_args.trace_id.as_str(), + active_request_finish_reason(log_args.status, log_args.error_code), + ); + log_args.emit_gateway_request_event( deps.app, error_category, @@ -887,8 +924,10 @@ pub(super) fn emit_request_event_and_spawn_request_log( #[cfg(test)] mod tests { use super::*; + use crate::gateway::active_requests::{ActiveRequestRegistry, ActiveRequestStart}; use crate::gateway::proxy::{ErrorCategory, GatewayErrorCode}; use serde_json::json; + use std::sync::Arc; fn sample_attempt() -> FailoverAttempt { FailoverAttempt { @@ -912,6 +951,10 @@ mod tests { circuit_state_after: None, circuit_failure_count: None, circuit_failure_threshold: None, + circuit_recover_at_unix: None, + circuit_trigger_error_code: None, + provider_bridged: Some(false), + timeout_secs: None, } } @@ -941,6 +984,10 @@ mod tests { circuit_state_after: Some("OPEN"), circuit_failure_count: Some(5), circuit_failure_threshold: Some(5), + circuit_recover_at_unix: None, + circuit_trigger_error_code: None, + provider_bridged: Some(false), + timeout_secs: Some(1), } } @@ -951,6 +998,59 @@ mod tests { .map(|text| text.chars().count()) } + fn active_request_start(trace_id: &str) -> ActiveRequestStart { + ActiveRequestStart { + trace_id: trace_id.to_string(), + cli_key: "claude".to_string(), + method: "POST".to_string(), + path: "/v1/messages".to_string(), + query: None, + session_id: Some("session-active".to_string()), + requested_model: Some("claude-sonnet-4".to_string()), + created_at_ms: 1_700_000_000_000, + } + } + + #[test] + fn observed_proxy_request_end_finishes_active_request() { + let app = tauri::test::mock_app(); + let app_handle = app.handle().clone(); + let db_dir = tempfile::tempdir().expect("db dir"); + let db = crate::db::init_for_tests(&db_dir.path().join("request-end.db")).expect("init db"); + let (log_tx, _log_rx) = tokio::sync::mpsc::channel(1); + let active_requests = Arc::new(ActiveRequestRegistry::default()); + active_requests.register(active_request_start("trace-active-end")); + + emit_request_event_and_spawn_request_log( + RequestEndArgs::from_context(RequestEndContextArgs { + deps: RequestEndDeps::new( + &app_handle, + &db, + &log_tx, + &GatewayPluginPipeline::empty_shared(), + &active_requests, + ), + trace_id: "trace-active-end", + cli_key: "claude", + method: "POST", + path: "/v1/messages", + observe: true, + query: None, + excluded_from_stats: false, + duration_ms: 10, + attempts: &[], + special_settings_json: None, + session_id: Some("session-active".to_string()), + requested_model: Some("claude-sonnet-4".to_string()), + created_at_ms: 1_700_000_000_000, + created_at: 1_700_000_000, + }) + .with_completion(RequestCompletion::success(200, None, None, None, None)), + ); + + assert!(active_requests.snapshot().is_empty()); + } + #[test] fn proxy_request_end_parts_apply_count_tokens_exclusion_and_serialize_attempts() { let attempts = vec![sample_attempt()]; @@ -985,6 +1085,30 @@ mod tests { assert_eq!(cloned_attempts[0].provider_id, 7); } + #[test] + fn serialize_attempts_encodes_timeout_secs_only_for_timeout_attempts() { + let mut timeout = timeout_attempt(10, 1, Some(true)); + timeout.timeout_secs = Some(30); + let attempts = vec![timeout, sample_attempt()]; + + let json = serialize_attempts(&attempts); + assert!(json.contains("\"timeout_secs\":30")); + + let encoded: Vec = serde_json::from_str(&json).expect("attempts json"); + assert_eq!( + encoded[0] + .get("timeout_secs") + .and_then(serde_json::Value::as_u64), + Some(30) + ); + // Non-timeout attempts serialize an explicit null (gateway event + // payloads must not use skip_serializing_if). + assert_eq!( + encoded[1].get("timeout_secs"), + Some(&serde_json::Value::Null) + ); + } + #[test] fn proxy_request_end_parts_preserve_timeout_storm_attempts_in_log_payload() { let attempts = vec![ @@ -1271,6 +1395,8 @@ mod tests { "[{\"cached\":true}]".to_string(), Some("gpt-5".to_string()), 300, + None, + None, 400, None, ); diff --git a/src-tauri/src/gateway/routes.rs b/src-tauri/src/gateway/routes.rs index cc104761..3fcbcee1 100644 --- a/src-tauri/src/gateway/routes.rs +++ b/src-tauri/src/gateway/routes.rs @@ -123,6 +123,7 @@ where mod tests { use super::build_router; use crate::app::plugins::{official, runtime_executor::RuntimeGatewayPluginExecutor}; + use crate::domain::plugin_contributions::PluginContributes; use crate::domain::plugins::{ PluginDetail, PluginHook, PluginHostCompatibility, PluginInstallSource, PluginManifest, PluginPermissionRisk, PluginRuntime, PluginStatus, PluginSummary, @@ -142,9 +143,10 @@ mod tests { use flate2::write::GzEncoder; use flate2::Compression; use serde_json::Value; - use std::collections::HashMap; + use std::collections::{BTreeMap, HashMap}; use std::ffi::OsString; use std::io::Write; + use std::path::Path; use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -626,6 +628,7 @@ mod tests { source_provider_id: None, bridge_type: None, stream_idle_timeout_seconds: None, + extension_values: None, }, ) .expect("insert provider") @@ -685,6 +688,7 @@ mod tests { source_provider_id: None, bridge_type: None, stream_idle_timeout_seconds: None, + extension_values: None, }, ) .expect("insert oauth provider") @@ -720,6 +724,7 @@ mod tests { source_provider_id: Some(source_provider_id), bridge_type: Some("cx2cc".to_string()), stream_idle_timeout_seconds: None, + extension_values: None, }, ) .expect("insert cx2cc bridge provider") @@ -778,6 +783,9 @@ mod tests { recent_errors: Arc::new(Mutex::new(RecentErrorCache::default())), latency_cache: Arc::new(Mutex::new(ProviderBaseUrlPingCache::default())), plugin_pipeline: GatewayPluginPipeline::empty_shared(), + active_requests: Arc::new( + crate::gateway::active_requests::ActiveRequestRegistry::default(), + ), } } @@ -800,7 +808,7 @@ mod tests { name: "Request Rewrite".to_string(), current_version: Some("1.0.0".to_string()), status: PluginStatus::Enabled, - runtime: "declarativeRules".to_string(), + runtime: "extensionHost".to_string(), permission_risk: PluginPermissionRisk::High, update_available: false, last_error: None, @@ -812,20 +820,29 @@ mod tests { name: "Request Rewrite".to_string(), version: "1.0.0".to_string(), api_version: "1.0.0".to_string(), - runtime: PluginRuntime::DeclarativeRules { - rules: vec!["rules/main.json".to_string()], + runtime: PluginRuntime::ExtensionHost { + language: "typescript".to_string(), }, - hooks: vec![PluginHook { - name: GatewayPluginHookName::RequestAfterBodyRead - .as_str() - .to_string(), - priority: 10, - failure_policy: Some("fail-open".to_string()), - }], - permissions: vec![ - "request.body.read".to_string(), - "request.body.write".to_string(), - ], + hooks: vec![], + permissions: vec![], + main: Some("dist/index.js".to_string()), + activation_events: vec![], + contributes: Some(PluginContributes { + providers: vec![], + protocols: vec![], + protocol_bridges: vec![], + commands: vec![], + gateway_hooks: vec![PluginHook { + name: GatewayPluginHookName::RequestAfterBodyRead + .as_str() + .to_string(), + priority: 10, + failure_policy: Some("fail-open".to_string()), + timeout_ms: None, + }], + ui: BTreeMap::new(), + }), + capabilities: vec!["gateway.hooks".to_string()], host_compatibility: PluginHostCompatibility { app: ">=0.56.0 <1.0.0".to_string(), plugin_api: "^1.0.0".to_string(), @@ -853,12 +870,24 @@ mod tests { pending_permissions: vec![], audit_logs: vec![], runtime_failures: vec![], + rollback_versions: vec![], } } fn fail_closed(mut plugin: PluginDetail) -> PluginDetail { - plugin.manifest.hooks[0].failure_policy = Some("fail-closed".to_string()); + gateway_hook_mut(&mut plugin).failure_policy = Some("fail-closed".to_string()); + plugin + } + + fn gateway_hook_mut(plugin: &mut PluginDetail) -> &mut PluginHook { plugin + .manifest + .contributes + .as_mut() + .expect("extension host gateway hook contributions") + .gateway_hooks + .first_mut() + .expect("gateway hook") } fn before_send_header_plugin() -> PluginDetail { @@ -867,14 +896,13 @@ mod tests { plugin.summary.name = "Before Send".to_string(); plugin.manifest.id = "test.before-send".to_string(); plugin.manifest.name = "Before Send".to_string(); - plugin.manifest.hooks[0].name = GatewayPluginHookName::RequestBeforeSend + gateway_hook_mut(&mut plugin).name = GatewayPluginHookName::RequestBeforeSend .as_str() .to_string(); - plugin.manifest.permissions = vec![ + plugin.granted_permissions = vec![ "request.meta.read".to_string(), "request.header.write".to_string(), ]; - plugin.granted_permissions = plugin.manifest.permissions.clone(); plugin } @@ -884,12 +912,12 @@ mod tests { plugin.summary.name = "Response After".to_string(); plugin.manifest.id = "test.response-after".to_string(); plugin.manifest.name = "Response After".to_string(); - plugin.manifest.hooks[0].name = GatewayPluginHookName::ResponseAfter.as_str().to_string(); - plugin.manifest.permissions = vec![ + gateway_hook_mut(&mut plugin).name = + GatewayPluginHookName::ResponseAfter.as_str().to_string(); + plugin.granted_permissions = vec![ "response.body.read".to_string(), "response.body.write".to_string(), ]; - plugin.granted_permissions = plugin.manifest.permissions.clone(); plugin } @@ -899,10 +927,10 @@ mod tests { plugin.summary.name = "Stream Chunk".to_string(); plugin.manifest.id = "test.stream-chunk".to_string(); plugin.manifest.name = "Stream Chunk".to_string(); - plugin.manifest.hooks[0].name = GatewayPluginHookName::ResponseChunk.as_str().to_string(); - plugin.manifest.permissions = + gateway_hook_mut(&mut plugin).name = + GatewayPluginHookName::ResponseChunk.as_str().to_string(); + plugin.granted_permissions = vec!["stream.inspect".to_string(), "stream.modify".to_string()]; - plugin.granted_permissions = plugin.manifest.permissions.clone(); plugin } @@ -912,17 +940,15 @@ mod tests { plugin.summary.name = "Log Redaction".to_string(); plugin.manifest.id = "test.log-redaction".to_string(); plugin.manifest.name = "Log Redaction".to_string(); - plugin.manifest.hooks[0].name = + gateway_hook_mut(&mut plugin).name = GatewayPluginHookName::LogBeforePersist.as_str().to_string(); - plugin.manifest.permissions = vec!["log.redact".to_string()]; - plugin.granted_permissions = plugin.manifest.permissions.clone(); + plugin.granted_permissions = vec!["log.redact".to_string()]; plugin } fn official_privacy_filter_for_tests() -> PluginDetail { let fixture = official::official_plugin("official.privacy-filter") .expect("official privacy filter fixture"); - let permissions = fixture.manifest.permissions.clone(); PluginDetail { summary: PluginSummary { id: 1, @@ -930,7 +956,7 @@ mod tests { name: fixture.manifest.name.clone(), current_version: Some(fixture.manifest.version.clone()), status: PluginStatus::Enabled, - runtime: "native:privacyFilter".to_string(), + runtime: "extensionHost".to_string(), permission_risk: PluginPermissionRisk::High, update_available: false, last_error: None, @@ -941,10 +967,11 @@ mod tests { install_source: PluginInstallSource::Official, installed_dir: Some(fixture.root_dir.to_string_lossy().to_string()), config: fixture.default_config, - granted_permissions: permissions, + granted_permissions: vec![], pending_permissions: vec![], audit_logs: vec![], runtime_failures: vec![], + rollback_versions: vec![], } } @@ -954,13 +981,12 @@ mod tests { plugin.summary.name = "Gateway Error".to_string(); plugin.manifest.id = "test.gateway-error".to_string(); plugin.manifest.name = "Gateway Error".to_string(); - plugin.manifest.hooks[0].name = GatewayPluginHookName::Error.as_str().to_string(); - plugin.manifest.permissions = vec![ + gateway_hook_mut(&mut plugin).name = GatewayPluginHookName::Error.as_str().to_string(); + plugin.granted_permissions = vec![ "response.body.read".to_string(), "response.body.write".to_string(), "response.header.write".to_string(), ]; - plugin.granted_permissions = plugin.manifest.permissions.clone(); plugin } @@ -1002,6 +1028,53 @@ mod tests { &plugin.pending_permissions, ) .expect("save plugin detail permissions"); + repository::save_plugin_config( + db, + &plugin.summary.plugin_id, + plugin.manifest.config_version.unwrap_or(1), + &plugin.config, + &[], + ) + .expect("save plugin detail config"); + } + + fn persist_and_reload_plugin_detail(db: &db::Db, plugin: &PluginDetail) -> PluginDetail { + persist_plugin_detail(db, plugin); + repository::get_plugin(db, &plugin.summary.plugin_id).expect("reload plugin detail") + } + + fn write_extension_host_test_entry( + source_root: Option<&Path>, + root: &Path, + manifest: &PluginManifest, + source: &str, + ) { + let main = manifest + .main + .as_deref() + .expect("extension host test manifest main"); + let entry_path = root.join(main); + std::fs::create_dir_all( + entry_path + .parent() + .expect("extension host test entry parent"), + ) + .expect("create extension host test entry parent"); + std::fs::write( + root.join("plugin.json"), + serde_json::to_vec_pretty(manifest).expect("serialize extension host test manifest"), + ) + .expect("write extension host test manifest"); + std::fs::write(entry_path, source).expect("write extension host test entry"); + if let Some(source_root) = source_root { + let source_rules = source_root.join("rules/gitleaks.toml"); + if source_rules.exists() { + let target_rules = root.join("rules/gitleaks.toml"); + std::fs::create_dir_all(target_rules.parent().expect("rules parent")) + .expect("create rules dir"); + std::fs::copy(source_rules, target_rules).expect("copy privacy rules"); + } + } } #[tokio::test(flavor = "current_thread")] @@ -1016,6 +1089,7 @@ mod tests { app_settings.upstream_first_byte_timeout_seconds = 1; app_settings.failover_max_attempts_per_provider = 1; app_settings.failover_max_providers_to_try = 1; + app_settings.circuit_breaker_failure_threshold = 1; settings::write(&app_handle, &app_settings).expect("write settings"); let db_dir = tempfile::tempdir().expect("db dir"); @@ -1025,7 +1099,21 @@ mod tests { let provider_id = insert_codex_provider(&db, upstream_base_url); let (log_tx, mut log_rx) = tokio::sync::mpsc::channel(4); - let router = build_router(gateway_state(app_handle, db, log_tx)); + let circuit = Arc::new(circuit_breaker::CircuitBreaker::new( + circuit_breaker::CircuitBreakerConfig { + failure_threshold: 1, + ..circuit_breaker::CircuitBreakerConfig::default() + }, + HashMap::new(), + None, + )); + let router = build_router(gateway_state_with_parts( + app_handle, + db, + log_tx, + circuit, + Arc::new(session_manager::SessionManager::new()), + )); let request = Request::builder() .method(Method::POST) .uri(format!( @@ -1172,12 +1260,21 @@ mod tests { let request_log = recv_terminal_request_log(&mut log_rx).await; assert_eq!(request_log.status, Some(200)); - let plugin_detail = repository::get_plugin(&db, &plugin.summary.plugin_id) - .expect("read persisted plugin detail"); - assert!(plugin_detail.audit_logs.iter().any(|audit| { - audit.trace_id.as_deref() == Some(request_log.trace_id.as_str()) - && audit.event_type == "plugin.hook.completed" - })); + // Audit persistence is fire-and-forget off the request path; poll for it. + let mut audit_persisted = false; + for _ in 0..40 { + let plugin_detail = repository::get_plugin(&db, &plugin.summary.plugin_id) + .expect("read persisted plugin detail"); + audit_persisted = plugin_detail.audit_logs.iter().any(|audit| { + audit.trace_id.as_deref() == Some(request_log.trace_id.as_str()) + && audit.event_type == "plugin.hook.completed" + }); + if audit_persisted { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + assert!(audit_persisted, "plugin audit log should be persisted"); upstream_task.abort(); } @@ -1202,7 +1299,6 @@ mod tests { .expect("init test db"); let fixture = official::official_plugin("official.privacy-filter") .expect("official privacy filter fixture"); - let permissions = fixture.manifest.permissions.clone(); let plugin = PluginDetail { summary: PluginSummary { id: 1, @@ -1210,7 +1306,7 @@ mod tests { name: fixture.manifest.name.clone(), current_version: Some(fixture.manifest.version.clone()), status: PluginStatus::Enabled, - runtime: "native:privacyFilter".to_string(), + runtime: "extensionHost".to_string(), permission_risk: PluginPermissionRisk::High, update_available: false, last_error: None, @@ -1221,10 +1317,11 @@ mod tests { install_source: PluginInstallSource::Official, installed_dir: Some(fixture.root_dir.to_string_lossy().to_string()), config: fixture.default_config, - granted_permissions: permissions.clone(), + granted_permissions: vec![], pending_permissions: vec![], audit_logs: vec![], runtime_failures: vec![], + rollback_versions: vec![], }; repository::insert_plugin( &db, @@ -1236,8 +1333,18 @@ mod tests { }, ) .expect("insert official privacy filter"); - repository::save_plugin_permissions(&db, &plugin.summary.plugin_id, &permissions, &[]) + repository::save_plugin_permissions(&db, &plugin.summary.plugin_id, &[], &[]) .expect("grant official privacy filter permissions"); + repository::save_plugin_config( + &db, + &plugin.summary.plugin_id, + plugin.manifest.config_version.unwrap_or(1), + &plugin.config, + &[], + ) + .expect("save official privacy filter config"); + let plugin = repository::get_plugin(&db, &plugin.summary.plugin_id) + .expect("reload official privacy filter"); let (upstream_base_url, captured_rx, upstream_task) = spawn_capturing_raw_upstream(r#"{"id":"stub-ok","object":"response","output":[]}"#) @@ -1245,7 +1352,7 @@ mod tests { let provider_id = insert_codex_provider(&db, upstream_base_url); let plugin_pipeline = GatewayPluginPipeline::for_tests_shared( vec![plugin], - Arc::new(RuntimeGatewayPluginExecutor::default()), + Arc::new(RuntimeGatewayPluginExecutor::with_db(db.clone())), GatewayPluginPipelineConfig::default(), ); @@ -1323,7 +1430,6 @@ mod tests { .expect("init test db"); let fixture = official::official_plugin("official.privacy-filter") .expect("official privacy filter fixture"); - let permissions = fixture.manifest.permissions.clone(); let plugin = PluginDetail { summary: PluginSummary { id: 1, @@ -1331,7 +1437,7 @@ mod tests { name: fixture.manifest.name.clone(), current_version: Some(fixture.manifest.version.clone()), status: PluginStatus::Enabled, - runtime: "native:privacyFilter".to_string(), + runtime: "extensionHost".to_string(), permission_risk: PluginPermissionRisk::High, update_available: false, last_error: None, @@ -1342,10 +1448,11 @@ mod tests { install_source: PluginInstallSource::Official, installed_dir: Some(fixture.root_dir.to_string_lossy().to_string()), config: fixture.default_config, - granted_permissions: permissions.clone(), + granted_permissions: vec![], pending_permissions: vec![], audit_logs: vec![], runtime_failures: vec![], + rollback_versions: vec![], }; repository::insert_plugin( &db, @@ -1357,8 +1464,18 @@ mod tests { }, ) .expect("insert official privacy filter"); - repository::save_plugin_permissions(&db, &plugin.summary.plugin_id, &permissions, &[]) + repository::save_plugin_permissions(&db, &plugin.summary.plugin_id, &[], &[]) .expect("grant official privacy filter permissions"); + repository::save_plugin_config( + &db, + &plugin.summary.plugin_id, + plugin.manifest.config_version.unwrap_or(1), + &plugin.config, + &[], + ) + .expect("save official privacy filter config"); + let plugin = repository::get_plugin(&db, &plugin.summary.plugin_id) + .expect("reload official privacy filter"); let (upstream_base_url, captured_rx, upstream_task) = spawn_capturing_raw_upstream(r#"{"id":"stub-ok","object":"response","output":[]}"#) @@ -1366,7 +1483,7 @@ mod tests { let provider_id = insert_codex_provider(&db, upstream_base_url); let plugin_pipeline = GatewayPluginPipeline::for_tests_shared( vec![plugin], - Arc::new(RuntimeGatewayPluginExecutor::default()), + Arc::new(RuntimeGatewayPluginExecutor::with_db(db.clone())), GatewayPluginPipelineConfig::default(), ); @@ -1501,9 +1618,41 @@ mod tests { let mut plugin = official_privacy_filter_for_tests(); plugin .manifest - .hooks + .contributes + .as_mut() + .expect("official privacy filter gateway contributions") + .gateway_hooks .retain(|hook| hook.name != "gateway.request.afterBodyRead"); - persist_plugin_detail(&db, &plugin); + let plugin_root = tempfile::tempdir().expect("plugin root"); + let source_root = plugin.installed_dir.as_deref().map(Path::new); + write_extension_host_test_entry( + source_root, + plugin_root.path(), + &plugin.manifest, + r#" +function handleRequestHook(api, payload) { + const body = + payload && payload.context && payload.context.request + ? payload.context.request.body + : undefined; + if (typeof body !== "string" || body.length === 0) { + return { action: "pass" }; + } + const result = api.privacy.redactRequestBody(body, {}); + return result && result.hit + ? { action: "replace", requestBody: result.redacted } + : { action: "pass" }; +} + +module.exports.activate = function activate(api) { + api.gateway.registerHook("gateway.request.beforeSend", function onBeforeSend(payload) { + return handleRequestHook(api, payload); + }); +}; +"#, + ); + plugin.installed_dir = Some(plugin_root.path().to_string_lossy().to_string()); + let plugin = persist_and_reload_plugin_detail(&db, &plugin); let (upstream_base_url, captured_rx, upstream_task) = spawn_capturing_raw_upstream(r#"{"id":"stub-ok","object":"response","output":[]}"#) @@ -1511,7 +1660,7 @@ mod tests { let provider_id = insert_codex_provider(&db, upstream_base_url); let plugin_pipeline = GatewayPluginPipeline::for_tests_shared( vec![plugin], - Arc::new(RuntimeGatewayPluginExecutor::default()), + Arc::new(RuntimeGatewayPluginExecutor::with_db(db.clone())), GatewayPluginPipelineConfig::default(), ); @@ -1580,11 +1729,10 @@ mod tests { let db = db::init_for_tests(&db_dir.path().join("privacy-filter-retry.sqlite")) .expect("init test db"); let mut plugin = before_send_header_plugin(); - plugin.manifest.permissions = vec![ + plugin.granted_permissions = vec![ "request.body.read".to_string(), "request.body.write".to_string(), ]; - plugin.granted_permissions = plugin.manifest.permissions.clone(); persist_plugin_detail(&db, &plugin); let (upstream_base_url, mut captured_rx, upstream_task) = @@ -2454,6 +2602,7 @@ mod tests { app_settings.upstream_first_byte_timeout_seconds = 1; app_settings.failover_max_attempts_per_provider = 1; app_settings.failover_max_providers_to_try = 2; + app_settings.circuit_breaker_failure_threshold = 1; app_settings.provider_cooldown_seconds = 0; settings::write(&app_handle, &app_settings).expect("write settings"); crate::cli_proxy::set_enabled(&app_handle, "codex", true, "http://127.0.0.1:37123") @@ -2471,7 +2620,21 @@ mod tests { insert_codex_provider_with_priority(&db, "Success Stub", success_base_url, 1); let (log_tx, mut log_rx) = tokio::sync::mpsc::channel(4); - let router = build_router(gateway_state(app_handle, db, log_tx)); + let circuit = Arc::new(circuit_breaker::CircuitBreaker::new( + circuit_breaker::CircuitBreakerConfig { + failure_threshold: 1, + ..circuit_breaker::CircuitBreakerConfig::default() + }, + HashMap::new(), + None, + )); + let router = build_router(gateway_state_with_parts( + app_handle, + db, + log_tx, + circuit, + Arc::new(session_manager::SessionManager::new()), + )); let request = Request::builder() .method(Method::POST) .uri("/v1/chat/completions") @@ -2741,6 +2904,7 @@ mod tests { let mut app_settings = settings::AppSettings::default(); app_settings.failover_max_attempts_per_provider = 1; app_settings.failover_max_providers_to_try = 1; + app_settings.circuit_breaker_failure_threshold = 1; app_settings.provider_cooldown_seconds = 0; settings::write(&app_handle, &app_settings).expect("write settings"); @@ -2763,7 +2927,21 @@ mod tests { insert_codex_provider_with_priority(&db, "Large Error Stub", upstream_base_url, 0); let (log_tx, mut log_rx) = tokio::sync::mpsc::channel(4); - let router = build_router(gateway_state(app_handle, db, log_tx)); + let circuit = Arc::new(circuit_breaker::CircuitBreaker::new( + circuit_breaker::CircuitBreakerConfig { + failure_threshold: 1, + ..circuit_breaker::CircuitBreakerConfig::default() + }, + HashMap::new(), + None, + )); + let router = build_router(gateway_state_with_parts( + app_handle, + db, + log_tx, + circuit, + Arc::new(session_manager::SessionManager::new()), + )); let request = Request::builder() .method(Method::POST) .uri(format!( @@ -4118,4 +4296,144 @@ mod tests { json_task.abort(); } + + #[tokio::test(flavor = "current_thread")] + async fn mock_runtime_router_claude_compact_request_persists_request_kind_special_setting() { + let _env_lock = crate::test_support::test_env_lock(); + let home = tempfile::tempdir().expect("home dir"); + let _env = isolate_app_env(home.path()); + let app = tauri::test::mock_app(); + let app_handle = app.handle().clone(); + + let app_settings = settings::AppSettings::default(); + settings::write(&app_handle, &app_settings).expect("write settings"); + + let db_dir = tempfile::tempdir().expect("db dir"); + let db = db::init_for_tests(&db_dir.path().join("gateway-route-compact-kind-test.sqlite")) + .expect("init test db"); + let (upstream_base_url, upstream_task) = spawn_json_upstream( + r#"{"id":"msg_compact","type":"message","role":"assistant","content":[{"type":"text","text":"summary"}],"model":"claude-3-5-sonnet","usage":{"input_tokens":1,"output_tokens":1}}"#, + ) + .await; + let provider_id = + insert_provider_with_priority(&db, "claude", "Compact Stub", upstream_base_url, 0); + + let (log_tx, mut log_rx) = tokio::sync::mpsc::channel(4); + let router = build_router(gateway_state(app_handle, db, log_tx)); + let request = Request::builder() + .method(Method::POST) + .uri(format!("/claude/_aio/provider/{provider_id}/v1/messages")) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"model":"claude-3-5-sonnet","max_tokens":512,"system":[{"type":"text","text":"You are a helpful AI assistant tasked with summarizing conversations. Follow the instructions."}],"messages":[{"role":"user","content":"Your task is to create a detailed summary of the conversation so far."}]}"#, + )) + .expect("request"); + + let response = router.oneshot(request).await.expect("route response"); + assert_eq!(response.status(), StatusCode::OK); + + let log = recv_terminal_request_log(&mut log_rx).await; + assert_eq!(log.cli_key, "claude"); + assert_eq!(log.path, "/v1/messages"); + assert_eq!(log.status, Some(200)); + + let special_settings: Value = serde_json::from_str( + log.special_settings_json + .as_deref() + .expect("special settings json"), + ) + .expect("special settings json parses"); + let special_settings = special_settings.as_array().expect("special settings array"); + assert!(special_settings.iter().any(|entry| { + entry.get("type").and_then(Value::as_str) == Some("request_kind") + && entry.get("kind").and_then(Value::as_str) == Some("compact") + })); + + upstream_task.abort(); + } + + /// Upstream that delays the first response byte, then sends a full JSON + /// response (models a provider re-processing a huge compact prompt). + async fn spawn_delayed_json_upstream( + body: &'static str, + first_byte_delay: Duration, + ) -> (String, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind delayed json upstream stub"); + let addr = listener.local_addr().expect("delayed json upstream addr"); + let task = tokio::spawn(async move { + if let Ok((mut socket, _)) = listener.accept().await { + let mut buf = [0_u8; 1024]; + let _ = socket.read(&mut buf).await; + tokio::time::sleep(first_byte_delay).await; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.shutdown().await; + } + }); + + (format!("http://{addr}"), task) + } + + /// Proves the compact first-byte timeout widening is wired through to the + /// actual send path: with a 1s configured first-byte timeout and an + /// upstream that stalls 2s before the first byte, a compact request must + /// still succeed (widened to 300s). Without the widening this setup times + /// out — see `mock_runtime_router_timeout_stub_returns_bad_gateway_and_emits_request_log`, + /// which proves the 1s timeout fires on the same send path. + #[tokio::test(flavor = "current_thread")] + async fn mock_runtime_router_claude_compact_request_survives_first_byte_delay_beyond_configured_timeout( + ) { + let _env_lock = crate::test_support::test_env_lock(); + let home = tempfile::tempdir().expect("home dir"); + let _env = isolate_app_env(home.path()); + let app = tauri::test::mock_app(); + let app_handle = app.handle().clone(); + + let mut app_settings = settings::AppSettings::default(); + app_settings.upstream_first_byte_timeout_seconds = 1; + app_settings.failover_max_attempts_per_provider = 1; + app_settings.failover_max_providers_to_try = 1; + settings::write(&app_handle, &app_settings).expect("write settings"); + + let db_dir = tempfile::tempdir().expect("db dir"); + let db = db::init_for_tests( + &db_dir + .path() + .join("gateway-route-compact-timeout-test.sqlite"), + ) + .expect("init test db"); + let (upstream_base_url, upstream_task) = spawn_delayed_json_upstream( + r#"{"id":"msg_compact_slow","type":"message","role":"assistant","content":[{"type":"text","text":"summary"}],"model":"claude-3-5-sonnet","usage":{"input_tokens":1,"output_tokens":1}}"#, + Duration::from_secs(2), + ) + .await; + let provider_id = + insert_provider_with_priority(&db, "claude", "Compact Slow Stub", upstream_base_url, 0); + + let (log_tx, mut log_rx) = tokio::sync::mpsc::channel(4); + let router = build_router(gateway_state(app_handle, db, log_tx)); + let request = Request::builder() + .method(Method::POST) + .uri(format!("/claude/_aio/provider/{provider_id}/v1/messages")) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"model":"claude-3-5-sonnet","max_tokens":512,"system":[{"type":"text","text":"You are a helpful AI assistant tasked with summarizing conversations. Follow the instructions."}],"messages":[{"role":"user","content":"Your task is to create a detailed summary of the conversation so far."}]}"#, + )) + .expect("request"); + + let response = router.oneshot(request).await.expect("route response"); + assert_eq!(response.status(), StatusCode::OK); + + let log = recv_terminal_request_log(&mut log_rx).await; + assert_eq!(log.status, Some(200)); + assert_eq!(log.error_code, None); + + upstream_task.abort(); + } } diff --git a/src-tauri/src/gateway/runtime.rs b/src-tauri/src/gateway/runtime.rs index 99039b9d..085f5f80 100644 --- a/src-tauri/src/gateway/runtime.rs +++ b/src-tauri/src/gateway/runtime.rs @@ -5,6 +5,7 @@ use crate::{circuit_breaker, db, request_logs, session_manager}; use std::sync::{Arc, Mutex}; use tokio::sync::oneshot; +use super::active_requests::{ActiveRequestFinishReason, ActiveRequestRegistry}; use super::background_tasks::GatewayBackgroundTasks; use super::codex_session_id::CodexSessionIdCache; use super::plugins::pipeline::GatewayPluginPipeline; @@ -21,6 +22,7 @@ pub(in crate::gateway) struct GatewayAppState { pub(super) recent_errors: Arc>, pub(super) latency_cache: Arc>, pub(super) plugin_pipeline: Arc, + pub(super) active_requests: Arc, } impl Clone for GatewayAppState { @@ -35,10 +37,45 @@ impl Clone for GatewayAppState { recent_errors: self.recent_errors.clone(), latency_cache: self.latency_cache.clone(), plugin_pipeline: self.plugin_pipeline.clone(), + active_requests: self.active_requests.clone(), } } } +#[cfg(test)] +mod tests { + use super::*; + use crate::gateway::active_requests::ActiveRequestStart; + + fn active_request_start(trace_id: &str) -> ActiveRequestStart { + ActiveRequestStart { + trace_id: trace_id.to_string(), + cli_key: "codex".to_string(), + method: "POST".to_string(), + path: "/v1/responses".to_string(), + query: None, + session_id: Some("sess-runtime".to_string()), + requested_model: Some("gpt-5".to_string()), + created_at_ms: 1_700_000_000_000, + } + } + + #[test] + fn into_handles_finishes_active_requests() { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let session = Arc::new(session_manager::SessionManager::new()); + let recent_errors = Arc::new(Mutex::new(RecentErrorCache::default())); + let runtime = GatewayRuntime::for_tests(&rt, session, recent_errors); + let active_requests = runtime.active_requests.clone(); + active_requests.register(active_request_start("trace-runtime-active")); + + assert_eq!(active_requests.snapshot().len(), 1); + let _handles = runtime.into_handles(); + + assert!(active_requests.snapshot().is_empty()); + } +} + impl GatewayAppState { pub(in crate::gateway) fn client(&self) -> reqwest::Client { super::http_client::get() @@ -76,6 +113,7 @@ pub(super) struct GatewayRuntimeInit { pub(super) session: Arc, pub(super) recent_errors: Arc>, pub(super) plugin_pipeline: Arc, + pub(super) active_requests: Arc, pub(super) shutdown: oneshot::Sender<()>, pub(super) task: tauri::async_runtime::JoinHandle<()>, pub(super) background_tasks: GatewayBackgroundTasks, @@ -89,6 +127,7 @@ pub(crate) struct GatewayRuntime { session: Arc, recent_errors: Arc>, plugin_pipeline: Arc, + active_requests: Arc, shutdown: oneshot::Sender<()>, task: tauri::async_runtime::JoinHandle<()>, background_tasks: GatewayBackgroundTasks, @@ -104,6 +143,7 @@ impl GatewayRuntime { session: init.session, recent_errors: init.recent_errors, plugin_pipeline: init.plugin_pipeline, + active_requests: init.active_requests, shutdown: init.shutdown, task: init.task, background_tasks: init.background_tasks, @@ -145,6 +185,12 @@ impl GatewayRuntime { } } + pub(crate) fn active_requests_snapshot( + &self, + ) -> Vec { + self.active_requests.snapshot() + } + pub(crate) fn circuit_status( &self, provider_ids: &[i64], @@ -193,6 +239,8 @@ impl GatewayRuntime { } pub(super) fn into_handles(self) -> GatewayRuntimeHandles { + self.active_requests + .finish_all(ActiveRequestFinishReason::GatewayStopped); let (log_task, circuit_task, oauth_refresh_shutdown, oauth_refresh_task) = self.background_tasks.into_handles(); ( @@ -226,6 +274,7 @@ impl GatewayRuntime { session, recent_errors, plugin_pipeline: GatewayPluginPipeline::empty_shared(), + active_requests: Arc::new(ActiveRequestRegistry::default()), shutdown, task: tauri::async_runtime::JoinHandle::Tokio(rt.spawn(async {})), background_tasks: GatewayBackgroundTasks::for_tests(rt), diff --git a/src-tauri/src/gateway/streams.rs b/src-tauri/src/gateway/streams.rs index 08607516..b8a75980 100644 --- a/src-tauri/src/gateway/streams.rs +++ b/src-tauri/src/gateway/streams.rs @@ -1,7 +1,7 @@ //! Usage: Gateway stream adapters (gunzip, relays, usage/timing tees). mod types; -pub(super) use types::StreamFinalizeCtx; +pub(super) use types::{StreamActivityTracker, StreamFinalizeCtx}; mod finalize; mod request_end; diff --git a/src-tauri/src/gateway/streams/plugin_chunk.rs b/src-tauri/src/gateway/streams/plugin_chunk.rs index eeb7a9d7..4344dc63 100644 --- a/src-tauri/src/gateway/streams/plugin_chunk.rs +++ b/src-tauri/src/gateway/streams/plugin_chunk.rs @@ -129,10 +129,11 @@ where }; match pipeline.run_stream_hook(input).await { Ok(output) => { - crate::gateway::plugins::audit::persist_gateway_plugin_audit_events( + crate::gateway::plugins::audit::persist_gateway_plugin_diagnostics( &db, &trace_id, output.audit_events.clone(), + output.execution_reports.clone(), ); if let Some(blocked) = output.blocked { tracing::warn!( @@ -173,6 +174,7 @@ where #[cfg(test)] mod tests { use super::*; + use crate::domain::plugin_contributions::PluginContributes; use crate::domain::plugins::{ PluginDetail, PluginHook, PluginHostCompatibility, PluginInstallSource, PluginManifest, PluginPermissionRisk, PluginRuntime, PluginStatus, PluginSummary, @@ -181,6 +183,7 @@ mod tests { use crate::gateway::plugins::pipeline::{ GatewayPluginPipeline, GatewayPluginPipelineConfig, InMemoryGatewayPluginExecutor, }; + use std::collections::BTreeMap; use std::sync::Arc; struct EmptyStream; @@ -201,7 +204,7 @@ mod tests { name: plugin_id.to_string(), current_version: Some("1.0.0".to_string()), status: PluginStatus::Enabled, - runtime: "declarativeRules".to_string(), + runtime: "extensionHost".to_string(), permission_risk: PluginPermissionRisk::High, update_available: false, last_error: None, @@ -213,15 +216,27 @@ mod tests { name: plugin_id.to_string(), version: "1.0.0".to_string(), api_version: "1.0.0".to_string(), - runtime: PluginRuntime::DeclarativeRules { - rules: vec!["rules/main.json".to_string()], + runtime: PluginRuntime::ExtensionHost { + language: "typescript".to_string(), }, - hooks: vec![PluginHook { - name: hook_name.as_str().to_string(), - priority: 0, - failure_policy: Some("fail-open".to_string()), - }], + hooks: vec![], permissions: vec![], + main: Some("dist/index.js".to_string()), + activation_events: vec![], + contributes: Some(PluginContributes { + providers: vec![], + protocols: vec![], + protocol_bridges: vec![], + commands: vec![], + gateway_hooks: vec![PluginHook { + name: hook_name.as_str().to_string(), + priority: 0, + failure_policy: Some("fail-open".to_string()), + timeout_ms: None, + }], + ui: BTreeMap::new(), + }), + capabilities: vec!["gateway.hooks".to_string()], host_compatibility: PluginHostCompatibility { app: ">=0.56.0 <1.0.0".to_string(), plugin_api: "^1.0.0".to_string(), @@ -246,6 +261,7 @@ mod tests { pending_permissions: vec![], audit_logs: vec![], runtime_failures: vec![], + rollback_versions: vec![], } } diff --git a/src-tauri/src/gateway/streams/request_end.rs b/src-tauri/src/gateway/streams/request_end.rs index 6a60efa9..7519863c 100644 --- a/src-tauri/src/gateway/streams/request_end.rs +++ b/src-tauri/src/gateway/streams/request_end.rs @@ -2,6 +2,7 @@ use super::finalize::finalize_circuit_and_session; use super::StreamFinalizeCtx; +use crate::gateway::active_requests::ActiveRequestFinishReason; use crate::gateway::proxy::{ spawn_enqueue_request_log_with_backpressure, GatewayErrorCode, RequestLogEnqueueArgs, }; @@ -13,6 +14,7 @@ pub(super) struct StreamRequestCompletion { pub(super) requested_model: Option, pub(super) usage_metrics: Option, pub(super) usage: Option, + pub(super) terminal_signal: Option<&'static str>, } impl StreamRequestCompletion { @@ -28,6 +30,7 @@ impl StreamRequestCompletion { requested_model, usage_metrics, usage, + terminal_signal: Some("completed"), } } @@ -44,6 +47,7 @@ impl StreamRequestCompletion { requested_model, usage_metrics, usage, + terminal_signal: Some("error"), } } @@ -59,6 +63,11 @@ impl StreamRequestCompletion { None => Self::success(ttfb_ms, requested_model, usage_metrics, usage), } } + + pub(super) fn with_terminal_signal(mut self, terminal_signal: Option<&'static str>) -> Self { + self.terminal_signal = terminal_signal; + self + } } fn ensure_stream_client_abort_setting( @@ -108,6 +117,41 @@ fn ensure_stream_client_abort_setting( ); } +fn status_for_stream_request_log(status: u16, error_code: Option<&'static str>) -> u16 { + match error_code { + Some(code) + if code == GatewayErrorCode::StreamError.as_str() + || code == GatewayErrorCode::Fake200.as_str() => + { + if (200..400).contains(&status) { + 502 + } else { + status + } + } + Some(code) + if code == GatewayErrorCode::StreamAborted.as_str() + || code == GatewayErrorCode::RequestAborted.as_str() => + { + 499 + } + _ => status, + } +} + +fn active_request_finish_reason(error_code: Option<&'static str>) -> ActiveRequestFinishReason { + match error_code { + Some(code) + if code == GatewayErrorCode::StreamAborted.as_str() + || code == GatewayErrorCode::RequestAborted.as_str() => + { + ActiveRequestFinishReason::ClientAborted + } + Some(_) => ActiveRequestFinishReason::Failed, + None => ActiveRequestFinishReason::Completed, + } +} + pub(super) fn emit_request_event_and_spawn_request_log( ctx: &StreamFinalizeCtx, completion: StreamRequestCompletion, @@ -144,6 +188,17 @@ pub(super) fn emit_request_event_and_spawn_request_log( (ctx.attempts.clone(), ctx.attempts_json.clone()) }; + let (last_activity_ms, activity_details_json) = ctx + .activity + .lock() + .map(|activity| { + ( + Some(activity.last_activity_ms()), + activity.details_json(completion.terminal_signal), + ) + }) + .unwrap_or((None, None)); + let (log_args, attempts) = RequestLogEnqueueArgs::from_stream_request_end_parts( ctx.trace_id.clone(), ctx.cli_key.clone(), @@ -153,7 +208,7 @@ pub(super) fn emit_request_event_and_spawn_request_log( ctx.query.clone(), ctx.excluded_from_stats, response_fixer::special_settings_json(&ctx.special_settings), - ctx.status, + status_for_stream_request_log(ctx.status, completion.error_code), completion.error_code, duration_ms, completion.ttfb_ms, @@ -161,10 +216,17 @@ pub(super) fn emit_request_event_and_spawn_request_log( attempts_json, completion.requested_model, ctx.created_at_ms, + last_activity_ms, + activity_details_json, ctx.created_at, completion.usage, ); + ctx.active_requests.finish( + ctx.trace_id.as_str(), + active_request_finish_reason(completion.error_code), + ); + log_args.emit_gateway_request_event( &ctx.app, effective_error_category, @@ -184,8 +246,83 @@ pub(super) fn emit_request_event_and_spawn_request_log( #[cfg(test)] mod tests { - use super::StreamRequestCompletion; + use super::{ + emit_request_event_and_spawn_request_log, status_for_stream_request_log, + StreamRequestCompletion, + }; + use crate::gateway::active_requests::{ActiveRequestRegistry, ActiveRequestStart}; use crate::gateway::proxy::GatewayErrorCode; + use crate::gateway::streams::{StreamActivityTracker, StreamFinalizeCtx}; + use crate::{circuit_breaker, db, request_logs, session_manager}; + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + use std::time::Instant; + + fn active_request_start(trace_id: &str) -> ActiveRequestStart { + ActiveRequestStart { + trace_id: trace_id.to_string(), + cli_key: "codex".to_string(), + method: "POST".to_string(), + path: "/v1/responses".to_string(), + query: None, + session_id: Some("sess-stream-end".to_string()), + requested_model: Some("gpt-5".to_string()), + created_at_ms: 1_700_000_000_000, + } + } + + fn test_stream_finalize_ctx( + app: tauri::AppHandle, + db: db::Db, + log_tx: tokio::sync::mpsc::Sender, + active_requests: Arc, + ) -> StreamFinalizeCtx { + StreamFinalizeCtx { + app, + db, + log_tx, + plugin_pipeline: crate::gateway::plugins::pipeline::GatewayPluginPipeline::empty_shared( + ), + circuit: Arc::new(circuit_breaker::CircuitBreaker::new( + circuit_breaker::CircuitBreakerConfig::default(), + HashMap::new(), + None, + )), + session: Arc::new(session_manager::SessionManager::new()), + session_id: Some("sess-stream-end".to_string()), + sort_mode_id: None, + trace_id: "trace-stream-end".to_string(), + cli_key: "codex".to_string(), + method: "POST".to_string(), + path: "/v1/responses".to_string(), + observe: true, + query: None, + excluded_from_stats: false, + special_settings: Arc::new(Mutex::new(Vec::new())), + status: 200, + error_category: None, + error_code: None, + started: Instant::now(), + attempts: Vec::new(), + attempts_json: "[]".to_string(), + requested_model: None, + created_at_ms: 1_700_000_000_000, + created_at: 1_700_000_000, + provider_cooldown_secs: 0, + provider_id: 1, + provider_name: "test-provider".to_string(), + base_url: "https://upstream.example".to_string(), + auth_mode: "api_key".to_string(), + fake_200_detected: false, + fake_200_quota_exhausted: false, + activity: Arc::new(Mutex::new(StreamActivityTracker::new( + "trace-stream-end", + "codex", + 1_700_000_000_000, + ))), + active_requests, + } + } #[test] fn stream_request_completion_builds_success_without_error_code() { @@ -217,4 +354,66 @@ mod tests { assert!(completion.usage_metrics.is_some()); assert!(completion.usage.is_none()); } + + #[test] + fn stream_error_status_for_log_maps_http_200_to_502() { + assert_eq!( + status_for_stream_request_log(200, Some(GatewayErrorCode::StreamError.as_str())), + 502 + ); + assert_eq!( + status_for_stream_request_log(200, Some(GatewayErrorCode::Fake200.as_str())), + 502 + ); + assert_eq!( + status_for_stream_request_log(499, Some(GatewayErrorCode::StreamAborted.as_str())), + 499 + ); + } + + #[test] + fn observed_stream_request_end_finishes_active_request() { + let app = tauri::test::mock_app(); + let db_dir = tempfile::tempdir().expect("db dir"); + let db = db::init_for_tests(&db_dir.path().join("stream-request-end.sqlite")) + .expect("init test db"); + let (log_tx, _log_rx) = tokio::sync::mpsc::channel(4); + let active_requests = Arc::new(ActiveRequestRegistry::default()); + active_requests.register(active_request_start("trace-stream-end")); + let ctx = + test_stream_finalize_ctx(app.handle().clone(), db, log_tx, active_requests.clone()); + + emit_request_event_and_spawn_request_log( + &ctx, + StreamRequestCompletion::success(None, Some("gpt-5".to_string()), None, None), + ); + + assert!(active_requests.snapshot().is_empty()); + } + + #[test] + fn observed_stream_abort_finishes_active_request() { + let app = tauri::test::mock_app(); + let db_dir = tempfile::tempdir().expect("db dir"); + let db = db::init_for_tests(&db_dir.path().join("stream-request-abort.sqlite")) + .expect("init test db"); + let (log_tx, _log_rx) = tokio::sync::mpsc::channel(4); + let active_requests = Arc::new(ActiveRequestRegistry::default()); + active_requests.register(active_request_start("trace-stream-end")); + let ctx = + test_stream_finalize_ctx(app.handle().clone(), db, log_tx, active_requests.clone()); + + emit_request_event_and_spawn_request_log( + &ctx, + StreamRequestCompletion::failure( + GatewayErrorCode::StreamAborted.as_str(), + None, + Some("gpt-5".to_string()), + None, + None, + ), + ); + + assert!(active_requests.snapshot().is_empty()); + } } diff --git a/src-tauri/src/gateway/streams/types.rs b/src-tauri/src/gateway/streams/types.rs index 36713be4..c51a94f4 100644 --- a/src-tauri/src/gateway/streams/types.rs +++ b/src-tauri/src/gateway/streams/types.rs @@ -1,5 +1,6 @@ //! Usage: Stream finalization context for gateway body relays. +use crate::gateway::active_requests::ActiveRequestRegistry; use crate::gateway::plugins::pipeline::GatewayPluginPipeline; use crate::{circuit_breaker, db, request_logs, session_manager}; use std::sync::{Arc, Mutex}; @@ -7,6 +8,59 @@ use std::time::Instant; use super::super::events::FailoverAttempt; +const ACTIVITY_FLUSH_INTERVAL_MS: i64 = 30_000; + +pub(in crate::gateway) struct StreamActivityTracker { + trace_id: String, + cli_key: String, + created_at_ms: i64, + last_activity_ms: i64, + last_flushed_activity_ms: i64, + chunk_count: i64, +} + +impl StreamActivityTracker { + pub(in crate::gateway) fn new(trace_id: &str, cli_key: &str, created_at_ms: i64) -> Self { + Self { + trace_id: trace_id.to_string(), + cli_key: cli_key.to_string(), + created_at_ms, + last_activity_ms: created_at_ms, + last_flushed_activity_ms: created_at_ms, + chunk_count: 0, + } + } + + pub(in crate::gateway) fn observe_chunk_at(&mut self, now_ms: i64) -> bool { + self.chunk_count = self.chunk_count.saturating_add(1); + self.last_activity_ms = now_ms.max(self.last_activity_ms).max(self.created_at_ms); + if self + .last_activity_ms + .saturating_sub(self.last_flushed_activity_ms) + < ACTIVITY_FLUSH_INTERVAL_MS + { + return false; + } + self.last_flushed_activity_ms = self.last_activity_ms; + true + } + + pub(in crate::gateway) fn last_activity_ms(&self) -> i64 { + self.last_activity_ms + } + + pub(in crate::gateway) fn details_json(&self, terminal_signal: Option<&str>) -> Option { + serde_json::to_string(&serde_json::json!({ + "trace_id": self.trace_id, + "cli_key": self.cli_key, + "chunk_count": self.chunk_count, + "last_activity_ms": self.last_activity_ms, + "terminal_signal": terminal_signal, + })) + .ok() + } +} + pub(in crate::gateway) struct StreamFinalizeCtx { pub(in crate::gateway) app: tauri::AppHandle, pub(in crate::gateway) db: db::Db, @@ -40,4 +94,6 @@ pub(in crate::gateway) struct StreamFinalizeCtx pub(in crate::gateway) auth_mode: String, pub(in crate::gateway) fake_200_detected: bool, pub(in crate::gateway) fake_200_quota_exhausted: bool, + pub(in crate::gateway) activity: Arc>, + pub(in crate::gateway) active_requests: Arc, } diff --git a/src-tauri/src/gateway/streams/usage_tee.rs b/src-tauri/src/gateway/streams/usage_tee.rs index a7971fe3..57685f05 100644 --- a/src-tauri/src/gateway/streams/usage_tee.rs +++ b/src-tauri/src/gateway/streams/usage_tee.rs @@ -13,7 +13,9 @@ use super::super::events::{emit_gateway_debug_log, emit_gateway_debug_log_lazy}; use super::super::proxy::{ is_fake_200_non_stream_body, upstream_client_error_rules, GatewayErrorCode, }; -use super::super::util::{lossy_utf8_preview, now_unix_seconds, MAX_DEBUG_BODY_PREVIEW_BYTES}; +use super::super::util::{ + lossy_utf8_preview, now_unix_millis, now_unix_seconds, MAX_DEBUG_BODY_PREVIEW_BYTES, +}; use super::plugin_chunk::PLUGIN_STREAM_ERROR_MARKER; use super::request_end::{emit_request_event_and_spawn_request_log, StreamRequestCompletion}; use super::{RelayBodyStream, StreamFinalizeCtx}; @@ -33,20 +35,15 @@ fn is_codex_client_abort_successish( saw_stream_output: bool, completion_seen: bool, usage_seen: bool, - terminal_error_seen: bool, - upstream_ended_normally: bool, + _terminal_error_seen: bool, + _upstream_ended_normally: bool, ) -> bool { is_codex_responses_path(cli_key, path) && (200..300).contains(&status) && saw_stream_output // For codex, downstream disconnect can race with trailing markers. - // If completion/usage is already observed, do not downgrade to 499. - && (usage_seen - || completion_seen - // If downstream disconnected and upstream never naturally ended, trailing terminal - // markers are often disconnect side-effects and should not force a 499. - || !terminal_error_seen - || !upstream_ended_normally) + // Completion/usage is required before treating the request as successful. + && (usage_seen || completion_seen) } fn is_codex_drop_successish( @@ -116,6 +113,33 @@ fn is_plugin_stream_error_chunk(chunk: &[u8]) -> bool { .any(|window| window == PLUGIN_STREAM_ERROR_MARKER.as_bytes()) } +fn spawn_touch_activity( + ctx: &StreamFinalizeCtx, + last_activity_ms: i64, + details: Option, +) { + if ctx.observe { + ctx.active_requests + .touch(ctx.trace_id.as_str(), last_activity_ms); + } + + let db = ctx.db.clone(); + let trace_id = ctx.trace_id.clone(); + let cli_key = ctx.cli_key.clone(); + tauri::async_runtime::spawn_blocking(move || { + if let Err(err) = + crate::request_logs::touch_activity(&db, &trace_id, &cli_key, last_activity_ms, details) + { + tracing::warn!( + trace_id = %trace_id, + cli = %cli_key, + error = %err, + "request log activity touch failed" + ); + } + }); +} + struct NextFuture<'a, S: Stream + Unpin>(&'a mut S); impl<'a, S: Stream + Unpin> Future for NextFuture<'a, S> { @@ -192,9 +216,11 @@ where match next { Poll::Pending => { + // Upstream has no data right now; only then check the idle timer. + // Drain path (enforce_idle_timeout=false) keeps its own deadline. if enforce_idle_timeout { - if let Some(timer) = self.idle_sleep.as_mut() { - if timer.as_mut().poll(cx).is_ready() { + if let Some(sleep) = self.idle_sleep.as_mut() { + if sleep.as_mut().poll(cx).is_ready() { self.finalize(Some(GatewayErrorCode::StreamIdleTimeout.as_str())); return Poll::Ready(None); } @@ -233,6 +259,15 @@ where }); let was_terminal_error = self.tracker.terminal_error_seen(); self.tracker.ingest_chunk(chunk.as_ref()); + if let Ok(mut activity) = self.ctx.activity.lock() { + if activity.observe_chunk_at(now_unix_millis().min(i64::MAX as u64) as i64) { + spawn_touch_activity( + &self.ctx, + activity.last_activity_ms(), + activity.details_json(None), + ); + } + } if self.tracker.terminal_error_seen() { if !was_terminal_error { emit_gateway_debug_log( @@ -297,6 +332,20 @@ where self.finalized = true; let usage = self.tracker.finalize(); + let terminal_signal = if error_code.is_some() { + Some("error") + } else if self.tracker.completion_seen() { + Some("completed") + } else { + None + }; + if let Ok(activity) = self.ctx.activity.lock() { + spawn_touch_activity( + &self.ctx, + activity.last_activity_ms(), + activity.details_json(terminal_signal), + ); + } // Propagate fake 200 detection from tracker to finalize context. if self.tracker.fake_200_detected() { @@ -317,7 +366,8 @@ where requested_model, usage_metrics, usage, - ), + ) + .with_terminal_signal(terminal_signal), ); } } @@ -830,9 +880,12 @@ mod tests { use super::{ is_codex_body_buffer_drop_successish, is_codex_client_abort_successish, is_codex_drop_successish, is_codex_responses_path, is_codex_stream_tail_error_successish, - is_codex_stream_terminal_error_successish, next_item, spawn_usage_sse_relay_body, - RelayBodyStream, StreamFinalizeCtx, + is_codex_stream_terminal_error_successish, is_plugin_stream_error_chunk, next_item, + spawn_touch_activity, spawn_usage_sse_relay_body, RelayBodyStream, StreamFinalizeCtx, }; + use crate::gateway::active_requests::{ActiveRequestRegistry, ActiveRequestStart}; + use crate::gateway::proxy::GatewayErrorCode; + use crate::gateway::streams::StreamActivityTracker; use crate::{circuit_breaker, db, request_logs, session_manager}; use axum::body::Bytes; use std::collections::HashMap; @@ -843,6 +896,7 @@ mod tests { app: tauri::AppHandle, db: db::Db, log_tx: tokio::sync::mpsc::Sender, + active_requests: Arc, ) -> StreamFinalizeCtx { StreamFinalizeCtx { app, @@ -882,6 +936,25 @@ mod tests { auth_mode: "api_key".to_string(), fake_200_detected: false, fake_200_quota_exhausted: false, + activity: Arc::new(Mutex::new(StreamActivityTracker::new( + "trace-usage-tee-drain", + "codex", + 1_700_000_000_000, + ))), + active_requests, + } + } + + fn active_request_start(trace_id: &str) -> ActiveRequestStart { + ActiveRequestStart { + trace_id: trace_id.to_string(), + cli_key: "codex".to_string(), + method: "POST".to_string(), + path: "/v1/responses".to_string(), + query: None, + session_id: Some("sess-usage-tee-drain".to_string()), + requested_model: Some("gpt-5".to_string()), + created_at_ms: 1_700_000_000_000, } } @@ -895,8 +968,50 @@ mod tests { } #[test] - fn codex_client_abort_successish_allows_terminal_marker_when_upstream_not_ended() { - assert!(is_codex_client_abort_successish( + fn stream_activity_tracker_flushes_at_most_every_30_seconds() { + let mut tracker = StreamActivityTracker::new("trace-a", "codex", 1_000); + assert!(!tracker.observe_chunk_at(10_000)); + assert!(!tracker.observe_chunk_at(30_999)); + assert!(tracker.observe_chunk_at(31_000)); + assert!(!tracker.observe_chunk_at(45_000)); + assert!(tracker.observe_chunk_at(61_000)); + } + + #[test] + fn spawn_touch_activity_updates_active_registry_immediately() { + let app = tauri::test::mock_app(); + let db_dir = tempfile::tempdir().expect("db dir"); + let db = db::init_for_tests(&db_dir.path().join("usage-tee-touch.sqlite")) + .expect("init test db"); + let (log_tx, _log_rx) = tokio::sync::mpsc::channel(4); + let active_requests = Arc::new(ActiveRequestRegistry::default()); + active_requests.register(active_request_start("trace-usage-tee-drain")); + let ctx = + test_stream_finalize_ctx(app.handle().clone(), db, log_tx, active_requests.clone()); + + spawn_touch_activity(&ctx, 1_700_000_045_000, None); + + assert_eq!( + active_requests.snapshot()[0].last_activity_ms, + 1_700_000_045_000 + ); + } + + #[test] + fn plugin_stream_error_chunk_is_still_detected_without_rewriting_marker() { + let chunk = Bytes::from_static( + b": aio-plugin-error\nevent: error\ndata: {\"error\":\"plugin_failed\"}\n\n", + ); + assert!(is_plugin_stream_error_chunk(chunk.as_ref())); + assert_eq!( + std::str::from_utf8(chunk.as_ref()).expect("utf8"), + ": aio-plugin-error\nevent: error\ndata: {\"error\":\"plugin_failed\"}\n\n" + ); + } + + #[test] + fn codex_client_abort_successish_rejects_terminal_marker_without_completion_or_usage() { + assert!(!is_codex_client_abort_successish( "codex", "/v1/responses", 200, @@ -908,6 +1023,30 @@ mod tests { )); } + #[test] + fn codex_client_abort_successish_requires_completion_or_usage() { + assert!(!is_codex_client_abort_successish( + "codex", + "/v1/responses", + 200, + true, + false, + false, + false, + false + )); + assert!(is_codex_client_abort_successish( + "codex", + "/v1/responses", + 200, + true, + true, + false, + false, + false + )); + } + #[test] fn codex_client_abort_successish_allows_completion_or_usage_when_upstream_ended() { assert!(is_codex_client_abort_successish( @@ -930,7 +1069,7 @@ mod tests { true, true )); - assert!(is_codex_client_abort_successish( + assert!(!is_codex_client_abort_successish( "codex", "/v1/responses", 200, @@ -1137,7 +1276,9 @@ mod tests { let db = db::init_for_tests(&db_dir.path().join("usage-tee-drain.sqlite")) .expect("init test db"); let (log_tx, mut log_rx) = tokio::sync::mpsc::channel(4); - let ctx = test_stream_finalize_ctx(app_handle, db, log_tx); + let active_requests = Arc::new(ActiveRequestRegistry::default()); + active_requests.register(active_request_start("trace-usage-tee-drain")); + let ctx = test_stream_finalize_ctx(app_handle, db, log_tx, active_requests.clone()); let (upstream_tx, upstream_rx) = tokio::sync::mpsc::channel::>(4); @@ -1191,4 +1332,124 @@ mod tests { .as_deref() .is_some_and(|value| value.contains("\"client_abort\""))); } + + #[tokio::test(flavor = "current_thread")] + async fn stream_idle_timeout_fires_after_configured_silence() { + let app = tauri::test::mock_app(); + let db_dir = tempfile::tempdir().expect("db dir"); + let db = db::init_for_tests(&db_dir.path().join("usage-tee-idle-timeout.sqlite")) + .expect("init test db"); + let (log_tx, mut log_rx) = tokio::sync::mpsc::channel(4); + let active_requests = Arc::new(ActiveRequestRegistry::default()); + active_requests.register(active_request_start("trace-usage-tee-drain")); + let ctx = test_stream_finalize_ctx(app.handle().clone(), db, log_tx, active_requests); + let (upstream_tx, upstream_rx) = + tokio::sync::mpsc::channel::>(4); + + let body = spawn_usage_sse_relay_body( + RelayBodyStream::new(upstream_rx), + ctx, + Some(Duration::from_millis(500)), + None, + ); + let mut body_stream = body.into_data_stream(); + + // Chunk gaps (100ms) are below the idle window (500ms) but sum above + // it: all chunks arriving proves each chunk resets the timer. The 5x + // margin absorbs scheduler hiccups on loaded CI runners; tokio::time + // pause is not an option because the request log is delivered from + // tauri's separate real-time runtime. + for _ in 0..3 { + upstream_tx + .send(Ok(Bytes::from_static( + b"data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n", + ))) + .await + .expect("send output chunk"); + let chunk = tokio::time::timeout(Duration::from_secs(1), next_item(&mut body_stream)) + .await + .expect("output chunk should arrive") + .expect("body should yield output chunk") + .expect("output chunk should be ok"); + assert!(chunk.as_ref().starts_with(b"data:")); + tokio::time::sleep(Duration::from_millis(100)).await; + } + + // Upstream goes silent without closing; downstream stays connected. + // The idle timeout must end the body stream. + let end = tokio::time::timeout(Duration::from_secs(2), next_item(&mut body_stream)) + .await + .expect("idle timeout should end the body stream"); + assert!(end.is_none()); + + let log = tokio::time::timeout(Duration::from_secs(2), log_rx.recv()) + .await + .expect("request log should be enqueued") + .expect("request log channel should stay open"); + assert_eq!( + log.error_code, + Some(GatewayErrorCode::StreamIdleTimeout.as_str().to_string()) + ); + drop(upstream_tx); + } + + #[tokio::test(flavor = "current_thread")] + async fn stream_idle_timeout_disabled_keeps_stream_open() { + let app = tauri::test::mock_app(); + let db_dir = tempfile::tempdir().expect("db dir"); + let db = db::init_for_tests(&db_dir.path().join("usage-tee-idle-disabled.sqlite")) + .expect("init test db"); + let (log_tx, mut log_rx) = tokio::sync::mpsc::channel(4); + let active_requests = Arc::new(ActiveRequestRegistry::default()); + active_requests.register(active_request_start("trace-usage-tee-drain")); + let ctx = test_stream_finalize_ctx(app.handle().clone(), db, log_tx, active_requests); + let (upstream_tx, upstream_rx) = + tokio::sync::mpsc::channel::>(4); + + let body = spawn_usage_sse_relay_body(RelayBodyStream::new(upstream_rx), ctx, None, None); + let mut body_stream = body.into_data_stream(); + + upstream_tx + .send(Ok(Bytes::from_static( + b"data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n", + ))) + .await + .expect("send first output chunk"); + let first = tokio::time::timeout(Duration::from_secs(1), next_item(&mut body_stream)) + .await + .expect("first output chunk should arrive") + .expect("body should yield first output") + .expect("first output should be ok"); + assert!(first.as_ref().starts_with(b"data:")); + + // Silence longer than any small idle window; disabled timeout must not + // interrupt the stream. + tokio::time::sleep(Duration::from_millis(50)).await; + + upstream_tx + .send(Ok(Bytes::from_static( + b"data: {\"type\":\"response.output_text.delta\",\"delta\":\"world\"}\n\n", + ))) + .await + .expect("send second output chunk"); + let second = tokio::time::timeout(Duration::from_secs(1), next_item(&mut body_stream)) + .await + .expect("second output chunk should arrive") + .expect("body should yield second output") + .expect("second output should be ok"); + assert!(second.as_ref().starts_with(b"data:")); + + // Let the stream end normally. + drop(upstream_tx); + let end = tokio::time::timeout(Duration::from_secs(1), next_item(&mut body_stream)) + .await + .expect("body stream should end after upstream closes"); + assert!(end.is_none()); + + let log = tokio::time::timeout(Duration::from_secs(2), log_rx.recv()) + .await + .expect("request log should be enqueued") + .expect("request log channel should stay open"); + assert_eq!(log.error_code, None); + } } diff --git a/src-tauri/src/gateway/util.rs b/src-tauri/src/gateway/util.rs index 63b69a9a..c32c4194 100644 --- a/src-tauri/src/gateway/util.rs +++ b/src-tauri/src/gateway/util.rs @@ -22,7 +22,7 @@ fn parse_request_body_limit_mb(raw: Option<&str>) -> usize { .clamp(MIN_REQUEST_BODY_MB, MAX_REQUEST_BODY_MB) } -pub(super) fn max_request_body_bytes() -> usize { +pub(crate) fn max_request_body_bytes() -> usize { parse_request_body_limit_mb( std::env::var("AIO_GATEWAY_MAX_REQUEST_BODY_MB") .ok() diff --git a/src-tauri/src/infra/data_management.rs b/src-tauri/src/infra/data_management.rs index 71e3a0e8..d18aee86 100644 --- a/src-tauri/src/infra/data_management.rs +++ b/src-tauri/src/infra/data_management.rs @@ -21,6 +21,12 @@ pub struct ClearRequestLogsResult { pub request_logs_deleted: u64, } +#[derive(Debug, Clone, Serialize, specta::Type)] +pub struct DbCompactResult { + pub before_bytes: u64, + pub after_bytes: u64, +} + fn file_len_or_zero(path: &Path) -> Result { match std::fs::metadata(path) { Ok(meta) => Ok(meta.len()), @@ -54,11 +60,10 @@ fn db_related_paths(db_path: &Path) -> (PathBuf, PathBuf) { (wal_path, shm_path) } -pub fn db_disk_usage_get(app: &tauri::AppHandle) -> crate::shared::error::AppResult { - let db_path = db::db_path(app)?; - let (wal_path, shm_path) = db_related_paths(&db_path); +fn disk_usage_at(db_path: &Path) -> Result { + let (wal_path, shm_path) = db_related_paths(db_path); - let db_bytes = file_len_or_zero(&db_path)?; + let db_bytes = file_len_or_zero(db_path)?; let wal_bytes = file_len_or_zero(&wal_path)?; let shm_bytes = file_len_or_zero(&shm_path)?; @@ -70,6 +75,43 @@ pub fn db_disk_usage_get(app: &tauri::AppHandle) -> crate::shared::error::AppRes }) } +pub fn db_disk_usage_get(app: &tauri::AppHandle) -> crate::shared::error::AppResult { + let db_path = db::db_path(app)?; + Ok(disk_usage_at(&db_path)?) +} + +pub fn db_compact( + app: &tauri::AppHandle, + db: &db::Db, +) -> crate::shared::error::AppResult { + let db_path = db::db_path(app)?; + db_compact_at(&db_path, db) +} + +fn db_compact_at(db_path: &Path, db: &db::Db) -> crate::shared::error::AppResult { + tracing::info!("compacting database (user-initiated)"); + + let before_bytes = disk_usage_at(db_path)?.total_bytes; + + let conn = db.open_connection()?; + + // Checkpoints stay best-effort (same sequence as request_logs_clear_all), + // but VACUUM failures must surface: this is a user-initiated action. + let _ = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);"); + conn.execute_batch("VACUUM;") + .map_err(|e| db_err!("failed to vacuum database: {e}"))?; + let _ = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);"); + + let after_bytes = disk_usage_at(db_path)?.total_bytes; + + tracing::info!(before_bytes, after_bytes, "database compacted"); + + Ok(DbCompactResult { + before_bytes, + after_bytes, + }) +} + pub fn request_logs_clear_all( db: &db::Db, ) -> crate::shared::error::AppResult { @@ -130,3 +172,95 @@ pub fn app_data_reset( Ok(true) } + +#[cfg(test)] +mod tests { + use super::db_compact_at; + use rusqlite::params; + use std::path::PathBuf; + use tempfile::TempDir; + + fn init_test_db() -> (crate::db::Db, PathBuf, TempDir) { + let dir = TempDir::new().expect("temp dir"); + let db_path = dir.path().join("data-management.sqlite"); + let db = crate::db::init_for_tests(&db_path).expect("init db"); + (db, db_path, dir) + } + + fn insert_request_log_rows(db: &crate::db::Db, count: usize) { + let conn = db.open_connection().expect("open connection"); + // Bulky payload so deletions leave measurable free pages behind. + let attempts_json = format!("[\"{}\"]", "x".repeat(4096)); + for idx in 0..count { + conn.execute( + r#" +INSERT INTO request_logs ( + trace_id, cli_key, method, path, status, duration_ms, attempts_json, + created_at, created_at_ms, excluded_from_stats +) VALUES (?1, 'claude', 'POST', '/v1/messages', 200, 10, ?2, 1770000000, 1770000000000, 0) +"#, + params![format!("trace-compact-{idx}"), attempts_json], + ) + .expect("insert request log row"); + } + } + + fn count_request_logs(db: &crate::db::Db) -> i64 { + let conn = db.open_connection().expect("open connection"); + conn.query_row("SELECT COUNT(1) FROM request_logs", [], |row| row.get(0)) + .expect("count request logs") + } + + #[test] + fn db_compact_keeps_rows_and_reclaims_space() { + let (db, db_path, _dir) = init_test_db(); + + insert_request_log_rows(&db, 300); + { + let conn = db.open_connection().expect("open connection"); + conn.execute("DELETE FROM request_logs WHERE rowid % 4 != 0", []) + .expect("delete rows"); + } + let rows_before = count_request_logs(&db); + assert!(rows_before > 0, "expected surviving rows before compact"); + + let result = db_compact_at(&db_path, &db).expect("compact db"); + + assert_eq!( + count_request_logs(&db), + rows_before, + "compact must not delete data" + ); + assert!( + result.after_bytes <= result.before_bytes, + "after_bytes {} must not exceed before_bytes {}", + result.after_bytes, + result.before_bytes + ); + } + + #[test] + fn db_compact_surfaces_vacuum_failure_and_keeps_rows() { + let (db, db_path, _dir) = init_test_db(); + insert_request_log_rows(&db, 4); + + // Hold the write lock on a separate connection so VACUUM cannot acquire it. + let blocker = rusqlite::Connection::open(&db_path).expect("open blocker connection"); + blocker + .execute_batch("BEGIN IMMEDIATE;") + .expect("begin immediate"); + + let err = db_compact_at(&db_path, &db).expect_err("vacuum must fail while db is locked"); + assert!( + err.to_string().contains("failed to vacuum database"), + "unexpected error: {err}" + ); + + blocker.execute_batch("ROLLBACK;").expect("rollback"); + assert_eq!( + count_request_logs(&db), + 4, + "rows must survive failed compact" + ); + } +} diff --git a/src-tauri/src/infra/db/migrations/baseline_v25.rs b/src-tauri/src/infra/db/migrations/baseline_v25.rs index 9f9535cb..9e2b69c1 100644 --- a/src-tauri/src/infra/db/migrations/baseline_v25.rs +++ b/src-tauri/src/infra/db/migrations/baseline_v25.rs @@ -69,6 +69,8 @@ CREATE TABLE IF NOT EXISTS request_logs ( excluded_from_stats INTEGER NOT NULL DEFAULT 0, special_settings_json TEXT, created_at_ms INTEGER NOT NULL DEFAULT 0, + last_activity_ms INTEGER, + activity_details_json TEXT, session_id TEXT, final_provider_id INTEGER ); @@ -182,6 +184,20 @@ CREATE TABLE IF NOT EXISTS provider_circuit_breakers ( CREATE INDEX IF NOT EXISTS idx_provider_circuit_breakers_state ON provider_circuit_breakers(state); +CREATE TABLE IF NOT EXISTS provider_extension_values ( + provider_id INTEGER NOT NULL, + plugin_id TEXT NOT NULL, + namespace TEXT NOT NULL, + values_json TEXT NOT NULL DEFAULT '{}', + updated_at INTEGER NOT NULL, + PRIMARY KEY(provider_id, plugin_id, namespace), + FOREIGN KEY(provider_id) REFERENCES providers(id) ON DELETE CASCADE, + FOREIGN KEY(plugin_id) REFERENCES plugins(plugin_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_provider_extension_values_plugin_namespace + ON provider_extension_values(plugin_id, namespace); + CREATE TABLE IF NOT EXISTS provider_pool_order ( cli_key TEXT NOT NULL, provider_id INTEGER NOT NULL, diff --git a/src-tauri/src/infra/db/migrations/ensure.rs b/src-tauri/src/infra/db/migrations/ensure.rs index 97c0462a..19f87c90 100644 --- a/src-tauri/src/infra/db/migrations/ensure.rs +++ b/src-tauri/src/infra/db/migrations/ensure.rs @@ -25,6 +25,7 @@ pub(super) fn apply_ensure_patches(conn: &mut Connection) -> crate::shared::erro ensure_provider_stream_idle_timeout(conn)?; ensure_skills_update_columns(conn)?; ensure_plugin_tables(conn)?; + ensure_provider_extension_values_table(conn)?; Ok(()) } @@ -997,6 +998,20 @@ fn ensure_request_logs_extended_columns(conn: &mut Connection) -> Result<(), Str .map_err(|e| format!("failed to ensure request_logs.error_details_json: {e}"))?; } + if !column_exists(conn, "request_logs", "last_activity_ms")? { + conn.execute_batch("ALTER TABLE request_logs ADD COLUMN last_activity_ms INTEGER;") + .map_err(|e| format!("failed to ensure request_logs.last_activity_ms: {e}"))?; + conn.execute_batch( + "UPDATE request_logs SET last_activity_ms = created_at_ms WHERE last_activity_ms IS NULL;", + ) + .map_err(|e| format!("failed to backfill request_logs.last_activity_ms: {e}"))?; + } + + if !column_exists(conn, "request_logs", "activity_details_json")? { + conn.execute_batch("ALTER TABLE request_logs ADD COLUMN activity_details_json TEXT;") + .map_err(|e| format!("failed to ensure request_logs.activity_details_json: {e}"))?; + } + Ok(()) } @@ -1105,6 +1120,36 @@ CREATE TABLE IF NOT EXISTS plugin_runtime_failures ( CREATE INDEX IF NOT EXISTS idx_plugin_runtime_failures_plugin_created_at ON plugin_runtime_failures(plugin_id, created_at); + +CREATE TABLE IF NOT EXISTS plugin_hook_execution_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + trace_id TEXT, + hook_name TEXT NOT NULL, + runtime_kind TEXT NOT NULL, + status TEXT NOT NULL, + started_at_ms INTEGER NOT NULL, + duration_ms INTEGER NOT NULL, + failure_kind TEXT, + error_code TEXT, + failure_policy TEXT, + circuit_state TEXT, + context_budget_json TEXT NOT NULL DEFAULT '{}', + output_budget_json TEXT NOT NULL DEFAULT '{}', + mutation_summary_json TEXT NOT NULL DEFAULT '{}', + replayable INTEGER NOT NULL DEFAULT 0, + replay_export_reason TEXT, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_plugin_hook_execution_reports_plugin_created_at + ON plugin_hook_execution_reports(plugin_id, created_at); +CREATE INDEX IF NOT EXISTS idx_plugin_hook_execution_reports_created_at + ON plugin_hook_execution_reports(created_at); +CREATE INDEX IF NOT EXISTS idx_plugin_hook_execution_reports_trace_id + ON plugin_hook_execution_reports(trace_id); +CREATE INDEX IF NOT EXISTS idx_plugin_hook_execution_reports_plugin_hook_created_at + ON plugin_hook_execution_reports(plugin_id, hook_name, created_at); "#, ) .map_err(|e| format!("failed to ensure plugin tables: {e}"))?; @@ -1115,6 +1160,31 @@ CREATE INDEX IF NOT EXISTS idx_plugin_runtime_failures_plugin_created_at Ok(()) } +fn ensure_provider_extension_values_table( + conn: &mut Connection, +) -> crate::shared::error::AppResult<()> { + conn.execute_batch( + r#" +CREATE TABLE IF NOT EXISTS provider_extension_values ( + provider_id INTEGER NOT NULL, + plugin_id TEXT NOT NULL, + namespace TEXT NOT NULL, + values_json TEXT NOT NULL DEFAULT '{}', + updated_at INTEGER NOT NULL, + PRIMARY KEY(provider_id, plugin_id, namespace), + FOREIGN KEY(provider_id) REFERENCES providers(id) ON DELETE CASCADE, + FOREIGN KEY(plugin_id) REFERENCES plugins(plugin_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_provider_extension_values_plugin_namespace + ON provider_extension_values(plugin_id, namespace); +"#, + ) + .map_err(|e| format!("failed to ensure provider extension values table: {e}"))?; + + Ok(()) +} + // --------------------------------------------------------------------------- // Shared helper // --------------------------------------------------------------------------- diff --git a/src-tauri/src/infra/db/migrations/mod.rs b/src-tauri/src/infra/db/migrations/mod.rs index 57f77708..e4f33a86 100644 --- a/src-tauri/src/infra/db/migrations/mod.rs +++ b/src-tauri/src/infra/db/migrations/mod.rs @@ -10,11 +10,13 @@ mod v29_to_v30; mod v30_to_v31; mod v31_to_v32; mod v32_to_v33; +mod v33_to_v34; +mod v34_to_v35; use rusqlite::Connection; -const LATEST_SCHEMA_VERSION: i64 = 33; -const MAX_COMPAT_SCHEMA_VERSION: i64 = 34; +const LATEST_SCHEMA_VERSION: i64 = 35; +const MAX_COMPAT_SCHEMA_VERSION: i64 = 35; const MIN_SUPPORTED_SCHEMA_VERSION: i64 = 25; pub(super) fn apply_migrations(conn: &mut Connection) -> crate::shared::error::AppResult<()> { @@ -55,6 +57,8 @@ pub(super) fn apply_migrations(conn: &mut Connection) -> crate::shared::error::A 30 => v30_to_v31::migrate_v30_to_v31(conn)?, 31 => v31_to_v32::migrate_v31_to_v32(conn)?, 32 => v32_to_v33::migrate_v32_to_v33(conn)?, + 33 => v33_to_v34::migrate_v33_to_v34(conn)?, + 34 => v34_to_v35::migrate_v34_to_v35(conn)?, v => { tracing::error!( version = v, diff --git a/src-tauri/src/infra/db/migrations/tests.rs b/src-tauri/src/infra/db/migrations/tests.rs index 955ccc12..a3e8a51a 100644 --- a/src-tauri/src/infra/db/migrations/tests.rs +++ b/src-tauri/src/infra/db/migrations/tests.rs @@ -289,6 +289,7 @@ fn ensure_plugin_tables_is_idempotent() { "plugin_audit_logs", "plugin_market_sources", "plugin_runtime_failures", + "plugin_hook_execution_reports", ] { assert!( test_has_table(&conn, table), @@ -307,6 +308,38 @@ fn ensure_plugin_tables_is_idempotent() { "plugin_permissions", "permissions_json" )); + assert!(test_has_index( + &conn, + "idx_plugin_hook_execution_reports_created_at" + )); +} + +#[test] +fn migrations_create_provider_extension_values_table() { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + apply_migrations(&mut conn).expect("apply migrations"); + + assert!(test_has_table(&conn, "provider_extension_values")); + assert!(test_has_column( + &conn, + "provider_extension_values", + "provider_id" + )); + assert!(test_has_column( + &conn, + "provider_extension_values", + "plugin_id" + )); + assert!(test_has_column( + &conn, + "provider_extension_values", + "namespace" + )); + assert!(test_has_column( + &conn, + "provider_extension_values", + "values_json" + )); } #[test] @@ -840,7 +873,7 @@ INSERT INTO providers( let user_version: i64 = conn .pragma_query_value(None, "user_version", |row| row.get(0)) .expect("read user_version"); - assert_eq!(user_version, 33); + assert_eq!(user_version, LATEST_SCHEMA_VERSION); for column in [ "auth_mode", @@ -1096,7 +1129,7 @@ INSERT INTO skills( let user_version: i64 = conn .pragma_query_value(None, "user_version", |row| row.get(0)) .expect("read user_version"); - assert_eq!(user_version, 33); + assert_eq!(user_version, LATEST_SCHEMA_VERSION); assert!(test_has_column(&conn, "workspaces", "cli_key")); assert!(test_has_column(&conn, "workspace_active", "workspace_id")); @@ -1217,7 +1250,7 @@ fn baseline_v25_creates_complete_schema_for_fresh_install() { let user_version: i64 = conn .pragma_query_value(None, "user_version", |row| row.get(0)) .expect("read user_version"); - assert_eq!(user_version, 33); + assert_eq!(user_version, LATEST_SCHEMA_VERSION); // Verify all tables exist let tables: Vec = { @@ -1242,6 +1275,7 @@ fn baseline_v25_creates_complete_schema_for_fresh_install() { assert!(tables.contains(&"sort_mode_providers".to_string())); assert!(tables.contains(&"sort_mode_active".to_string())); assert!(tables.contains(&"claude_model_validation_runs".to_string())); + assert!(tables.contains(&"plugin_hook_execution_reports".to_string())); assert!(tables.contains(&"schema_migrations".to_string())); // Tables from ensure patches @@ -1452,7 +1486,7 @@ PRAGMA user_version = 33; let user_version: i64 = conn .pragma_query_value(None, "user_version", |row| row.get(0)) .expect("read user_version"); - assert_eq!(user_version, 33); + assert_eq!(user_version, LATEST_SCHEMA_VERSION); assert!(test_has_column(&conn, "providers", "limit_5h_usd")); assert!(test_has_column(&conn, "providers", "limit_daily_usd")); @@ -1462,6 +1496,7 @@ PRAGMA user_version = 33; assert!(test_has_column(&conn, "providers", "limit_monthly_usd")); assert!(test_has_column(&conn, "providers", "limit_total_usd")); assert!(test_has_column(&conn, "skills", "installed_content_hash")); + assert!(test_has_table(&conn, "plugin_hook_execution_reports")); let active_id: i64 = conn .query_row( diff --git a/src-tauri/src/infra/db/migrations/v33_to_v34.rs b/src-tauri/src/infra/db/migrations/v33_to_v34.rs new file mode 100644 index 00000000..6cebe838 --- /dev/null +++ b/src-tauri/src/infra/db/migrations/v33_to_v34.rs @@ -0,0 +1,62 @@ +//! Usage: SQLite migration v33->v34 - Add structured plugin hook execution reports. + +use crate::shared::time::now_unix_seconds; +use rusqlite::Connection; + +pub(super) fn migrate_v33_to_v34(conn: &mut Connection) -> Result<(), String> { + const VERSION: i64 = 34; + let tx = conn + .transaction() + .map_err(|e| format!("failed to start sqlite transaction: {e}"))?; + let now = now_unix_seconds(); + + tx.execute_batch( + r#" +CREATE TABLE IF NOT EXISTS plugin_hook_execution_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + trace_id TEXT, + hook_name TEXT NOT NULL, + runtime_kind TEXT NOT NULL, + status TEXT NOT NULL, + started_at_ms INTEGER NOT NULL, + duration_ms INTEGER NOT NULL, + failure_kind TEXT, + error_code TEXT, + failure_policy TEXT, + circuit_state TEXT, + context_budget_json TEXT NOT NULL DEFAULT '{}', + output_budget_json TEXT NOT NULL DEFAULT '{}', + mutation_summary_json TEXT NOT NULL DEFAULT '{}', + replayable INTEGER NOT NULL DEFAULT 0, + replay_export_reason TEXT, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_plugin_hook_execution_reports_plugin_created_at + ON plugin_hook_execution_reports(plugin_id, created_at); +CREATE INDEX IF NOT EXISTS idx_plugin_hook_execution_reports_trace_id + ON plugin_hook_execution_reports(trace_id); +CREATE INDEX IF NOT EXISTS idx_plugin_hook_execution_reports_plugin_hook_created_at + ON plugin_hook_execution_reports(plugin_id, hook_name, created_at); +"#, + ) + .map_err(|e| format!("failed to create plugin hook execution report table: {e}"))?; + + tx.execute_batch( + "CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)", + ) + .map_err(|e| format!("failed to create schema_migrations table: {e}"))?; + tx.execute( + "INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (?, ?)", + [VERSION, now], + ) + .map_err(|e| format!("failed to insert schema_migrations row for v{VERSION}: {e}"))?; + + super::set_user_version(&tx, VERSION)?; + + tx.commit() + .map_err(|e| format!("failed to commit sqlite transaction: {e}"))?; + + Ok(()) +} diff --git a/src-tauri/src/infra/db/migrations/v34_to_v35.rs b/src-tauri/src/infra/db/migrations/v34_to_v35.rs new file mode 100644 index 00000000..80d24d04 --- /dev/null +++ b/src-tauri/src/infra/db/migrations/v34_to_v35.rs @@ -0,0 +1,35 @@ +//! Usage: SQLite migration v34->v35 - Add plugin-owned provider extension values. + +use rusqlite::Connection; + +pub(super) fn migrate_v34_to_v35(conn: &mut Connection) -> Result<(), String> { + let tx = conn + .transaction() + .map_err(|e| format!("failed to start v34->v35: {e}"))?; + + tx.execute_batch( + r#" +CREATE TABLE IF NOT EXISTS provider_extension_values ( + provider_id INTEGER NOT NULL, + plugin_id TEXT NOT NULL, + namespace TEXT NOT NULL, + values_json TEXT NOT NULL DEFAULT '{}', + updated_at INTEGER NOT NULL, + PRIMARY KEY(provider_id, plugin_id, namespace), + FOREIGN KEY(provider_id) REFERENCES providers(id) ON DELETE CASCADE, + FOREIGN KEY(plugin_id) REFERENCES plugins(plugin_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_provider_extension_values_plugin_namespace + ON provider_extension_values(plugin_id, namespace); +"#, + ) + .map_err(|e| format!("failed to migrate v34->v35: {e}"))?; + + super::set_user_version(&tx, 35)?; + + tx.commit() + .map_err(|e| format!("failed to commit v34->v35: {e}"))?; + + Ok(()) +} diff --git a/src-tauri/src/infra/plugins/market.rs b/src-tauri/src/infra/plugins/market.rs index 6a6b5505..2800579b 100644 --- a/src-tauri/src/infra/plugins/market.rs +++ b/src-tauri/src/infra/plugins/market.rs @@ -11,6 +11,7 @@ pub(crate) struct PluginMarketListing { pub(crate) name: String, pub(crate) latest_version: Option, pub(crate) download_url: Option, + pub(crate) market_source_url: Option, pub(crate) checksum: Option, pub(crate) signature: Option, pub(crate) risk_labels: Vec, @@ -22,6 +23,7 @@ pub(crate) struct PluginMarketListing { pub(crate) fn parse_market_index( bytes: &[u8], + market_source_url: Option<&str>, host_version: &str, installed_versions: &HashMap, ) -> AppResult> { @@ -57,7 +59,10 @@ pub(crate) fn parse_market_index( compare_semver(installed, &version.version).is_lt() }); - let install_block_reason = if revoked { + let reserved_official_namespace = is_reserved_official_plugin_id(&plugin.id); + let install_block_reason = if reserved_official_namespace { + Some("reserved_official_namespace".to_string()) + } else if revoked { Some("revoked".to_string()) } else if compatible_version.is_none() { Some("incompatible".to_string()) @@ -71,6 +76,7 @@ pub(crate) fn parse_market_index( name: plugin.name, latest_version, download_url: selected.map(|version| version.download_url.clone()), + market_source_url: market_source_url.map(str::to_string), checksum: selected.map(|version| version.checksum.clone()), signature: selected.and_then(|version| version.signature.clone()), risk_labels: plugin.risk_labels, @@ -86,13 +92,14 @@ pub(crate) fn parse_market_index( pub(crate) fn parse_signed_market_index( bytes: &[u8], + market_source_url: Option<&str>, signature: &str, public_key: &str, host_version: &str, installed_versions: &HashMap, ) -> AppResult> { crate::infra::plugins::signing::verify_ed25519_signature(bytes, signature, public_key)?; - parse_market_index(bytes, host_version, installed_versions) + parse_market_index(bytes, market_source_url, host_version, installed_versions) } #[derive(Debug, Deserialize)] @@ -155,6 +162,10 @@ fn validate_market_plugin_id(plugin_id: &str) -> AppResult<()> { Ok(()) } +fn is_reserved_official_plugin_id(plugin_id: &str) -> bool { + plugin_id.starts_with("official.") +} + fn validate_market_version(version: &RawMarketVersion) -> AppResult<()> { if parse_semver(&version.version).is_none() { return Err(AppError::new( @@ -356,9 +367,13 @@ mod tests { let mut installed = HashMap::new(); installed.insert("community.prompt-tools".to_string(), "1.0.0".to_string()); - let listings = - parse_market_index(market_index().to_string().as_bytes(), "0.56.0", &installed) - .unwrap(); + let listings = parse_market_index( + market_index().to_string().as_bytes(), + None, + "0.56.0", + &installed, + ) + .unwrap(); let prompt_tools = listings .iter() @@ -374,10 +389,29 @@ mod tests { assert_eq!(prompt_tools.install_block_reason, None); } + #[test] + fn parse_market_index_preserves_market_source_url() { + let index_url = "https://plugins.example.test/index.json"; + let listings = parse_market_index( + market_index().to_string().as_bytes(), + Some(index_url), + "0.56.0", + &HashMap::new(), + ) + .unwrap(); + + let prompt_tools = listings + .iter() + .find(|item| item.plugin_id == "community.prompt-tools") + .unwrap(); + assert_eq!(prompt_tools.market_source_url.as_deref(), Some(index_url)); + } + #[test] fn plugin_market_index_marks_incompatible_plugins_as_blocked() { let listings = parse_market_index( market_index().to_string().as_bytes(), + None, "0.56.0", &HashMap::new(), ) @@ -395,6 +429,7 @@ mod tests { fn plugin_market_index_marks_revoked_plugins_as_blocked() { let listings = parse_market_index( market_index().to_string().as_bytes(), + None, "0.56.0", &HashMap::new(), ) @@ -409,13 +444,54 @@ mod tests { assert_eq!(revoked.install_block_reason.as_deref(), Some("revoked")); } + #[test] + fn parse_market_index_marks_reserved_official_namespace_uninstallable() { + let installed = HashMap::new(); + let listings = parse_market_index( + br#"{ + "schemaVersion": "1", + "plugins": [ + { + "id": "official.privacy-filter", + "name": "Fake Official", + "riskLabels": ["request.body.read"], + "versions": [ + { + "version": "1.0.0", + "downloadUrl": "https://plugins.example.test/fake-official.aio-plugin", + "checksum": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "hostCompatibility": { + "app": ">=0.56.0 <1.0.0", + "pluginApi": "^1.0.0", + "platforms": ["macos", "windows", "linux"] + } + } + ] + } + ] + }"#, + Some("https://plugins.example.test/index.json"), + "0.62.2", + &installed, + ) + .unwrap(); + + assert_eq!(listings.len(), 1); + assert_eq!(listings[0].plugin_id, "official.privacy-filter"); + assert!(!listings[0].compatible); + assert_eq!( + listings[0].install_block_reason.as_deref(), + Some("reserved_official_namespace") + ); + } + #[test] fn plugin_market_index_rejects_invalid_checksum() { let mut raw = market_index(); raw["plugins"][0]["versions"][1]["checksum"] = serde_json::json!("sha256:not-hex"); - let err = - parse_market_index(raw.to_string().as_bytes(), "0.56.0", &HashMap::new()).unwrap_err(); + let err = parse_market_index(raw.to_string().as_bytes(), None, "0.56.0", &HashMap::new()) + .unwrap_err(); assert!(err .to_string() @@ -436,6 +512,7 @@ mod tests { let listings = parse_signed_market_index( &bytes, + None, &signature_b64, &public_key_b64, "0.56.0", @@ -449,6 +526,7 @@ mod tests { let err = parse_signed_market_index( b"{\"schemaVersion\":\"1.0.0\",\"plugins\":[]}", + None, &signature_b64, &public_key_b64, "0.56.0", diff --git a/src-tauri/src/infra/plugins/mod.rs b/src-tauri/src/infra/plugins/mod.rs index a59ee7c6..aa08c4c8 100644 --- a/src-tauri/src/infra/plugins/mod.rs +++ b/src-tauri/src/infra/plugins/mod.rs @@ -2,5 +2,7 @@ pub(crate) mod market; pub(crate) mod package; +pub(crate) mod replay_export; pub(crate) mod repository; +pub(crate) mod runtime_reports; pub(crate) mod signing; diff --git a/src-tauri/src/infra/plugins/package.rs b/src-tauri/src/infra/plugins/package.rs index 3a013e94..47808683 100644 --- a/src-tauri/src/infra/plugins/package.rs +++ b/src-tauri/src/infra/plugins/package.rs @@ -6,6 +6,8 @@ use sha2::{Digest, Sha256}; use std::io::Write; use std::path::{Component, Path, PathBuf}; +const EXTENSION_MAIN_MAX_BYTES: u64 = 1024 * 1024; + #[derive(Debug, Clone)] pub(crate) struct PluginPackageLimits { pub(crate) max_package_bytes: u64, @@ -31,10 +33,19 @@ pub(crate) struct ExtractedPluginPackage { pub(crate) package_bytes: Vec, } -pub(crate) fn extract_plugin_package( +pub(crate) fn extract_plugin_package_for_inspection( + package_path: &Path, + staging_dir: &Path, + limits: PluginPackageLimits, +) -> AppResult { + extract_plugin_package_with_mode(package_path, staging_dir, limits, false) +} + +fn extract_plugin_package_with_mode( package_path: &Path, staging_dir: &Path, limits: PluginPackageLimits, + validate_manifest_for_host: bool, ) -> AppResult { let metadata = std::fs::metadata(package_path).map_err(|error| { AppError::new( @@ -71,15 +82,13 @@ pub(crate) fn extract_plugin_package( } if staging_dir.exists() { - std::fs::remove_dir_all(staging_dir).map_err(|error| { - AppError::new( - "PLUGIN_PACKAGE_STAGING_FAILED", - format!( - "failed to clean staging dir {}: {error}", - staging_dir.display() - ), - ) - })?; + return Err(AppError::new( + "PLUGIN_PACKAGE_STAGING_FAILED", + format!( + "plugin package staging dir already exists: {}", + staging_dir.display() + ), + )); } std::fs::create_dir_all(staging_dir).map_err(|error| { AppError::new( @@ -92,7 +101,13 @@ pub(crate) fn extract_plugin_package( })?; let checksum = format!("sha256:{:x}", Sha256::digest(&bytes)); - match extract_zip_bytes(bytes, staging_dir, &limits, checksum) { + match extract_zip_bytes( + bytes, + staging_dir, + &limits, + checksum, + validate_manifest_for_host, + ) { Ok(extracted) => Ok(extracted), Err(error) => { let _ = std::fs::remove_dir_all(staging_dir); @@ -106,6 +121,7 @@ fn extract_zip_bytes( staging_dir: &Path, limits: &PluginPackageLimits, checksum: String, + validate_manifest_for_host: bool, ) -> AppResult { let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).map_err(|error| { @@ -223,13 +239,17 @@ fn extract_zip_bytes( "plugin package must contain plugin.json", ) })?; + reject_unsupported_manifest_runtime(&manifest_bytes)?; let manifest: PluginManifest = serde_json::from_slice(&manifest_bytes).map_err(|error| { AppError::new( "PLUGIN_INVALID_MANIFEST", format!("failed to parse plugin package manifest: {error}"), ) })?; - crate::domain::plugins::validate_manifest(&manifest, env!("CARGO_PKG_VERSION"))?; + validate_extension_main(&root_dir, &manifest)?; + if validate_manifest_for_host { + crate::domain::plugins::validate_manifest(&manifest, env!("CARGO_PKG_VERSION"))?; + } Ok(ExtractedPluginPackage { root_dir, @@ -239,6 +259,108 @@ fn extract_zip_bytes( }) } +fn validate_extension_main(root_dir: &Path, manifest: &PluginManifest) -> AppResult<()> { + let main = manifest.main.as_deref().ok_or_else(|| { + AppError::new( + "PLUGIN_EXTENSION_MAIN_MISSING", + "extensionHost runtime requires main", + ) + })?; + let relative_path = extension_main_relative_path(main)?; + let extension = relative_path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if extension != "js" && extension != "cjs" { + return Err(AppError::new( + "PLUGIN_EXTENSION_MAIN_INVALID", + "extensionHost main must point to a .js or .cjs file", + )); + } + + let main_path = root_dir.join(&relative_path); + let metadata = std::fs::metadata(&main_path).map_err(|_| { + AppError::new( + "PLUGIN_EXTENSION_MAIN_MISSING", + format!("extensionHost main file does not exist: {main}"), + ) + })?; + if !metadata.is_file() { + return Err(AppError::new( + "PLUGIN_EXTENSION_MAIN_MISSING", + format!("extensionHost main is not a file: {main}"), + )); + } + if metadata.len() > EXTENSION_MAIN_MAX_BYTES { + return Err(AppError::new( + "PLUGIN_EXTENSION_MAIN_TOO_LARGE", + format!("extensionHost main exceeds {EXTENSION_MAIN_MAX_BYTES} bytes: {main}"), + )); + } + + Ok(()) +} + +fn extension_main_relative_path(raw_main: &str) -> AppResult { + let trimmed = raw_main.trim(); + if trimmed.is_empty() { + return Err(AppError::new( + "PLUGIN_EXTENSION_MAIN_MISSING", + "extensionHost runtime requires main", + )); + } + if has_windows_drive_prefix(trimmed) || trimmed.starts_with("//") || trimmed.starts_with("\\\\") + { + return Err(AppError::new( + "PLUGIN_EXTENSION_MAIN_INVALID", + "extensionHost main must be a relative path inside the package", + )); + } + let normalized = trimmed.replace('\\', "/"); + let path = Path::new(&normalized); + if path.is_absolute() || normalized.starts_with('/') { + return Err(AppError::new( + "PLUGIN_EXTENSION_MAIN_INVALID", + "extensionHost main must be a relative path inside the package", + )); + } + + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(value) => { + let segment = value.to_string_lossy(); + if segment.is_empty() || segment == "." || segment == ".." { + return Err(AppError::new( + "PLUGIN_EXTENSION_MAIN_INVALID", + "extensionHost main must be a relative path inside the package", + )); + } + out.push(value); + } + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err(AppError::new( + "PLUGIN_EXTENSION_MAIN_INVALID", + "extensionHost main must be a relative path inside the package", + )); + } + } + } + if out.as_os_str().is_empty() { + return Err(AppError::new( + "PLUGIN_EXTENSION_MAIN_MISSING", + "extensionHost runtime requires main", + )); + } + Ok(out) +} + +fn has_windows_drive_prefix(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' +} + fn safe_zip_entry_path(raw_name: &str) -> AppResult { let normalized = raw_name.replace('\\', "/"); if normalized.is_empty() { @@ -277,6 +399,34 @@ fn safe_zip_entry_path(raw_name: &str) -> AppResult { Ok(out) } +fn reject_unsupported_manifest_runtime(manifest_bytes: &[u8]) -> AppResult<()> { + let raw: serde_json::Value = serde_json::from_slice(manifest_bytes).map_err(|error| { + AppError::new( + "PLUGIN_INVALID_MANIFEST", + format!("failed to parse plugin package manifest: {error}"), + ) + })?; + let Some(kind) = raw + .get("runtime") + .and_then(|runtime| runtime.get("kind")) + .and_then(serde_json::Value::as_str) + else { + return Ok(()); + }; + + match kind { + "native" | "wasm" | "process" => Err(unsupported_runtime_error(kind)), + _ => Ok(()), + } +} + +fn unsupported_runtime_error(kind: &str) -> AppError { + AppError::new( + "PLUGIN_UNSUPPORTED_RUNTIME", + format!("unsupported pre-release plugin runtime: {kind}"), + ) +} + fn package_root_dir(staging_dir: &Path) -> AppResult { let direct_manifest = staging_dir.join("plugin.json"); if direct_manifest.exists() { @@ -332,17 +482,18 @@ mod tests { "version": "1.0.0", "apiVersion": "1.0.0", "runtime": { - "kind": "declarativeRules", - "rules": ["rules/main.json"] + "kind": "extensionHost", + "language": "typescript" }, - "hooks": [ - { + "main": "dist/extension.js", + "contributes": { + "gatewayHooks": [{ "name": "gateway.request.afterBodyRead", "priority": 10, "failurePolicy": "fail-open" - } - ], - "permissions": ["request.body.read"], + }] + }, + "capabilities": ["gateway.hooks"], "hostCompatibility": { "app": ">=0.56.0 <1.0.0", "pluginApi": "^1.0.0", @@ -352,6 +503,28 @@ mod tests { .to_string() } + fn extension_manifest_json(plugin_id: &str, main: Option<&str>) -> String { + let mut manifest = serde_json::json!({ + "id": plugin_id, + "name": "Extension Test Plugin", + "version": "1.0.0", + "apiVersion": "1.0.0", + "runtime": { + "kind": "extensionHost", + "language": "typescript" + }, + "hostCompatibility": { + "app": ">=0.62.0 <1.0.0", + "pluginApi": "^1.0.0", + "platforms": ["macos", "windows", "linux"] + } + }); + if let Some(main) = main { + manifest["main"] = serde_json::json!(main); + } + manifest.to_string() + } + fn write_package(path: &Path, entries: &[(&str, &[u8])]) { let file = File::create(path).expect("create package"); let mut zip = zip::ZipWriter::new(file); @@ -363,6 +536,35 @@ mod tests { zip.finish().expect("finish package"); } + fn extract_plugin_package( + package_path: &Path, + staging_dir: &Path, + limits: PluginPackageLimits, + ) -> AppResult { + extract_plugin_package_with_mode(package_path, staging_dir, limits, true) + } + + #[test] + fn plugin_package_staging_rejects_existing_dir_without_deleting_it() { + let dir = tempfile::tempdir().unwrap(); + let package_path = dir.path().join("valid.aio-plugin"); + let staging_dir = dir.path().join("staging"); + let marker_path = staging_dir.join("marker.txt"); + write_package( + &package_path, + &[("plugin.json", manifest_json("local.safe").as_bytes())], + ); + std::fs::create_dir_all(&staging_dir).expect("create staging"); + std::fs::write(&marker_path, b"caller-owned").expect("write marker"); + + let err = + extract_plugin_package(&package_path, &staging_dir, PluginPackageLimits::default()) + .unwrap_err(); + + assert_eq!(err.code(), "PLUGIN_PACKAGE_STAGING_FAILED"); + assert!(marker_path.exists()); + } + #[test] fn plugin_package_security_rejects_zip_slip_entries() { let dir = tempfile::tempdir().unwrap(); @@ -435,6 +637,7 @@ mod tests { &[ ("plugin.json", manifest_json("local.safe").as_bytes()), ("rules/main.json", br#"{"rules":[]}"#), + ("dist/extension.js", b"export default {};"), ("README.md", b"# Local Test Plugin\n"), ], ); @@ -463,6 +666,7 @@ mod tests { manifest_json("local.safe").as_bytes(), ), ("local-safe/rules/main.json", br#"{"rules":[]}"#), + ("local-safe/dist/extension.js", b"export default {};"), ], ); @@ -577,4 +781,151 @@ mod tests { assert!(err.to_string().starts_with("PLUGIN_PACKAGE_INVALID_PATH:")); } + + #[test] + fn contribution_impact_extension_main_validation_rejects_missing_main() { + let dir = tempfile::tempdir().unwrap(); + let package_path = dir.path().join("extension-missing-main.aio-plugin"); + write_package( + &package_path, + &[( + "plugin.json", + extension_manifest_json("local.extension-missing", None).as_bytes(), + )], + ); + + let err = extract_plugin_package( + &package_path, + &dir.path().join("staging"), + PluginPackageLimits::default(), + ) + .unwrap_err(); + + assert_eq!(err.code(), "PLUGIN_EXTENSION_MAIN_MISSING"); + assert!(err + .to_string() + .contains("extensionHost runtime requires main")); + } + + #[test] + fn contribution_impact_extension_main_validation_rejects_invalid_main_path() { + let dir = tempfile::tempdir().unwrap(); + let package_path = dir.path().join("extension-invalid-main.aio-plugin"); + write_package( + &package_path, + &[( + "plugin.json", + extension_manifest_json("local.extension-invalid", Some("../extension.js")) + .as_bytes(), + )], + ); + + let err = extract_plugin_package( + &package_path, + &dir.path().join("staging"), + PluginPackageLimits::default(), + ) + .unwrap_err(); + + assert_eq!(err.code(), "PLUGIN_EXTENSION_MAIN_INVALID"); + assert!(err.to_string().contains("relative path inside the package")); + } + + #[test] + fn extension_main_validation_rejects_windows_drive_and_unc_paths() { + for (index, main) in [ + "C:/dist/main.js", + "C:\\dist\\main.js", + "//server/share/main.js", + "\\\\server\\share\\main.js", + ] + .into_iter() + .enumerate() + { + let dir = tempfile::tempdir().unwrap(); + let package_path = dir.path().join(format!( + "extension-invalid-platform-path-{index}.aio-plugin" + )); + write_package( + &package_path, + &[( + "plugin.json", + extension_manifest_json( + &format!("local.extension-invalid-platform-path-{index}"), + Some(main), + ) + .as_bytes(), + )], + ); + + let err = extract_plugin_package( + &package_path, + &dir.path().join("staging"), + PluginPackageLimits::default(), + ) + .unwrap_err(); + + assert_eq!(err.code(), "PLUGIN_EXTENSION_MAIN_INVALID", "{main}"); + } + } + + #[test] + fn contribution_impact_extension_main_validation_rejects_invalid_main_extension() { + let dir = tempfile::tempdir().unwrap(); + let package_path = dir + .path() + .join("extension-invalid-main-extension.aio-plugin"); + write_package( + &package_path, + &[ + ( + "plugin.json", + extension_manifest_json( + "local.extension-invalid-extension", + Some("dist/main.txt"), + ) + .as_bytes(), + ), + ("dist/main.txt", b"export default {};"), + ], + ); + + let err = extract_plugin_package( + &package_path, + &dir.path().join("staging"), + PluginPackageLimits::default(), + ) + .unwrap_err(); + + assert_eq!(err.code(), "PLUGIN_EXTENSION_MAIN_INVALID"); + assert!(err.to_string().contains(".js or .cjs")); + } + + #[test] + fn contribution_impact_extension_main_validation_rejects_too_large_main() { + let dir = tempfile::tempdir().unwrap(); + let package_path = dir.path().join("extension-large-main.aio-plugin"); + let large_main = vec![b' '; 1024 * 1024 + 1]; + write_package( + &package_path, + &[ + ( + "plugin.json", + extension_manifest_json("local.extension-large", Some("dist/extension.cjs")) + .as_bytes(), + ), + ("dist/extension.cjs", large_main.as_slice()), + ], + ); + + let err = extract_plugin_package( + &package_path, + &dir.path().join("staging"), + PluginPackageLimits::default(), + ) + .unwrap_err(); + + assert_eq!(err.code(), "PLUGIN_EXTENSION_MAIN_TOO_LARGE"); + assert!(err.to_string().contains("exceeds 1048576 bytes")); + } } diff --git a/src-tauri/src/infra/plugins/replay_export.rs b/src-tauri/src/infra/plugins/replay_export.rs new file mode 100644 index 00000000..d77ee8b1 --- /dev/null +++ b/src-tauri/src/infra/plugins/replay_export.rs @@ -0,0 +1,263 @@ +//! Usage: Export host-owned plugin replay fixtures from request traces. + +use crate::db; +use crate::domain::plugins::{ + PluginReplayFixture, PluginReplayFixtureAttempt, PluginReplayFixtureLog, + PluginReplayFixtureRequest, PluginReplayFixtureResponse, PluginReplayFixtureSource, +}; +use crate::shared::error::{AppError, AppResult}; +use crate::shared::time::now_unix_millis; + +const PLUGIN_REPLAY_FIXTURE_SCHEMA_VERSION: u32 = 1; +const PLUGIN_REPLAY_ATTEMPT_LIMIT: usize = 200; +const PLUGIN_REPLAY_RUNTIME_REPORT_LIMIT: usize = 100; + +#[derive(Debug, Clone)] +pub(crate) struct ExportPluginReplayFixtureInput { + pub(crate) trace_id: String, + pub(crate) hook_name: String, + pub(crate) plugin_id: Option, +} + +pub(crate) fn export_plugin_replay_fixture( + db: &db::Db, + input: ExportPluginReplayFixtureInput, +) -> AppResult { + let trace_id = normalize_required_text("trace_id", input.trace_id)?; + let hook_name = normalize_required_text("hook_name", input.hook_name)?; + if !crate::gateway::plugins::contract::is_active_hook(&hook_name) { + return Err(AppError::new( + "PLUGIN_REPLAY_UNAVAILABLE", + format!("hook does not support replay export: {hook_name}"), + )); + } + let plugin_id = input + .plugin_id + .map(|raw| normalize_required_text("plugin_id", raw)) + .transpose()?; + + let request_log = crate::request_logs::get_by_trace_id(db, &trace_id)?.ok_or_else(|| { + AppError::new( + "PLUGIN_REPLAY_UNAVAILABLE", + format!("request log not found for trace_id: {trace_id}"), + ) + })?; + let attempts = + crate::request_attempt_logs::list_by_trace_id(db, &trace_id, PLUGIN_REPLAY_ATTEMPT_LIMIT)?; + let runtime_reports = crate::infra::plugins::runtime_reports::list_hook_execution_reports( + db, + plugin_id.as_deref(), + Some(&hook_name), + Some(&trace_id), + PLUGIN_REPLAY_RUNTIME_REPORT_LIMIT, + )?; + + let mut notes = vec![ + "request body is not persisted in request_logs; fixture includes host metadata, attempts, and plugin runtime reports only".to_string(), + "response body, stream chunks, and log body are not persisted in request_logs".to_string(), + ]; + if runtime_reports.is_empty() { + notes.push(match plugin_id.as_deref() { + Some(plugin_id) => format!( + "no runtime reports were found for plugin {plugin_id}, hook {hook_name}, trace {trace_id}" + ), + None => format!("no runtime reports were found for hook {hook_name}, trace {trace_id}"), + }); + } + + let provider_chain = parse_optional_json(request_log.provider_chain_json.as_deref()); + let error_details = parse_optional_json(request_log.error_details_json.as_deref()); + let usage = parse_optional_json(request_log.usage_json.as_deref()); + let special_settings = parse_optional_json(request_log.special_settings_json.as_deref()); + + Ok(PluginReplayFixture { + schema_version: PLUGIN_REPLAY_FIXTURE_SCHEMA_VERSION, + trace_id: trace_id.clone(), + source: PluginReplayFixtureSource { + app_version: env!("CARGO_PKG_VERSION").to_string(), + trace_id: trace_id.clone(), + exported_at_ms: now_unix_millis(), + request_log_id: request_log.id, + created_at_ms: request_log.created_at_ms, + }, + hook_name, + plugin_id, + request: PluginReplayFixtureRequest { + cli_key: request_log.cli_key.clone(), + session_id: request_log.session_id.clone(), + method: Some(request_log.method.clone()), + path: Some(request_log.path.clone()), + query: request_log.query.clone(), + provider: Some(request_log.final_provider_name.clone()), + provider_source: request_log.final_provider_source_name.clone(), + model: request_log.requested_model.clone(), + headers: None, + body: None, + normalized_messages: Vec::new(), + meta: serde_json::json!({ + "excludedFromStats": request_log.excluded_from_stats, + "durationMs": request_log.duration_ms, + "ttfbMs": request_log.ttfb_ms, + "inputTokens": request_log.input_tokens, + "outputTokens": request_log.output_tokens, + "totalTokens": request_log.total_tokens, + "cacheReadInputTokens": request_log.cache_read_input_tokens, + "cacheCreationInputTokens": request_log.cache_creation_input_tokens, + "cacheCreation5mInputTokens": request_log.cache_creation_5m_input_tokens, + "cacheCreation1hInputTokens": request_log.cache_creation_1h_input_tokens, + "costUsd": request_log.cost_usd, + "costMultiplier": request_log.cost_multiplier, + "providerChain": provider_chain, + "specialSettings": special_settings, + }), + }, + response: PluginReplayFixtureResponse { + status: request_log.status, + error_code: request_log.error_code.clone(), + headers: None, + body: None, + chunks: Vec::new(), + meta: serde_json::json!({ + "errorDetails": error_details, + "usage": usage, + }), + }, + log: PluginReplayFixtureLog { + body: None, + meta: serde_json::json!({ + "requestLogCreatedAt": request_log.created_at, + "requestLogCreatedAtMs": request_log.created_at_ms, + }), + }, + attempts: attempts + .into_iter() + .map(|attempt| PluginReplayFixtureAttempt { + id: attempt.id, + trace_id: attempt.trace_id, + cli_key: attempt.cli_key, + attempt_index: attempt.attempt_index, + provider_id: attempt.provider_id, + provider_name: attempt.provider_name, + base_url: attempt.base_url, + outcome: attempt.outcome, + status: attempt.status, + attempt_started_ms: attempt.attempt_started_ms, + attempt_duration_ms: attempt.attempt_duration_ms, + created_at: attempt.created_at, + }) + .collect(), + runtime_reports, + notes, + }) +} + +fn normalize_required_text(label: &str, raw: String) -> AppResult { + let value = raw.trim(); + if value.is_empty() { + return Err(AppError::new( + "SEC_INVALID_INPUT", + format!("{label} is required"), + )); + } + Ok(value.to_string()) +} + +fn parse_optional_json(raw: Option<&str>) -> Option { + let raw = raw?.trim(); + if raw.is_empty() { + return None; + } + serde_json::from_str(raw) + .ok() + .or_else(|| Some(serde_json::Value::String(raw.to_string()))) +} + +#[cfg(test)] +mod tests { + use crate::infra::plugins::runtime_reports::{ + record_hook_execution_report, RecordPluginHookExecutionReportInput, + }; + + #[test] + fn export_replay_fixture_uses_trace_attempts_and_runtime_reports() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + let conn = db.open_connection().unwrap(); + + conn.execute( + r#" +INSERT INTO request_logs ( + id, trace_id, cli_key, session_id, method, path, query, excluded_from_stats, + special_settings_json, status, error_code, duration_ms, ttfb_ms, attempts_json, + input_tokens, output_tokens, total_tokens, cache_read_input_tokens, + cache_creation_input_tokens, cache_creation_5m_input_tokens, + cache_creation_1h_input_tokens, usage_json, requested_model, cost_usd_femto, + cost_multiplier, created_at_ms, created_at, final_provider_id, + provider_chain_json, error_details_json +) VALUES ( + 1, 'trace-replay-1', 'codex', 'session-replay', 'POST', '/v1/responses', '?stream=false', 0, + '{"temperature":0.2}', 200, NULL, 42, 11, + '[{"provider_id":7,"provider_name":"OpenAI Primary","base_url":"https://api.openai.example/v1","outcome":"success","status":200,"attempt_started_ms":1000,"attempt_duration_ms":41}]', + 10, 20, 30, NULL, NULL, NULL, NULL, '{"total_tokens":30}', 'gpt-5-mini', + NULL, 1.0, 1700000000000, 1700000000, 7, + '[{"provider_id":7,"provider_name":"OpenAI Primary","outcome":"success"}]', + NULL +) +"#, + [], + ) + .unwrap(); + drop(conn); + + record_hook_execution_report( + &db, + RecordPluginHookExecutionReportInput { + plugin_id: "community.prompt-helper".to_string(), + trace_id: Some("trace-replay-1".to_string()), + hook_name: "gateway.request.afterBodyRead".to_string(), + runtime_kind: "extensionHost".to_string(), + status: "completed".to_string(), + started_at_ms: 1_700_000_000_001, + duration_ms: 8, + failure_kind: None, + error_code: None, + failure_policy: Some("fail-open".to_string()), + circuit_state: Some("closed".to_string()), + context_budget_json: serde_json::json!({"bodyBytes": 4096}), + output_budget_json: serde_json::json!({"bodyBytes": 2048}), + mutation_summary_json: serde_json::json!({"changed": true, "field": "requestBody"}), + replayable: true, + replay_export_reason: None, + }, + ) + .unwrap(); + + let fixture = super::export_plugin_replay_fixture( + &db, + super::ExportPluginReplayFixtureInput { + trace_id: " trace-replay-1 ".to_string(), + hook_name: "gateway.request.afterBodyRead".to_string(), + plugin_id: Some("community.prompt-helper".to_string()), + }, + ) + .unwrap(); + + assert_eq!(fixture.trace_id, "trace-replay-1"); + assert_eq!(fixture.hook_name, "gateway.request.afterBodyRead"); + assert_eq!(fixture.source.trace_id, "trace-replay-1"); + assert_eq!(fixture.request.method.as_deref(), Some("POST")); + assert_eq!(fixture.request.path.as_deref(), Some("/v1/responses")); + assert_eq!(fixture.request.model.as_deref(), Some("gpt-5-mini")); + assert!(fixture.request.body.is_none()); + assert!(!fixture.attempts.is_empty()); + assert_eq!(fixture.runtime_reports.len(), 1); + assert_eq!( + fixture.runtime_reports[0].plugin_id, + "community.prompt-helper" + ); + assert!(fixture + .notes + .iter() + .any(|note| note.contains("request body is not persisted"))); + } +} diff --git a/src-tauri/src/infra/plugins/repository.rs b/src-tauri/src/infra/plugins/repository.rs index 246f2b39..4a354c36 100644 --- a/src-tauri/src/infra/plugins/repository.rs +++ b/src-tauri/src/infra/plugins/repository.rs @@ -1,7 +1,8 @@ use crate::db; use crate::domain::plugins::{ - validate_manifest, PluginAuditLog, PluginDetail, PluginInstallSource, PluginManifest, - PluginPermissionRisk, PluginRuntime, PluginRuntimeFailure, PluginStatus, PluginSummary, + manifest_permission_risk, validate_manifest, validate_manifest_for_official_plugin, + PluginAuditLog, PluginDetail, PluginInstallSource, PluginManifest, PluginPermissionRisk, + PluginRuntimeFailure, PluginStatus, PluginSummary, }; use crate::shared::error::{db_err, AppResult}; use crate::shared::time::now_unix_seconds; @@ -56,12 +57,18 @@ WHERE enabled = 1 }) .map_err(|e| db_err!("failed to query plugin market sources: {e}"))?; + let mut sources = Vec::new(); for row in rows { let (index_url, public_key) = row.map_err(|e| db_err!("failed to read plugin market source: {e}"))?; - if index_url == url { - return Ok(Some(public_key)); - } + sources.push((index_url, public_key)); + } + + if let Some((_, public_key)) = sources.iter().find(|(index_url, _)| index_url == url) { + return Ok(Some(public_key.clone())); + } + + for (index_url, public_key) in sources { let source_host = reqwest::Url::parse(&index_url) .ok() .and_then(|parsed| parsed.host_str().map(str::to_ascii_lowercase)); @@ -147,6 +154,8 @@ WHERE plugin_id = ?1 load_plugin_permissions(conn, plugin_id)?.unwrap_or((row.granted_permissions, Vec::new())); let audit_logs = list_audit_logs_with_conn(conn, Some(plugin_id), 50)?; let runtime_failures = list_runtime_failures_with_conn(conn, plugin_id, 50)?; + let rollback_versions = + list_rollback_versions_with_conn(conn, plugin_id, row.summary.current_version.as_deref())?; Ok(PluginDetail { summary: row.summary, @@ -158,12 +167,92 @@ WHERE plugin_id = ?1 pending_permissions, audit_logs, runtime_failures, + rollback_versions, }) } +fn list_rollback_versions_with_conn( + conn: &rusqlite::Connection, + plugin_id: &str, + current_version: Option<&str>, +) -> AppResult> { + let mut stmt = conn + .prepare_cached( + r#" +SELECT version, installed_dir +FROM plugin_versions +WHERE plugin_id = ?1 +ORDER BY created_at DESC, version DESC +"#, + ) + .map_err(|e| db_err!("failed to prepare plugin rollback versions query: {e}"))?; + + let rows = stmt + .query_map(params![plugin_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)) + }) + .map_err(|e| db_err!("failed to query plugin rollback versions: {e}"))?; + + let mut versions = Vec::new(); + for row in rows { + let (version, installed_dir) = + row.map_err(|e| db_err!("failed to read plugin rollback version row: {e}"))?; + if Some(version.as_str()) == current_version { + continue; + } + if installed_dir + .as_deref() + .is_some_and(plugin_installed_dir_available) + { + versions.push(version); + } + } + Ok(versions) +} + +pub(crate) fn plugin_installed_dir_available(installed_dir: &str) -> bool { + let root = std::path::Path::new(installed_dir); + root.is_dir() && root.join("plugin.json").is_file() +} + +pub(crate) fn with_plugin_transaction( + db: &db::Db, + f: impl FnOnce(&rusqlite::Transaction<'_>) -> AppResult, +) -> AppResult { + let mut conn = db.open_connection()?; + let tx = conn + .transaction() + .map_err(|e| db_err!("failed to start plugin transaction: {e}"))?; + match f(&tx) { + Ok(value) => { + tx.commit() + .map_err(|e| db_err!("failed to commit plugin transaction: {e}"))?; + Ok(value) + } + Err(err) => { + let _ = tx.rollback(); + Err(err) + } + } +} + pub(crate) fn insert_plugin(db: &db::Db, input: InsertPluginInput) -> AppResult { - validate_manifest(&input.manifest, env!("CARGO_PKG_VERSION"))?; let conn = db.open_connection()?; + insert_plugin_with_conn(&conn, input) +} + +pub(crate) fn insert_plugin_with_tx( + conn: &rusqlite::Transaction<'_>, + input: InsertPluginInput, +) -> AppResult { + insert_plugin_with_conn(conn, input) +} + +fn insert_plugin_with_conn( + conn: &rusqlite::Connection, + input: InsertPluginInput, +) -> AppResult { + validate_manifest_for_source(&input.manifest, input.install_source)?; let now = now_unix_seconds(); let manifest_json = serde_json::to_string(&input.manifest) .map_err(|e| format!("PLUGIN_INVALID_MANIFEST: failed to serialize manifest: {e}"))?; @@ -226,7 +315,7 @@ INSERT OR IGNORE INTO plugin_versions( ) .map_err(|e| db_err!("failed to insert plugin version: {e}"))?; - get_plugin_with_conn(&conn, &input.manifest.id) + get_plugin_with_conn(conn, &input.manifest.id) } pub(crate) fn update_plugin_status( @@ -255,13 +344,31 @@ WHERE plugin_id = ?4 get_plugin_with_conn(&conn, plugin_id) } +#[allow(dead_code)] pub(crate) fn update_plugin_manifest( db: &db::Db, manifest: PluginManifest, installed_dir: Option, ) -> AppResult { - validate_manifest(&manifest, env!("CARGO_PKG_VERSION"))?; let conn = db.open_connection()?; + update_plugin_manifest_with_conn(&conn, manifest, installed_dir) +} + +pub(crate) fn update_plugin_manifest_with_tx( + conn: &rusqlite::Transaction<'_>, + manifest: PluginManifest, + installed_dir: Option, +) -> AppResult { + update_plugin_manifest_with_conn(conn, manifest, installed_dir) +} + +fn update_plugin_manifest_with_conn( + conn: &rusqlite::Connection, + manifest: PluginManifest, + installed_dir: Option, +) -> AppResult { + let install_source = install_source_for_plugin_with_conn(conn, &manifest.id)?; + validate_manifest_for_source(&manifest, install_source)?; let now = now_unix_seconds(); let plugin_id = manifest.id.clone(); let version = manifest.version.clone(); @@ -320,7 +427,7 @@ INSERT OR IGNORE INTO plugin_versions( ) .map_err(|e| db_err!("failed to insert plugin version: {e}"))?; - get_plugin_with_conn(&conn, &manifest.id) + get_plugin_with_conn(conn, &manifest.id) } pub(crate) fn get_plugin_version( @@ -361,7 +468,27 @@ pub(crate) fn save_plugin_config( sensitive_keys: &[String], ) -> AppResult { let conn = db.open_connection()?; - ensure_plugin_exists(&conn, plugin_id)?; + save_plugin_config_with_conn(&conn, plugin_id, config_version, config, sensitive_keys) +} + +pub(crate) fn save_plugin_config_with_tx( + conn: &rusqlite::Transaction<'_>, + plugin_id: &str, + config_version: u32, + config: &serde_json::Value, + sensitive_keys: &[String], +) -> AppResult { + save_plugin_config_with_conn(conn, plugin_id, config_version, config, sensitive_keys) +} + +fn save_plugin_config_with_conn( + conn: &rusqlite::Connection, + plugin_id: &str, + config_version: u32, + config: &serde_json::Value, + sensitive_keys: &[String], +) -> AppResult { + ensure_plugin_exists(conn, plugin_id)?; let now = now_unix_seconds(); let config_json = serde_json::to_string(config) .map_err(|e| format!("PLUGIN_INVALID_CONFIG: failed to serialize config: {e}"))?; @@ -399,7 +526,7 @@ ON CONFLICT(plugin_id) DO UPDATE SET ) .map_err(|e| db_err!("failed to mirror plugin config: {e}"))?; - get_plugin_with_conn(&conn, plugin_id) + get_plugin_with_conn(conn, plugin_id) } pub(crate) fn plugin_config_version(db: &db::Db, plugin_id: &str) -> AppResult> { @@ -420,7 +547,25 @@ pub(crate) fn save_plugin_permissions( pending_permissions: &[String], ) -> AppResult { let conn = db.open_connection()?; - ensure_plugin_exists(&conn, plugin_id)?; + save_plugin_permissions_with_conn(&conn, plugin_id, permissions, pending_permissions) +} + +pub(crate) fn save_plugin_permissions_with_tx( + conn: &rusqlite::Transaction<'_>, + plugin_id: &str, + permissions: &[String], + pending_permissions: &[String], +) -> AppResult { + save_plugin_permissions_with_conn(conn, plugin_id, permissions, pending_permissions) +} + +fn save_plugin_permissions_with_conn( + conn: &rusqlite::Connection, + plugin_id: &str, + permissions: &[String], + pending_permissions: &[String], +) -> AppResult { + ensure_plugin_exists(conn, plugin_id)?; let now = now_unix_seconds(); let permissions_json = serde_json::to_string(permissions) .map_err(|e| format!("PLUGIN_INVALID_PERMISSION: failed to serialize permissions: {e}"))?; @@ -451,7 +596,7 @@ ON CONFLICT(plugin_id) DO UPDATE SET ) .map_err(|e| db_err!("failed to mirror plugin permissions: {e}"))?; - get_plugin_with_conn(&conn, plugin_id) + get_plugin_with_conn(conn, plugin_id) } pub(crate) fn append_audit_log( @@ -459,6 +604,20 @@ pub(crate) fn append_audit_log( input: AppendPluginAuditLogInput, ) -> AppResult { let conn = db.open_connection()?; + append_audit_log_with_conn(&conn, input) +} + +pub(crate) fn append_audit_log_with_tx( + conn: &rusqlite::Transaction<'_>, + input: AppendPluginAuditLogInput, +) -> AppResult { + append_audit_log_with_conn(conn, input) +} + +fn append_audit_log_with_conn( + conn: &rusqlite::Connection, + input: AppendPluginAuditLogInput, +) -> AppResult { let details_json = serde_json::to_string(&input.details) .map_err(|e| format!("PLUGIN_INVALID_AUDIT: failed to serialize details: {e}"))?; let now = now_unix_seconds(); @@ -487,7 +646,7 @@ INSERT INTO plugin_audit_logs( .map_err(|e| db_err!("failed to append plugin audit log: {e}"))?; let id = conn.last_insert_rowid(); - get_audit_log_by_id(&conn, id) + get_audit_log_by_id(conn, id) } pub(crate) fn list_audit_logs( @@ -549,10 +708,11 @@ fn summary_from_row(row: &rusqlite::Row<'_>) -> Result) -> Result) -> Result { let manifest_json: String = row.get("manifest_json")?; - let manifest: PluginManifest = serde_json::from_str(&manifest_json).map_err(|err| { - rusqlite::Error::FromSqlConversionFailure( - manifest_json.len(), - rusqlite::types::Type::Text, - Box::new(err), - ) - })?; + let manifest: PluginManifest = match serde_json::from_str(&manifest_json) { + Ok(manifest) => manifest, + Err(_) if legacy_runtime_name(&manifest_json).is_some() => { + legacy_manifest_placeholder(&manifest_json).map_err(|placeholder_err| { + rusqlite::Error::FromSqlConversionFailure( + manifest_json.len(), + rusqlite::types::Type::Text, + Box::new(placeholder_err), + ) + })? + } + Err(err) => { + return Err(rusqlite::Error::FromSqlConversionFailure( + manifest_json.len(), + rusqlite::types::Type::Text, + Box::new(err), + )); + } + }; let install_source_raw: String = row.get("install_source")?; let install_source = PluginInstallSource::parse(&install_source_raw).unwrap_or(PluginInstallSource::Local); @@ -598,25 +770,78 @@ fn detail_from_row(row: &rusqlite::Row<'_>) -> Result String { - match manifest.runtime { - PluginRuntime::DeclarativeRules { .. } => "declarativeRules".to_string(), - PluginRuntime::Native { ref engine } => format!("native:{engine}"), - PluginRuntime::Wasm { .. } => "wasm".to_string(), + let _ = manifest; + "extensionHost".to_string() +} + +fn legacy_runtime_name(manifest_json: &str) -> Option { + let raw: serde_json::Value = serde_json::from_str(manifest_json).ok()?; + let kind = raw + .get("runtime") + .and_then(|runtime| runtime.get("kind")) + .and_then(serde_json::Value::as_str)?; + match kind { + "wasm" | "process" => Some(kind.to_string()), + "native" => raw + .get("runtime") + .and_then(|runtime| runtime.get("engine")) + .and_then(serde_json::Value::as_str) + .map(|engine| format!("native:{engine}")) + .or_else(|| Some("native".to_string())), + _ => None, } } -fn highest_permission_risk(permissions: &[String]) -> PluginPermissionRisk { - use crate::domain::plugins::permission_risk; - permissions - .iter() - .filter_map(|permission| permission_risk(permission)) - .max_by_key(|risk| match risk { - PluginPermissionRisk::Low => 0, - PluginPermissionRisk::Medium => 1, - PluginPermissionRisk::High => 2, - PluginPermissionRisk::Critical => 3, - }) - .unwrap_or(PluginPermissionRisk::Low) +fn legacy_manifest_placeholder(manifest_json: &str) -> Result { + let mut raw: serde_json::Value = serde_json::from_str(manifest_json)?; + raw["runtime"] = serde_json::json!({ + "kind": "extensionHost", + "language": "typescript" + }); + raw["main"] = serde_json::json!("legacy/unsupported.js"); + raw["hooks"] = serde_json::json!([]); + raw["permissions"] = serde_json::json!([]); + raw["activationEvents"] = serde_json::json!([]); + raw["contributes"] = serde_json::json!({ + "providers": [], + "protocols": [], + "protocolBridges": [], + "commands": [], + "gatewayHooks": [], + "ui": {} + }); + raw["capabilities"] = serde_json::json!([]); + serde_json::from_value(raw) +} + +fn validate_manifest_for_source( + manifest: &PluginManifest, + install_source: PluginInstallSource, +) -> AppResult<()> { + if install_source == PluginInstallSource::Official { + validate_manifest_for_official_plugin(manifest, env!("CARGO_PKG_VERSION"))?; + } else { + validate_manifest(manifest, env!("CARGO_PKG_VERSION"))?; + } + Ok(()) +} + +fn install_source_for_plugin_with_conn( + conn: &rusqlite::Connection, + plugin_id: &str, +) -> AppResult { + let raw = conn + .query_row( + "SELECT install_source FROM plugins WHERE plugin_id = ?1", + params![plugin_id], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| db_err!("failed to read plugin install source: {e}"))?; + Ok(raw + .as_deref() + .and_then(PluginInstallSource::parse) + .unwrap_or(PluginInstallSource::Local)) } fn parse_manifest_lossy(raw: &str) -> Option { @@ -825,17 +1050,18 @@ mod tests { "version": "1.0.0", "apiVersion": "1.0.0", "runtime": { - "kind": "declarativeRules", - "rules": ["rules/main.json"] + "kind": "extensionHost", + "language": "typescript" }, - "hooks": [ - { + "main": "dist/index.js", + "contributes": { + "gatewayHooks": [{ "name": "gateway.request.afterBodyRead", "priority": 100, "failurePolicy": "fail-open" - } - ], - "permissions": ["request.body.read", "request.body.write"], + }] + }, + "capabilities": ["gateway.hooks"], "hostCompatibility": { "app": ">=0.56.0 <1.0.0", "pluginApi": "^1.0.0", @@ -845,6 +1071,69 @@ mod tests { .unwrap() } + fn test_manifest(plugin_id: &str, version: &str) -> PluginManifest { + let mut manifest = manifest(); + manifest.id = plugin_id.to_string(); + manifest.version = version.to_string(); + manifest + } + + #[test] + fn plugin_repository_transaction_rolls_back_partial_plugin_writes() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + let manifest = test_manifest("tx.rollback", "1.0.0"); + let granted = vec!["request.body.read".to_string()]; + let pending = vec!["request.body.write".to_string()]; + + let result: AppResult<()> = with_plugin_transaction(&db, |tx| { + insert_plugin_with_tx( + tx, + InsertPluginInput { + manifest: manifest.clone(), + install_source: PluginInstallSource::Local, + status: PluginStatus::Disabled, + installed_dir: Some("/tmp/plugin".to_string()), + }, + )?; + save_plugin_permissions_with_tx(tx, &manifest.id, &granted, &pending)?; + append_audit_log_with_tx( + tx, + AppendPluginAuditLogInput { + plugin_id: Some(manifest.id.clone()), + trace_id: None, + event_type: "plugin.test".to_string(), + risk_level: "low".to_string(), + message: "test audit".to_string(), + details: serde_json::json!({ "test": true }), + }, + )?; + Err(crate::shared::error::AppError::new( + "TEST_ROLLBACK", + "force rollback", + )) + }); + + assert!(result.is_err()); + assert!(get_plugin(&db, "tx.rollback").is_err()); + let conn = db.open_connection().unwrap(); + for table in [ + "plugins", + "plugin_versions", + "plugin_permissions", + "plugin_audit_logs", + ] { + let count: i64 = conn + .query_row( + &format!("SELECT COUNT(*) FROM {table} WHERE plugin_id = ?1"), + params!["tx.rollback"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 0, "{table} rows should roll back"); + } + } + #[test] fn repository_round_trips_plugin_state_config_permissions_and_audit() { let dir = tempfile::tempdir().unwrap(); @@ -909,7 +1198,7 @@ mod tests { assert_eq!(list.len(), 1); assert_eq!(list[0].plugin_id, "community.prompt-helper"); assert_eq!(list[0].status, PluginStatus::Enabled); - assert_eq!(list[0].runtime, "declarativeRules"); + assert_eq!(list[0].runtime, "extensionHost"); let detail = get_plugin(&db, "community.prompt-helper").unwrap(); assert_eq!(detail.manifest, manifest); @@ -987,6 +1276,58 @@ INSERT INTO plugin_market_sources( assert_eq!(disabled, None); } + #[test] + fn trusted_market_public_key_prefers_exact_url_before_host_fallback() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + let conn = db.open_connection().unwrap(); + conn.execute( + r#" +INSERT INTO plugin_market_sources( + name, + index_url, + enabled, + trusted_public_key, + created_at, + updated_at +) VALUES (?1, ?2, 1, ?3, 1, 1) +"#, + rusqlite::params![ + "Host Fallback", + "https://plugins.example.test/community/index.json", + "host-key" + ], + ) + .unwrap(); + conn.execute( + r#" +INSERT INTO plugin_market_sources( + name, + index_url, + enabled, + trusted_public_key, + created_at, + updated_at +) VALUES (?1, ?2, 1, ?3, 2, 2) +"#, + rusqlite::params![ + "Exact Source", + "https://plugins.example.test/official/index.json", + "exact-key" + ], + ) + .unwrap(); + drop(conn); + + let key = trusted_market_public_key_for_url( + &db, + "https://plugins.example.test/official/index.json", + ) + .unwrap(); + + assert_eq!(key.as_deref(), Some("exact-key")); + } + #[test] fn repository_maps_missing_plugin_to_not_found() { let dir = tempfile::tempdir().unwrap(); @@ -1000,7 +1341,7 @@ INSERT INTO plugin_market_sources( let dir = tempfile::tempdir().unwrap(); let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); let mut manifest = manifest(); - manifest.permissions.push("unknown.permission".to_string()); + manifest.capabilities.push("unknown.capability".to_string()); let err = insert_plugin( &db, @@ -1013,6 +1354,6 @@ INSERT INTO plugin_market_sources( ) .unwrap_err(); - assert!(err.to_string().starts_with("PLUGIN_UNKNOWN_PERMISSION:")); + assert!(err.to_string().starts_with("PLUGIN_UNKNOWN_CAPABILITY:")); } } diff --git a/src-tauri/src/infra/plugins/runtime_reports.rs b/src-tauri/src/infra/plugins/runtime_reports.rs new file mode 100644 index 00000000..0200054f --- /dev/null +++ b/src-tauri/src/infra/plugins/runtime_reports.rs @@ -0,0 +1,790 @@ +//! Usage: Structured plugin hook execution report persistence. + +use crate::db; +use crate::domain::plugins::{PluginExtensionExecutionReport, PluginHookExecutionReport}; +use crate::shared::error::{db_err, AppResult}; +use crate::shared::time::now_unix_seconds; +use rusqlite::{params, params_from_iter, types::Value}; + +const DEFAULT_PLUGIN_RUNTIME_REPORT_RETENTION_DAYS: i64 = 30; +const DEFAULT_PLUGIN_RUNTIME_REPORTS_PER_PLUGIN: usize = 5_000; +const SECONDS_PER_DAY: i64 = 86_400; +const CONTRIBUTION_TYPE_META_KEY: &str = "__aioContributionType"; + +#[derive(Debug, Clone)] +pub(crate) struct RecordPluginHookExecutionReportInput { + pub(crate) plugin_id: String, + pub(crate) trace_id: Option, + pub(crate) hook_name: String, + pub(crate) runtime_kind: String, + pub(crate) status: String, + pub(crate) started_at_ms: i64, + pub(crate) duration_ms: i64, + pub(crate) failure_kind: Option, + pub(crate) error_code: Option, + pub(crate) failure_policy: Option, + pub(crate) circuit_state: Option, + pub(crate) context_budget_json: serde_json::Value, + pub(crate) output_budget_json: serde_json::Value, + pub(crate) mutation_summary_json: serde_json::Value, + pub(crate) replayable: bool, + pub(crate) replay_export_reason: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct RecordPluginExtensionExecutionReportInput { + pub(crate) plugin_id: String, + pub(crate) contribution_type: String, + pub(crate) contribution_id: String, + pub(crate) command_or_hook: Option, + pub(crate) trace_id: Option, + pub(crate) status: String, + pub(crate) started_at_ms: i64, + pub(crate) duration_ms: i64, + pub(crate) failure_kind: Option, + pub(crate) error_code: Option, + pub(crate) input_budget: serde_json::Value, + pub(crate) output_budget: serde_json::Value, + pub(crate) mutation_summary: serde_json::Value, + pub(crate) replayable: bool, +} + +pub(crate) fn record_hook_execution_report( + db: &db::Db, + input: RecordPluginHookExecutionReportInput, +) -> AppResult { + let conn = db.open_connection()?; + record_hook_execution_report_with_conn(&conn, input) +} + +pub(crate) fn record_extension_execution_report( + db: &db::Db, + input: RecordPluginExtensionExecutionReportInput, +) -> AppResult { + let contribution_type = normalize_contribution_type(&input.contribution_type)?; + let contribution_id = input.contribution_id.clone(); + let command_or_hook = input + .command_or_hook + .clone() + .unwrap_or_else(|| contribution_id.clone()); + let mut mutation_summary = input.mutation_summary; + if let Some(object) = mutation_summary.as_object_mut() { + object.insert( + CONTRIBUTION_TYPE_META_KEY.to_string(), + serde_json::Value::String(contribution_type.to_string()), + ); + } else { + mutation_summary = serde_json::json!({ + "value": mutation_summary, + CONTRIBUTION_TYPE_META_KEY: contribution_type, + }); + } + let report = record_hook_execution_report( + db, + RecordPluginHookExecutionReportInput { + plugin_id: input.plugin_id, + trace_id: input.trace_id, + hook_name: contribution_id, + runtime_kind: runtime_kind_for_contribution_type(contribution_type).to_string(), + status: input.status, + started_at_ms: input.started_at_ms, + duration_ms: input.duration_ms, + failure_kind: input.failure_kind, + error_code: input.error_code, + failure_policy: None, + circuit_state: None, + context_budget_json: input.input_budget, + output_budget_json: input.output_budget, + mutation_summary_json: mutation_summary, + replayable: input.replayable, + replay_export_reason: None, + }, + )?; + Ok(extension_execution_report_from_hook_report( + report, + contribution_type, + Some(command_or_hook), + )) +} + +fn record_hook_execution_report_with_conn( + conn: &rusqlite::Connection, + input: RecordPluginHookExecutionReportInput, +) -> AppResult { + let context_budget_json = serde_json::to_string(&input.context_budget_json).map_err(|e| { + format!("PLUGIN_RUNTIME_REPORT_INVALID: failed to serialize context budget: {e}") + })?; + let output_budget_json = serde_json::to_string(&input.output_budget_json).map_err(|e| { + format!("PLUGIN_RUNTIME_REPORT_INVALID: failed to serialize output budget: {e}") + })?; + let mutation_summary_json = + serde_json::to_string(&input.mutation_summary_json).map_err(|e| { + format!("PLUGIN_RUNTIME_REPORT_INVALID: failed to serialize mutation summary: {e}") + })?; + let now = now_unix_seconds(); + + conn.execute( + r#" +INSERT INTO plugin_hook_execution_reports( + plugin_id, + trace_id, + hook_name, + runtime_kind, + status, + started_at_ms, + duration_ms, + failure_kind, + error_code, + failure_policy, + circuit_state, + context_budget_json, + output_budget_json, + mutation_summary_json, + replayable, + replay_export_reason, + created_at +) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17) +"#, + params![ + input.plugin_id, + input.trace_id, + input.hook_name, + input.runtime_kind, + input.status, + input.started_at_ms, + input.duration_ms, + input.failure_kind, + input.error_code, + input.failure_policy, + input.circuit_state, + context_budget_json, + output_budget_json, + mutation_summary_json, + if input.replayable { 1 } else { 0 }, + input.replay_export_reason, + now, + ], + ) + .map_err(|e| db_err!("failed to record plugin hook execution report: {e}"))?; + + let id = conn.last_insert_rowid(); + let report = get_hook_execution_report_by_id(conn, id)?; + prune_after_insert_best_effort(conn, &report.plugin_id, now); + Ok(report) +} + +fn prune_hook_execution_reports_for_plugin_with_conn( + conn: &rusqlite::Connection, + plugin_id: &str, + max_reports: usize, +) -> AppResult { + let deleted = conn + .execute( + r#" +DELETE FROM plugin_hook_execution_reports +WHERE id IN ( + SELECT id + FROM plugin_hook_execution_reports + WHERE plugin_id = ?1 + ORDER BY created_at DESC, id DESC + LIMIT -1 OFFSET ?2 +) +"#, + params![plugin_id, max_reports as i64], + ) + .map_err(|e| db_err!("failed to prune plugin hook execution reports by cap: {e}"))?; + Ok(deleted) +} + +fn prune_hook_execution_reports_before_with_conn( + conn: &rusqlite::Connection, + cutoff_created_at: i64, +) -> AppResult { + let deleted = conn + .execute( + "DELETE FROM plugin_hook_execution_reports WHERE created_at < ?1", + params![cutoff_created_at], + ) + .map_err(|e| db_err!("failed to prune old plugin hook execution reports: {e}"))?; + Ok(deleted) +} + +fn prune_after_insert_best_effort(conn: &rusqlite::Connection, plugin_id: &str, now: i64) { + let cutoff = now - DEFAULT_PLUGIN_RUNTIME_REPORT_RETENTION_DAYS * SECONDS_PER_DAY; + if let Err(err) = prune_hook_execution_reports_before_with_conn(conn, cutoff) { + tracing::warn!(error = %err, "failed to prune old plugin hook execution reports"); + } + if let Err(err) = prune_hook_execution_reports_for_plugin_with_conn( + conn, + plugin_id, + DEFAULT_PLUGIN_RUNTIME_REPORTS_PER_PLUGIN, + ) { + tracing::warn!( + plugin_id, + error = %err, + "failed to prune plugin hook execution reports by cap" + ); + } +} + +pub(crate) fn list_hook_execution_reports( + db: &db::Db, + plugin_id: Option<&str>, + hook_name: Option<&str>, + trace_id: Option<&str>, + limit: usize, +) -> AppResult> { + let conn = db.open_connection()?; + let limit = limit.clamp(1, 500) as i64; + + let mut sql = String::from( + r#" +SELECT + id, + plugin_id, + trace_id, + hook_name, + runtime_kind, + status, + started_at_ms, + duration_ms, + failure_kind, + error_code, + failure_policy, + circuit_state, + context_budget_json, + output_budget_json, + mutation_summary_json, + replayable, + replay_export_reason, + created_at +FROM plugin_hook_execution_reports +"#, + ); + let mut conditions = Vec::new(); + if plugin_id.is_some() { + conditions.push("plugin_id = ?"); + } + if hook_name.is_some() { + conditions.push("hook_name = ?"); + } + if trace_id.is_some() { + conditions.push("trace_id = ?"); + } + if !conditions.is_empty() { + sql.push_str("WHERE "); + sql.push_str(&conditions.join(" AND ")); + sql.push('\n'); + } + sql.push_str("ORDER BY created_at DESC, id DESC\nLIMIT ?"); + + let mut stmt = conn + .prepare_cached(&sql) + .map_err(|e| db_err!("failed to prepare plugin hook execution report query: {e}"))?; + let mut values = Vec::new(); + if let Some(plugin_id) = plugin_id { + values.push(Value::Text(plugin_id.to_string())); + } + if let Some(hook_name) = hook_name { + values.push(Value::Text(hook_name.to_string())); + } + if let Some(trace_id) = trace_id { + values.push(Value::Text(trace_id.to_string())); + } + values.push(Value::Integer(limit)); + + let rows = stmt + .query_map(params_from_iter(values), hook_execution_report_from_row) + .map_err(|e| db_err!("failed to query plugin hook execution reports: {e}"))?; + let mut out = Vec::new(); + for row in rows { + out.push(row.map_err(|e| db_err!("failed to read plugin hook execution report: {e}"))?); + } + Ok(out) +} + +pub(crate) fn list_extension_execution_reports( + db: &db::Db, + plugin_id: Option<&str>, + contribution_type: Option<&str>, + contribution_id: Option<&str>, + trace_id: Option<&str>, + limit: usize, +) -> AppResult> { + let conn = db.open_connection()?; + let limit = limit.clamp(1, 500) as i64; + let contribution_type = contribution_type + .map(normalize_contribution_type) + .transpose()?; + + let mut sql = String::from( + r#" +SELECT + id, + plugin_id, + trace_id, + hook_name, + runtime_kind, + status, + started_at_ms, + duration_ms, + failure_kind, + error_code, + context_budget_json, + output_budget_json, + mutation_summary_json, + replayable, + created_at +FROM plugin_hook_execution_reports +"#, + ); + let mut conditions = Vec::new(); + if plugin_id.is_some() { + conditions.push("plugin_id = ?"); + } + if let Some(contribution_type) = contribution_type { + match contribution_type { + "command" => conditions.push(extension_contribution_type_condition("command")), + "hook" => conditions.push(extension_contribution_type_condition("hook")), + _ => unreachable!("contribution type already normalized"), + } + } + if contribution_id.is_some() { + conditions.push("hook_name = ?"); + } + if trace_id.is_some() { + conditions.push("trace_id = ?"); + } + if !conditions.is_empty() { + sql.push_str("WHERE "); + sql.push_str(&conditions.join(" AND ")); + sql.push('\n'); + } + sql.push_str("ORDER BY created_at DESC, id DESC\nLIMIT ?"); + + let mut stmt = conn + .prepare_cached(&sql) + .map_err(|e| db_err!("failed to prepare plugin extension execution report query: {e}"))?; + let mut values = Vec::new(); + if let Some(plugin_id) = plugin_id { + values.push(Value::Text(plugin_id.to_string())); + } + if let Some(contribution_id) = contribution_id { + values.push(Value::Text(contribution_id.to_string())); + } + if let Some(trace_id) = trace_id { + values.push(Value::Text(trace_id.to_string())); + } + values.push(Value::Integer(limit)); + + let rows = stmt + .query_map( + params_from_iter(values), + extension_execution_report_from_row, + ) + .map_err(|e| db_err!("failed to query plugin extension execution reports: {e}"))?; + let mut out = Vec::new(); + for row in rows { + out.push( + row.map_err(|e| db_err!("failed to read plugin extension execution report: {e}"))?, + ); + } + Ok(out) +} + +fn get_hook_execution_report_by_id( + conn: &rusqlite::Connection, + id: i64, +) -> AppResult { + conn.query_row( + r#" +SELECT + id, + plugin_id, + trace_id, + hook_name, + runtime_kind, + status, + started_at_ms, + duration_ms, + failure_kind, + error_code, + failure_policy, + circuit_state, + context_budget_json, + output_budget_json, + mutation_summary_json, + replayable, + replay_export_reason, + created_at +FROM plugin_hook_execution_reports +WHERE id = ?1 +"#, + params![id], + hook_execution_report_from_row, + ) + .map_err(|e| db_err!("failed to query inserted plugin hook execution report: {e}")) +} + +fn normalize_contribution_type(raw: &str) -> AppResult<&'static str> { + match raw.trim() { + "command" => Ok("command"), + "hook" => Ok("hook"), + other => Err(format!( + "PLUGIN_RUNTIME_REPORT_INVALID: unsupported contribution type: {other}" + ) + .into()), + } +} + +fn runtime_kind_for_contribution_type(contribution_type: &str) -> &'static str { + match contribution_type { + "command" => "extensionHost", + "hook" => "extensionHost", + _ => "extension", + } +} + +fn extension_contribution_type_condition(contribution_type: &'static str) -> &'static str { + match contribution_type { + "command" => { + "(json_extract(mutation_summary_json, '$.__aioContributionType') = 'command' OR (json_extract(mutation_summary_json, '$.__aioContributionType') IS NULL AND runtime_kind = 'extensionHost' AND hook_name NOT LIKE 'gateway.%' AND hook_name NOT LIKE 'log.%'))" + } + "hook" => { + "(json_extract(mutation_summary_json, '$.__aioContributionType') = 'hook' OR (json_extract(mutation_summary_json, '$.__aioContributionType') IS NULL AND (runtime_kind <> 'extensionHost' OR hook_name LIKE 'gateway.%' OR hook_name LIKE 'log.%')))" + } + _ => "1 = 0", + } +} + +fn hook_execution_report_from_row( + row: &rusqlite::Row<'_>, +) -> Result { + let context_budget_json: String = row.get("context_budget_json")?; + let output_budget_json: String = row.get("output_budget_json")?; + let mutation_summary_json: String = row.get("mutation_summary_json")?; + let replayable: i64 = row.get("replayable")?; + Ok(PluginHookExecutionReport { + id: row.get("id")?, + plugin_id: row.get("plugin_id")?, + trace_id: row.get("trace_id")?, + hook_name: row.get("hook_name")?, + runtime_kind: row.get("runtime_kind")?, + status: row.get("status")?, + started_at_ms: row.get("started_at_ms")?, + duration_ms: row.get("duration_ms")?, + failure_kind: row.get("failure_kind")?, + error_code: row.get("error_code")?, + failure_policy: row.get("failure_policy")?, + circuit_state: row.get("circuit_state")?, + context_budget: parse_json_value(&context_budget_json), + output_budget: parse_json_value(&output_budget_json), + mutation_summary: parse_json_value(&mutation_summary_json), + replayable: replayable != 0, + replay_export_reason: row.get("replay_export_reason")?, + created_at: row.get("created_at")?, + }) +} + +fn extension_execution_report_from_row( + row: &rusqlite::Row<'_>, +) -> Result { + let context_budget_json: String = row.get("context_budget_json")?; + let output_budget_json: String = row.get("output_budget_json")?; + let mutation_summary_json: String = row.get("mutation_summary_json")?; + let replayable: i64 = row.get("replayable")?; + let contribution_id: String = row.get("hook_name")?; + let mutation_summary = parse_json_value(&mutation_summary_json); + let runtime_kind: String = row.get("runtime_kind")?; + let contribution_type = + extension_contribution_type(&mutation_summary, &runtime_kind, &contribution_id); + let mut public_mutation_summary = mutation_summary; + if let Some(object) = public_mutation_summary.as_object_mut() { + object.remove(CONTRIBUTION_TYPE_META_KEY); + } + Ok(PluginExtensionExecutionReport { + id: row.get("id")?, + plugin_id: row.get("plugin_id")?, + contribution_type: contribution_type.to_string(), + contribution_id: contribution_id.clone(), + command_or_hook: Some(contribution_id), + trace_id: row.get("trace_id")?, + status: row.get("status")?, + started_at_ms: row.get("started_at_ms")?, + duration_ms: row.get("duration_ms")?, + failure_kind: row.get("failure_kind")?, + error_code: row.get("error_code")?, + input_budget: parse_json_value(&context_budget_json), + output_budget: parse_json_value(&output_budget_json), + mutation_summary: public_mutation_summary, + replayable: replayable != 0, + created_at: row.get("created_at")?, + }) +} + +fn extension_contribution_type( + mutation_summary: &serde_json::Value, + runtime_kind: &str, + contribution_id: &str, +) -> &'static str { + if mutation_summary + .get(CONTRIBUTION_TYPE_META_KEY) + .and_then(serde_json::Value::as_str) + .is_some_and(|value| value == "hook") + { + return "hook"; + } + if mutation_summary + .get(CONTRIBUTION_TYPE_META_KEY) + .and_then(serde_json::Value::as_str) + .is_some_and(|value| value == "command") + { + return "command"; + } + if runtime_kind != "extensionHost" + || contribution_id.starts_with("gateway.") + || contribution_id.starts_with("log.") + { + "hook" + } else { + "command" + } +} + +fn extension_execution_report_from_hook_report( + report: PluginHookExecutionReport, + contribution_type: &str, + command_or_hook: Option, +) -> PluginExtensionExecutionReport { + PluginExtensionExecutionReport { + id: report.id, + plugin_id: report.plugin_id, + contribution_type: contribution_type.to_string(), + contribution_id: report.hook_name.clone(), + command_or_hook, + trace_id: report.trace_id, + status: report.status, + started_at_ms: report.started_at_ms, + duration_ms: report.duration_ms, + failure_kind: report.failure_kind, + error_code: report.error_code, + input_budget: report.context_budget, + output_budget: report.output_budget, + mutation_summary: report.mutation_summary, + replayable: report.replayable, + created_at: report.created_at, + } +} + +fn parse_json_value(raw: &str) -> serde_json::Value { + serde_json::from_str(raw).unwrap_or_else(|_| serde_json::json!({})) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn report_input(plugin_id: &str, trace_id: &str) -> RecordPluginHookExecutionReportInput { + RecordPluginHookExecutionReportInput { + plugin_id: plugin_id.to_string(), + trace_id: Some(trace_id.to_string()), + hook_name: "gateway.request.afterBodyRead".to_string(), + runtime_kind: "extensionHost".to_string(), + status: "completed".to_string(), + started_at_ms: 1000, + duration_ms: 5, + failure_kind: None, + error_code: None, + failure_policy: Some("fail-open".to_string()), + circuit_state: Some("closed".to_string()), + context_budget_json: serde_json::json!({ "messages": 1 }), + output_budget_json: serde_json::json!({ "bytes": 1 }), + mutation_summary_json: serde_json::json!({ "changed": false }), + replayable: true, + replay_export_reason: None, + } + } + + #[test] + fn repository_prunes_plugin_hook_execution_reports_by_plugin_cap() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + + for index in 0..5 { + record_hook_execution_report( + &db, + report_input("community.cap", &format!("trace-{index}")), + ) + .unwrap(); + } + + let conn = db.open_connection().unwrap(); + let deleted = + prune_hook_execution_reports_for_plugin_with_conn(&conn, "community.cap", 3).unwrap(); + drop(conn); + + let reports = + list_hook_execution_reports(&db, Some("community.cap"), None, None, 10).unwrap(); + + assert_eq!(deleted, 2); + assert_eq!(reports.len(), 3); + assert_eq!(reports[0].trace_id.as_deref(), Some("trace-4")); + assert_eq!(reports[2].trace_id.as_deref(), Some("trace-2")); + } + + #[test] + fn repository_prunes_plugin_hook_execution_reports_before_cutoff() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + + record_hook_execution_report(&db, report_input("community.age", "trace-old")).unwrap(); + record_hook_execution_report(&db, report_input("community.age", "trace-new")).unwrap(); + + let conn = db.open_connection().unwrap(); + conn.execute( + "UPDATE plugin_hook_execution_reports SET created_at = ?1 WHERE trace_id = ?2", + rusqlite::params![1_i64, "trace-old"], + ) + .unwrap(); + drop(conn); + + let conn = db.open_connection().unwrap(); + let deleted = prune_hook_execution_reports_before_with_conn(&conn, 2).unwrap(); + drop(conn); + + let reports = + list_hook_execution_reports(&db, Some("community.age"), None, None, 10).unwrap(); + + assert_eq!(deleted, 1); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].trace_id.as_deref(), Some("trace-new")); + } + + #[test] + fn repository_auto_prunes_old_plugin_hook_execution_reports_after_recording() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + + record_hook_execution_report(&db, report_input("community.auto-age", "trace-old")).unwrap(); + + let conn = db.open_connection().unwrap(); + conn.execute( + "UPDATE plugin_hook_execution_reports SET created_at = ?1 WHERE trace_id = ?2", + rusqlite::params![1_i64, "trace-old"], + ) + .unwrap(); + drop(conn); + + let new_report = + record_hook_execution_report(&db, report_input("community.auto-age", "trace-new")) + .unwrap(); + let reports = + list_hook_execution_reports(&db, Some("community.auto-age"), None, None, 10).unwrap(); + + assert_eq!(new_report.trace_id.as_deref(), Some("trace-new")); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].trace_id.as_deref(), Some("trace-new")); + } + + #[test] + fn repository_records_and_lists_plugin_hook_execution_reports() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + + let report = record_hook_execution_report( + &db, + RecordPluginHookExecutionReportInput { + plugin_id: "community.prompt-helper".to_string(), + trace_id: Some("trace-report-1".to_string()), + hook_name: "gateway.request.afterBodyRead".to_string(), + runtime_kind: "extensionHost".to_string(), + status: "completed".to_string(), + started_at_ms: 1_000, + duration_ms: 17, + failure_kind: None, + error_code: None, + failure_policy: Some("fail-open".to_string()), + circuit_state: Some("closed".to_string()), + context_budget_json: serde_json::json!({"bodyBytes": 4096}), + output_budget_json: serde_json::json!({"bodyBytes": 2048}), + mutation_summary_json: serde_json::json!({"changed": true, "field": "requestBody"}), + replayable: true, + replay_export_reason: None, + }, + ) + .unwrap(); + + let list = list_hook_execution_reports( + &db, + Some("community.prompt-helper"), + Some("gateway.request.afterBodyRead"), + Some("trace-report-1"), + 50, + ) + .unwrap(); + + assert_eq!(report.plugin_id, "community.prompt-helper"); + assert_eq!(list.len(), 1); + assert_eq!(list[0].status, "completed"); + assert_eq!(list[0].mutation_summary["field"], "requestBody"); + } + + #[test] + fn extension_runtime_reports_filter_extension_host_commands_and_hooks_separately() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("extension-reports.db")).unwrap(); + + record_extension_execution_report( + &db, + RecordPluginExtensionExecutionReportInput { + plugin_id: "community.prompt-helper".to_string(), + contribution_type: "command".to_string(), + contribution_id: "community.prompt-helper.hello".to_string(), + command_or_hook: None, + trace_id: Some("trace-command".to_string()), + status: "completed".to_string(), + started_at_ms: 1_000, + duration_ms: 3, + failure_kind: None, + error_code: None, + input_budget: serde_json::json!({}), + output_budget: serde_json::json!({}), + mutation_summary: serde_json::json!({ "changed": false }), + replayable: false, + }, + ) + .unwrap(); + record_extension_execution_report( + &db, + RecordPluginExtensionExecutionReportInput { + plugin_id: "community.prompt-helper".to_string(), + contribution_type: "hook".to_string(), + contribution_id: "gateway.request.afterBodyRead".to_string(), + command_or_hook: None, + trace_id: Some("trace-hook".to_string()), + status: "completed".to_string(), + started_at_ms: 2_000, + duration_ms: 5, + failure_kind: None, + error_code: None, + input_budget: serde_json::json!({}), + output_budget: serde_json::json!({}), + mutation_summary: serde_json::json!({ "changed": true }), + replayable: true, + }, + ) + .unwrap(); + + let commands = + list_extension_execution_reports(&db, None, Some("command"), None, None, 50).unwrap(); + let hooks = + list_extension_execution_reports(&db, None, Some("hook"), None, None, 50).unwrap(); + + assert_eq!(commands.len(), 1); + assert_eq!(commands[0].contribution_type, "command"); + assert_eq!(commands[0].trace_id.as_deref(), Some("trace-command")); + assert_eq!(hooks.len(), 1); + assert_eq!(hooks[0].contribution_type, "hook"); + assert_eq!(hooks[0].trace_id.as_deref(), Some("trace-hook")); + } +} diff --git a/src-tauri/src/infra/request_logs.rs b/src-tauri/src/infra/request_logs.rs index bbbf69b8..7e278508 100644 --- a/src-tauri/src/infra/request_logs.rs +++ b/src-tauri/src/infra/request_logs.rs @@ -390,6 +390,118 @@ pub fn spawn_write_through( true } +pub(crate) fn touch_activity( + db: &db::Db, + trace_id: &str, + cli_key: &str, + last_activity_ms: i64, + details: Option, +) -> AppResult { + validate_cli_key(cli_key).map_err(crate::shared::error::AppError::from)?; + let last_activity_ms = last_activity_ms.max(0); + let conn = db.open_connection()?; + conn.execute( + r#" +UPDATE request_logs +SET + last_activity_ms = CASE + WHEN last_activity_ms IS NULL OR ?3 > last_activity_ms THEN ?3 + ELSE last_activity_ms + END, + activity_details_json = CASE + WHEN last_activity_ms IS NULL OR ?3 >= last_activity_ms THEN COALESCE(?4, activity_details_json) + ELSE activity_details_json + END +WHERE trace_id = ?1 + AND cli_key = ?2 + AND status IS NULL + AND error_code IS NULL +"#, + params![trace_id, cli_key, last_activity_ms, details], + ) + .map_err(|e| db_err!("failed to touch request log activity: {e}")) +} + +const RETENTION_PURGE_BATCH_SIZE: usize = 1000; +const RETENTION_PURGE_BATCH_PAUSE_MS: u64 = 50; +const RETENTION_TASK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); + +/// Deletes request logs older than `retention_days`, in small batches so the +/// write lock is never held long (WAL-friendly). `retention_days == 0` means +/// retention is disabled (keep forever) and nothing is deleted. +pub fn purge_expired(db: &db::Db, retention_days: u32, now_unix: i64) -> AppResult { + if retention_days == 0 { + return Ok(0); + } + let cutoff = now_unix.saturating_sub(i64::from(retention_days).saturating_mul(24 * 60 * 60)); + let mut total: u64 = 0; + loop { + // Re-acquire per batch so the pooled connection (pool max is small) is + // not held across the inter-batch pauses of a long purge. + let conn = db.open_connection()?; + let deleted = conn + .execute( + "DELETE FROM request_logs WHERE id IN ( + SELECT id FROM request_logs WHERE created_at < ?1 LIMIT ?2 + )", + params![cutoff, RETENTION_PURGE_BATCH_SIZE as i64], + ) + .map_err(|e| db_err!("failed to purge expired request_logs: {e}"))?; + drop(conn); + total = total.saturating_add(deleted as u64); + if deleted < RETENTION_PURGE_BATCH_SIZE { + break; + } + std::thread::sleep(Duration::from_millis(RETENTION_PURGE_BATCH_PAUSE_MS)); + } + Ok(total) +} + +/// Spawns the daily request-log retention job (idempotent). Reads the setting +/// fresh on each tick — fail-open to disabled — so changes apply without a +/// restart. Lives at app level, not in the gateway: retention must not depend +/// on the gateway running. +pub(crate) fn spawn_retention_task(app: tauri::AppHandle, db: db::Db) { + static STARTED: OnceLock<()> = OnceLock::new(); + if STARTED.set(()).is_err() { + return; + } + + tauri::async_runtime::spawn(async move { + run_retention_once(&app, &db).await; + + let mut interval = tokio::time::interval(RETENTION_TASK_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + // First tick is immediate; skip it so we don't run twice at startup. + interval.tick().await; + loop { + interval.tick().await; + run_retention_once(&app, &db).await; + } + }); +} + +async fn run_retention_once(app: &tauri::AppHandle, db: &db::Db) { + let app = app.clone(); + let db = db.clone(); + let result = crate::blocking::run("request_log_retention", move || { + let retention_days = crate::settings::request_log_retention_days_fail_open(&app); + if retention_days == 0 { + return Ok::(0); + } + let deleted = purge_expired(&db, retention_days, now_unix_seconds())?; + if deleted > 0 { + tracing::info!(retention_days, deleted, "purged expired request logs"); + } + Ok(deleted) + }) + .await; + + if let Err(err) = result { + tracing::warn!("request-log retention task failed: {}", err); + } +} + pub(crate) fn reconcile_unresolved_pending( db: &db::Db, reason: RequestLogReconcileReason, @@ -543,11 +655,13 @@ fn insert_batch_once( cost_usd_femto, cost_multiplier, created_at_ms, + last_activity_ms, + activity_details_json, created_at, final_provider_id, provider_chain_json, error_details_json - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29) + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31) ON CONFLICT(trace_id) DO UPDATE SET method = excluded.method, path = excluded.path, @@ -575,10 +689,21 @@ fn insert_batch_once( WHEN request_logs.created_at_ms = 0 THEN excluded.created_at_ms ELSE request_logs.created_at_ms END, + last_activity_ms = CASE + WHEN request_logs.last_activity_ms IS NULL THEN excluded.last_activity_ms + WHEN excluded.last_activity_ms > request_logs.last_activity_ms THEN excluded.last_activity_ms + ELSE request_logs.last_activity_ms + END, + activity_details_json = COALESCE(request_logs.activity_details_json, excluded.activity_details_json), created_at = CASE WHEN request_logs.created_at = 0 THEN excluded.created_at ELSE request_logs.created_at END, final_provider_id = excluded.final_provider_id, provider_chain_json = excluded.provider_chain_json, error_details_json = excluded.error_details_json + WHERE NOT ( + (request_logs.status IS NOT NULL OR request_logs.error_code IS NOT NULL) + AND excluded.status IS NULL + AND excluded.error_code IS NULL + ) "#, ) .map_err(|e| DbWriteError::from_rusqlite("failed to prepare insert", e))?; @@ -706,6 +831,8 @@ fn insert_batch_once( cost_usd_femto, cost_multiplier, item.created_at_ms, + item.last_activity_ms.unwrap_or(item.created_at_ms), + item.activity_details_json, item.created_at, final_provider_id_db, item.provider_chain_json, @@ -811,10 +938,10 @@ GROUP BY cli_key, session_id #[cfg(test)] mod tests { use super::{ - parse_cx2cc_cost_basis, reconcile_unresolved_pending, try_acquire_write_through_permit, - writer_loop, InsertBatchCache, RequestLogInsert, RequestLogReconcileReason, - COST_MULTIPLIER_CACHE_MAX_ENTRIES, EFFECTIVE_COST_MULTIPLIER_SQL, - MODEL_PRICE_CACHE_MAX_ENTRIES, WRITE_BATCH_MAX, + insert_batch_once, parse_cx2cc_cost_basis, purge_expired, reconcile_unresolved_pending, + touch_activity, try_acquire_write_through_permit, writer_loop, InsertBatchCache, + RequestLogInsert, RequestLogReconcileReason, COST_MULTIPLIER_CACHE_MAX_ENTRIES, + EFFECTIVE_COST_MULTIPLIER_SQL, MODEL_PRICE_CACHE_MAX_ENTRIES, WRITE_BATCH_MAX, }; use rusqlite::{params, Connection}; use std::sync::Arc; @@ -848,6 +975,8 @@ mod tests { provider_chain_json: None, error_details_json: None, created_at_ms: 1_770_000_000_000, + last_activity_ms: None, + activity_details_json: None, created_at: 1_770_000_000, } } @@ -977,6 +1106,336 @@ WHERE trace_id = ?1 drop(second); } + #[test] + fn purge_expired_deletes_only_rows_older_than_retention() { + let (app, db, _dir) = init_test_db(); + let app_handle = app.handle().clone(); + let mut cache = InsertBatchCache::default(); + let now_unix = 1_770_000_000_i64; + let day_secs = 24 * 60 * 60; + + insert_batch_once( + &app_handle, + &db, + &[ + RequestLogInsert { + created_at: now_unix - 10 * day_secs, + created_at_ms: (now_unix - 10 * day_secs) * 1000, + ..request_log_insert("trace-purge-old") + }, + RequestLogInsert { + created_at: now_unix - day_secs / 2, + created_at_ms: (now_unix - day_secs / 2) * 1000, + ..request_log_insert("trace-purge-recent") + }, + ], + &mut cache, + ) + .expect("insert rows"); + + let deleted = purge_expired(&db, 7, now_unix).expect("purge"); + assert_eq!(deleted, 1); + assert_eq!(count_request_logs(&db), 1); + + let conn = db.open_connection().expect("open connection"); + let remaining: String = conn + .query_row("SELECT trace_id FROM request_logs", [], |row| row.get(0)) + .expect("remaining row"); + assert_eq!(remaining, "trace-purge-recent"); + } + + #[test] + fn purge_expired_is_disabled_when_retention_is_zero() { + let (app, db, _dir) = init_test_db(); + let app_handle = app.handle().clone(); + let mut cache = InsertBatchCache::default(); + let now_unix = 1_770_000_000_i64; + + insert_batch_once( + &app_handle, + &db, + &[RequestLogInsert { + created_at: now_unix - 400 * 24 * 60 * 60, + created_at_ms: (now_unix - 400 * 24 * 60 * 60) * 1000, + ..request_log_insert("trace-purge-disabled") + }], + &mut cache, + ) + .expect("insert row"); + + let deleted = purge_expired(&db, 0, now_unix).expect("purge disabled"); + assert_eq!(deleted, 0); + assert_eq!(count_request_logs(&db), 1); + } + + #[test] + fn purge_expired_drains_multiple_batches() { + let (app, db, _dir) = init_test_db(); + let app_handle = app.handle().clone(); + let mut cache = InsertBatchCache::default(); + let now_unix = 1_770_000_000_i64; + let old_created_at = now_unix - 30 * 24 * 60 * 60; + + // More rows than one purge batch (batch size 1000) to cover the loop. + let rows: Vec = (0..1100) + .map(|index| RequestLogInsert { + created_at: old_created_at, + created_at_ms: old_created_at * 1000, + ..request_log_insert(&format!("trace-purge-batch-{index}")) + }) + .collect(); + for chunk in rows.chunks(WRITE_BATCH_MAX) { + insert_batch_once(&app_handle, &db, chunk, &mut cache).expect("insert chunk"); + } + + let deleted = purge_expired(&db, 7, now_unix).expect("purge batches"); + assert_eq!(deleted, 1100); + assert_eq!(count_request_logs(&db), 0); + } + + #[test] + fn request_log_insert_initializes_last_activity_from_created_at() { + let (app, db, _dir) = init_test_db(); + let app_handle = app.handle().clone(); + let mut cache = InsertBatchCache::default(); + insert_batch_once( + &app_handle, + &db, + &[RequestLogInsert { + status: None, + error_code: None, + ..request_log_insert("trace-activity-init") + }], + &mut cache, + ) + .expect("insert placeholder"); + + let conn = db.open_connection().expect("open connection"); + let value: i64 = conn + .query_row( + "SELECT last_activity_ms FROM request_logs WHERE trace_id = ?1", + ["trace-activity-init"], + |row| row.get(0), + ) + .expect("read last activity"); + assert_eq!(value, 1_770_000_000_000); + } + + #[test] + fn touch_activity_only_updates_pending_rows_and_never_moves_backwards() { + let (app, db, _dir) = init_test_db(); + let app_handle = app.handle().clone(); + let mut cache = InsertBatchCache::default(); + insert_batch_once( + &app_handle, + &db, + &[RequestLogInsert { + status: None, + error_code: None, + ..request_log_insert("trace-touch") + }], + &mut cache, + ) + .expect("insert pending"); + + touch_activity( + &db, + "trace-touch", + "claude", + 1_770_000_030_000, + Some(r#"{"chunk_count":1}"#.to_string()), + ) + .expect("touch newer"); + touch_activity( + &db, + "trace-touch", + "claude", + 1_770_000_010_000, + Some(r#"{"chunk_count":0}"#.to_string()), + ) + .expect("older touch ignored"); + + let conn = db.open_connection().expect("open connection"); + let row: (i64, Option) = conn + .query_row( + "SELECT last_activity_ms, activity_details_json FROM request_logs WHERE trace_id = ?1", + ["trace-touch"], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("read activity"); + assert_eq!(row.0, 1_770_000_030_000); + assert_eq!(row.1.as_deref(), Some(r#"{"chunk_count":1}"#)); + drop(conn); + + insert_batch_once( + &app_handle, + &db, + &[request_log_insert("trace-touch")], + &mut cache, + ) + .expect("finalize"); + let changed = touch_activity(&db, "trace-touch", "claude", 1_770_000_060_000, None) + .expect("touch completed row"); + assert_eq!(changed, 0); + } + + #[test] + fn request_log_finalize_preserves_newer_last_activity_from_insert_payload() { + let (app, db, _dir) = init_test_db(); + let app_handle = app.handle().clone(); + let mut cache = InsertBatchCache::default(); + insert_batch_once( + &app_handle, + &db, + &[RequestLogInsert { + status: None, + error_code: None, + ..request_log_insert("trace-final-activity") + }], + &mut cache, + ) + .expect("insert pending"); + + insert_batch_once( + &app_handle, + &db, + &[RequestLogInsert { + last_activity_ms: Some(1_770_000_090_000), + activity_details_json: Some(r#"{"terminal_signal":"completed"}"#.to_string()), + ..request_log_insert("trace-final-activity") + }], + &mut cache, + ) + .expect("finalize"); + + let conn = db.open_connection().expect("open connection"); + let row: (i64, Option) = conn + .query_row( + "SELECT last_activity_ms, activity_details_json FROM request_logs WHERE trace_id = ?1", + ["trace-final-activity"], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("read activity"); + assert_eq!(row.0, 1_770_000_090_000); + assert_eq!(row.1.as_deref(), Some(r#"{"terminal_signal":"completed"}"#)); + } + + #[test] + fn late_placeholder_does_not_downgrade_terminal_request_log() { + let (app, db, _dir) = init_test_db(); + let app_handle = app.handle().clone(); + let mut cache = InsertBatchCache::default(); + + insert_batch_once( + &app_handle, + &db, + &[RequestLogInsert { + attempts_json: r#"[{"outcome":"success"}]"#.to_string(), + input_tokens: Some(12), + output_tokens: Some(34), + total_tokens: Some(46), + usage_json: Some(r#"{"input_tokens":12,"output_tokens":34}"#.to_string()), + requested_model: Some("claude-sonnet-4".to_string()), + provider_chain_json: Some(r#"[{"provider":"anthropic"}]"#.to_string()), + ..request_log_insert("trace-late-placeholder") + }], + &mut cache, + ) + .expect("insert terminal"); + + insert_batch_once( + &app_handle, + &db, + &[RequestLogInsert { + status: None, + error_code: None, + duration_ms: 0, + ttfb_ms: None, + attempts_json: "[]".to_string(), + input_tokens: None, + output_tokens: None, + total_tokens: None, + usage_json: None, + requested_model: None, + provider_chain_json: None, + error_details_json: None, + ..request_log_insert("trace-late-placeholder") + }], + &mut cache, + ) + .expect("insert late placeholder"); + + struct TerminalRow { + status: Option, + error_code: Option, + duration_ms: i64, + ttfb_ms: Option, + input_tokens: Option, + output_tokens: Option, + total_tokens: Option, + attempts_json: String, + usage_json: Option, + requested_model: Option, + provider_chain_json: Option, + } + + let conn = db.open_connection().expect("open connection"); + let row = conn + .query_row( + r#" +SELECT + status, + error_code, + duration_ms, + ttfb_ms, + input_tokens, + output_tokens, + total_tokens, + attempts_json, + usage_json, + requested_model, + provider_chain_json +FROM request_logs +WHERE trace_id = ?1 +"#, + ["trace-late-placeholder"], + |row| { + Ok(TerminalRow { + status: row.get(0)?, + error_code: row.get(1)?, + duration_ms: row.get(2)?, + ttfb_ms: row.get(3)?, + input_tokens: row.get(4)?, + output_tokens: row.get(5)?, + total_tokens: row.get(6)?, + attempts_json: row.get(7)?, + usage_json: row.get(8)?, + requested_model: row.get(9)?, + provider_chain_json: row.get(10)?, + }) + }, + ) + .expect("read request log"); + + assert_eq!(row.status, Some(200)); + assert_eq!(row.error_code, None); + assert_eq!(row.duration_ms, 10); + assert_eq!(row.ttfb_ms, Some(5)); + assert_eq!(row.input_tokens, Some(12)); + assert_eq!(row.output_tokens, Some(34)); + assert_eq!(row.total_tokens, Some(46)); + assert_eq!(row.attempts_json, r#"[{"outcome":"success"}]"#); + assert_eq!( + row.usage_json.as_deref(), + Some(r#"{"input_tokens":12,"output_tokens":34}"#) + ); + assert_eq!(row.requested_model.as_deref(), Some("claude-sonnet-4")); + assert_eq!( + row.provider_chain_json.as_deref(), + Some(r#"[{"provider":"anthropic"}]"#) + ); + } + #[test] fn reconcile_unresolved_pending_marks_only_pending_rows() { let (_app, db, _dir) = init_test_db(); diff --git a/src-tauri/src/infra/request_logs/queries.rs b/src-tauri/src/infra/request_logs/queries.rs index 149923c1..dad60b62 100644 --- a/src-tauri/src/infra/request_logs/queries.rs +++ b/src-tauri/src/infra/request_logs/queries.rs @@ -38,6 +38,8 @@ const REQUEST_LOG_SUMMARY_FIELDS: &str = " cost_usd_femto, cost_multiplier, created_at_ms, + last_activity_ms, + activity_details_json, created_at, provider_chain_json, error_details_json @@ -71,6 +73,8 @@ const REQUEST_LOG_DETAIL_FIELDS: &str = " cost_usd_femto, cost_multiplier, created_at_ms, + last_activity_ms, + activity_details_json, created_at, provider_chain_json, error_details_json @@ -204,6 +208,8 @@ pub(super) fn route_from_attempts(attempts: &[AttemptRow]) -> Vec, source_provider_name: Option, + // Same predicate as the usage-stats SQL: source id present OR cx2cc bridge. + bridged: bool, } fn normalize_source_provider_name(name: Option) -> Option { @@ -236,11 +242,11 @@ fn load_source_provider_info_map( SELECT bridge.id, bridge.source_provider_id, - source.name + source.name, + bridge.bridge_type FROM providers bridge LEFT JOIN providers source ON source.id = bridge.source_provider_id WHERE bridge.id IN ({placeholders}) - AND bridge.source_provider_id IS NOT NULL "# ); @@ -265,12 +271,19 @@ WHERE bridge.id IN ({placeholders}) let source_provider_name: Option = row .get(2) .map_err(|e| db_err!("invalid provider source name: {e}"))?; + let bridge_type: Option = row + .get(3) + .map_err(|e| db_err!("invalid provider bridge type: {e}"))?; out.insert( bridge_id, SourceProviderInfo { source_provider_id, source_provider_name: normalize_source_provider_name(source_provider_name), + bridged: crate::usage_stats::is_bridged_input_semantics( + source_provider_id, + bridge_type.as_deref(), + ), }, ); } @@ -286,10 +299,18 @@ fn attach_source_provider_info( let info_by_bridge_id = load_source_provider_info_map(conn, &ids)?; for item in items.iter_mut() { + let mut bridged = false; if let Some(info) = info_by_bridge_id.get(&item.final_provider_id) { item.final_provider_source_id = info.source_provider_id; item.final_provider_source_name = info.source_provider_name.clone(); + bridged = info.bridged; } + item.effective_input_tokens = crate::usage_stats::effective_input_tokens_display( + &item.cli_key, + bridged, + item.input_tokens, + item.cache_read_input_tokens, + ); } Ok(()) @@ -302,13 +323,19 @@ fn row_to_summary(row: &rusqlite::Row<'_>) -> Result0 的 + // skipped attempt 也计入 hop(见 route_includes_skipped_attempts 测试);前端 + // src/services/gateway/traceRoute.ts 复刻此语义,两侧需保持同步。 let has_failover = route.len() > 1; let session_reuse = attempts .iter() .any(|row| row.session_reuse.unwrap_or(false)); let cost_usd = cost_usd_from_femto(row.get("cost_usd_femto")?); + let status: Option = row.get("status")?; + let error_code: Option = row.get("error_code")?; + let is_interrupted = status.is_none() && error_code.is_none(); + Ok(RequestLogSummary { id: row.get("id")?, trace_id: row.get("trace_id")?, @@ -319,8 +346,9 @@ fn row_to_summary(row: &rusqlite::Row<'_>) -> Result("excluded_from_stats").unwrap_or(0) != 0, special_settings_json: row.get("special_settings_json")?, requested_model: row.get("requested_model")?, - status: row.get("status")?, - error_code: row.get("error_code")?, + status, + error_code, + is_interrupted, duration_ms: row.get("duration_ms")?, ttfb_ms: row.get("ttfb_ms")?, attempt_count, @@ -340,9 +368,13 @@ fn row_to_summary(row: &rusqlite::Row<'_>) -> Result) -> Result = row.get("status")?; + let error_code: Option = row.get("error_code")?; + let is_interrupted = status.is_none() && error_code.is_none(); Ok(RequestLogDetail { id: row.get("id")?, @@ -365,8 +400,9 @@ fn row_to_detail(row: &rusqlite::Row<'_>) -> Result("excluded_from_stats").unwrap_or(0) != 0, special_settings_json: row.get("special_settings_json")?, - status: row.get("status")?, - error_code: row.get("error_code")?, + status, + error_code, + is_interrupted, duration_ms: row.get("duration_ms")?, ttfb_ms: row.get("ttfb_ms")?, attempts_json, @@ -377,6 +413,8 @@ fn row_to_detail(row: &rusqlite::Row<'_>) -> Result) -> Result crate::shared::error::AppResult<()> { let info_by_bridge_id = load_source_provider_info_map(conn, &[item.final_provider_id])?; + let mut bridged = false; if let Some(info) = info_by_bridge_id.get(&item.final_provider_id) { item.final_provider_source_id = info.source_provider_id; item.final_provider_source_name = info.source_provider_name.clone(); + bridged = info.bridged; } + item.effective_input_tokens = crate::usage_stats::effective_input_tokens_display( + &item.cli_key, + bridged, + item.input_tokens, + item.cache_read_input_tokens, + ); Ok(()) } @@ -758,15 +806,16 @@ INSERT INTO request_logs ( CREATE TABLE providers ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, - source_provider_id INTEGER + source_provider_id INTEGER, + bridge_type TEXT ); -INSERT INTO providers (id, name, source_provider_id) VALUES (7, 'OpenAI Primary', NULL); -INSERT INTO providers (id, name, source_provider_id) VALUES (12, 'Claude Bridge', 7); +INSERT INTO providers (id, name, source_provider_id, bridge_type) VALUES (7, 'OpenAI Primary', NULL, NULL); +INSERT INTO providers (id, name, source_provider_id, bridge_type) VALUES (12, 'Claude Bridge', 7, 'cx2cc'); "#, ) .unwrap(); - let info = load_source_provider_info_map(&conn, &[12, 99]).unwrap(); + let info = load_source_provider_info_map(&conn, &[7, 12, 99]).unwrap(); let bridge = info.get(&12).expect("bridge provider source info"); assert_eq!(bridge.source_provider_id, Some(7)); @@ -774,6 +823,12 @@ INSERT INTO providers (id, name, source_provider_id) VALUES (12, 'Claude Bridge' bridge.source_provider_name.as_deref(), Some("OpenAI Primary") ); + assert!(bridge.bridged); + + let plain = info.get(&7).expect("plain provider info"); + assert_eq!(plain.source_provider_id, None); + assert!(!plain.bridged); + assert!(!info.contains_key(&99)); } diff --git a/src-tauri/src/infra/request_logs/types.rs b/src-tauri/src/infra/request_logs/types.rs index a9b43d48..e185b9b8 100644 --- a/src-tauri/src/infra/request_logs/types.rs +++ b/src-tauri/src/infra/request_logs/types.rs @@ -29,6 +29,8 @@ pub struct RequestLogInsert { pub provider_chain_json: Option, pub error_details_json: Option, pub created_at_ms: i64, + pub last_activity_ms: Option, + pub activity_details_json: Option, pub created_at: i64, } @@ -68,6 +70,10 @@ pub struct RequestLogSummary { pub requested_model: Option, pub status: Option, pub error_code: Option, + // Persisted row never resolved (no status, no error): the request was cut + // off by a crash/stop before reconciliation. Owned here so the frontend + // does not re-derive the predicate. + pub is_interrupted: bool, pub duration_ms: i64, pub ttfb_ms: Option, pub attempt_count: i64, @@ -87,11 +93,17 @@ pub struct RequestLogSummary { pub cache_creation_input_tokens: Option, pub cache_creation_5m_input_tokens: Option, pub cache_creation_1h_input_tokens: Option, + // Computed by the backend via domain::usage_stats::effective_input_tokens_display + // (single source of truth shared with the usage aggregates). None = usage + // unknown (no input_tokens recorded), rendered as "—" by the frontend. + pub effective_input_tokens: Option, pub cost_usd: Option, pub provider_chain_json: Option, pub error_details_json: Option, pub cost_multiplier: f64, pub created_at_ms: i64, + pub last_activity_ms: Option, + pub activity_details_json: Option, pub created_at: i64, } @@ -108,6 +120,8 @@ pub struct RequestLogDetail { pub special_settings_json: Option, pub status: Option, pub error_code: Option, + // See RequestLogSummary::is_interrupted. + pub is_interrupted: bool, pub duration_ms: i64, pub ttfb_ms: Option, pub attempts_json: String, @@ -118,6 +132,8 @@ pub struct RequestLogDetail { pub cache_creation_input_tokens: Option, pub cache_creation_5m_input_tokens: Option, pub cache_creation_1h_input_tokens: Option, + // See RequestLogSummary::effective_input_tokens. + pub effective_input_tokens: Option, pub usage_json: Option, pub requested_model: Option, pub final_provider_id: i64, @@ -129,6 +145,8 @@ pub struct RequestLogDetail { pub error_details_json: Option, pub cost_multiplier: f64, pub created_at_ms: i64, + pub last_activity_ms: Option, + pub activity_details_json: Option, pub created_at: i64, } diff --git a/src-tauri/src/infra/settings/defaults.rs b/src-tauri/src/infra/settings/defaults.rs index f2fd01a8..47470ca3 100644 --- a/src-tauri/src/infra/settings/defaults.rs +++ b/src-tauri/src/infra/settings/defaults.rs @@ -2,7 +2,7 @@ use std::time::Duration; -pub const SCHEMA_VERSION: u32 = 33; +pub const SCHEMA_VERSION: u32 = 34; pub const DEFAULT_GATEWAY_PORT: u16 = 37123; pub const MAX_GATEWAY_PORT: u16 = 37199; pub const DEFAULT_PROVIDER_COOLDOWN_SECONDS: u32 = 30; @@ -40,9 +40,15 @@ pub(super) const SCHEMA_VERSION_RAISE_STREAM_IDLE_TIMEOUT_DEFAULT: u32 = 30; pub(super) const SCHEMA_VERSION_ADD_UPSTREAM_PROXY: u32 = 31; pub(super) const SCHEMA_VERSION_ADD_UPSTREAM_PROXY_CREDENTIALS: u32 = 32; pub(super) const SCHEMA_VERSION_ADD_CODEX_OAUTH_COMPATIBLE_PROXY_MODE: u32 = 33; +pub(super) const SCHEMA_VERSION_ADD_REQUEST_LOG_RETENTION: u32 = 34; pub(super) const DEFAULT_LOG_RETENTION_DAYS: u32 = 7; pub(super) const MAX_LOG_RETENTION_DAYS: u32 = 3650; +// Request-log DB retention: 0 = keep forever. Deliberately NOT sharing +// log_retention_days — request_logs feed long-horizon usage/cost stats and +// must never be silently trimmed by the file-log default. +pub(super) const DEFAULT_REQUEST_LOG_RETENTION_DAYS: u32 = 0; +pub(super) const MAX_REQUEST_LOG_RETENTION_DAYS: u32 = 3650; pub(super) const DEFAULT_FAILOVER_MAX_ATTEMPTS_PER_PROVIDER: u32 = 5; pub(super) const DEFAULT_FAILOVER_MAX_PROVIDERS_TO_TRY: u32 = 5; pub(super) const DEFAULT_CIRCUIT_BREAKER_FAILURE_THRESHOLD: u32 = 5; diff --git a/src-tauri/src/infra/settings/migration.rs b/src-tauri/src/infra/settings/migration.rs index 145852d5..dddc05af 100644 --- a/src-tauri/src/infra/settings/migration.rs +++ b/src-tauri/src/infra/settings/migration.rs @@ -142,6 +142,14 @@ pub(super) fn sanitize_log_retention_days(settings: &mut AppSettings) -> bool { false } +pub(super) fn sanitize_request_log_retention_days(settings: &mut AppSettings) -> bool { + if settings.request_log_retention_days > MAX_REQUEST_LOG_RETENTION_DAYS { + settings.request_log_retention_days = MAX_REQUEST_LOG_RETENTION_DAYS; + return true; + } + false +} + pub(super) fn sanitize_provider_cooldown_seconds(settings: &mut AppSettings) -> bool { if settings.provider_cooldown_seconds > MAX_PROVIDER_COOLDOWN_SECONDS { settings.provider_cooldown_seconds = MAX_PROVIDER_COOLDOWN_SECONDS; @@ -628,9 +636,21 @@ fn migrate_add_codex_oauth_compatible_proxy_mode( ) } +fn migrate_add_request_log_retention( + settings: &mut AppSettings, + schema_version_present: bool, +) -> bool { + // v34: Add request-log DB retention days (default 0 = keep forever). + migrate_bump_schema_version( + settings, + schema_version_present, + SCHEMA_VERSION_ADD_REQUEST_LOG_RETENTION, + ) +} + type SettingsMigration = fn(&mut AppSettings, bool) -> bool; -const SETTINGS_MIGRATIONS: [SettingsMigration; 27] = [ +const SETTINGS_MIGRATIONS: [SettingsMigration; 28] = [ migrate_disable_upstream_timeouts, migrate_add_gateway_rectifiers, migrate_add_circuit_breaker_notice, @@ -658,6 +678,7 @@ const SETTINGS_MIGRATIONS: [SettingsMigration; 27] = [ migrate_add_upstream_proxy, migrate_add_upstream_proxy_credentials, migrate_add_codex_oauth_compatible_proxy_mode, + migrate_add_request_log_retention, ]; fn apply_settings_migrations(settings: &mut AppSettings, schema_version_present: bool) -> bool { @@ -675,6 +696,7 @@ pub(super) fn repair_settings( ) -> AppResult { let mut repaired = apply_settings_migrations(settings, schema_version_present); repaired |= sanitize_log_retention_days(settings); + repaired |= sanitize_request_log_retention_days(settings); repaired |= sanitize_failover_settings(settings); repaired |= sanitize_circuit_breaker_settings(settings); repaired |= sanitize_provider_cooldown_seconds(settings); diff --git a/src-tauri/src/infra/settings/mod.rs b/src-tauri/src/infra/settings/mod.rs index 7d5981f7..66029f05 100644 --- a/src-tauri/src/infra/settings/mod.rs +++ b/src-tauri/src/infra/settings/mod.rs @@ -15,7 +15,9 @@ pub use defaults::{ MIN_UPSTREAM_STREAM_IDLE_TIMEOUT_SECONDS, SCHEMA_VERSION, }; pub(crate) use persistence::validate_bounds; -pub use persistence::{clear_cache, log_retention_days_fail_open, read, write}; +pub use persistence::{ + clear_cache, log_retention_days_fail_open, read, request_log_retention_days_fail_open, write, +}; pub use types::{ AppSettings, CodexHomeMode, GatewayListenMode, HomeUsagePeriod, WslHostAddressMode, WslTargetCli, diff --git a/src-tauri/src/infra/settings/persistence.rs b/src-tauri/src/infra/settings/persistence.rs index e342c3af..e4f4fcc0 100644 --- a/src-tauri/src/infra/settings/persistence.rs +++ b/src-tauri/src/infra/settings/persistence.rs @@ -49,8 +49,11 @@ fn legacy_settings_path(app: &tauri::AppHandle) -> AppResu Ok(config_dir.join(LEGACY_IDENTIFIER).join("settings.json")) } +// Read-path corruption: the file on disk cannot be trusted. Uses the recovery +// code (not SEC_INVALID_INPUT) so the frontend can match the code instead of +// sniffing the message text; write-path validation keeps SEC_INVALID_INPUT. fn invalid_settings_json(reason: impl std::fmt::Display) -> crate::shared::error::AppError { - format!("SEC_INVALID_INPUT: invalid settings.json: {reason}").into() + format!("SETTINGS_RECOVERY_REQUIRED: invalid settings.json: {reason}").into() } fn read_settings_json_file(path: &Path) -> AppResult { @@ -177,25 +180,21 @@ pub fn read(app: &tauri::AppHandle) -> AppResult= 1" - .to_string() - .into(), - ); + return Err(invalid_settings_json("log_retention_days must be >= 1")); } // Best-effort migration: copy legacy settings into the new dotdir (do not delete legacy file). let mut settings = settings; let repaired = repair_settings(&mut settings, schema_version_present, &raw_settings_json)?; - validate_bounds(&settings)?; + // Read-path bounds failures are file corruption (repair does not + // sanitize every field): surface the recovery code, not SEC_*. + validate_bounds(&settings).map_err(invalid_settings_json)?; if repaired { // best-effort: persist sanitized defaults } @@ -215,22 +214,17 @@ pub fn read(app: &tauri::AppHandle) -> AppResult= 1" - .to_string() - .into(), - ); + return Err(invalid_settings_json("log_retention_days must be >= 1")); } let repaired = repair_settings(&mut settings, schema_version_present, &raw_settings_json)?; - validate_bounds(&settings)?; + // See the legacy branch above: read-path bounds failures use the recovery code. + validate_bounds(&settings).map_err(invalid_settings_json)?; if repaired { // Best-effort: persist repaired values while keeping read semantics. let _ = write(app, &settings); @@ -256,6 +250,21 @@ pub fn log_retention_days_fail_open(app: &tauri::AppHandle } } +/// Fail-open to 0 (retention disabled): when settings cannot be read we must +/// never delete request-log history based on a guessed value. +pub fn request_log_retention_days_fail_open(app: &tauri::AppHandle) -> u32 { + match read(app) { + Ok(cfg) => cfg.request_log_retention_days, + Err(err) => { + tracing::warn!( + "settings read failed, request-log retention disabled for this run: {}", + err + ); + 0 + } + } +} + pub(crate) fn validate_bounds(settings: &AppSettings) -> AppResult<()> { if settings.preferred_port < 1024 { return Err("SEC_INVALID_INPUT: preferred_port must be between 1024 and 65535".into()); @@ -328,6 +337,13 @@ pub(crate) fn validate_bounds(settings: &AppSettings) -> AppResult<()> { ) .into()); } + // 0 is allowed: it disables request-log retention (keep forever). + if settings.request_log_retention_days > MAX_REQUEST_LOG_RETENTION_DAYS { + return Err(format!( + "SEC_INVALID_INPUT: request_log_retention_days must be <= {MAX_REQUEST_LOG_RETENTION_DAYS}" + ) + .into()); + } if settings.provider_cooldown_seconds > MAX_PROVIDER_COOLDOWN_SECONDS { return Err(format!( "SEC_INVALID_INPUT: provider_cooldown_seconds must be <= {MAX_PROVIDER_COOLDOWN_SECONDS}" diff --git a/src-tauri/src/infra/settings/types.rs b/src-tauri/src/infra/settings/types.rs index 00f5044b..bbcb76ef 100644 --- a/src-tauri/src/infra/settings/types.rs +++ b/src-tauri/src/infra/settings/types.rs @@ -102,6 +102,8 @@ pub struct AppSettings { // Startup crash recovery for CLI proxy takeover (default enabled). pub enable_cli_proxy_startup_recovery: bool, pub log_retention_days: u32, + // Request-log DB retention in days; 0 = keep forever. + pub request_log_retention_days: u32, pub provider_cooldown_seconds: u32, pub provider_base_url_ping_cache_ttl_seconds: u32, pub upstream_first_byte_timeout_seconds: u32, @@ -182,6 +184,7 @@ impl Default for AppSettings { tray_enabled: true, enable_cli_proxy_startup_recovery: DEFAULT_ENABLE_CLI_PROXY_STARTUP_RECOVERY, log_retention_days: DEFAULT_LOG_RETENTION_DAYS, + request_log_retention_days: DEFAULT_REQUEST_LOG_RETENTION_DAYS, provider_cooldown_seconds: DEFAULT_PROVIDER_COOLDOWN_SECONDS, provider_base_url_ping_cache_ttl_seconds: DEFAULT_PROVIDER_BASE_URL_PING_CACHE_TTL_SECONDS, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 84140e00..189dbfc9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -38,6 +38,10 @@ pub fn run() { app.run(crate::app::lifecycle::handle_run_event); } +pub fn run_extension_host_worker() { + crate::app::plugins::extension_host_worker::run_stdio_worker(); +} + /// 导出前端使用的 TypeScript IPC 绑定。 pub fn export_typescript_bindings(output_path: &str) -> Result<(), String> { commands::registry::export_typescript_bindings(output_path) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 5a473b2d..4078c475 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -86,6 +86,11 @@ fn ensure_webview2_or_exit() { } fn main() { + if std::env::args().any(|arg| arg == "--extension-host-worker") { + aio_coding_hub_lib::run_extension_host_worker(); + return; + } + #[cfg(windows)] ensure_webview2_or_exit(); diff --git a/src-tauri/src/shared/circuit_breaker.rs b/src-tauri/src/shared/circuit_breaker.rs index f0362115..757c8283 100644 --- a/src-tauri/src/shared/circuit_breaker.rs +++ b/src-tauri/src/shared/circuit_breaker.rs @@ -98,6 +98,8 @@ impl CircuitBreaker { open_until: item.open_until, cooldown_until: None, updated_at: item.updated_at, + // Trigger attribution is in-memory only; lost across restart. + last_trigger_error_code: None, }; if Self::is_inert_closed_health(&health) { continue; @@ -125,6 +127,7 @@ impl CircuitBreaker { failure_threshold: cfg.failure_threshold, open_until: None, cooldown_until: None, + last_trigger_error_code: None, } } @@ -294,6 +297,7 @@ impl CircuitBreaker { match entry.state { CircuitState::Closed => { entry.cooldown_until = None; + entry.last_trigger_error_code = None; if !entry.failure_timestamps.is_empty() { entry.failure_timestamps.clear(); entry.updated_at = now_unix; @@ -309,6 +313,7 @@ impl CircuitBreaker { entry.failure_timestamps.clear(); entry.half_open_success_count = 0; entry.cooldown_until = None; + entry.last_trigger_error_code = None; entry.updated_at = now_unix; transition = Some(CircuitTransition { @@ -340,7 +345,12 @@ impl CircuitBreaker { } } - pub fn record_failure(&self, provider_id: i64, now_unix: i64) -> CircuitChange { + pub fn record_failure( + &self, + provider_id: i64, + now_unix: i64, + trigger_error_code: Option<&'static str>, + ) -> CircuitChange { let cfg = self.read_config(); if provider_id <= 0 { let snap = Self::closed_snapshot(&cfg); @@ -363,6 +373,12 @@ impl CircuitBreaker { let before = Self::snapshot_from_health(&cfg, entry, now_u64); + // Remember the most recent attributed failure; an unattributed + // failure must not erase a known trigger. + if trigger_error_code.is_some() && entry.state != CircuitState::Open { + entry.last_trigger_error_code = trigger_error_code; + } + match entry.state { CircuitState::Closed => { entry.failure_timestamps.push(now_u64); @@ -432,6 +448,7 @@ impl CircuitBreaker { failure_threshold: cfg.failure_threshold, open_until: health.open_until, cooldown_until: health.cooldown_until, + last_trigger_error_code: health.last_trigger_error_code, } } diff --git a/src-tauri/src/shared/circuit_breaker/tests.rs b/src-tauri/src/shared/circuit_breaker/tests.rs index 11ce72af..bf09e00e 100644 --- a/src-tauri/src/shared/circuit_breaker/tests.rs +++ b/src-tauri/src/shared/circuit_breaker/tests.rs @@ -12,7 +12,7 @@ fn closed_to_open_after_threshold() { let pid = 1; let now = 1_000; for i in 1..=DEFAULT_FAILURE_THRESHOLD { - let change = cb.record_failure(pid, now + i as i64); + let change = cb.record_failure(pid, now + i as i64, None); if i < DEFAULT_FAILURE_THRESHOLD { assert_eq!(change.after.state, CircuitState::Closed); } @@ -29,7 +29,7 @@ fn open_expires_to_half_open() { let pid = 1; let now = 1_000; for i in 1..=DEFAULT_FAILURE_THRESHOLD { - cb.record_failure(pid, now + i as i64); + cb.record_failure(pid, now + i as i64, None); } let snap = cb.snapshot(pid, now + 10); @@ -52,7 +52,7 @@ fn half_open_one_success_stays_half_open() { let pid = 1; let now = 1_000; for i in 1..=DEFAULT_FAILURE_THRESHOLD { - cb.record_failure(pid, now + i as i64); + cb.record_failure(pid, now + i as i64, None); } let open_until = cb.snapshot(pid, now + 10).open_until.expect("open_until"); @@ -69,7 +69,7 @@ fn half_open_two_successes_stays_half_open() { let pid = 1; let now = 1_000; for i in 1..=DEFAULT_FAILURE_THRESHOLD { - cb.record_failure(pid, now + i as i64); + cb.record_failure(pid, now + i as i64, None); } let open_until = cb.snapshot(pid, now + 10).open_until.expect("open_until"); @@ -87,7 +87,7 @@ fn half_open_three_successes_transitions_to_closed() { let pid = 1; let now = 1_000; for i in 1..=DEFAULT_FAILURE_THRESHOLD { - cb.record_failure(pid, now + i as i64); + cb.record_failure(pid, now + i as i64, None); } let open_until = cb.snapshot(pid, now + 10).open_until.expect("open_until"); @@ -111,7 +111,7 @@ fn half_open_two_successes_then_failure_resets_to_open() { let pid = 1; let now = 1_000; for i in 1..=DEFAULT_FAILURE_THRESHOLD { - cb.record_failure(pid, now + i as i64); + cb.record_failure(pid, now + i as i64, None); } let open_until = cb.snapshot(pid, now + 10).open_until.expect("open_until"); @@ -119,7 +119,7 @@ fn half_open_two_successes_then_failure_resets_to_open() { cb.record_success(pid, open_until + 1); cb.record_success(pid, open_until + 2); - let change = cb.record_failure(pid, open_until + 3); + let change = cb.record_failure(pid, open_until + 3, None); assert_eq!(change.after.state, CircuitState::Open); assert!(change.after.open_until.is_some()); assert!(change.transition.is_some()); @@ -146,13 +146,13 @@ fn half_open_failure_transitions_back_to_open() { let pid = 1; let now = 1_000; for i in 1..=DEFAULT_FAILURE_THRESHOLD { - cb.record_failure(pid, now + i as i64); + cb.record_failure(pid, now + i as i64, None); } let open_until = cb.snapshot(pid, now + 10).open_until.expect("open_until"); cb.should_allow(pid, open_until); // transitions to HalfOpen - let change = cb.record_failure(pid, open_until + 1); + let change = cb.record_failure(pid, open_until + 1, None); assert_eq!(change.after.state, CircuitState::Open); assert!(change.after.open_until.is_some()); assert!(change.transition.is_some()); @@ -167,7 +167,7 @@ fn success_clears_failure_timestamps() { let cb = breaker(); let pid = 1; let now = 1_000; - cb.record_failure(pid, now); + cb.record_failure(pid, now, None); let before = cb.snapshot(pid, now + 1); assert_eq!(before.failure_count, 1); @@ -191,15 +191,15 @@ fn failures_within_window_counted_correctly() { let now = 1_000; // Record 2 failures within the window - cb.record_failure(pid, now); - cb.record_failure(pid, now + 10); + cb.record_failure(pid, now, None); + cb.record_failure(pid, now + 10, None); let snap = cb.snapshot(pid, now + 20); assert_eq!(snap.state, CircuitState::Closed); assert_eq!(snap.failure_count, 2); // Third failure within window trips the breaker - let change = cb.record_failure(pid, now + 20); + let change = cb.record_failure(pid, now + 20, None); assert_eq!(change.after.state, CircuitState::Open); } @@ -217,8 +217,8 @@ fn failures_older_than_window_not_counted() { let now: i64 = 1_000; // Record 2 failures - cb.record_failure(pid, now); - cb.record_failure(pid, now + 1); + cb.record_failure(pid, now, None); + cb.record_failure(pid, now + 1, None); // Jump forward past the window (300s) let later = now + (FAILURE_WINDOW_SECS as i64) + 10; @@ -228,17 +228,17 @@ fn failures_older_than_window_not_counted() { assert_eq!(snap.failure_count, 0); // Need 3 fresh failures to trip, not 1 - cb.record_failure(pid, later); + cb.record_failure(pid, later, None); let snap = cb.snapshot(pid, later + 1); assert_eq!(snap.state, CircuitState::Closed); assert_eq!(snap.failure_count, 1); - cb.record_failure(pid, later + 2); + cb.record_failure(pid, later + 2, None); let snap = cb.snapshot(pid, later + 3); assert_eq!(snap.state, CircuitState::Closed); assert_eq!(snap.failure_count, 2); - let change = cb.record_failure(pid, later + 3); + let change = cb.record_failure(pid, later + 3, None); assert_eq!(change.after.state, CircuitState::Open); } @@ -256,7 +256,7 @@ fn should_allow_prunes_expired_closed_failures_and_removes_inert_entry() { let provider_id = 1; let now = 1_000; - cb.record_failure(provider_id, now); + cb.record_failure(provider_id, now, None); let _ = rx.try_recv().expect("failure state persisted"); assert_eq!(cb.health.lock().expect("health lock").len(), 1); @@ -287,8 +287,8 @@ fn persist_queue_full_keeps_latest_state_in_bounded_backlog_and_flushes_later() ); let now = 1_000; - cb.record_failure(1, now); - cb.record_failure(2, now); + cb.record_failure(1, now, None); + cb.record_failure(2, now, None); { let backlog = cb.persist_backlog.lock().expect("backlog lock"); @@ -298,7 +298,7 @@ fn persist_queue_full_keeps_latest_state_in_bounded_backlog_and_flushes_later() let first = rx.try_recv().expect("first state queued"); assert_eq!(first.provider_id, 1); - cb.record_failure(3, now); + cb.record_failure(3, now, None); let flushed = rx.try_recv().expect("backlog state flushed first"); assert_eq!(flushed.provider_id, 2); @@ -321,8 +321,8 @@ async fn persist_backlog_flushes_in_background_without_future_state_changes() { ); let now = 1_000; - cb.record_failure(1, now); - cb.record_failure(2, now); + cb.record_failure(1, now, None); + cb.record_failure(2, now, None); { let backlog = cb.persist_backlog.lock().expect("backlog lock"); @@ -355,9 +355,9 @@ async fn persist_backlog_background_flush_sends_latest_state_after_waiting_for_c ); let now = 1_000; - cb.record_failure(1, now); - cb.record_failure(2, now); - cb.record_failure(2, now + 1); + cb.record_failure(1, now, None); + cb.record_failure(2, now, None); + cb.record_failure(2, now + 1, None); let first = rx.recv().await.expect("first state queued"); assert_eq!(first.provider_id, 1); @@ -397,7 +397,7 @@ fn persist_backlog_flushes_oldest_updated_state_first() { Some(tx), ); - cb.record_failure(1, 1_000); + cb.record_failure(1, 1_000, None); { let mut backlog = cb.persist_backlog.lock().expect("backlog lock"); backlog.insert(3, persisted_state(3, 3_000)); @@ -407,7 +407,7 @@ fn persist_backlog_flushes_oldest_updated_state_first() { let first = rx.try_recv().expect("first state queued"); assert_eq!(first.provider_id, 1); - cb.record_failure(4, 4_000); + cb.record_failure(4, 4_000, None); let flushed = rx.try_recv().expect("oldest backlog state flushed first"); assert_eq!(flushed.provider_id, 2); @@ -447,7 +447,7 @@ fn reset_clears_open_and_cooldown() { let pid = 1; let now = 1_000; for i in 1..=DEFAULT_FAILURE_THRESHOLD { - cb.record_failure(pid, now + i as i64); + cb.record_failure(pid, now + i as i64, None); } let open = cb.snapshot(pid, now + 10); @@ -469,7 +469,7 @@ fn reset_clears_half_open() { let pid = 1; let now = 1_000; for i in 1..=DEFAULT_FAILURE_THRESHOLD { - cb.record_failure(pid, now + i as i64); + cb.record_failure(pid, now + i as i64, None); } let open_until = cb.snapshot(pid, now + 10).open_until.expect("open_until"); @@ -491,7 +491,7 @@ fn update_config_recalculates_open_until() { // Trip the circuit breaker for i in 1..=DEFAULT_FAILURE_THRESHOLD { - cb.record_failure(pid, now + i as i64); + cb.record_failure(pid, now + i as i64, None); } let snap = cb.snapshot(pid, now + 10); @@ -540,7 +540,7 @@ fn failure_timestamps_capped_at_max() { // Record more failures than the hard cap, all within the window for i in 0..(MAX_FAILURE_TIMESTAMPS + 50) { - cb.record_failure(pid, now + i as i64); + cb.record_failure(pid, now + i as i64, None); } let snap = cb.snapshot(pid, now + (MAX_FAILURE_TIMESTAMPS + 50) as i64); @@ -584,7 +584,7 @@ fn reset_removes_runtime_health_entry_after_failure() { let provider_id = 1; let now = 1_000; - cb.record_failure(provider_id, now); + cb.record_failure(provider_id, now, None); assert_eq!(cb.health.lock().expect("health lock").len(), 1); let reset = cb.reset(provider_id, now + 1); @@ -646,8 +646,8 @@ fn update_config_new_failures_use_new_duration() { }); // Trip the circuit - cb.record_failure(pid, now); - cb.record_failure(pid, now + 1); + cb.record_failure(pid, now, None); + cb.record_failure(pid, now + 1, None); let snap = cb.snapshot(pid, now + 2); assert_eq!(snap.state, CircuitState::Open); @@ -655,3 +655,60 @@ fn update_config_new_failures_use_new_duration() { let open_until = snap.open_until.expect("open_until"); assert_eq!(open_until, (now + 1) + 30); } + +#[test] +fn record_failure_remembers_trigger_error_code_until_closed() { + let cb = breaker(); + let pid = 1; + let now = 1_000; + + // Attributed failures trip the circuit and remember the trigger. + for i in 1..=DEFAULT_FAILURE_THRESHOLD { + cb.record_failure(pid, now + i as i64, Some("GW_UPSTREAM_TIMEOUT")); + } + let snap = cb.snapshot(pid, now + 10); + assert_eq!(snap.state, CircuitState::Open); + assert_eq!(snap.last_trigger_error_code, Some("GW_UPSTREAM_TIMEOUT")); + + // Recover: Open -> HalfOpen keeps the trigger for attribution. + let open_until = snap.open_until.expect("open_until"); + let check = cb.should_allow(pid, open_until); + assert_eq!(check.after.state, CircuitState::HalfOpen); + assert_eq!( + check.after.last_trigger_error_code, + Some("GW_UPSTREAM_TIMEOUT") + ); + + // HalfOpen -> Closed clears the trigger (no longer meaningful). + cb.record_success(pid, open_until + 1); + cb.record_success(pid, open_until + 2); + let change = cb.record_success(pid, open_until + 3); + assert_eq!(change.after.state, CircuitState::Closed); + assert_eq!(change.after.last_trigger_error_code, None); +} + +#[test] +fn unattributed_failure_keeps_known_trigger_error_code() { + let cb = breaker(); + let pid = 1; + let now = 1_000; + + cb.record_failure(pid, now, Some("GW_UPSTREAM_5XX")); + let change = cb.record_failure(pid, now + 1, None); + assert_eq!( + change.after.last_trigger_error_code, + Some("GW_UPSTREAM_5XX") + ); +} + +#[test] +fn closed_success_clears_trigger_error_code() { + let cb = breaker(); + let pid = 1; + let now = 1_000; + + cb.record_failure(pid, now, Some("GW_UPSTREAM_5XX")); + let change = cb.record_success(pid, now + 1); + assert_eq!(change.after.state, CircuitState::Closed); + assert_eq!(change.after.last_trigger_error_code, None); +} diff --git a/src-tauri/src/shared/circuit_breaker/types.rs b/src-tauri/src/shared/circuit_breaker/types.rs index 4b41c742..971994db 100644 --- a/src-tauri/src/shared/circuit_breaker/types.rs +++ b/src-tauri/src/shared/circuit_breaker/types.rs @@ -61,6 +61,9 @@ pub struct CircuitSnapshot { pub failure_threshold: u32, pub open_until: Option, pub cooldown_until: Option, + /// Error code of the most recent attributed failure (in-memory only; + /// intentionally not persisted, lost across restart). + pub last_trigger_error_code: Option<&'static str>, } #[derive(Debug, Clone)] @@ -103,6 +106,9 @@ pub(super) struct ProviderHealth { pub(super) open_until: Option, pub(super) cooldown_until: Option, pub(super) updated_at: i64, + /// Most recent attributed failure error code; cleared when the circuit + /// returns to Closed. Never persisted. + pub(super) last_trigger_error_code: Option<&'static str>, } impl ProviderHealth { @@ -116,6 +122,7 @@ impl ProviderHealth { open_until: None, cooldown_until: None, updated_at: now_unix, + last_trigger_error_code: None, }, ) } diff --git a/src-tauri/src/test_support.rs b/src-tauri/src/test_support.rs index 28294214..7c184dd6 100644 --- a/src-tauri/src/test_support.rs +++ b/src-tauri/src/test_support.rs @@ -316,6 +316,7 @@ pub fn provider_upsert_json( source_provider_id: None, bridge_type: None, stream_idle_timeout_seconds: None, + extension_values: None, }, )?; serialize_json(provider) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8b5dbfbd..7a4c7393 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "AIO Coding Hub", - "version": "0.60.2", + "version": "0.60.11", "identifier": "io.aio.codinghub", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src-tauri/tests/fixtures/plugins/official/privacy-filter/dist/extension.js b/src-tauri/tests/fixtures/plugins/official/privacy-filter/dist/extension.js new file mode 100644 index 00000000..4626a814 --- /dev/null +++ b/src-tauri/tests/fixtures/plugins/official/privacy-filter/dist/extension.js @@ -0,0 +1,68 @@ +function arrayOption(value) { + return Array.isArray(value) + ? value.filter(function filterString(item) { + return typeof item === "string"; + }) + : undefined; +} + +function privacyOptions(config) { + return { + sensitiveTypes: arrayOption(config && config.sensitiveTypes), + redactionScopes: arrayOption(config && config.redactionScopes), + }; +} + +function handleRequestHook(api, payload) { + const config = payload && payload.config ? payload.config : {}; + if (config.redactBeforeUpstream !== true) { + return { action: "pass" }; + } + const body = + payload && payload.context && payload.context.request + ? payload.context.request.body + : undefined; + if (typeof body !== "string" || body.length === 0) { + return { action: "pass" }; + } + const result = api.privacy.redactRequestBody(body, privacyOptions(config)); + return result && result.hit + ? { action: "replace", requestBody: result.redacted } + : { action: "pass" }; +} + +function handleLogHook(api, payload) { + const config = payload && payload.config ? payload.config : {}; + if (config.redactLogs !== true) { + return { action: "pass" }; + } + const message = + payload && payload.context && payload.context.log + ? payload.context.log.message + : undefined; + if (typeof message !== "string" || message.length === 0) { + return { action: "pass" }; + } + const result = api.privacy.redactText(message, privacyOptions(config)); + return result && result.hit + ? { action: "replace", logMessage: result.redacted } + : { action: "pass" }; +} + +module.exports.activate = function activate(api) { + api.gateway.registerHook( + "gateway.request.afterBodyRead", + function onAfterBodyRead(payload) { + return handleRequestHook(api, payload); + } + ); + api.gateway.registerHook( + "gateway.request.beforeSend", + function onBeforeSend(payload) { + return handleRequestHook(api, payload); + } + ); + api.gateway.registerHook("log.beforePersist", function onBeforePersist(payload) { + return handleLogHook(api, payload); + }); +}; diff --git a/src-tauri/tests/fixtures/plugins/official/privacy-filter/plugin.json b/src-tauri/tests/fixtures/plugins/official/privacy-filter/plugin.json index 41bd901e..1fc249c8 100644 --- a/src-tauri/tests/fixtures/plugins/official/privacy-filter/plugin.json +++ b/src-tauri/tests/fixtures/plugins/official/privacy-filter/plugin.json @@ -5,7 +5,7 @@ "apiVersion": "1.0.0", "configVersion": 3, "category": "privacy", - "description": "Official native privacy filter aligned with packyme/privacy-filter for pre-upstream prompt and log redaction.", + "description": "Official Extension Host privacy filter aligned with packyme/privacy-filter for pre-upstream prompt and log redaction.", "homepage": "https://github.com/packyme/privacy-filter", "repository": { "type": "git", @@ -13,29 +13,40 @@ }, "license": "MIT", "runtime": { - "kind": "native", - "engine": "privacyFilter" + "kind": "extensionHost", + "language": "typescript" }, - "hooks": [ - { - "name": "gateway.request.afterBodyRead", - "priority": 5, - "failurePolicy": "fail-closed" - }, - { - "name": "gateway.request.beforeSend", - "priority": 5, - "failurePolicy": "fail-closed" - }, - { - "name": "log.beforePersist", - "priority": 1, - "failurePolicy": "fail-closed" - } + "main": "dist/extension.js", + "activationEvents": [ + "onGatewayHook:gateway.request.afterBodyRead", + "onGatewayHook:gateway.request.beforeSend", + "onGatewayHook:log.beforePersist" ], - "permissions": ["request.body.read", "request.body.write", "log.redact"], + "capabilities": ["gateway.hooks", "privacy.redact"], + "contributes": { + "gatewayHooks": [ + { + "name": "gateway.request.afterBodyRead", + "priority": 5, + "failurePolicy": "fail-closed", + "timeoutMs": 5000 + }, + { + "name": "gateway.request.beforeSend", + "priority": 5, + "failurePolicy": "fail-closed", + "timeoutMs": 5000 + }, + { + "name": "log.beforePersist", + "priority": 1, + "failurePolicy": "fail-closed", + "timeoutMs": 5000 + } + ] + }, "hostCompatibility": { - "app": ">=0.56.0 <1.0.0", + "app": ">=0.60.0 <1.0.0", "pluginApi": "^1.0.0", "platforms": ["macos", "windows", "linux"] }, @@ -166,12 +177,7 @@ "type": "array", "title": "请求处理范围", "description": "选择发送给模型前需要脱敏的协议文本字段。工具定义、工具调用参数、元数据、推理块和文件/图片引用不会被处理。", - "default": [ - "system_instructions", - "user_prompts", - "tool_results", - "legacy_prompt" - ], + "default": ["system_instructions", "user_prompts", "tool_results", "legacy_prompt"], "items": { "type": "string", "enum": [ diff --git a/src/__tests__/msw-default-settings.test.ts b/src/__tests__/msw-default-settings.test.ts index 857e2c12..b6af8929 100644 --- a/src/__tests__/msw-default-settings.test.ts +++ b/src/__tests__/msw-default-settings.test.ts @@ -6,7 +6,7 @@ describe("MSW defaults", () => { resetMswState(); expect(getSettingsState()).toEqual({ - schema_version: 32, + schema_version: 34, preferred_port: 37123, show_home_heatmap: true, show_home_usage: true, @@ -26,10 +26,11 @@ describe("MSW defaults", () => { tray_enabled: true, enable_cli_proxy_startup_recovery: true, log_retention_days: 7, + request_log_retention_days: 0, provider_cooldown_seconds: 30, provider_base_url_ping_cache_ttl_seconds: 60, upstream_first_byte_timeout_seconds: 30, - upstream_stream_idle_timeout_seconds: 120, + upstream_stream_idle_timeout_seconds: 300, upstream_request_timeout_non_streaming_seconds: 0, update_releases_url: "https://github.com/dyndynjyxa/aio-coding-hub/releases", failover_max_attempts_per_provider: 5, @@ -41,7 +42,7 @@ describe("MSW defaults", () => { intercept_anthropic_warmup_requests: true, enable_thinking_signature_rectifier: true, enable_thinking_budget_rectifier: true, - enable_billing_header_rectifier: true, + enable_billing_header_rectifier: false, enable_codex_session_id_completion: true, enable_claude_metadata_user_id_injection: true, enable_cache_anomaly_monitor: false, diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index 08132fdd..4e7fbbca 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -53,7 +53,7 @@ function PageLoadingFallback() { ); } -function renderLazyPage(Page: ComponentType) { +function LazyPage({ Page }: { Page: ComponentType }) { return ( }> @@ -66,24 +66,27 @@ export function AppRoutes() { }> } /> - - - + } /> + } /> + } + /> } /> - - - - - - - - - - - + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/src/app/__tests__/AppRoutes.test.tsx b/src/app/__tests__/AppRoutes.test.tsx new file mode 100644 index 00000000..9ed678c1 --- /dev/null +++ b/src/app/__tests__/AppRoutes.test.tsx @@ -0,0 +1,111 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import { AppRoutes } from "../AppRoutes"; + +vi.mock("../../layout/AppLayout", async () => { + const { Outlet } = await vi.importActual("react-router-dom"); + return { + AppLayout: () => ( +
+ layout-shell + +
+ ), + }; +}); + +vi.mock("../../ui/Spinner", () => ({ + Spinner: () =>
loading-route
, +})); + +vi.mock("../../pages/HomePage", () => ({ + HomePage: () =>

home-route

, +})); +vi.mock("../../pages/ProvidersPage", () => ({ + ProvidersPage: () =>

providers-route

, +})); +vi.mock("../../pages/SessionsPage", () => ({ + SessionsPage: () =>

sessions-route

, +})); +vi.mock("../../pages/SessionsProjectPage", () => ({ + SessionsProjectPage: () =>

sessions-project-route

, +})); +vi.mock("../../pages/SessionsMessagesPage", () => ({ + SessionsMessagesPage: () =>

sessions-messages-route

, +})); +vi.mock("../../pages/WorkspacesPage", () => ({ + WorkspacesPage: () =>

workspaces-route

, +})); +vi.mock("../../pages/PromptsPage", () => ({ + PromptsPage: () =>

prompts-route

, +})); +vi.mock("../../pages/McpPage", () => ({ + McpPage: () =>

mcp-route

, +})); +vi.mock("../../pages/PluginsPage", () => ({ + PluginsPage: () =>

plugins-route

, +})); +vi.mock("../../pages/LogsPage", () => ({ + LogsPage: () =>

logs-route

, +})); +vi.mock("../../pages/ConsolePage", () => ({ + ConsolePage: () =>

console-route

, +})); +vi.mock("../../pages/UsagePage", () => ({ + UsagePage: () =>

usage-route

, +})); +vi.mock("../../pages/SettingsPage", () => ({ + SettingsPage: () =>

settings-route

, +})); +vi.mock("../../pages/CliManagerPage", () => ({ + CliManagerPage: () =>

cli-manager-route

, +})); +vi.mock("../../pages/SkillsPage", () => ({ + SkillsPage: () =>

skills-route

, +})); +vi.mock("../../pages/SkillsMarketPage", () => ({ + SkillsMarketPage: () =>

skills-market-route

, +})); + +function renderRoute(path: string) { + return render( + + + + ); +} + +describe("app/AppRoutes", () => { + it.each([ + ["/", "home-route"], + ["/providers", "providers-route"], + ["/sessions", "sessions-route"], + ["/sessions/claude/project-1", "sessions-project-route"], + ["/sessions/claude/project-1/session/session-1", "sessions-messages-route"], + ["/workspaces", "workspaces-route"], + ["/prompts", "prompts-route"], + ["/mcp", "mcp-route"], + ["/plugins", "plugins-route"], + ["/logs", "logs-route"], + ["/console", "console-route"], + ["/usage", "usage-route"], + ["/settings/general", "settings-route"], + ["/cli-manager", "cli-manager-route"], + ["/skills", "skills-route"], + ["/skills/market", "skills-market-route"], + ])("renders %s", async (path, heading) => { + renderRoute(path); + + expect(await screen.findByRole("heading", { name: heading })).toBeInTheDocument(); + expect(screen.getByText("layout-shell")).toBeInTheDocument(); + }); + + it("redirects unknown paths to home", async () => { + renderRoute("/missing"); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "home-route" })).toBeInTheDocument(); + }); + }); +}); diff --git a/src/app/__tests__/settingsRuntimeController.test.ts b/src/app/__tests__/settingsRuntimeController.test.ts new file mode 100644 index 00000000..9ed8c429 --- /dev/null +++ b/src/app/__tests__/settingsRuntimeController.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + applySettingsRuntimeSnapshot, + resetSettingsRuntimeController, + type SettingsRuntimeSnapshot, +} from "../settingsRuntimeController"; +import { setCacheAnomalyMonitorEnabled } from "../../services/gateway/cacheAnomalyMonitor"; +import { setCircuitBreakerNoticeEnabled } from "../../services/gateway/circuitNotice"; +import { setNotificationSoundEnabled } from "../../services/notification/notificationSound"; +import { setTaskCompleteNotifyEnabled } from "../../services/notification/taskCompleteNotifyEvents"; + +vi.mock("../../services/gateway/cacheAnomalyMonitor", () => ({ + setCacheAnomalyMonitorEnabled: vi.fn(), +})); + +vi.mock("../../services/gateway/circuitNotice", () => ({ + setCircuitBreakerNoticeEnabled: vi.fn(), +})); + +vi.mock("../../services/notification/notificationSound", () => ({ + setNotificationSoundEnabled: vi.fn(), +})); + +vi.mock("../../services/notification/taskCompleteNotifyEvents", () => ({ + setTaskCompleteNotifyEnabled: vi.fn(), +})); + +const runtimeSnapshot: SettingsRuntimeSnapshot = { + enableCacheAnomalyMonitor: true, + enableTaskCompleteNotify: false, + enableNotificationSound: true, + enableCircuitBreakerNotice: true, +}; + +describe("app/settingsRuntimeController", () => { + beforeEach(() => { + resetSettingsRuntimeController(); + }); + + it("applies normalized app settings once and skips duplicate snapshots", () => { + applySettingsRuntimeSnapshot({ + enable_cache_anomaly_monitor: true, + enable_task_complete_notify: false, + enable_notification_sound: true, + enable_circuit_breaker_notice: true, + } as any); + applySettingsRuntimeSnapshot({ ...runtimeSnapshot }); + + expect(setCacheAnomalyMonitorEnabled).toHaveBeenCalledTimes(1); + expect(setCacheAnomalyMonitorEnabled).toHaveBeenCalledWith(true); + expect(setTaskCompleteNotifyEnabled).toHaveBeenCalledTimes(1); + expect(setTaskCompleteNotifyEnabled).toHaveBeenCalledWith(false); + expect(setNotificationSoundEnabled).toHaveBeenCalledTimes(1); + expect(setNotificationSoundEnabled).toHaveBeenCalledWith(true); + expect(setCircuitBreakerNoticeEnabled).toHaveBeenCalledTimes(1); + expect(setCircuitBreakerNoticeEnabled).toHaveBeenCalledWith(true); + }); + + it("ignores empty settings and reapplies after reset", () => { + applySettingsRuntimeSnapshot(null); + applySettingsRuntimeSnapshot(undefined); + expect(setCacheAnomalyMonitorEnabled).not.toHaveBeenCalled(); + + applySettingsRuntimeSnapshot(runtimeSnapshot); + resetSettingsRuntimeController(); + applySettingsRuntimeSnapshot(runtimeSnapshot); + + expect(setCacheAnomalyMonitorEnabled).toHaveBeenCalledTimes(2); + expect(setTaskCompleteNotifyEnabled).toHaveBeenCalledTimes(2); + expect(setNotificationSoundEnabled).toHaveBeenCalledTimes(2); + expect(setCircuitBreakerNoticeEnabled).toHaveBeenCalledTimes(2); + }); + + it("applies changes when any runtime value differs", () => { + applySettingsRuntimeSnapshot(runtimeSnapshot); + applySettingsRuntimeSnapshot({ + ...runtimeSnapshot, + enableNotificationSound: false, + enableCircuitBreakerNotice: false, + }); + + expect(setCacheAnomalyMonitorEnabled).toHaveBeenLastCalledWith(true); + expect(setTaskCompleteNotifyEnabled).toHaveBeenLastCalledWith(false); + expect(setNotificationSoundEnabled).toHaveBeenLastCalledWith(false); + expect(setCircuitBreakerNoticeEnabled).toHaveBeenLastCalledWith(false); + }); +}); diff --git a/src/app/settingsRuntimeController.ts b/src/app/settingsRuntimeController.ts index 439b9dcd..72df40d7 100644 --- a/src/app/settingsRuntimeController.ts +++ b/src/app/settingsRuntimeController.ts @@ -1,5 +1,6 @@ import type { AppSettings } from "../services/settings/settings"; import { setCacheAnomalyMonitorEnabled } from "../services/gateway/cacheAnomalyMonitor"; +import { setCircuitBreakerNoticeEnabled } from "../services/gateway/circuitNotice"; import { setNotificationSoundEnabled } from "../services/notification/notificationSound"; import { setTaskCompleteNotifyEnabled } from "../services/notification/taskCompleteNotifyEvents"; @@ -7,6 +8,7 @@ export type SettingsRuntimeSnapshot = { enableCacheAnomalyMonitor: boolean; enableTaskCompleteNotify: boolean; enableNotificationSound: boolean; + enableCircuitBreakerNotice: boolean; }; let lastAppliedSnapshot: SettingsRuntimeSnapshot | null = null; @@ -26,6 +28,7 @@ function normalizeSettingsRuntimeSnapshot( enableCacheAnomalyMonitor: settings.enable_cache_anomaly_monitor, enableTaskCompleteNotify: settings.enable_task_complete_notify, enableNotificationSound: settings.enable_notification_sound, + enableCircuitBreakerNotice: settings.enable_circuit_breaker_notice, }; } @@ -36,7 +39,8 @@ function sameSnapshot( return ( left?.enableCacheAnomalyMonitor === right?.enableCacheAnomalyMonitor && left?.enableTaskCompleteNotify === right?.enableTaskCompleteNotify && - left?.enableNotificationSound === right?.enableNotificationSound + left?.enableNotificationSound === right?.enableNotificationSound && + left?.enableCircuitBreakerNotice === right?.enableCircuitBreakerNotice ); } @@ -52,6 +56,7 @@ export function applySettingsRuntimeSnapshot( setCacheAnomalyMonitorEnabled(snapshot.enableCacheAnomalyMonitor); setTaskCompleteNotifyEnabled(snapshot.enableTaskCompleteNotify); setNotificationSoundEnabled(snapshot.enableNotificationSound); + setCircuitBreakerNoticeEnabled(snapshot.enableCircuitBreakerNotice); } export function resetSettingsRuntimeController() { diff --git a/src/components/ProviderChainView.tsx b/src/components/ProviderChainView.tsx index 00e4bd0b..c17b1b77 100644 --- a/src/components/ProviderChainView.tsx +++ b/src/components/ProviderChainView.tsx @@ -4,6 +4,8 @@ import { cn } from "../utils/cn"; import { Globe, AlertTriangle, Zap, ChevronDown, ArrowRight } from "lucide-react"; import { getGatewayErrorShortLabel } from "../constants/gatewayErrorCodes"; import { DisclosureSection } from "./home/DisclosureSection"; +import { parseAttemptsJson, type AttemptJsonEntry } from "../services/gateway/attemptsJson"; +import { formatCircuitRecovery } from "../utils/formatters"; export type ProviderChainAttemptLog = { attempt_index: number; @@ -16,29 +18,6 @@ export type ProviderChainAttemptLog = { attempt_duration_ms?: number | null; }; -type ProviderChainAttemptJson = { - provider_id: number; - provider_name: string; - base_url: string; - outcome: string; - status: number | null; - provider_index?: number | null; - retry_index?: number | null; - session_reuse?: boolean | null; - error_category?: string | null; - error_code?: string | null; - decision?: string | null; - reason?: string | null; - selection_method?: string | null; - reason_code?: string | null; - attempt_started_ms?: number | null; - attempt_duration_ms?: number | null; - circuit_state_before?: string | null; - circuit_state_after?: string | null; - circuit_failure_count?: number | null; - circuit_failure_threshold?: number | null; -}; - type ProviderChainAttempt = { attempt_index: number; provider_id: number; @@ -61,6 +40,8 @@ type ProviderChainAttempt = { circuit_state_after: string | null; circuit_failure_count: number | null; circuit_failure_threshold: number | null; + circuit_recover_at_unix: number | null; + circuit_trigger_error_code: string | null; }; export function ProviderChainView({ @@ -73,17 +54,10 @@ export function ProviderChainView({ attemptsJson: string | null | undefined; }) { const parsedAttemptsJson = useMemo(() => { - if (!attemptsJson) - return { ok: false as const, attempts: null as ProviderChainAttemptJson[] | null }; - try { - const parsed = JSON.parse(attemptsJson); - if (!Array.isArray(parsed)) { - return { ok: false as const, attempts: null }; - } - return { ok: true as const, attempts: parsed as ProviderChainAttemptJson[] }; - } catch { - return { ok: false as const, attempts: null }; - } + const attempts = parseAttemptsJson(attemptsJson); + return attempts + ? { ok: true as const, attempts } + : { ok: false as const, attempts: null as AttemptJsonEntry[] | null }; }, [attemptsJson]); const attempts = useMemo((): ProviderChainAttempt[] | null => { @@ -115,10 +89,12 @@ export function ProviderChainView({ circuit_state_after: a.circuit_state_after ?? null, circuit_failure_count: a.circuit_failure_count ?? null, circuit_failure_threshold: a.circuit_failure_threshold ?? null, + circuit_recover_at_unix: a.circuit_recover_at_unix ?? null, + circuit_trigger_error_code: a.circuit_trigger_error_code ?? null, })); } - const byAttemptIndex: Record = {}; + const byAttemptIndex: Record = {}; if (jsonAttempts) { for (let i = 0; i < jsonAttempts.length; i += 1) { byAttemptIndex[i + 1] = jsonAttempts[i]; @@ -152,6 +128,8 @@ export function ProviderChainView({ circuit_state_after: json?.circuit_state_after ?? null, circuit_failure_count: json?.circuit_failure_count ?? null, circuit_failure_threshold: json?.circuit_failure_threshold ?? null, + circuit_recover_at_unix: json?.circuit_recover_at_unix ?? null, + circuit_trigger_error_code: json?.circuit_trigger_error_code ?? null, }; }); @@ -364,10 +342,29 @@ function AttemptCard({ ) : null} {hasCircuitBreaker ? ( -
+
熔断器: + {attempt.circuit_trigger_error_code ? ( + + 触发:{getGatewayErrorShortLabel(attempt.circuit_trigger_error_code)} + + ) : null} + {attempt.circuit_recover_at_unix != null ? ( + + {formatCircuitRecovery(attempt.circuit_recover_at_unix)} + + ) : null} +
+ ) : null} + + {skipped && attempt.reason ? ( +
+
跳过原因
+
+                  {attempt.reason}
+                
) : null} diff --git a/src/components/ProviderCircuitBadge.tsx b/src/components/ProviderCircuitBadge.tsx index de23f4b1..4f2f2ec9 100644 --- a/src/components/ProviderCircuitBadge.tsx +++ b/src/components/ProviderCircuitBadge.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { cliBadgeTone, cliShortLabel } from "../constants/clis"; import { useNowUnix } from "../hooks/useNowUnix"; import type { CliKey } from "../services/providers/providers"; @@ -28,14 +28,15 @@ export function ProviderCircuitBadge({ resettingProviderIds, }: ProviderCircuitBadgeProps) { const count = rows.length; - const [popoverOpen, setPopoverOpen] = useState(false); - const nowUnix = useNowUnix(count > 0 && popoverOpen); + const [popoverState, setPopoverState] = useState({ rowCount: count, open: false }); + let popoverOpen = popoverState.open; - useEffect(() => { - if (count === 0 && popoverOpen) { - setPopoverOpen(false); - } - }, [count, popoverOpen]); + if (popoverState.rowCount !== count) { + popoverOpen = count > 0 && popoverOpen; + setPopoverState({ rowCount: count, open: popoverOpen }); + } + + const nowUnix = useNowUnix(popoverOpen); const groupedByCli = useMemo(() => { const grouped: Record = { @@ -61,12 +62,22 @@ export function ProviderCircuitBadge({ return grouped; }, [rows]); + const visibleCliKeys = useMemo(() => { + const keys: CliKey[] = []; + for (const cliKey of Object.keys(groupedByCli) as CliKey[]) { + if (groupedByCli[cliKey].length > 0) { + keys.push(cliKey); + } + } + return keys; + }, [groupedByCli]); + if (count === 0) return null; return ( setPopoverState({ rowCount: count, open: count > 0 && open })} placement="bottom" align="end" trigger={ @@ -87,63 +98,61 @@ export function ProviderCircuitBadge({ 熔断列表 ({count})
- {(Object.keys(groupedByCli) as CliKey[]) - .filter((cliKey) => groupedByCli[cliKey].length > 0) - .map((cliKey) => ( -
-
- - {cliShortLabel(cliKey)} - - - {groupedByCli[cliKey].length} 个熔断 - -
-
- {groupedByCli[cliKey].map((row) => { - const remaining = - row.open_until != null && Number.isFinite(row.open_until) - ? formatCountdownSeconds(row.open_until - nowUnix) - : "—"; - const isResetting = resettingProviderIds.has(row.provider_id); - return ( -
-
-
- {row.provider_name || "未知"} -
-
-
- {remaining} -
- + {row.provider_name || "未知"} +
+
+
+ {remaining}
- ); - })} -
+ +
+ ); + })} - ))} + + ))} ); diff --git a/src/components/UpdateDialog.tsx b/src/components/UpdateDialog.tsx index bd736f75..e70d197a 100644 --- a/src/components/UpdateDialog.tsx +++ b/src/components/UpdateDialog.tsx @@ -28,24 +28,54 @@ const READONLY_PLUGINS = [ thematicBreakPlugin(), ]; +async function openChangelogLink(url: string) { + try { + await openDesktopUrl(url); + } catch (err) { + logToConsole("error", "打开更新日志链接失败", { error: String(err), url }); + toast("打开链接失败:请查看控制台日志"); + } +} + +async function openReleases() { + try { + await openDesktopUrl(AIO_RELEASES_URL); + } catch (err) { + logToConsole("error", "打开 Releases 失败", { error: String(err), url: AIO_RELEASES_URL }); + toast("打开下载页失败:请查看控制台日志"); + } +} + +function getChangelogLinkHref(target: EventTarget | null) { + if (!(target instanceof Element)) return null; + + const anchor = target.closest("a[href]"); + const href = anchor?.getAttribute("href"); + return href || null; +} + +function handleChangelogLinkClick(event: MouseEvent) { + const href = getChangelogLinkHref(event.target); + if (!href) return; + + event.preventDefault(); + event.stopPropagation(); + void openChangelogLink(href); +} + +function connectChangelogLinks(node: HTMLElement | null) { + if (!node) return; + + node.addEventListener("click", handleChangelogLinkClick); + return () => node.removeEventListener("click", handleChangelogLinkClick); +} + export function UpdateDialog() { const meta = useUpdateMeta(); const updateCandidate = meta.updateCandidate; const about = meta.about; const isPortable = about?.run_mode === "portable"; - async function openReleases() { - try { - await openDesktopUrl(AIO_RELEASES_URL); - } catch (err) { - logToConsole("error", "打开 Releases 失败", { error: String(err), url: AIO_RELEASES_URL }); - try { - window.open(AIO_RELEASES_URL, "_blank", "noopener,noreferrer"); - } catch {} - toast("打开下载页失败:请查看控制台日志"); - } - } - async function installUpdate() { if (!updateCandidate) return; if (meta.installingUpdate) return; @@ -121,14 +151,18 @@ export function UpdateDialog() { {updateCandidate?.body ? (
更新日志 -
+
-
+
) : null} diff --git a/src/components/UsageHeatmap15d.tsx b/src/components/UsageHeatmap15d.tsx index cbc238cc..19da92fa 100644 --- a/src/components/UsageHeatmap15d.tsx +++ b/src/components/UsageHeatmap15d.tsx @@ -24,6 +24,8 @@ const LEVEL_CLASS: Record = { 4: "bg-heatmap-4", }; +const INTEGER_FORMATTER = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }); + function clampNumber(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); } @@ -37,7 +39,7 @@ function pad2(v: number) { function formatNumber(value: number) { if (!Number.isFinite(value)) return "0"; try { - return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value); + return INTEGER_FORMATTER.format(value); } catch { return String(value); } @@ -228,12 +230,14 @@ export function UsageHeatmap15d({ refreshing && "animate-spin" )} title="刷新" + aria-label="刷新用量热力图" >
+
{label}
+ {warnItems.length > 0 ? ( +
预警(<60%): {warnItems.length}
+ ) : ( +
+ 供应商: {items.length} +
+ )} + {sliced.map((item: TooltipItem) => { + const isWarn = item.value < WARN_THRESHOLD; + const valueColor = isWarn ? "#b91c1c" : isDark ? "#e2e8f0" : "#0f172a"; + + return ( +
+
+ + + {item.name} + + + {formatPercent(item.value, 2)} + +
+
+ denom {formatInteger(item.denomTokens)} · read {formatInteger(item.cacheReadTokens)} · + ok {formatInteger(item.requestsSuccess)} +
+
+ ); + })} + {hidden > 0 && ( +
+ ... +{hidden}(可通过 legend 过滤) +
+ )} +
+ ); +} + function toMmDd(dayKey: string) { const mmdd = dayKey.slice(5); return mmdd.replace("-", "/"); @@ -303,184 +436,77 @@ export function UsageProviderCacheRateTrendChart({ return xLabels.filter((_, i) => i % interval === 0); }, [xLabels, period]); - const CustomTooltip = ({ active, payload, label }: TooltipProps) => { - if (!active || !payload || payload.length === 0) return null; - - const items: TooltipItem[] = payload - .map((entry) => { - const providerKey = entry.dataKey; - const meta = (entry.payload as ChartDataPoint | undefined)?.[`${providerKey}_meta`] as - | PointMeta - | undefined; - const value = entry.value; - if (value == null || !Number.isFinite(value as number) || !meta) return null; - - return { - name: entry.name as string, - color: entry.color ?? "", - value: value as number, - ...meta, - }; - }) - .filter((v): v is TooltipItem => v != null); - - const warnItems = items - .filter((item) => item.value < WARN_THRESHOLD) - .sort((a, b) => a.value - b.value); - const okItems = items - .filter((item) => item.value >= WARN_THRESHOLD) - .sort((a, b) => b.denomTokens - a.denomTokens); - - const MAX_ITEMS = 12; - const renderItems = warnItems.length > 0 ? warnItems : okItems; - const sliced = renderItems.slice(0, MAX_ITEMS); - const hidden = renderItems.length - sliced.length; - - return ( -
-
{label}
- {warnItems.length > 0 ? ( -
- 预警(<60%): {warnItems.length} -
- ) : ( -
- 供应商: {items.length} -
- )} - {sliced.map((item: TooltipItem, idx: number) => { - const isWarn = item.value < WARN_THRESHOLD; - const valueColor = isWarn ? "#b91c1c" : isDark ? "#e2e8f0" : "#0f172a"; - - return ( -
-
- - - {item.name} - - - {formatPercent(item.value, 2)} - -
-
- denom {formatInteger(item.denomTokens)} · read {formatInteger(item.cacheReadTokens)}{" "} - · ok {formatInteger(item.requestsSuccess)} -
-
- ); - })} - {hidden > 0 && ( -
- ... +{hidden}(可通过 legend 过滤) -
- )} -
- ); - }; - const lineWidth = providers.length > 25 ? 1.5 : 2; return (
- - - - - formatPercent(v, 0)} - width={45} - /> - } /> - - - {warnRanges.map((range, idx) => ( - }> + + + + + formatPercent(v, 0)} + width={45} + /> + + } + /> + - ))} - {providers.map((provider) => ( - - ))} - - + {warnRanges.map((range) => ( + + ))} + {providers.map((provider) => ( + + ))} + + +
); } diff --git a/src/components/UsageTokensChart.tsx b/src/components/UsageTokensChart.tsx index c31547e7..2dadf830 100644 --- a/src/components/UsageTokensChart.tsx +++ b/src/components/UsageTokensChart.tsx @@ -1,15 +1,13 @@ -import { useMemo } from "react"; +import { Suspense, useMemo } from "react"; import { - ResponsiveContainer, - AreaChart, Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, XAxis, YAxis, - Tooltip, - CartesianGrid, -} from "recharts"; -import type { TooltipProps } from "recharts"; -import type { ValueType, NameType } from "recharts/types/component/DefaultTooltipContent"; +} from "./charts/lazyRecharts"; import type { UsageHourlyRow } from "../services/usage/usage"; import { useTheme } from "../hooks/useTheme"; import { cn } from "../utils/cn"; @@ -22,27 +20,20 @@ import { getCursorStroke, CHART_ANIMATION, } from "./charts/chartTheme"; +import { buildUsageTokensXAxisTicks } from "./usageTokensChartModel"; type ChartDataPoint = { label: string; tokens: number; }; -export function buildUsageTokensXAxisTicks(labels: string[]) { - if (labels.length <= 7) return labels; - - const interval = Math.max(1, Math.ceil((labels.length - 1) / 6)); - const ticks = labels.filter((_, i) => i % interval === 0); - const last = labels[labels.length - 1]; - - if (last && ticks[ticks.length - 1] !== last) { - ticks.push(last); - } - - return ticks; -} +type ChartTooltipProps = { + active?: boolean; + payload?: Array<{ value?: unknown }>; + label?: string; +}; -const CustomTooltip = ({ active, payload, label }: TooltipProps) => { +const CustomTooltip = ({ active, payload, label }: ChartTooltipProps) => { if (active && payload && payload.length) { const value = typeof payload[0]?.value === "number" ? payload[0].value : 0; @@ -121,54 +112,59 @@ export function UsageTokensChart({ return (
- - - - - - - - - - - - } cursor={{ stroke: cursorStroke, strokeWidth: 1 }} /> - - - + }> + + + + + + + + + + + + } + cursor={{ stroke: cursorStroke, strokeWidth: 1 }} + /> + + + +
); } diff --git a/src/components/__tests__/ProviderChainView.test.tsx b/src/components/__tests__/ProviderChainView.test.tsx index 91c35ae4..dc8a71cb 100644 --- a/src/components/__tests__/ProviderChainView.test.tsx +++ b/src/components/__tests__/ProviderChainView.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { ProviderChainView } from "../ProviderChainView"; @@ -101,4 +101,176 @@ describe("components/ProviderChainView", () => { // Endpoint shown in detail body expect(screen.getByText("https://p99")).toBeInTheDocument(); }); + + it("renders skipped summary attempts with fallback values and collapsed details", () => { + render( + + ); + + expect(screen.getByText("当前显示的是摘要链路,未拿到逐次尝试日志")).toBeInTheDocument(); + expect(screen.getByText("最终失败")).toBeInTheDocument(); + expect(screen.getAllByText("未知(id=0)")).toHaveLength(2); + expect(screen.getByText("跳过")).toBeInTheDocument(); + expect(screen.getByText("priority")).toBeInTheDocument(); + expect(screen.getByText("retry")).toBeInTheDocument(); + expect(screen.getByText("closed")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /跳过/ })); + expect(screen.queryByText("Provider ID:")).not.toBeInTheDocument(); + }); + + it("renders reason and circuit attribution for circuit-gate skipped attempts", () => { + const farFutureUnix = 4_102_444_800; // 2100-01-01, keeps "约 N 分钟后" stable + render( + + ); + + expect(screen.getByText("跳过")).toBeInTheDocument(); + expect(screen.getByText("跳过原因")).toBeInTheDocument(); + expect(screen.getByText("provider skipped by circuit breaker (open)")).toBeInTheDocument(); + expect(screen.getByText("5/5 次失败")).toBeInTheDocument(); + expect(screen.getByText("触发:上游超时")).toBeInTheDocument(); + expect(screen.getByText(/约 \d+ 分钟后/)).toBeInTheDocument(); + }); + + it("degrades gracefully for skipped attempts without circuit attribution", () => { + render( + + ); + + expect(screen.getByText("跳过原因")).toBeInTheDocument(); + expect(screen.getByText("provider skipped by rate limit")).toBeInTheDocument(); + expect(screen.queryByText(/触发:/)).not.toBeInTheDocument(); + expect(screen.queryByText("熔断器:")).not.toBeInTheDocument(); + }); + + it("renders structured failures with circuit transitions and gateway labels", () => { + render( + + ); + + expect(screen.getByText("起始供应商:")).toBeInTheDocument(); + expect(screen.getByText("最终供应商:")).toBeInTheDocument(); + expect(screen.getByText("Fallback")).toBeInTheDocument(); + expect(screen.getByText("最终成功")).toBeInTheDocument(); + expect(screen.getByText("重试 #1")).toBeInTheDocument(); + expect(screen.getByText("+31ms")).toBeInTheDocument(); + expect(screen.getByText("HTTP 502")).toBeInTheDocument(); + expect(screen.getByText("first failed")).toBeInTheDocument(); + expect(screen.getByText("HTTP_502")).toBeInTheDocument(); + expect(screen.getByText("upstream")).toBeInTheDocument(); + expect(screen.getByText("switch")).toBeInTheDocument(); + expect(screen.getByText("weighted")).toBeInTheDocument(); + expect(screen.getByText("3/3 次失败")).toBeInTheDocument(); + expect(screen.getByText("open")).toBeInTheDocument(); + expect(screen.getByText("half_open")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "结构化错误详情" })); + const structuredDetails = screen.getByText("熔断器变化:").closest("div")!; + expect(structuredDetails).toHaveTextContent("closed"); + expect(structuredDetails).toHaveTextContent("open"); + }); }); diff --git a/src/components/__tests__/ProviderCircuitBadge.test.tsx b/src/components/__tests__/ProviderCircuitBadge.test.tsx index 1202e1b8..cd8fd0de 100644 --- a/src/components/__tests__/ProviderCircuitBadge.test.tsx +++ b/src/components/__tests__/ProviderCircuitBadge.test.tsx @@ -12,6 +12,7 @@ describe("components/ProviderCircuitBadge", () => { it("renders rows, opens popover, and calls onResetProvider", async () => { const onResetProvider = vi.fn(); + const nowUnix = Math.floor(Date.now() / 1000); render( { cli_key: "claude", provider_id: 1, provider_name: "P1", - open_until: Math.floor(Date.now() / 1000) + 10, + open_until: nowUnix + 10, }, { cli_key: "claude", @@ -31,7 +32,7 @@ describe("components/ProviderCircuitBadge", () => { cli_key: "codex", provider_id: 3, provider_name: "P3", - open_until: Math.floor(Date.now() / 1000) + 5, + open_until: nowUnix + 5, }, ]} onResetProvider={onResetProvider} @@ -56,6 +57,7 @@ describe("components/ProviderCircuitBadge", () => { }); it("auto closes popover when rows become empty", async () => { + const nowUnix = Math.floor(Date.now() / 1000); const { rerender } = render( { cli_key: "claude", provider_id: 1, provider_name: "P1", - open_until: Math.floor(Date.now() / 1000) + 10, + open_until: nowUnix + 10, }, ]} onResetProvider={() => {}} diff --git a/src/components/__tests__/UpdateDialog.test.tsx b/src/components/__tests__/UpdateDialog.test.tsx index 08acc37c..481d4431 100644 --- a/src/components/__tests__/UpdateDialog.test.tsx +++ b/src/components/__tests__/UpdateDialog.test.tsx @@ -78,6 +78,40 @@ describe("components/UpdateDialog", () => { expect(screen.getByText("新增功能")).toBeInTheDocument(); }); + it("opens changelog links externally without allowing webview navigation", async () => { + vi.mocked(useUpdateMeta).mockReturnValue({ + about: { run_mode: "desktop", app_version: "0.0.0" }, + updateCandidate: { + rid: 1, + version: "1.2.0", + currentVersion: "1.1.0", + date: "2026-04-12T11:00:00Z", + body: "## 1.2.0\n\n* [新增功能](https://example.com/commit/abc)", + }, + checkingUpdate: false, + dialogOpen: true, + installingUpdate: false, + installError: null, + installTotalBytes: null, + installDownloadedBytes: 0, + } as any); + vi.mocked(tauriOpenUrl).mockResolvedValue(undefined as never); + const windowOpen = vi.spyOn(window, "open").mockImplementation(() => null); + + render(); + + const link = screen.getByText("新增功能").closest("a"); + expect(link).not.toBeNull(); + + const defaultAllowed = fireEvent.click(link!); + + expect(defaultAllowed).toBe(false); + await waitFor(() => { + expect(tauriOpenUrl).toHaveBeenCalledWith("https://example.com/commit/abc"); + }); + expect(windowOpen).not.toHaveBeenCalled(); + }); + it("renders publish date, installing progress, and install error state", () => { vi.mocked(useUpdateMeta).mockReturnValue({ about: { run_mode: "desktop", app_version: "0.0.0" }, diff --git a/src/components/__tests__/UsageProviderCacheRateTrendChart.test.tsx b/src/components/__tests__/UsageProviderCacheRateTrendChart.test.tsx index 09d472ed..35438f2a 100644 --- a/src/components/__tests__/UsageProviderCacheRateTrendChart.test.tsx +++ b/src/components/__tests__/UsageProviderCacheRateTrendChart.test.tsx @@ -6,6 +6,82 @@ vi.mock("../../hooks/useTheme", () => ({ useTheme: () => ({ theme: "light", resolvedTheme: "light", setTheme: vi.fn() }), })); +vi.mock("../charts/lazyRecharts", () => { + const renderTooltipContent = (content: any, props: any) => { + if (!content || typeof content.type !== "function") return null; + const TooltipContent = content.type; + return ; + }; + + const warnPayload = Array.from({ length: 13 }, (_, index) => { + const key = `warn-${index}`; + return { + dataKey: key, + name: `Warn ${index}`, + color: "#ef4444", + value: 0.2 + index / 100, + payload: { + [`${key}_meta`]: { + denomTokens: 1000 + index, + cacheReadTokens: 200 + index, + requestsSuccess: 10 + index, + }, + }, + }; + }); + + const okPayload = [ + { + dataKey: "ok", + name: "OK", + color: "#22c55e", + value: 0.9, + payload: { + ok_meta: { + denomTokens: 5000, + cacheReadTokens: 4500, + requestsSuccess: 20, + }, + }, + }, + ]; + + return { + CartesianGrid: () => , + Legend: () =>
, + Line: ({ dataKey }: any) => , + LineChart: ({ children, data }: any) => ( +
+ {children} +
+ ), + ReferenceArea: ({ x1, x2 }: any) => ( +
+ ), + ReferenceLine: ({ y }: any) =>
, + ResponsiveContainer: ({ children }: any) =>
{children}
, + Tooltip: ({ content }: any) => ( +
+ {renderTooltipContent(content, { active: false, payload: null, label: "empty" })} + {renderTooltipContent(content, { active: true, payload: [], label: "empty-list" })} + {renderTooltipContent(content, { + active: true, + label: "warn", + payload: [ + { dataKey: "", value: 0.5, payload: {} }, + { dataKey: "missing-meta", value: 0.5, payload: {} }, + { dataKey: "nan", value: Number.NaN, payload: { nan_meta: {} } }, + ...warnPayload, + ], + })} + {renderTooltipContent(content, { active: true, label: "ok", payload: okPayload })} +
+ ), + XAxis: ({ ticks }: any) =>
, + YAxis: ({ ticks }: any) =>
, + }; +}); + import { UsageProviderCacheRateTrendChart } from "../UsageProviderCacheRateTrendChart"; const sampleRow: UsageProviderCacheRateTrendRowV1 = { diff --git a/src/components/__tests__/UsageTokensChart.render.test.tsx b/src/components/__tests__/UsageTokensChart.render.test.tsx new file mode 100644 index 00000000..f5f17917 --- /dev/null +++ b/src/components/__tests__/UsageTokensChart.render.test.tsx @@ -0,0 +1,87 @@ +import { render, screen, within } from "@testing-library/react"; +import { createElement } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { UsageTokensChart } from "../UsageTokensChart"; +import type { UsageHourlyRow } from "../../services/usage/usage"; + +vi.mock("../charts/lazyRecharts", async () => { + const actual = + await vi.importActual("../charts/lazyRecharts"); + return { + ...actual, + ResponsiveContainer: ({ children }: any) => ( +
+ {typeof children === "function" ? children({ width: 400, height: 300 }) : children} +
+ ), + AreaChart: ({ children, data }: any) => ( +
+ {children} +
+ ), + CartesianGrid: (props: any) =>
, + XAxis: (props: any) =>
, + YAxis: (props: any) => ( +
+ {props.tickFormatter?.(1_500_000)} +
+ ), + Tooltip: ({ content }: any) => ( +
+ {createElement(content.type, { + active: true, + payload: [{ value: 1_500_000 }], + label: "03/12", + })} +
+ {createElement(content.type, { active: false, payload: [], label: "03/12" })} +
+
+ ), + Area: (props: any) => ( +
+ ), + }; +}); + +function makeHourlyRow(overrides: Partial = {}): UsageHourlyRow { + return { + day: "2026-03-17", + hour: 0, + requests_total: 1, + requests_with_usage: 1, + requests_success: 1, + requests_failed: 0, + total_tokens: 1_000_000, + ...overrides, + }; +} + +describe("UsageTokensChart rendering", () => { + it("renders aggregated token data through the chart primitives", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-18T12:00:00Z")); + + const rows: UsageHourlyRow[] = [ + makeHourlyRow({ day: "2026-03-17", hour: 0, total_tokens: 1_000_000 }), + makeHourlyRow({ day: "2026-03-17", hour: 1, total_tokens: "500000" as unknown as number }), + makeHourlyRow({ day: "2026-03-18", hour: 1, total_tokens: null as unknown as number }), + makeHourlyRow({ day: "", hour: 1, total_tokens: 99_999 }), + ]; + + render(); + + const root = screen.getByTestId("responsive-container").parentElement!; + expect(root).toHaveClass("custom-chart"); + expect(screen.getByTestId("area-chart")).toBeInTheDocument(); + expect(screen.getByTestId("area-chart")).toHaveAttribute("data-points", "7"); + expect(screen.getByTestId("x-axis").dataset.ticks).toContain("03/18"); + expect(screen.getByTestId("y-axis").textContent).toContain("1.5M"); + expect(screen.getByTestId("area")).toHaveAttribute("data-key", "tokens"); + expect(within(screen.getByTestId("tooltip")).getByText("03/12")).toBeInTheDocument(); + expect(within(screen.getByTestId("tooltip")).getByText("Tokens")).toBeInTheDocument(); + expect(screen.getByTestId("empty-tooltip")).toBeEmptyDOMElement(); + + vi.useRealTimers(); + }); +}); diff --git a/src/components/__tests__/UsageTokensChart.test.ts b/src/components/__tests__/UsageTokensChart.test.ts index 73ec3e97..49ca9742 100644 --- a/src/components/__tests__/UsageTokensChart.test.ts +++ b/src/components/__tests__/UsageTokensChart.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from "vitest"; -import { buildUsageTokensXAxisTicks } from "../UsageTokensChart"; +import { Area as RechartsArea, AreaChart as RechartsAreaChart } from "recharts"; +import { Area, AreaChart } from "../charts/lazyRecharts"; +import { buildUsageTokensXAxisTicks } from "../usageTokensChartModel"; describe("components/UsageTokensChart", () => { + it("preserves Recharts primitive component identity for child parsing", () => { + expect(Area).toBe(RechartsArea); + expect(AreaChart).toBe(RechartsAreaChart); + }); + it("shows all labels for a 7-day window", () => { const labels = ["03/12", "03/13", "03/14", "03/15", "03/16", "03/17", "03/18"]; expect(buildUsageTokensXAxisTicks(labels)).toEqual(labels); diff --git a/src/components/app/__tests__/AppStartupStatusBanner.test.tsx b/src/components/app/__tests__/AppStartupStatusBanner.test.tsx new file mode 100644 index 00000000..9d277b6a --- /dev/null +++ b/src/components/app/__tests__/AppStartupStatusBanner.test.tsx @@ -0,0 +1,127 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + resetAppStartupStatusStore, + setAppStartupStatusSnapshot, +} from "../../../app/startupStatusStore"; +import { appStartupRetry } from "../../../services/app/startupStatus"; +import { logToConsole } from "../../../services/consoleLog"; +import { AppStartupStatusBanner } from "../AppStartupStatusBanner"; + +const navigate = vi.fn(); + +vi.mock("react-router-dom", () => ({ + useNavigate: () => navigate, +})); + +vi.mock("sonner", () => ({ toast: vi.fn() })); +vi.mock("../../../services/consoleLog", () => ({ logToConsole: vi.fn() })); +vi.mock("../../../services/app/startupStatus", async () => { + const actual = await vi.importActual( + "../../../services/app/startupStatus" + ); + return { + ...actual, + appStartupRetry: vi.fn(), + }; +}); + +function setFailedStatus(partial: Record = {}) { + setAppStartupStatusSnapshot({ + running: false, + currentStage: "failed", + failedStage: "starting_gateway", + errorMessage: "gateway boom", + canRetry: true, + ...partial, + } as any); +} + +describe("components/app/AppStartupStatusBanner", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetAppStartupStatusStore(); + }); + + afterEach(() => { + resetAppStartupStatusStore(); + }); + + it("stays hidden unless startup has failed", () => { + const { rerender } = render(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + + setAppStartupStatusSnapshot({ + running: true, + currentStage: "starting_gateway", + failedStage: null, + errorMessage: null, + canRetry: false, + } as any); + rerender(); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("renders failed stage details and navigates to settings", () => { + setFailedStatus(); + + render(); + + expect(screen.getByRole("alert")).toHaveTextContent("网关启动失败:gateway boom"); + + fireEvent.click(screen.getByRole("button", { name: "打开设置" })); + expect(navigate).toHaveBeenCalledWith("/settings"); + }); + + it("uses fallback stage labels and disables retry when retry is not allowed", () => { + setFailedStatus({ + failedStage: null, + errorMessage: null, + canRetry: false, + }); + + render(); + + expect(screen.getByRole("alert")).toHaveTextContent("应用启动失败:应用启动失败"); + expect(screen.getByRole("button", { name: "重试启动" })).toBeDisabled(); + }); + + it("updates the startup snapshot after retry succeeds", async () => { + setFailedStatus({ failedStage: "reading_settings" }); + vi.mocked(appStartupRetry).mockResolvedValue({ + running: false, + currentStage: "ready", + failedStage: null, + errorMessage: null, + canRetry: false, + } as any); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "重试启动" })); + expect(screen.getByRole("button", { name: "重试中..." })).toBeDisabled(); + + await waitFor(() => expect(appStartupRetry).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument()); + }); + + it("logs and restores retry state when retry fails", async () => { + setFailedStatus({ failedStage: "finalizing_wsl", errorMessage: null }); + vi.mocked(appStartupRetry).mockRejectedValue(new Error("retry boom")); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "重试启动" })); + + await waitFor(() => + expect(logToConsole).toHaveBeenCalledWith( + "error", + "重试启动任务失败", + expect.objectContaining({ failed_stage: "finalizing_wsl", error: "Error: retry boom" }) + ) + ); + expect(screen.getByRole("button", { name: "重试启动" })).toBeEnabled(); + expect(screen.getByRole("alert")).toHaveTextContent("WSL 启动收尾失败:WSL 启动收尾失败"); + }); +}); diff --git a/src/components/charts/chartTheme.ts b/src/components/charts/chartTheme.ts index d10f1660..f724cb56 100644 --- a/src/components/charts/chartTheme.ts +++ b/src/components/charts/chartTheme.ts @@ -23,7 +23,7 @@ export const CHART_COLORS = { /** * Color palette for multi-series charts */ -export const MULTI_SERIES_PALETTE: readonly string[] = CHART_PALETTE; +const MULTI_SERIES_PALETTE: readonly string[] = CHART_PALETTE; /** * Pick a color from palette by index, with HSL fallback for large series @@ -38,16 +38,6 @@ export function pickPaletteColor(index: number): string { return `hsl(${hue} 70% 45%)`; } -/** - * Default grid padding for charts - */ -export const CHART_GRID = { - left: 0, - right: 16, - top: 8, - bottom: 24, -} as const; - /** * Axis styling (dark-mode aware) */ @@ -60,9 +50,6 @@ export function getAxisStyle(isDark: boolean) { }; } -/** @deprecated Use getAxisStyle(isDark) for dark mode support */ -export const AXIS_STYLE = getAxisStyle(false); - /** * Grid line styling (dark-mode aware) */ @@ -73,9 +60,6 @@ export function getGridLineStyle(isDark: boolean) { }; } -/** @deprecated Use getGridLineStyle(isDark) for dark mode support */ -export const GRID_LINE_STYLE = getGridLineStyle(false); - /** * Tooltip styling (dark-mode aware) */ @@ -90,9 +74,6 @@ export function getTooltipStyle(isDark: boolean) { }; } -/** @deprecated Use getTooltipStyle(isDark) for dark mode support */ -export const TOOLTIP_STYLE = getTooltipStyle(false); - /** * Legend styling (dark-mode aware) */ @@ -104,9 +85,6 @@ export function getLegendStyle(isDark: boolean) { }; } -/** @deprecated Use getLegendStyle(isDark) for dark mode support */ -export const LEGEND_STYLE = getLegendStyle(false); - /** * Axis line stroke color (dark-mode aware) */ diff --git a/src/components/charts/lazyRecharts.ts b/src/components/charts/lazyRecharts.ts new file mode 100644 index 00000000..000c4ab4 --- /dev/null +++ b/src/components/charts/lazyRecharts.ts @@ -0,0 +1,22 @@ +export { + Area, + AreaChart, + CartesianGrid, + Cell, + Label, + LabelList, + Legend, + Line, + LineChart, + Pie, + PieChart, + ReferenceArea, + ReferenceLine, + ResponsiveContainer, + Scatter, + ScatterChart, + Tooltip, + XAxis, + YAxis, + ZAxis, +} from "recharts"; diff --git a/src/components/cli-manager/NetworkSettingsCard.tsx b/src/components/cli-manager/NetworkSettingsCard.tsx index 7f6e72c8..c64b5a03 100644 --- a/src/components/cli-manager/NetworkSettingsCard.tsx +++ b/src/components/cli-manager/NetworkSettingsCard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useReducer } from "react"; import { toast } from "sonner"; import type { AppSettings, GatewayListenMode } from "../../services/settings/settings"; import { logToConsole } from "../../services/consoleLog"; @@ -25,6 +25,38 @@ export type NetworkSettingsCardProps = { ) => Promise; }; +type NetworkDraftState = { + sourceKey: string; + listenMode: GatewayListenMode; + customAddress: string; +}; + +type NetworkDraftAction = + | { type: "resetFromSettings"; state: NetworkDraftState } + | { type: "setListenMode"; listenMode: GatewayListenMode } + | { type: "setCustomAddress"; customAddress: string }; + +function createNetworkDraftState(settings: AppSettings): NetworkDraftState { + return { + sourceKey: `${settings.gateway_listen_mode}:${settings.gateway_custom_listen_address}`, + listenMode: settings.gateway_listen_mode, + customAddress: settings.gateway_custom_listen_address, + }; +} + +function networkDraftReducer( + state: NetworkDraftState, + action: NetworkDraftAction +): NetworkDraftState { + if (action.type === "resetFromSettings") { + return action.state; + } + if (action.type === "setListenMode") { + return { ...state, listenMode: action.listenMode }; + } + return { ...state, customAddress: action.customAddress }; +} + export function NetworkSettingsCard({ available, saving, @@ -34,22 +66,26 @@ export function NetworkSettingsCard({ const gatewayMeta = useGatewayMeta(); const gateway = gatewayMeta.gateway; - const [listenMode, setListenMode] = useState(settings.gateway_listen_mode); - const [customAddress, setCustomAddress] = useState( - settings.gateway_custom_listen_address - ); + const nextDraftState = createNetworkDraftState(settings); + const [draftState, dispatchDraft] = useReducer(networkDraftReducer, nextDraftState); + const effectiveDraftState = + draftState.sourceKey === nextDraftState.sourceKey ? draftState : nextDraftState; + if (draftState.sourceKey !== nextDraftState.sourceKey) { + dispatchDraft({ type: "resetFromSettings", state: nextDraftState }); + } + const { listenMode, customAddress } = effectiveDraftState; const wslHostQuery = useWslHostAddressQuery({ enabled: available && listenMode === "wsl_auto", }); const wslHost = wslHostQuery.data ?? null; - useEffect(() => { - setListenMode(settings.gateway_listen_mode); - }, [settings.gateway_listen_mode]); + function setListenMode(listenMode: GatewayListenMode) { + dispatchDraft({ type: "setListenMode", listenMode }); + } - useEffect(() => { - setCustomAddress(settings.gateway_custom_listen_address); - }, [settings.gateway_custom_listen_address]); + function setCustomAddress(customAddress: string) { + dispatchDraft({ type: "setCustomAddress", customAddress }); + } const currentListenAddress = useMemo(() => { if (gateway?.running && gateway.listen_addr) return gateway.listen_addr; diff --git a/src/components/cli-manager/WslSettingsCard.tsx b/src/components/cli-manager/WslSettingsCard.tsx index cabe96bf..b31638bd 100644 --- a/src/components/cli-manager/WslSettingsCard.tsx +++ b/src/components/cli-manager/WslSettingsCard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useReducer, useRef, useState } from "react"; import { toast } from "sonner"; import type { AppSettings, WslHostAddressMode } from "../../services/settings/settings"; import { logToConsole } from "../../services/consoleLog"; @@ -24,11 +24,493 @@ export type WslSettingsCardProps = { settings: AppSettings; }; -export function WslSettingsCard({ available, saving, settings }: WslSettingsCardProps) { +type WslAddressDraftState = { + sourceKey: string; + hostAddressMode: WslHostAddressMode; + customHostAddress: string; +}; + +type WslAddressDraftAction = + | { type: "resetFromSettings"; state: WslAddressDraftState } + | { type: "setHostAddressMode"; hostAddressMode: WslHostAddressMode } + | { type: "setCustomHostAddress"; customHostAddress: string }; + +type WslStatusRow = NonNullable< + NonNullable["data"]>["statusRows"] +>[number]; + +type WslSettingsCardController = { + aboutOs: string | null; + checkedOnce: boolean; + codexHomeMode: AppSettings["codex_home_mode"]; + codexHostConfigPath: string; + codexWslSyncEnabled: boolean; + configuring: boolean; + customHostAddress: string; + detectionPresent: boolean; + distros: string[]; + effectiveHost: string; + hostAddressMode: WslHostAddressMode; + hostIp: string | null; + lastReport: WslConfigureReport | null; + listenModeIsLocalhost: boolean; + loading: boolean; + settingsMutating: boolean; + showListenModeDialog: boolean; + statusRows: WslStatusRow[] | null; + switchingListenMode: boolean; + wslDetected: boolean; + wslSupported: boolean; + commitCustomHostAddress: () => Promise; + commitHostAddressMode: (next: WslHostAddressMode) => Promise; + commitWslAutoConfig: (value: boolean) => Promise; + configureNow: () => Promise; + confirmSwitchListenMode: () => Promise; + refreshAll: () => Promise; + setCustomHostAddress: (customHostAddress: string) => void; + setShowListenModeDialog: (show: boolean) => void; +}; + +function createWslAddressDraftState(settings: AppSettings): WslAddressDraftState { + return { + sourceKey: `${settings.wsl_host_address_mode}:${settings.wsl_custom_host_address}`, + hostAddressMode: settings.wsl_host_address_mode, + customHostAddress: settings.wsl_custom_host_address, + }; +} + +function wslAddressDraftReducer( + state: WslAddressDraftState, + action: WslAddressDraftAction +): WslAddressDraftState { + if (action.type === "resetFromSettings") { + return action.state; + } + if (action.type === "setHostAddressMode") { + return { ...state, hostAddressMode: action.hostAddressMode }; + } + return { ...state, customHostAddress: action.customHostAddress }; +} + +function WslSettingsHeader({ + available, + loading, + onRefresh, +}: { + available: boolean; + loading: boolean; + onRefresh: () => void; +}) { + return ( +
+
+
+ + WSL 配置 +
+
+ +
+ ); +} + +function WslUnavailableMessage({ children }: { children: string }) { + return ( +
+ {children} +
+ ); +} + +function WslDetectionSummary({ + checkedOnce, + detectionPresent, + distros, + loading, + wslDetected, +}: { + checkedOnce: boolean; + detectionPresent: boolean; + distros: string[]; + loading: boolean; + wslDetected: boolean; +}) { + return ( + <> + +
+ + + {!checkedOnce + ? loading + ? "检测中..." + : "等待检测" + : wslDetected + ? "已检测到 WSL" + : "未检测到 WSL"} + + {checkedOnce && detectionPresent ? ( + ({distros.length} 个发行版) + ) : null} +
+
+ + {wslDetected && distros.length > 0 ? ( + +
+ {distros.map((d) => ( + + {d} + + ))} +
+
+ ) : null} + + ); +} + +function WslStatusCell({ + auth, + cliKey, + mcp, + prompt, +}: { + auth: boolean; + cliKey: string; + mcp: boolean; + prompt: boolean; +}) { + return ( + +
+
+ + ); +} + +function WslStatusTable({ statusRows }: { statusRows: WslStatusRow[] | null }) { + if (!statusRows || statusRows.length === 0) return null; + + return ( +
+
配置状态
+
+ + + + + + + + + + + {statusRows.map((row) => ( + + + + + + + ))} + +
发行版Claude CodeCodexGemini
+ {row.distro} +
+
+
+ + Auth + + + MCP + + + Prompt + + + 未配置 + +
+
+ ); +} + +function WslAutoConfigSection({ + disabled, + listenModeIsLocalhost, + settings, + onCommitWslAutoConfig, +}: { + disabled: boolean; + listenModeIsLocalhost: boolean; + settings: AppSettings; + onCommitWslAutoConfig: (value: boolean) => void; +}) { + return ( + <> +
+ + + +
+ +
+
+ + + {settings.wsl_auto_config + ? "已启用:应用启动时自动检测并配置 WSL 环境,修改相关设置时自动同步。" + : '未启用:WSL 不会在启动时自动配置,可使用下方"立即配置"按钮手动执行。'} + +
+ {listenModeIsLocalhost && settings.wsl_auto_config ? ( +
+ + 当前监听模式为"仅本地",WSL 无法访问网关。启动时会提示切换监听模式。 +
+ ) : null} +
+ + ); +} + +function WslCodexSyncTarget({ + codexHomeMode, + codexHostConfigPath, + codexWslSyncEnabled, +}: { + codexHomeMode: AppSettings["codex_home_mode"]; + codexHostConfigPath: string; + codexWslSyncEnabled: boolean; +}) { + return ( +
+
WSL 中的 Codex 同步目标
+
+ $CODEX_HOME/config.toml +
+
+ {codexWslSyncEnabled + ? "已纳入 WSL 自动同步。同步时,每个 distro 都会在自己的环境里独立解析 $CODEX_HOME/config.toml;如果没有设置,则回退到 ~/.codex/config.toml。" + : "当前未启用 Codex 的 WSL 自动同步。若后续启用,目标仍然是每个 distro 内独立解析出的 $CODEX_HOME/config.toml(未设置时回退到 ~/.codex/config.toml)。"} +
+
+ 仅 Windows 本机当前 + {codexHomeMode === "custom" + ? "使用自定义位置" + : codexHomeMode === "follow_codex_home" + ? "跟随 Windows 侧 $CODEX_HOME" + : "固定使用 Windows 当前用户目录"} + :{codexHostConfigPath} + 。这只影响 Windows 本机,不会覆盖 WSL 内的目标路径。 +
+
+ ); +} + +function WslConfigureActions({ + configuring, + saving, + statusRows, + onConfigureNow, +}: { + configuring: boolean; + saving: boolean; + statusRows: WslStatusRow[] | null; + onConfigureNow: () => void; +}) { + return ( + <> +
+
+ {statusRows ? ( + + 已检测到至少一个 CLI 已配置: + {statusRows.filter((r) => r.claude || r.codex || r.gemini).length}/{statusRows.length}{" "} + 个 distro + + ) : null} +
+ +
+ +
+ + + 同步时会自动将 MCP 服务器配置和提示词模板同步到 WSL。stdio 类型 MCP + 的命令路径会自动尝试转换(去除 .cmd/.bat 扩展名,Windows 绝对路径取文件名),但不保证 100% + 正确。 + +
+ + ); +} + +function WslAdvancedAddressOptions({ + customHostAddress, + disabled, + effectiveHost, + hostAddressMode, + hostIp, + onCommitCustomHostAddress, + onCommitHostAddressMode, + onCustomHostAddressChange, +}: { + customHostAddress: string; + disabled: boolean; + effectiveHost: string; + hostAddressMode: WslHostAddressMode; + hostIp: string | null; + onCommitCustomHostAddress: () => void; + onCommitHostAddressMode: (next: WslHostAddressMode) => void; + onCustomHostAddressChange: (value: string) => void; +}) { + return ( +
+ + 高级选项(地址兜底) + +
+
+ 当自动检测到的宿主机地址不可用(WSL 无法访问网关)时,可手动指定一个可用的 + host/IP;修改后通常需要重启应用/网关后生效。 +
+ + +
+ {effectiveHost} +
+
+ + +
+ {hostIp ?? "(未检测到)"} +
+
+ + + onCommitHostAddressMode(checked ? "custom" : "auto")} + disabled={disabled} + /> + + + {hostAddressMode === "custom" ? ( + + onCustomHostAddressChange(e.currentTarget.value)} + onBlur={onCommitCustomHostAddress} + disabled={disabled} + className="font-mono" + /> + + ) : null} +
+
+ ); +} + +function WslConfigureReportBanner({ report }: { report: WslConfigureReport | null }) { + if (!report) return null; + + return ( +
+ {report.message} +
+ ); +} + +function useWslSettingsCardController({ + available, + saving, + settings, +}: WslSettingsCardProps): WslSettingsCardController { const aboutQuery = useAppAboutQuery({ enabled: available }); const aboutOs = aboutQuery.data?.os ?? null; - const wslSupported = useMemo(() => aboutOs === "windows", [aboutOs]); + const wslSupported = aboutOs === "windows"; const settingsPatchMutation = useSettingsPatchMutation(); const settingsMutating = settingsPatchMutation.isPending; @@ -36,6 +518,8 @@ export function WslSettingsCard({ available, saving, settings }: WslSettingsCard const wslOverviewQuery = useWslOverviewQuery({ enabled: available && wslSupported, }); + const refetchWslOverviewRef = useRef(wslOverviewQuery.refetch); + refetchWslOverviewRef.current = wslOverviewQuery.refetch; const wslConfigureMutation = useWslConfigureClientsMutation(); const detection = wslOverviewQuery.data?.detection ?? null; @@ -62,18 +546,27 @@ export function WslSettingsCard({ available, saving, settings }: WslSettingsCard const wslDetected = Boolean(detection?.detected); const distros = detection?.distros ?? []; - const [hostAddressMode, setHostAddressMode] = useState( - settings.wsl_host_address_mode + const nextAddressDraftState = createWslAddressDraftState(settings); + const [addressDraftState, dispatchAddressDraft] = useReducer( + wslAddressDraftReducer, + nextAddressDraftState ); - const [customHostAddress, setCustomHostAddress] = useState(settings.wsl_custom_host_address); + const effectiveAddressDraftState = + addressDraftState.sourceKey === nextAddressDraftState.sourceKey + ? addressDraftState + : nextAddressDraftState; + if (addressDraftState.sourceKey !== nextAddressDraftState.sourceKey) { + dispatchAddressDraft({ type: "resetFromSettings", state: nextAddressDraftState }); + } + const { hostAddressMode, customHostAddress } = effectiveAddressDraftState; - useEffect(() => { - setHostAddressMode(settings.wsl_host_address_mode); - }, [settings.wsl_host_address_mode]); + function setHostAddressMode(hostAddressMode: WslHostAddressMode) { + dispatchAddressDraft({ type: "setHostAddressMode", hostAddressMode }); + } - useEffect(() => { - setCustomHostAddress(settings.wsl_custom_host_address); - }, [settings.wsl_custom_host_address]); + function setCustomHostAddress(customHostAddress: string) { + dispatchAddressDraft({ type: "setCustomHostAddress", customHostAddress }); + } // 监听后端启动时自动配置结果事件 + 监听模式切换提示 useEffect(() => { @@ -85,7 +578,7 @@ export function WslSettingsCard({ available, saving, settings }: WslSettingsCard void Promise.allSettled([ listenDesktopEvent("wsl:auto_config_result", (payload) => { setLastReport(payload); - void wslOverviewQuery.refetch(); + void refetchWslOverviewRef.current(); }), listenDesktopEvent("wsl:localhost_switch_prompt", () => { setShowListenModeDialog(true); @@ -129,7 +622,6 @@ export function WslSettingsCard({ available, saving, settings }: WslSettingsCard cancelled = true; cleanupFns.forEach((fn) => fn()); }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [available, wslSupported]); async function refreshAll() { @@ -271,312 +763,105 @@ export function WslSettingsCard({ available, saving, settings }: WslSettingsCard ? customHostAddress.trim() || "127.0.0.1" : (hostIp ?? "127.0.0.1"); + return { + aboutOs, + checkedOnce, + codexHomeMode, + codexHostConfigPath, + codexWslSyncEnabled, + configuring, + customHostAddress, + detectionPresent: Boolean(detection), + distros, + effectiveHost, + hostAddressMode, + hostIp, + lastReport, + listenModeIsLocalhost, + loading, + settingsMutating, + showListenModeDialog, + statusRows, + switchingListenMode, + wslDetected, + wslSupported, + commitCustomHostAddress, + commitHostAddressMode, + commitWslAutoConfig, + configureNow, + confirmSwitchListenMode, + refreshAll, + setCustomHostAddress, + setShowListenModeDialog, + }; +} + +export function WslSettingsCard({ available, saving, settings }: WslSettingsCardProps) { + const controller = useWslSettingsCardController({ available, saving, settings }); + return ( -
-
-
- - WSL 配置 -
-
- -
+ void controller.refreshAll()} + /> {!available ? ( -
- 数据不可用 -
- ) : aboutOs && !wslSupported ? ( -
- 仅 Windows 支持 WSL 配置 -
+ 数据不可用 + ) : controller.aboutOs && !controller.wslSupported ? ( + 仅 Windows 支持 WSL 配置 ) : (
- -
- - - {!checkedOnce - ? loading - ? "检测中..." - : "等待检测" - : wslDetected - ? "已检测到 WSL" - : "未检测到 WSL"} - - {checkedOnce && detection ? ( - ({distros.length} 个发行版) - ) : null} -
-
- - {wslDetected && distros.length > 0 ? ( - -
- {distros.map((d) => ( - - {d} - - ))} -
-
- ) : null} - - {statusRows && statusRows.length > 0 ? ( -
-
配置状态
-
- - - - - - - - - - - {statusRows.map((row) => ( - - - {( - [ - [ - "claude", - row.claude, - row.claude_mcp ?? false, - row.claude_prompt ?? false, - ], - ["codex", row.codex, row.codex_mcp ?? false, row.codex_prompt ?? false], - [ - "gemini", - row.gemini, - row.gemini_mcp ?? false, - row.gemini_prompt ?? false, - ], - ] as [string, boolean, boolean, boolean][] - ).map(([key, auth, mcp, prompt]) => ( - - ))} - - ))} - -
发行版Claude CodeCodexGemini
- {row.distro} - -
- - - -
-
-
-
- - Auth - - - MCP - - - Prompt - - - {" "} - 未配置 - -
-
- ) : null} - -
- - void commitWslAutoConfig(checked)} - disabled={saving || settingsMutating} - /> - -
- -
-
- - - {settings.wsl_auto_config - ? "已启用:应用启动时自动检测并配置 WSL 环境,修改相关设置时自动同步。" - : '未启用:WSL 不会在启动时自动配置,可使用下方"立即配置"按钮手动执行。'} - -
- {listenModeIsLocalhost && settings.wsl_auto_config ? ( -
- - 当前监听模式为"仅本地",WSL 无法访问网关。启动时会提示切换监听模式。 -
- ) : null} -
- -
-
- WSL 中的 Codex 同步目标 -
-
- $CODEX_HOME/config.toml -
-
- {codexWslSyncEnabled - ? "已纳入 WSL 自动同步。同步时,每个 distro 都会在自己的环境里独立解析 $CODEX_HOME/config.toml;如果没有设置,则回退到 ~/.codex/config.toml。" - : "当前未启用 Codex 的 WSL 自动同步。若后续启用,目标仍然是每个 distro 内独立解析出的 $CODEX_HOME/config.toml(未设置时回退到 ~/.codex/config.toml)。"} -
-
- 仅 Windows 本机当前 - {codexHomeMode === "custom" - ? "使用自定义位置" - : codexHomeMode === "follow_codex_home" - ? "跟随 Windows 侧 $CODEX_HOME" - : "固定使用 Windows 当前用户目录"} - :{codexHostConfigPath} - 。这只影响 Windows 本机,不会覆盖 WSL 内的目标路径。 -
-
- -
-
- {statusRows ? ( - - 已检测到至少一个 CLI 已配置: - {statusRows.filter((r) => r.claude || r.codex || r.gemini).length}/ - {statusRows.length} 个 distro - - ) : null} -
- -
- -
- - - 同步时会自动将 MCP 服务器配置和提示词模板同步到 WSL。stdio 类型 MCP - 的命令路径会自动尝试转换(去除 .cmd/.bat 扩展名,Windows 绝对路径取文件名),但不保证 - 100% 正确。 - -
- -
- - 高级选项(地址兜底) - -
-
- 当自动检测到的宿主机地址不可用(WSL 无法访问网关)时,可手动指定一个可用的 - host/IP;修改后通常需要重启应用/网关后生效。 -
- - -
- {effectiveHost} -
-
- - -
- {hostIp ?? "(未检测到)"} -
-
- - - - void commitHostAddressMode(checked ? "custom" : "auto") - } - disabled={saving || settingsMutating} - /> - - - {hostAddressMode === "custom" ? ( - - setCustomHostAddress(e.currentTarget.value)} - onBlur={() => void commitCustomHostAddress()} - disabled={saving || settingsMutating} - className="font-mono" - /> - - ) : null} -
-
- - {lastReport ? ( -
- {lastReport.message} -
- ) : null} + + + void controller.commitWslAutoConfig(checked)} + /> + + void controller.configureNow()} + /> + void controller.commitCustomHostAddress()} + onCommitHostAddressMode={(next) => void controller.commitHostAddressMode(next)} + onCustomHostAddressChange={controller.setCustomHostAddress} + /> +
)} setShowListenModeDialog(false)} - onConfirm={() => void confirmSwitchListenMode()} + onClose={() => controller.setShowListenModeDialog(false)} + onConfirm={() => void controller.confirmSwitchListenMode()} confirmLabel="切换" confirmingLabel="切换中..." - confirming={switchingListenMode} - disabled={saving || settingsMutating} + confirming={controller.switchingListenMode} + disabled={saving || controller.settingsMutating} />
); diff --git a/src/components/cli-manager/__tests__/WslSettingsCard.test.tsx b/src/components/cli-manager/__tests__/WslSettingsCard.test.tsx index 4bd07fc8..bf5a64a5 100644 --- a/src/components/cli-manager/__tests__/WslSettingsCard.test.tsx +++ b/src/components/cli-manager/__tests__/WslSettingsCard.test.tsx @@ -838,4 +838,165 @@ describe("components/cli-manager/WslSettingsCard", () => { fireEvent.click(screen.getByRole("button", { name: "切换" })); expect(settingsSetMutation.mutateAsync).not.toHaveBeenCalled(); }); + + it("confirms localhost listen-mode switch from desktop event", async () => { + const settingsSetMutation = { isPending: false, mutateAsync: vi.fn().mockResolvedValue({}) }; + vi.mocked(useSettingsPatchMutation).mockReturnValue(settingsSetMutation as any); + vi.mocked(useAppAboutQuery).mockReturnValue({ data: { os: "windows" } } as any); + vi.mocked(useWslOverviewQuery).mockReturnValue({ + data: { detection: { detected: true, distros: ["Ubuntu"] }, hostIp: null, statusRows: [] }, + isFetched: true, + isFetching: false, + refetch: vi.fn(), + } as any); + vi.mocked(useWslConfigureClientsMutation).mockReturnValue({ + isPending: false, + mutateAsync: vi.fn(), + } as any); + + render( + + ); + + await waitFor(() => { + expect(tauriListen).toHaveBeenCalledWith("wsl:localhost_switch_prompt", expect.any(Function)); + }); + + emitTauriEvent("wsl:localhost_switch_prompt", {}); + expect(await screen.findByText("检测到 WSL 环境")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "切换" })); + + await waitFor(() => { + expect(settingsSetMutation.mutateAsync).toHaveBeenCalledWith({ + gateway_listen_mode: "wsl_auto", + }); + }); + expect(toast).toHaveBeenCalledWith('已切换到"WSL 自动检测"模式'); + }); + + it("rolls back WSL address updates when settings persistence fails", async () => { + const settingsSetMutation = { isPending: false, mutateAsync: vi.fn() }; + settingsSetMutation.mutateAsync.mockRejectedValueOnce(new Error("no")); + vi.mocked(useSettingsPatchMutation).mockReturnValue(settingsSetMutation as any); + vi.mocked(useAppAboutQuery).mockReturnValue({ data: { os: "windows" } } as any); + vi.mocked(useWslOverviewQuery).mockReturnValue({ + data: { + detection: { detected: true, distros: ["Ubuntu"] }, + hostIp: "172.20.0.1", + statusRows: [], + }, + isFetched: true, + isFetching: false, + refetch: vi.fn(), + } as any); + vi.mocked(useWslConfigureClientsMutation).mockReturnValue({ + isPending: false, + mutateAsync: vi.fn(), + } as any); + + const settings = { + gateway_listen_mode: "wsl_auto", + wsl_auto_config: false, + wsl_target_cli: { claude: true, codex: false, gemini: false }, + codex_home_mode: "follow_codex_home", + codex_home_override: "", + wsl_host_address_mode: "custom", + wsl_custom_host_address: "127.0.0.1", + preferred_port: 37123, + auto_start: false, + log_retention_days: 7, + failover_max_attempts_per_provider: 5, + failover_max_providers_to_try: 5, + } as any; + + render(); + + fireEvent.click(screen.getByText("高级选项(地址兜底)")); + const input = screen.getByDisplayValue("127.0.0.1"); + fireEvent.change(input, { target: { value: "172.20.0.77" } }); + fireEvent.blur(input); + + await waitFor(() => { + expect(toast).toHaveBeenCalledWith("更新失败:请稍后重试"); + }); + expect(settingsSetMutation.mutateAsync).toHaveBeenCalledWith({ + wsl_host_address_mode: "custom", + wsl_custom_host_address: "172.20.0.77", + }); + }); + + it("persists auto-config switch and handles failures", async () => { + const settingsSetMutation = { isPending: false, mutateAsync: vi.fn() }; + settingsSetMutation.mutateAsync + .mockResolvedValueOnce({}) + .mockRejectedValueOnce(new Error("no")); + vi.mocked(useSettingsPatchMutation).mockReturnValue(settingsSetMutation as any); + vi.mocked(useAppAboutQuery).mockReturnValue({ data: { os: "windows" } } as any); + vi.mocked(useWslOverviewQuery).mockReturnValue({ + data: { detection: { detected: true, distros: ["Ubuntu"] }, hostIp: null, statusRows: [] }, + isFetched: true, + isFetching: false, + refetch: vi.fn(), + } as any); + vi.mocked(useWslConfigureClientsMutation).mockReturnValue({ + isPending: false, + mutateAsync: vi.fn(), + } as any); + + render( + + ); + + expect(screen.getByText(/当前未启用 Codex 的 WSL 自动同步/)).toBeInTheDocument(); + expect(screen.getByText(/使用自定义位置/)).toBeInTheDocument(); + + const autoConfigSwitch = screen.getAllByRole("switch")[0]; + fireEvent.click(autoConfigSwitch); + await waitFor(() => { + expect(settingsSetMutation.mutateAsync).toHaveBeenCalledWith({ wsl_auto_config: true }); + }); + expect(toast).toHaveBeenCalledWith("已保存"); + + vi.mocked(toast).mockClear(); + fireEvent.click(autoConfigSwitch); + await waitFor(() => { + expect(toast).toHaveBeenCalledWith("更新失败:请稍后重试"); + }); + }); }); diff --git a/src/components/cli-manager/tabs/ClaudeOAuthCard.tsx b/src/components/cli-manager/tabs/ClaudeOAuthCard.tsx index 3df335f8..4e61568d 100644 --- a/src/components/cli-manager/tabs/ClaudeOAuthCard.tsx +++ b/src/components/cli-manager/tabs/ClaudeOAuthCard.tsx @@ -17,6 +17,12 @@ export type ClaudeOAuthCardProps = { }; type OAuthStatus = NonNullable>>; +type OAuthStatusState = { + providerKey: string; + status: OAuthStatus | null; + statusLoading: boolean; + statusError: string | null; +}; function buildInitialStatus(provider: ProviderSummary): OAuthStatus { return { @@ -34,6 +40,27 @@ function formatExpiresAt(expiresAt: number | null | undefined) { return new Date(ts).toLocaleString("zh-CN", { hour12: false }); } +function buildProviderKey(provider: ProviderSummary | null) { + if (!provider) return "none"; + return [ + provider.id, + provider.oauth_provider_type ?? "", + provider.oauth_email ?? "", + provider.oauth_expires_at ?? "", + provider.oauth_last_error ?? "", + ].join(":"); +} + +function buildStatusState(provider: ProviderSummary | null): OAuthStatusState { + const providerKey = buildProviderKey(provider); + return { + providerKey, + status: provider ? buildInitialStatus(provider) : null, + statusLoading: Boolean(provider), + statusError: null, + }; +} + export function ClaudeOAuthCard({ providers }: ClaudeOAuthCardProps) { const oauthProvider = useMemo( () => @@ -42,52 +69,55 @@ export function ClaudeOAuthCard({ providers }: ClaudeOAuthCardProps) { ) ?? null, [providers] ); - const [status, setStatus] = useState( - oauthProvider ? buildInitialStatus(oauthProvider) : null - ); - const [statusLoading, setStatusLoading] = useState(false); + const providerKey = buildProviderKey(oauthProvider); + const [statusState, setStatusState] = useState(() => buildStatusState(oauthProvider)); + let effectiveStatusState = statusState; + + if (statusState.providerKey !== providerKey) { + effectiveStatusState = buildStatusState(oauthProvider); + setStatusState(effectiveStatusState); + } + const [actionLoading, setActionLoading] = useState<"login" | "refresh" | "disconnect" | null>( null ); - const [statusError, setStatusError] = useState(null); useEffect(() => { - if (!oauthProvider) { - setStatus(null); - setStatusError(null); - setStatusLoading(false); - return; - } + if (!oauthProvider) return; let cancelled = false; - setStatus(buildInitialStatus(oauthProvider)); - setStatusError(null); - setStatusLoading(true); - void providerOAuthStatus(oauthProvider.id) - .then((next) => { - if (cancelled || !next) return; - setStatus(next); - }) - .catch((error) => { + void (async () => { + try { + const next = await providerOAuthStatus(oauthProvider.id); if (cancelled) return; - setStatusError(error instanceof Error ? error.message : String(error)); - }) - .finally(() => { - if (!cancelled) { - setStatusLoading(false); - } - }); + setStatusState((current) => + current.providerKey === providerKey + ? { ...current, status: next ?? current.status, statusLoading: false } + : current + ); + } catch (error) { + if (cancelled) return; + setStatusState((current) => + current.providerKey === providerKey + ? { + ...current, + statusError: error instanceof Error ? error.message : String(error), + statusLoading: false, + } + : current + ); + } + })(); return () => { cancelled = true; }; - }, [oauthProvider]); + }, [oauthProvider, providerKey]); async function reloadStatus(providerId: number) { const next = await providerOAuthStatus(providerId); - setStatus(next); - setStatusError(null); + setStatusState((current) => ({ ...current, status: next, statusError: null })); return next; } @@ -113,6 +143,7 @@ export function ClaudeOAuthCard({ providers }: ClaudeOAuthCardProps) { // TypeScript doesn't narrow useMemo results across early returns, so re-bind. const provider = oauthProvider; + const { status, statusLoading, statusError } = effectiveStatusState; const connected = status?.connected ?? false; const busy = actionLoading !== null; const effectiveError = statusError ?? provider.oauth_last_error ?? null; @@ -159,14 +190,18 @@ export function ClaudeOAuthCard({ providers }: ClaudeOAuthCardProps) { toast.error("断开连接失败"); return; } - setStatus({ - connected: false, - provider_type: status?.provider_type ?? provider.oauth_provider_type ?? null, - email: null, - expires_at: null, - has_refresh_token: null, + setStatusState({ + providerKey, + status: { + connected: false, + provider_type: status?.provider_type ?? provider.oauth_provider_type ?? null, + email: null, + expires_at: null, + has_refresh_token: null, + }, + statusLoading: false, + statusError: null, }); - setStatusError(null); toast.success("已断开 Claude OAuth 连接"); } catch (error) { toast.error(error instanceof Error ? error.message : "断开连接失败"); diff --git a/src/components/cli-manager/tabs/ClaudeTab.tsx b/src/components/cli-manager/tabs/ClaudeTab.tsx index 93f10c0c..bf5854e8 100644 --- a/src/components/cli-manager/tabs/ClaudeTab.tsx +++ b/src/components/cli-manager/tabs/ClaudeTab.tsx @@ -1,6 +1,6 @@ // Usage: UI for configuring Claude Code global settings (settings.json) and safe env toggles. -import { useCallback, useEffect, useState, type ReactNode } from "react"; +import { useCallback, useId, useReducer, useState, type ReactNode } from "react"; import { toast } from "sonner"; import type { ClaudeCliInfo, @@ -90,10 +90,10 @@ function boolOrDefault(value: boolean | null | undefined, fallback: boolean) { } function parseLines(text: string): string[] { - return text - .split("\n") - .map((l) => l.trim()) - .filter(Boolean); + return text.split("\n").flatMap((l) => { + const line = l.trim(); + return line ? [line] : []; + }); } function PermissionTextareaItem({ @@ -298,6 +298,1041 @@ type HookEditorState = { timeout: string; }; +type ClaudeHookEditorIds = { + eventSelectId: string; + matcherInputId: string; + commandInputId: string; + timeoutInputId: string; +}; + +type ClaudeDraftKey = + | "modelText" + | "outputStyleText" + | "languageText" + | "mcpTimeoutMsText" + | "mcpToolTimeoutMsText" + | "autoCompactWindowText" + | "blockingLimitOverrideText" + | "maxOutputTokensText" + | "maxMcpOutputTokensText" + | "permissionsAllowText" + | "permissionsAskText" + | "permissionsDenyText"; + +type ClaudeDraftState = { + sourceKey: string; + values: Record; +}; + +type ClaudeDraftAction = + | { type: "resetFromSettings"; state: ClaudeDraftState } + | { type: "setValue"; key: ClaudeDraftKey; value: string }; + +const EMPTY_CLAUDE_DRAFT_VALUES: Record = { + modelText: "", + outputStyleText: "", + languageText: "", + mcpTimeoutMsText: "", + mcpToolTimeoutMsText: "", + autoCompactWindowText: "", + blockingLimitOverrideText: "", + maxOutputTokensText: "", + maxMcpOutputTokensText: "", + permissionsAllowText: "", + permissionsAskText: "", + permissionsDenyText: "", +}; + +function formatNullableNumber(value: number | null | undefined) { + return value == null ? "" : String(value); +} + +function createClaudeDraftState(claudeSettings: ClaudeSettingsState | null): ClaudeDraftState { + if (!claudeSettings) { + return { sourceKey: "empty", values: EMPTY_CLAUDE_DRAFT_VALUES }; + } + + const values: Record = { + modelText: claudeSettings.model ?? "", + outputStyleText: claudeSettings.output_style ?? "", + languageText: claudeSettings.language ?? "", + mcpTimeoutMsText: formatNullableNumber(claudeSettings.env_mcp_timeout_ms), + mcpToolTimeoutMsText: formatNullableNumber(claudeSettings.env_mcp_tool_timeout_ms), + autoCompactWindowText: formatNullableNumber(claudeSettings.env_claude_code_auto_compact_window), + blockingLimitOverrideText: formatNullableNumber( + claudeSettings.env_claude_code_blocking_limit_override + ), + maxOutputTokensText: formatNullableNumber(claudeSettings.env_claude_code_max_output_tokens), + maxMcpOutputTokensText: formatNullableNumber(claudeSettings.env_max_mcp_output_tokens), + permissionsAllowText: (claudeSettings.permissions_allow ?? []).join("\n"), + permissionsAskText: (claudeSettings.permissions_ask ?? []).join("\n"), + permissionsDenyText: (claudeSettings.permissions_deny ?? []).join("\n"), + }; + + return { + sourceKey: Object.values(values).join("\u0000"), + values, + }; +} + +function claudeDraftReducer(state: ClaudeDraftState, action: ClaudeDraftAction): ClaudeDraftState { + if (action.type === "resetFromSettings") { + return action.state; + } + return { + ...state, + values: { + ...state.values, + [action.key]: action.value, + }, + }; +} + +function ClaudeHooksToolbar({ + count, + loading, + hasError, + onCreate, + onRefresh, +}: { + count: number; + loading: boolean; + hasError: boolean; + onCreate: () => void; + onRefresh: () => void; +}) { + return ( +
+

+ + Claude Code Hooks ({count}) +

+
+ + +
+
+ ); +} + +function ClaudeHooksList({ + groups, + onDelete, + onEdit, +}: { + groups: ClaudeHookGroup[]; + onDelete: (index: number) => void; + onEdit: (index: number, hookIndex?: number) => void; +}) { + return ( +
+ {groups.map((group, index) => ( +
+
+
+ + {group.event} + + {group.matcher ? ( + + {group.matcher} + + ) : null} +
+ {group.hooks.map((hook, hookIndex) => ( +
+
+ {hook.command} + {hook.timeout != null ? ( + ({hook.timeout}s) + ) : null} +
+ {group.hooks.length > 1 ? ( + + ) : null} +
+ ))} +
+
+ + +
+
+ ))} +
+ ); +} + +function ClaudeHookEditorDialog({ + editor, + ids, + saving, + onClose, + onSave, + onUpdate, +}: { + editor: HookEditorState | null; + ids: ClaudeHookEditorIds; + saving: boolean; + onClose: () => void; + onSave: () => void; + onUpdate: (editor: HookEditorState) => void; +}) { + if (!editor) return null; + + return ( + { + if (!open && !saving) onClose(); + }} + title={editor.mode === "create" ? "添加 Hook" : "编辑 Hook"} + className="max-w-lg" + > +
+
+ + +
+
+ + onUpdate({ ...editor, matcher: event.target.value })} + placeholder="例如 Edit|Write 或留空" + className="text-sm" + /> +
+
+ + onUpdate({ ...editor, command: event.target.value })} + placeholder="要执行的 shell 命令" + className="font-mono text-sm" + /> +
+
+ + + onUpdate({ + ...editor, + timeout: event.target.value.replace(/[^0-9]/g, ""), + }) + } + placeholder="例如 30" + className="text-sm" + /> +
+
+ + +
+
+
+ ); +} + +function ClaudeHookDeleteDialog({ + deleteTarget, + groups, + saving, + onCancel, + onConfirm, +}: { + deleteTarget: number | null; + groups: ClaudeHookGroup[]; + saving: boolean; + onCancel: () => void; + onConfirm: () => void; +}) { + return ( + { + if (!open && !saving) onCancel(); + }} + title="确认删除 Hook" + description={ + deleteTarget != null && groups[deleteTarget] + ? `将删除事件 ${groups[deleteTarget].event} 的 Hook${ + groups[deleteTarget].matcher ? `(matcher: ${groups[deleteTarget].matcher})` : "" + }` + : undefined + } + className="max-w-lg" + > +
+ + +
+
+ ); +} + +function ClaudeHeader({ + claudeAvailable, + claudeInfo, + loading, + versionRefreshToken, + onRefresh, +}: { + claudeAvailable: CliManagerAvailability; + claudeInfo: ClaudeCliInfo | null; + loading: boolean; + versionRefreshToken: number; + onRefresh: () => void; +}) { + return ( +
+
+
+ +
+
+

Claude Code

+
+ {claudeAvailable === "available" && claudeInfo?.found ? ( + <> + + + 已安装 {claudeInfo.version} + + + + ) : claudeAvailable === "checking" || loading ? ( + + + 加载中... + + ) : ( + + 未检测到 + + )} +
+
+
+ + +
+ ); +} + +function ClaudeInfoGrid({ + claudeInfo, + claudeSettings, + configDir, + settingsPath, + onOpenConfigDir, +}: { + claudeInfo: ClaudeCliInfo | null; + claudeSettings: ClaudeSettingsState | null; + configDir?: string | null; + settingsPath?: string | null; + onOpenConfigDir: () => void; +}) { + if (!configDir && !settingsPath && !claudeInfo) return null; + + return ( +
+
+
+ + 配置目录 +
+
+
+ {configDir ?? "—"} +
+ +
+
+ +
+
+ + settings.json +
+
+ {settingsPath ?? "—"} +
+ {claudeSettings ? ( +
+ {claudeSettings.exists ? "已存在" : "不存在(将自动创建)"} +
+ ) : null} +
+ +
+
+ + 可执行文件 +
+
+ {claudeInfo?.executable_path ?? "—"} +
+
+ +
+
+ + 解析方式 +
+
+ {claudeInfo?.resolved_via ?? "—"} +
+
+ SHELL: {claudeInfo?.shell ?? "—"} +
+
+
+ ); +} + +function ClaudeBasicSettingsSection({ + claudeSettings, + draftValues, + saving, + persistClaudeSettings, + setDraftValue, +}: { + claudeSettings: ClaudeSettingsState; + draftValues: Record; + saving: boolean; + persistClaudeSettings: (patch: ClaudeSettingsPatch) => Promise | void; + setDraftValue: (key: ClaudeDraftKey, value: string) => void; +}) { + return ( +
+

+ + 基础配置 +

+
+ + setDraftValue("modelText", e.currentTarget.value)} + onBlur={() => void persistClaudeSettings({ model: draftValues.modelText.trim() })} + placeholder="例如:claude-sonnet-4-5-20250929" + className="font-mono w-[320px] max-w-full" + disabled={saving} + /> + + + + setDraftValue("outputStyleText", e.currentTarget.value)} + onBlur={() => + void persistClaudeSettings({ output_style: draftValues.outputStyleText.trim() }) + } + placeholder='例如:"Explanatory"' + className="font-mono w-[320px] max-w-full" + disabled={saving} + /> + + + + setDraftValue("languageText", e.currentTarget.value)} + onBlur={() => void persistClaudeSettings({ language: draftValues.languageText.trim() })} + placeholder='例如:"japanese"' + className="font-mono w-[320px] max-w-full" + disabled={saving} + /> + + + + + void persistClaudeSettings({ always_thinking_enabled: checked }) + } + disabled={saving} + /> + +
+
+ ); +} + +function ClaudeInteractionSettingsSection({ + claudeSettings, + saving, + persistClaudeSettings, +}: { + claudeSettings: ClaudeSettingsState; + saving: boolean; + persistClaudeSettings: (patch: ClaudeSettingsPatch) => Promise | void; +}) { + return ( +
+

+ + 交互与显示 +

+
+ + + void persistClaudeSettings({ show_turn_duration: checked }) + } + disabled={saving} + /> + + + + + void persistClaudeSettings({ spinner_tips_enabled: checked }) + } + disabled={saving} + /> + + + + + void persistClaudeSettings({ terminal_progress_bar_enabled: checked }) + } + disabled={saving} + /> + + + + + void persistClaudeSettings({ respect_gitignore: checked }) + } + disabled={saving} + /> + + + + + void persistClaudeSettings({ disable_git_participant: checked }) + } + disabled={saving} + /> + +
+
+ ); +} + +function ClaudePermissionsSection({ + draftValues, + saving, + persistClaudeSettings, + setDraftValue, +}: { + draftValues: Record; + saving: boolean; + persistClaudeSettings: (patch: ClaudeSettingsPatch) => Promise | void; + setDraftValue: (key: ClaudeDraftKey, value: string) => void; +}) { + return ( +
+

+ + Permissions +

+
+ setDraftValue("permissionsAllowText", value)} + onPersist={(lines) => void persistClaudeSettings({ permissions_allow: lines })} + disabled={saving} + placeholder={"例如:\nBash(git diff:*)\nRead(./docs/**)"} + /> + + setDraftValue("permissionsAskText", value)} + onPersist={(lines) => void persistClaudeSettings({ permissions_ask: lines })} + disabled={saving} + placeholder={"例如:\nBash(git push:*)"} + /> + + setDraftValue("permissionsDenyText", value)} + onPersist={(lines) => void persistClaudeSettings({ permissions_deny: lines })} + disabled={saving} + placeholder={"例如:\nRead(./.env)\nRead(./secrets/**)\nBash(rm -rf:*)"} + /> +
+
+ ); +} + +function ClaudeExperimentalSection({ + claudeSettings, + maxMcpOutputTokensText, + saving, + persistClaudeSettings, + revertMaxMcpOutputTokensInput, + setMaxMcpOutputTokensText, +}: { + claudeSettings: ClaudeSettingsState; + maxMcpOutputTokensText: string; + saving: boolean; + persistClaudeSettings: (patch: ClaudeSettingsPatch) => Promise | void; + revertMaxMcpOutputTokensInput: () => void; + setMaxMcpOutputTokensText: (value: string) => void; +}) { + return ( +
+

+ + 实验性功能 +

+

+ 以下功能为实验性质,可能随时变更或移除。 +

+
+ + { + if (checked) { + void persistClaudeSettings({ + env_enable_experimental_mcp_cli: true, + env_enable_tool_search: false, + }); + } else { + void persistClaudeSettings({ env_enable_experimental_mcp_cli: false }); + } + }} + disabled={saving} + /> + + + + { + if (checked) { + void persistClaudeSettings({ + env_enable_tool_search: true, + env_enable_experimental_mcp_cli: false, + }); + } else { + void persistClaudeSettings({ env_enable_tool_search: false }); + } + }} + disabled={saving} + /> + + + +
+
+ ); +} + +function ClaudeEnvironmentSection({ + claudeSettings, + draftValues, + maxTimeoutMs, + saving, + normalizeTimeoutMsOrZero, + persistClaudeSettings, + revertAutoCompactWindowInput, + revertBlockingLimitOverrideInput, + revertMaxOutputTokensInput, + revertTimeoutInputs, + setDraftValue, +}: { + claudeSettings: ClaudeSettingsState; + draftValues: Record; + maxTimeoutMs: number; + saving: boolean; + normalizeTimeoutMsOrZero: (raw: string) => number; + persistClaudeSettings: (patch: ClaudeSettingsPatch) => Promise | void; + revertAutoCompactWindowInput: () => void; + revertBlockingLimitOverrideInput: () => void; + revertMaxOutputTokensInput: () => void; + revertTimeoutInputs: () => void; + setDraftValue: (key: ClaudeDraftKey, value: string) => void; +}) { + return ( +
+

+ + 环境配置(env / 白名单) +

+
+ + + void persistClaudeSettings({ env_experimental_agent_teams: checked }) + } + disabled={saving} + /> + + + setDraftValue("mcpTimeoutMsText", value)} + patchKey="env_mcp_timeout_ms" + maxTimeoutMs={maxTimeoutMs} + disabled={saving} + normalizeTimeoutMsOrZero={normalizeTimeoutMsOrZero} + revert={revertTimeoutInputs} + persist={persistClaudeSettings} + /> + + setDraftValue("mcpToolTimeoutMsText", value)} + patchKey="env_mcp_tool_timeout_ms" + maxTimeoutMs={maxTimeoutMs} + disabled={saving} + normalizeTimeoutMsOrZero={normalizeTimeoutMsOrZero} + revert={revertTimeoutInputs} + persist={persistClaudeSettings} + /> + + setDraftValue("autoCompactWindowText", value)} + patchKey="env_claude_code_auto_compact_window" + disabled={saving} + revert={revertAutoCompactWindowInput} + persist={persistClaudeSettings} + placeholder="例如: 200000" + /> + + setDraftValue("blockingLimitOverrideText", value)} + patchKey="env_claude_code_blocking_limit_override" + disabled={saving} + revert={revertBlockingLimitOverrideInput} + persist={persistClaudeSettings} + placeholder="例如:193000" + /> + + setDraftValue("maxOutputTokensText", value)} + patchKey="env_claude_code_max_output_tokens" + disabled={saving} + revert={revertMaxOutputTokensInput} + persist={persistClaudeSettings} + placeholder="默认" + /> + + + + void persistClaudeSettings({ env_claude_code_attribution_header: checked }) + } + disabled={saving} + /> + + + + + void persistClaudeSettings({ env_disable_background_tasks: checked }) + } + disabled={saving} + /> + + + + + void persistClaudeSettings({ env_disable_terminal_title: checked }) + } + disabled={saving} + /> + + + + + void persistClaudeSettings({ env_claude_bash_no_login: checked }) + } + disabled={saving} + /> + + + + + void persistClaudeSettings({ env_claude_code_disable_nonessential_traffic: checked }) + } + disabled={saving} + /> + + + + + void persistClaudeSettings({ env_claude_code_proxy_resolves_hosts: checked }) + } + disabled={saving} + /> + + + + + void persistClaudeSettings({ env_claude_code_disable_1m_context: checked }) + } + disabled={saving} + /> + + + + + void persistClaudeSettings({ env_claude_code_skip_prompt_history: checked }) + } + disabled={saving} + /> + +
+
+ ); +} + function ClaudeHooksSection() { const hooksQuery = useCliManagerClaudeHooksQuery(); const hooksMutation = useCliManagerClaudeHooksSetMutation(); @@ -306,6 +1341,13 @@ function ClaudeHooksSection() { const loading = hooksQuery.isLoading; const loadError = hooksQuery.isError ? String(hooksQuery.error) : ""; const saving = hooksMutation.isPending; + const hookEditorIdPrefix = useId(); + const editorIds: ClaudeHookEditorIds = { + eventSelectId: `${hookEditorIdPrefix}-hook-event`, + matcherInputId: `${hookEditorIdPrefix}-hook-matcher`, + commandInputId: `${hookEditorIdPrefix}-hook-command`, + timeoutInputId: `${hookEditorIdPrefix}-hook-timeout`, + }; const [editor, setEditor] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); @@ -402,34 +1444,13 @@ function ClaudeHooksSection() { return (
-
-

- - Claude Code Hooks ({groups.length}) -

-
- - -
-
+ void hooksQuery.refetch()} + /> {loading ? (
@@ -449,183 +1470,25 @@ function ClaudeHooksSection() { variant="dashed" /> ) : ( -
- {groups.map((group, index) => ( -
-
-
- - {group.event} - - {group.matcher ? ( - - {group.matcher} - - ) : null} -
- {group.hooks.map((hook, hookIndex) => ( -
-
- {hook.command} - {hook.timeout != null ? ( - ({hook.timeout}s) - ) : null} -
- {group.hooks.length > 1 ? ( - - ) : null} -
- ))} -
-
- - -
-
- ))} -
+ )} - {editor ? ( - { - if (!open && !saving) setEditor(null); - }} - title={editor.mode === "create" ? "添加 Hook" : "编辑 Hook"} - className="max-w-lg" - > -
-
- - -
-
- - setEditor({ ...editor, matcher: event.target.value })} - placeholder="例如 Edit|Write 或留空" - className="text-sm" - /> -
-
- - setEditor({ ...editor, command: event.target.value })} - placeholder="要执行的 shell 命令" - className="font-mono text-sm" - /> -
-
- - - setEditor({ - ...editor, - timeout: event.target.value.replace(/[^0-9]/g, ""), - }) - } - placeholder="例如 30" - className="text-sm" - /> -
-
- - -
-
-
- ) : null} + setEditor(null)} + onSave={() => void handleSave()} + onUpdate={setEditor} + /> - { - if (!open && !saving) setDeleteTarget(null); - }} - title="确认删除 Hook" - description={ - deleteTarget != null && groups[deleteTarget] - ? `将删除事件 ${groups[deleteTarget].event} 的 Hook${ - groups[deleteTarget].matcher ? `(matcher: ${groups[deleteTarget].matcher})` : "" - }` - : undefined - } - className="max-w-lg" - > -
- - -
-
+ setDeleteTarget(null)} + onConfirm={() => void handleDelete()} + />
); } @@ -642,55 +1505,14 @@ export function CliManagerClaudeTab({ persistClaudeSettings, }: CliManagerClaudeTabProps) { const [versionRefreshToken, setVersionRefreshToken] = useState(0); - const [modelText, setModelText] = useState(""); - const [outputStyleText, setOutputStyleText] = useState(""); - const [languageText, setLanguageText] = useState(""); - const [mcpTimeoutMsText, setMcpTimeoutMsText] = useState(""); - const [mcpToolTimeoutMsText, setMcpToolTimeoutMsText] = useState(""); - const [autoCompactWindowText, setAutoCompactWindowText] = useState(""); - const [blockingLimitOverrideText, setBlockingLimitOverrideText] = useState(""); - const [maxOutputTokensText, setMaxOutputTokensText] = useState(""); - const [maxMcpOutputTokensText, setMaxMcpOutputTokensText] = useState(""); - const [permissionsAllowText, setPermissionsAllowText] = useState(""); - const [permissionsAskText, setPermissionsAskText] = useState(""); - const [permissionsDenyText, setPermissionsDenyText] = useState(""); - useEffect(() => { - if (!claudeSettings) return; - setModelText(claudeSettings.model ?? ""); - setOutputStyleText(claudeSettings.output_style ?? ""); - setLanguageText(claudeSettings.language ?? ""); - setMcpTimeoutMsText( - claudeSettings.env_mcp_timeout_ms == null ? "" : String(claudeSettings.env_mcp_timeout_ms) - ); - setMcpToolTimeoutMsText( - claudeSettings.env_mcp_tool_timeout_ms == null - ? "" - : String(claudeSettings.env_mcp_tool_timeout_ms) - ); - setAutoCompactWindowText( - claudeSettings.env_claude_code_auto_compact_window == null - ? "" - : String(claudeSettings.env_claude_code_auto_compact_window) - ); - setBlockingLimitOverrideText( - claudeSettings.env_claude_code_blocking_limit_override == null - ? "" - : String(claudeSettings.env_claude_code_blocking_limit_override) - ); - setMaxOutputTokensText( - claudeSettings.env_claude_code_max_output_tokens == null - ? "" - : String(claudeSettings.env_claude_code_max_output_tokens) - ); - setMaxMcpOutputTokensText( - claudeSettings.env_max_mcp_output_tokens == null - ? "" - : String(claudeSettings.env_max_mcp_output_tokens) - ); - setPermissionsAllowText((claudeSettings.permissions_allow ?? []).join("\n")); - setPermissionsAskText((claudeSettings.permissions_ask ?? []).join("\n")); - setPermissionsDenyText((claudeSettings.permissions_deny ?? []).join("\n")); - }, [claudeSettings]); + const nextDraftState = createClaudeDraftState(claudeSettings); + const [draftState, dispatchDraft] = useReducer(claudeDraftReducer, nextDraftState); + const effectiveDraftState = + draftState.sourceKey === nextDraftState.sourceKey ? draftState : nextDraftState; + if (draftState.sourceKey !== nextDraftState.sourceKey) { + dispatchDraft({ type: "resetFromSettings", state: nextDraftState }); + } + const { maxMcpOutputTokensText } = effectiveDraftState.values; const loading = claudeLoading || claudeSettingsLoading; const saving = claudeSettingsSaving; @@ -706,6 +1528,34 @@ export function CliManagerClaudeTab({ } } + function setDraftValue(key: ClaudeDraftKey, value: string) { + dispatchDraft({ type: "setValue", key, value }); + } + + function setMcpTimeoutMsText(value: string) { + setDraftValue("mcpTimeoutMsText", value); + } + + function setMcpToolTimeoutMsText(value: string) { + setDraftValue("mcpToolTimeoutMsText", value); + } + + function setAutoCompactWindowText(value: string) { + setDraftValue("autoCompactWindowText", value); + } + + function setBlockingLimitOverrideText(value: string) { + setDraftValue("blockingLimitOverrideText", value); + } + + function setMaxOutputTokensText(value: string) { + setDraftValue("maxOutputTokensText", value); + } + + function setMaxMcpOutputTokensText(value: string) { + setDraftValue("maxMcpOutputTokensText", value); + } + const MAX_TIMEOUT_MS = 24 * 60 * 60 * 1000; function normalizeTimeoutMsOrZero(raw: string): number { const trimmed = raw.trim(); @@ -769,128 +1619,20 @@ export function CliManagerClaudeTab({
-
-
-
- -
-
-

Claude Code

-
- {claudeAvailable === "available" && claudeInfo?.found ? ( - <> - - - 已安装 {claudeInfo.version} - - - - ) : claudeAvailable === "checking" || loading ? ( - - - 加载中... - - ) : ( - - 未检测到 - - )} -
-
-
- - -
- - {(configDir || settingsPath || claudeInfo) && ( -
-
-
- - 配置目录 -
-
-
- {configDir ?? "—"} -
- -
-
- -
-
- - settings.json -
-
- {settingsPath ?? "—"} -
- {claudeSettings ? ( -
- {claudeSettings.exists ? "已存在" : "不存在(将自动创建)"} -
- ) : null} -
- -
-
- - 可执行文件 -
-
- {claudeInfo?.executable_path ?? "—"} -
-
- -
-
- - 解析方式 -
-
- {claudeInfo?.resolved_via ?? "—"} -
-
- SHELL: {claudeInfo?.shell ?? "—"} -
-
-
- )} + void refreshClaudeStatus()} + /> + void openClaudeConfigDir()} + />
@@ -904,437 +1646,51 @@ export function CliManagerClaudeTab({
) : (
-
-

- - 基础配置 -

-
- - setModelText(e.currentTarget.value)} - onBlur={() => void persistClaudeSettings({ model: modelText.trim() })} - placeholder="例如:claude-sonnet-4-5-20250929" - className="font-mono w-[320px] max-w-full" - disabled={saving} - /> - - - - setOutputStyleText(e.currentTarget.value)} - onBlur={() => - void persistClaudeSettings({ output_style: outputStyleText.trim() }) - } - placeholder='例如:"Explanatory"' - className="font-mono w-[320px] max-w-full" - disabled={saving} - /> - - - - setLanguageText(e.currentTarget.value)} - onBlur={() => void persistClaudeSettings({ language: languageText.trim() })} - placeholder='例如:"japanese"' - className="font-mono w-[320px] max-w-full" - disabled={saving} - /> - - - - - void persistClaudeSettings({ always_thinking_enabled: checked }) - } - disabled={saving} - /> - -
-
- -
-

- - 交互与显示 -

-
- - - void persistClaudeSettings({ show_turn_duration: checked }) - } - disabled={saving} - /> - - - - - void persistClaudeSettings({ spinner_tips_enabled: checked }) - } - disabled={saving} - /> - - - - - void persistClaudeSettings({ terminal_progress_bar_enabled: checked }) - } - disabled={saving} - /> - - - - - void persistClaudeSettings({ respect_gitignore: checked }) - } - disabled={saving} - /> - - - - - void persistClaudeSettings({ disable_git_participant: checked }) - } - disabled={saving} - /> - -
-
- -
-

- - Permissions -

-
- void persistClaudeSettings({ permissions_allow: lines })} - disabled={saving} - placeholder={"例如:\nBash(git diff:*)\nRead(./docs/**)"} - /> - - void persistClaudeSettings({ permissions_ask: lines })} - disabled={saving} - placeholder={"例如:\nBash(git push:*)"} - /> - - void persistClaudeSettings({ permissions_deny: lines })} - disabled={saving} - placeholder={"例如:\nRead(./.env)\nRead(./secrets/**)\nBash(rm -rf:*)"} - /> -
-
+ + + + + -
-

- - 实验性功能 -

-

- 以下功能为实验性质,可能随时变更或移除。 -

-
- - { - if (checked) { - void persistClaudeSettings({ - env_enable_experimental_mcp_cli: true, - env_enable_tool_search: false, - }); - } else { - void persistClaudeSettings({ env_enable_experimental_mcp_cli: false }); - } - }} - disabled={saving} - /> - - - - { - if (checked) { - void persistClaudeSettings({ - env_enable_tool_search: true, - env_enable_experimental_mcp_cli: false, - }); - } else { - void persistClaudeSettings({ env_enable_tool_search: false }); - } - }} - disabled={saving} - /> - - - -
-
- -
-

- - 环境配置(env / 白名单) -

-
- - - void persistClaudeSettings({ env_experimental_agent_teams: checked }) - } - disabled={saving} - /> - - - - - - - - - - - - - - - void persistClaudeSettings({ - env_claude_code_attribution_header: checked, - }) - } - disabled={saving} - /> - - - - - void persistClaudeSettings({ env_disable_background_tasks: checked }) - } - disabled={saving} - /> - - - - - void persistClaudeSettings({ env_disable_terminal_title: checked }) - } - disabled={saving} - /> - - - - - void persistClaudeSettings({ env_claude_bash_no_login: checked }) - } - disabled={saving} - /> - - - - - void persistClaudeSettings({ - env_claude_code_disable_nonessential_traffic: checked, - }) - } - disabled={saving} - /> - - - - - void persistClaudeSettings({ env_claude_code_proxy_resolves_hosts: checked }) - } - disabled={saving} - /> - - - - - void persistClaudeSettings({ env_claude_code_disable_1m_context: checked }) - } - disabled={saving} - /> - - - - - void persistClaudeSettings({ env_claude_code_skip_prompt_history: checked }) - } - disabled={saving} - /> - -
-
+ + +
)} diff --git a/src/components/cli-manager/tabs/CodexTab.tsx b/src/components/cli-manager/tabs/CodexTab.tsx index 982ff517..e2b14669 100644 --- a/src/components/cli-manager/tabs/CodexTab.tsx +++ b/src/components/cli-manager/tabs/CodexTab.tsx @@ -3,9 +3,10 @@ import { Suspense, useCallback, useEffect, + useId, useMemo, + useReducer, useRef, - useState, type ReactNode, } from "react"; import { @@ -115,6 +116,173 @@ function normalizeComparablePath(path: string) { .toLowerCase(); } +type CodexConfigDraft = { + configKey: string; + modelText: string; + contextWindowText: string; + autoCompactLimitText: string; + sandboxModeText: string; + webSearchText: string; + personalityText: string; + reasoningEffortText: string; + planModeReasoningEffortText: string; +}; + +function buildCodexConfigKey(codexConfig: CodexConfigState | null) { + if (!codexConfig) return "none"; + return [ + codexConfig.model ?? "", + codexConfig.model_context_window ?? "", + codexConfig.model_auto_compact_token_limit ?? "", + codexConfig.sandbox_mode ?? "", + codexConfig.web_search ?? "", + codexConfig.personality ?? "", + codexConfig.model_reasoning_effort ?? "", + codexConfig.plan_mode_reasoning_effort ?? "", + ].join("\u0000"); +} + +function buildCodexConfigDraft(codexConfig: CodexConfigState | null): CodexConfigDraft { + return { + configKey: buildCodexConfigKey(codexConfig), + modelText: codexConfig?.model ?? "", + contextWindowText: + codexConfig?.model_context_window != null ? String(codexConfig.model_context_window) : "", + autoCompactLimitText: + codexConfig?.model_auto_compact_token_limit != null + ? String(codexConfig.model_auto_compact_token_limit) + : "", + sandboxModeText: codexConfig?.sandbox_mode ?? "", + webSearchText: codexConfig?.web_search ?? "cached", + personalityText: codexConfig?.personality?.trim() || "none", + reasoningEffortText: codexConfig?.model_reasoning_effort ?? "", + planModeReasoningEffortText: codexConfig?.plan_mode_reasoning_effort ?? "", + }; +} + +type ConfigLocationDraft = { + settingsKey: string; + configLocationMode: CodexHomeMode; + customHomeText: string; + configLocationError: string | null; +}; + +function readConfigLocationSettings(appSettings: AppSettings | null | undefined) { + const savedOverride = appSettings?.codex_home_override?.trim() ?? ""; + const savedMode = + appSettings?.codex_home_mode ?? (savedOverride ? "custom" : "user_home_default"); + return { savedMode, savedOverride }; +} + +function buildConfigLocationKey(appSettings: AppSettings | null | undefined) { + const { savedMode, savedOverride } = readConfigLocationSettings(appSettings); + return [savedMode, savedOverride].join("\u0000"); +} + +function buildConfigLocationDraft( + appSettings: AppSettings | null | undefined +): ConfigLocationDraft { + const { savedMode, savedOverride } = readConfigLocationSettings(appSettings); + return { + settingsKey: buildConfigLocationKey(appSettings), + configLocationMode: savedMode, + customHomeText: savedOverride, + configLocationError: null, + }; +} + +type TomlDraftState = { + sourceKey: string; + configPath: string | null; + tomlDraft: string; + tomlDirty: boolean; + tomlValidating: boolean; + tomlValidation: CodexConfigTomlValidationResult | null; + tomlEditEnabled: boolean; +}; + +function buildTomlSourceKey(codexConfigToml: CodexConfigTomlState | null) { + if (!codexConfigToml) return "none"; + return [codexConfigToml.config_path ?? "", codexConfigToml.toml ?? ""].join("\u0000"); +} + +function buildTomlDraftState(codexConfigToml: CodexConfigTomlState | null): TomlDraftState { + return { + sourceKey: buildTomlSourceKey(codexConfigToml), + configPath: codexConfigToml?.config_path ?? null, + tomlDraft: codexConfigToml?.toml ?? "", + tomlDirty: false, + tomlValidating: false, + tomlValidation: null, + tomlEditEnabled: false, + }; +} + +type CodexTabUiState = { + versionRefreshToken: number; + codexDraft: CodexConfigDraft; + configLocationDraft: ConfigLocationDraft; + selectingCodexHomeDir: boolean; + tomlAdvancedOpen: boolean; + tomlState: TomlDraftState; +}; + +type CodexTabUiAction = + | { type: "incrementVersionRefreshToken" } + | { type: "setCodexDraft"; draft: CodexConfigDraft } + | { type: "patchCodexDraft"; patch: Partial> } + | { type: "setConfigLocationDraft"; draft: ConfigLocationDraft } + | { type: "patchConfigLocationDraft"; patch: Partial> } + | { type: "setSelectingCodexHomeDir"; value: boolean } + | { type: "setTomlAdvancedOpen"; value: boolean } + | { type: "setTomlState"; state: TomlDraftState } + | { type: "patchTomlState"; patch: Partial> }; + +function initCodexTabUiState({ + codexConfig, + codexConfigToml, + appSettings, +}: { + codexConfig: CodexConfigState | null; + codexConfigToml: CodexConfigTomlState | null; + appSettings: AppSettings | null | undefined; +}): CodexTabUiState { + return { + versionRefreshToken: 0, + codexDraft: buildCodexConfigDraft(codexConfig), + configLocationDraft: buildConfigLocationDraft(appSettings), + selectingCodexHomeDir: false, + tomlAdvancedOpen: false, + tomlState: buildTomlDraftState(codexConfigToml), + }; +} + +function codexTabUiReducer(state: CodexTabUiState, action: CodexTabUiAction): CodexTabUiState { + switch (action.type) { + case "incrementVersionRefreshToken": + return { ...state, versionRefreshToken: state.versionRefreshToken + 1 }; + case "setCodexDraft": + return { ...state, codexDraft: action.draft }; + case "patchCodexDraft": + return { ...state, codexDraft: { ...state.codexDraft, ...action.patch } }; + case "setConfigLocationDraft": + return { ...state, configLocationDraft: action.draft }; + case "patchConfigLocationDraft": + return { + ...state, + configLocationDraft: { ...state.configLocationDraft, ...action.patch }, + }; + case "setSelectingCodexHomeDir": + return { ...state, selectingCodexHomeDir: action.value }; + case "setTomlAdvancedOpen": + return { ...state, tomlAdvancedOpen: action.value }; + case "setTomlState": + return { ...state, tomlState: action.state }; + case "patchTomlState": + return { ...state, tomlState: { ...state.tomlState, ...action.patch } }; + } +} + export type CliManagerAvailability = "checking" | "available" | "unavailable"; export type CliManagerCodexTabProps = { @@ -176,112 +344,1167 @@ function enumOrDefault(value: string | null, fallback: string) { return (value ?? fallback).trim(); } -export function CliManagerCodexTab({ +type UpdateCodexDraft = (patch: Partial>) => void; +type UpdateConfigLocationDraft = (patch: Partial>) => void; +type UpdateTomlState = (patch: Partial>) => void; + +function CodexHeader({ codexAvailable, + codexInfo, + loading, + versionRefreshToken, + refreshCodexStatus, +}: { + codexAvailable: CliManagerAvailability; + codexInfo: SimpleCliInfo | null; + loading: boolean; + versionRefreshToken: number; + refreshCodexStatus: () => Promise; +}) { + return ( +
+
+
+ +
+
+

Codex

+
+ {codexAvailable === "available" && codexInfo?.found ? ( + <> + + + 已安装 {codexInfo.version} + + + + ) : codexAvailable === "checking" || loading ? ( + + + 加载中... + + ) : ( + + 未检测到 + + )} +
+
+
+ + +
+ ); +} + +function CodexInfoGrid({ + codexConfig, + codexInfo, + activeConfigDirSummaryText, + openCodexConfigDir, +}: { + codexConfig: CodexConfigState; + codexInfo: SimpleCliInfo | null; + activeConfigDirSummaryText: string; + openCodexConfigDir: () => Promise | void; +}) { + return ( +
+
+
+ + 当前 .codex 目录 +
+
+
+ {codexConfig.config_dir} +
+ +
+
{activeConfigDirSummaryText}
+ {!codexConfig.can_open_config_dir ? ( +
+ 受权限限制,无法自动打开该目录;请手动打开该路径。 +
+ ) : null} +
+ +
+
+ + config.toml +
+
+ {codexConfig.config_path} +
+
+ {codexConfig.exists ? "已存在" : "不存在(将自动创建)"} +
+
+ +
+
+ + 可执行文件 +
+
+ {codexInfo?.executable_path ?? "—"} +
+
+ +
+
+ + 解析方式 +
+
+ {codexInfo?.resolved_via ?? "—"} +
+
+ SHELL: {codexInfo?.shell ?? "—"} +
+
+
+ ); +} + +function CodexConfigLocationSection({ + codexConfig, + customHomeInputId, + configLocationMode, + customHomeText, + configLocationError, + selectingCodexHomeDir, + configLocationControlsDisabled, + activeConfigModeBadgeText, + activeConfigDirPrimaryText, + activeConfigDirSummaryText, + configLocationSummaryText, + followModeLabel, + followModeMatchesDefault, + userDefaultResolvedHomeDir, + followCodexHomeResolvedDir, + configLocationPreviewPath, + resetConfigLocation, + handleConfigLocationModeChange, + updateConfigLocationDraft, + persistConfigLocation, + restoreSavedConfigLocationState, + handlePickCustomHome, +}: { + codexConfig: CodexConfigState; + customHomeInputId: string; + configLocationMode: CodexHomeMode; + customHomeText: string; + configLocationError: string | null; + selectingCodexHomeDir: boolean; + configLocationControlsDisabled: boolean; + activeConfigModeBadgeText: string; + activeConfigDirPrimaryText: string; + activeConfigDirSummaryText: string; + configLocationSummaryText: string; + followModeLabel: string; + followModeMatchesDefault: boolean; + userDefaultResolvedHomeDir: string; + followCodexHomeResolvedDir: string; + configLocationPreviewPath: string; + resetConfigLocation: () => Promise; + handleConfigLocationModeChange: (nextMode: CodexHomeMode) => Promise; + updateConfigLocationDraft: UpdateConfigLocationDraft; + persistConfigLocation: ( + nextMode: CodexHomeMode, + nextCustomHome?: string + ) => Promise; + restoreSavedConfigLocationState: () => void; + handlePickCustomHome: () => Promise; +}) { + return ( +
+
+
+
+
Windows 本机配置
+
+ 仅影响 Windows 本机上的 Codex 用户级 .codex{" "} + 目录,不改写 WSL 内各 distro 的目标路径。 +
+
+ +
+ + {activeConfigModeBadgeText} + + +
+
+ +
+
+
+
+ 当前会使用 +
+
+ {activeConfigDirPrimaryText} +
+
+ {configLocationSummaryText} +
+
+ +
+
+ config.toml +
+
+ {codexConfig.config_path} +
+
+ {activeConfigDirSummaryText} +
+
+
+
+ +
+
目录来源
+
+ + void handleConfigLocationModeChange( + value === "follow_codex_home" + ? "follow_codex_home" + : value === "custom" + ? "custom" + : "user_home_default" + ) + } + options={[ + { value: "user_home_default", label: "固定到 Windows 用户目录" }, + { value: "follow_codex_home", label: followModeLabel }, + { value: "custom", label: "手动指定目录" }, + ]} + disabled={configLocationControlsDisabled} + /> +
+
+
+ 固定目录:{userDefaultResolvedHomeDir} +
+
+ $CODEX_HOME 当前解析: + {followCodexHomeResolvedDir} + {followModeMatchesDefault ? ( + + 当前路径相同,但后续会随 $CODEX_HOME 变化。 + + ) : null} +
+
+
+ + {configLocationMode === "custom" ? ( +
+ + +
+ { + const next = e.currentTarget.value; + updateConfigLocationDraft({ + customHomeText: next, + configLocationError: configLocationError + ? validateCustomCodexHome(next) + : configLocationError, + }); + }} + onBlur={() => { + if (configLocationMode !== "custom") return; + void persistConfigLocation("custom", customHomeText).then((result) => { + if (result === "persist_failed") { + restoreSavedConfigLocationState(); + } + }); + }} + onKeyDown={(e) => { + if (e.key === "Enter") e.currentTarget.blur(); + }} + placeholder="例如:D:\\Users\\you\\.codex" + className={cn( + "font-mono text-xs lg:flex-1", + configLocationError && + "border-rose-300 focus-visible:ring-rose-200 dark:border-rose-700" + )} + disabled={configLocationControlsDisabled} + /> + +
+ +
+
+ +
+ {configLocationError + ? configLocationError + : configLocationPreviewPath + ? `保存后将使用 ${configLocationPreviewPath}。支持普通 Windows 路径、UNC 路径,也可以点“选择目录”。` + : "请输入一个 .codex 目录路径,然后按 Enter、移出输入框,或直接使用目录选择器保存。"} +
+
+ ) : ( +
+ {configLocationMode === "follow_codex_home" + ? `当前为跟随模式,手动目录选择器已收起;现在会使用 ${followCodexHomeResolvedDir}。` + : `当前为默认模式,手动目录选择器已收起;固定使用 ${userDefaultResolvedHomeDir}。`} +
+ )} +
+
+ ); +} + +function CodexOauthProxySection({ + appSettings, + proxyModeControlsDisabled, + persistCodexOauthCompatibleProxyMode, +}: { + appSettings: AppSettings; + proxyModeControlsDisabled: boolean; + persistCodexOauthCompatibleProxyMode?: (enabled: boolean) => Promise | boolean; +}) { + return ( +
+
+
+
OAuth 兼容代理模式
+
+ 开启后,AIO 接管 Codex 代理时只写入 config.toml 的 + AIO provider,不创建、不备份、 不恢复 auth.json + 。适合继续使用 Codex 自己的 ChatGPT/OAuth 登录状态。 +
+
+ 该模式不会写入 preferred_auth_method = "chatgpt" + ;会在配置中保留 requires_openai_auth = true。 +
+
+ void persistCodexOauthCompatibleProxyMode?.(checked)} + disabled={proxyModeControlsDisabled} + /> +
+
+ ); +} + +function CodexBasicConfigSection({ + codexConfig, + saving, + modelText, + contextWindowText, + autoCompactLimitText, + sandboxModeText, + reasoningEffortText, + planModeReasoningEffortText, + webSearchText, + personalityText, + showsGpt54LinkedSettings, + updateCodexDraft, + persistCodexConfig, +}: { + codexConfig: CodexConfigState; + saving: boolean; + modelText: string; + contextWindowText: string; + autoCompactLimitText: string; + sandboxModeText: string; + reasoningEffortText: string; + planModeReasoningEffortText: string; + webSearchText: string; + personalityText: string; + showsGpt54LinkedSettings: boolean; + updateCodexDraft: UpdateCodexDraft; + persistCodexConfig: CliManagerCodexTabProps["persistCodexConfig"]; +}) { + return ( +
+

+ + 基础配置 +

+
+ + updateCodexDraft({ modelText: e.currentTarget.value })} + onBlur={() => + void persistCodexConfig( + buildModelPatch(modelText, contextWindowText, autoCompactLimitText) + ) + } + placeholder="例如:gpt-5-codex" + className="font-mono w-[280px] max-w-full" + disabled={saving} + /> + + + {showsGpt54LinkedSettings ? ( + <> + + updateCodexDraft({ contextWindowText: e.currentTarget.value })} + onBlur={() => + void persistCodexConfig({ + model_context_window: parsePositiveInt(contextWindowText), + }) + } + placeholder={String(GPT_54_CONTEXT_WINDOW)} + className="font-mono w-[220px] max-w-full" + disabled={saving} + /> + + + + updateCodexDraft({ autoCompactLimitText: e.currentTarget.value })} + onBlur={() => + void persistCodexConfig({ + model_auto_compact_token_limit: parsePositiveInt(autoCompactLimitText), + }) + } + placeholder={String(GPT_54_AUTO_COMPACT_TOKEN_LIMIT)} + className="font-mono w-[220px] max-w-full" + disabled={saving} + /> + + + ) : null} + + + + + + + + + + + { + updateCodexDraft({ reasoningEffortText: value }); + void persistCodexConfig({ model_reasoning_effort: value }); + }} + options={[ + { value: "", label: "默认" }, + { value: "minimal", label: "最低 (minimal)" }, + { value: "low", label: "低 (low)" }, + { value: "medium", label: "中 (medium)" }, + { value: "high", label: "高 (high)" }, + { value: "xhigh", label: "极高 (xhigh)" }, + ]} + disabled={saving} + /> + + + + { + updateCodexDraft({ planModeReasoningEffortText: value }); + void persistCodexConfig({ plan_mode_reasoning_effort: value }); + }} + options={[ + { value: "", label: "默认" }, + { value: "low", label: "低 (low)" }, + { value: "medium", label: "中 (medium)" }, + { value: "high", label: "高 (high)" }, + { value: "xhigh", label: "极高 (xhigh)" }, + ]} + disabled={saving} + /> + + + + { + updateCodexDraft({ webSearchText: value }); + void persistCodexConfig({ web_search: value }); + }} + options={[ + { value: "cached", label: "缓存 (cached)" }, + { value: "live", label: "实时 (live)" }, + { value: "disabled", label: "禁用 (disabled)" }, + ]} + disabled={saving} + /> + + + + { + updateCodexDraft({ personalityText: value }); + void persistCodexConfig(buildPersonalityPatch(value)); + }} + options={[ + { value: "pragmatic", label: "务实 (pragmatic)" }, + { value: "friendly", label: "友好 (friendly)" }, + { value: "none", label: "默认 / 删除配置 (none)" }, + ]} + disabled={saving} + /> + +
+
+ ); +} + +function CodexSandboxSection({ + codexConfig, + saving, + effectiveSandboxMode, + persistCodexConfig, +}: { + codexConfig: CodexConfigState; + saving: boolean; + effectiveSandboxMode: string; + persistCodexConfig: CliManagerCodexTabProps["persistCodexConfig"]; +}) { + return ( +
+

+ + Sandbox(workspace-write) +

+
+ + + void persistCodexConfig({ sandbox_workspace_write_network_access: checked }) + } + disabled={saving} + /> + +
+ {effectiveSandboxMode !== "workspace-write" ? ( +
+ +
+ 当前 sandbox_mode 不是 workspace-write + ,此分区设置可能不会生效。 +
+
+ ) : null} +
+ ); +} + +function CodexFeaturesSection({ + codexConfig, + saving, + effectiveFastModeEnabled, + persistCodexConfig, +}: { + codexConfig: CodexConfigState; + saving: boolean; + effectiveFastModeEnabled: boolean; + persistCodexConfig: CliManagerCodexTabProps["persistCodexConfig"]; +}) { + return ( +
+

+ + Features(实验/可选能力) +

+
+ + + void persistCodexConfig({ features_shell_snapshot: checked }) + } + disabled={saving} + /> + + + + + void persistCodexConfig({ features_unified_exec: checked }) + } + disabled={saving} + /> + + + + void persistCodexConfig({ features_shell_tool: checked })} + disabled={saving} + /> + + + + + void persistCodexConfig({ features_exec_policy: checked }) + } + disabled={saving} + /> + + + + + void persistCodexConfig({ features_apply_patch_freeform: checked }) + } + disabled={saving} + /> + + + + + void persistCodexConfig({ features_remote_compaction: checked }) + } + disabled={saving} + /> + + + + void persistCodexConfig(buildFastModePatch(checked))} + disabled={saving} + /> + + + + + void persistCodexConfig({ features_responses_websockets_v2: checked }) + } + disabled={saving} + /> + + + + + void persistCodexConfig({ features_multi_agent: checked }) + } + disabled={saving} + /> + +
+
+ ); +} + +function CodexTomlAdvancedSection({ + codexConfig, + codexConfigToml, + codexConfigTomlLoading, + tomlAdvancedOpen, + tomlBusy, + tomlEditEnabled, + tomlDraft, + tomlDirty, + tomlValidating, + tomlValidation, + setTomlAdvancedOpen, + updateTomlState, + validateToml, + saveTomlDraft, +}: { + codexConfig: CodexConfigState; + codexConfigToml: CodexConfigTomlState | null; + codexConfigTomlLoading: boolean; + tomlAdvancedOpen: boolean; + tomlBusy: boolean; + tomlEditEnabled: boolean; + tomlDraft: string; + tomlDirty: boolean; + tomlValidating: boolean; + tomlValidation: CodexConfigTomlValidationResult | null; + setTomlAdvancedOpen: (value: boolean) => void; + updateTomlState: UpdateTomlState; + validateToml: (toml: string) => Promise; + saveTomlDraft: () => Promise; +}) { + return ( +
+
setTomlAdvancedOpen((e.currentTarget as HTMLDetailsElement).open)} + > + + + + 高级配置(config.toml) + + + 仅在需要编辑原始 TOML 时使用 + + + + {tomlAdvancedOpen ? ( +
+
+
+
路径
+
+ {codexConfig.config_path ?? codexConfigToml?.config_path ?? "—"} +
+
+
+ + + {!tomlEditEnabled ? ( + + ) : ( + <> + + + + )} +
+
+ + {codexConfigTomlLoading ? ( +
加载中…
+ ) : ( + 加载编辑器…
+ } + > + { + updateTomlState({ tomlDraft: next, tomlDirty: true }); + } + : undefined + } + readOnly={!tomlEditEnabled || tomlBusy} + language="toml" + minHeight="260px" + placeholder='例如:approval_policy = "on-request"' + /> + + )} + + {tomlValidation?.ok === false && tomlValidation.error ? ( +
+ +
+
TOML 校验失败
+
+ {tomlValidation.error.message} + {tomlValidation.error.line ? ( + + (line {tomlValidation.error.line} + {tomlValidation.error.column + ? `, column ${tomlValidation.error.column}` + : ""} + ) + + ) : null} +
+
+
+ ) : ( +
+ 保存前会进行后端 TOML 校验;校验失败不会写入文件。 +
+ )} +
+ ) : null} +
+ + ); +} + +function useCodexTabController({ codexLoading, codexConfigLoading, codexConfigSaving, codexConfigTomlLoading, codexConfigTomlSaving, - codexInfo, codexConfig, codexConfigToml, appSettings, codexHomeSettingsSaving = false, refreshCodex, - openCodexConfigDir, - persistCodexConfig, persistCodexConfigToml, persistCodexHomeSettings, persistCodexOauthCompatibleProxyMode, pickCodexHomeDirectory, }: CliManagerCodexTabProps) { - const [versionRefreshToken, setVersionRefreshToken] = useState(0); - const [modelText, setModelText] = useState(""); - const [contextWindowText, setContextWindowText] = useState(""); - const [autoCompactLimitText, setAutoCompactLimitText] = useState(""); - const [sandboxModeText, setSandboxModeText] = useState(""); - const [webSearchText, setWebSearchText] = useState(""); - const [personalityText, setPersonalityText] = useState("none"); - const [reasoningEffortText, setReasoningEffortText] = useState(""); - const [planModeReasoningEffortText, setPlanModeReasoningEffortText] = useState(""); - const [configLocationMode, setConfigLocationMode] = useState("user_home_default"); - const [customHomeText, setCustomHomeText] = useState(""); - const [configLocationError, setConfigLocationError] = useState(null); - const [selectingCodexHomeDir, setSelectingCodexHomeDir] = useState(false); - - const [tomlAdvancedOpen, setTomlAdvancedOpen] = useState(false); - const [tomlEditEnabled, setTomlEditEnabled] = useState(false); - const [tomlDraft, setTomlDraft] = useState(""); - const [tomlDirty, setTomlDirty] = useState(false); - const [tomlValidating, setTomlValidating] = useState(false); - const [tomlValidation, setTomlValidation] = useState( - null + const customHomeInputId = useId(); + const [uiState, dispatchUiState] = useReducer( + codexTabUiReducer, + { + codexConfig, + codexConfigToml, + appSettings, + }, + initCodexTabUiState ); + const { + versionRefreshToken, + codexDraft, + configLocationDraft, + selectingCodexHomeDir, + tomlAdvancedOpen, + tomlState, + } = uiState; + const codexConfigKey = buildCodexConfigKey(codexConfig); + let effectiveCodexDraft = codexDraft; + + if (codexDraft.configKey !== codexConfigKey) { + effectiveCodexDraft = buildCodexConfigDraft(codexConfig); + dispatchUiState({ type: "setCodexDraft", draft: effectiveCodexDraft }); + } + + const configLocationKey = buildConfigLocationKey(appSettings); + let effectiveConfigLocationDraft = configLocationDraft; + + if (configLocationDraft.settingsKey !== configLocationKey) { + effectiveConfigLocationDraft = buildConfigLocationDraft(appSettings); + dispatchUiState({ type: "setConfigLocationDraft", draft: effectiveConfigLocationDraft }); + } + + const nextTomlSourceKey = buildTomlSourceKey(codexConfigToml); + const nextTomlConfigPath = codexConfigToml?.config_path ?? null; + let effectiveTomlState = tomlState; const validateSeqRef = useRef(0); const validateTimerRef = useRef(null); - const lastTomlConfigPathRef = useRef(null); + + if ( + tomlState.configPath !== nextTomlConfigPath || + (!tomlState.tomlDirty && tomlState.sourceKey !== nextTomlSourceKey) + ) { + if (tomlState.configPath !== nextTomlConfigPath) { + if (validateTimerRef.current) { + window.clearTimeout(validateTimerRef.current); + validateTimerRef.current = null; + } + validateSeqRef.current += 1; + } + effectiveTomlState = buildTomlDraftState(codexConfigToml); + dispatchUiState({ type: "setTomlState", state: effectiveTomlState }); + } + + const { + modelText, + contextWindowText, + autoCompactLimitText, + sandboxModeText, + webSearchText, + personalityText, + reasoningEffortText, + planModeReasoningEffortText, + } = effectiveCodexDraft; + const { configLocationMode, customHomeText, configLocationError } = effectiveConfigLocationDraft; + const { tomlDraft, tomlDirty, tomlValidating, tomlValidation, tomlEditEnabled } = + effectiveTomlState; + + function updateCodexDraft(patch: Partial>) { + dispatchUiState({ type: "patchCodexDraft", patch }); + } + + function updateConfigLocationDraft(patch: Partial>) { + dispatchUiState({ type: "patchConfigLocationDraft", patch }); + } + + function updateTomlState(patch: Partial>) { + dispatchUiState({ type: "patchTomlState", patch }); + } const validateToml = useCallback( async (toml: string): Promise => { const seq = validateSeqRef.current + 1; validateSeqRef.current = seq; - setTomlValidating(true); + dispatchUiState({ type: "patchTomlState", patch: { tomlValidating: true } }); try { - const result = await cliManagerCodexConfigTomlValidate(toml); if (seq !== validateSeqRef.current) return null; - if (!result) return null; - setTomlValidation(result); - return result; + const result = await cliManagerCodexConfigTomlValidate(toml); + if (seq === validateSeqRef.current && result) { + dispatchUiState({ type: "patchTomlState", patch: { tomlValidation: result } }); + return result; + } + return null; } finally { if (seq === validateSeqRef.current) { - setTomlValidating(false); + dispatchUiState({ type: "patchTomlState", patch: { tomlValidating: false } }); } } }, [] ); - useEffect(() => { - if (!codexConfig) return; - setModelText(codexConfig.model ?? ""); - setContextWindowText( - codexConfig.model_context_window != null ? String(codexConfig.model_context_window) : "" - ); - setAutoCompactLimitText( - codexConfig.model_auto_compact_token_limit != null - ? String(codexConfig.model_auto_compact_token_limit) - : "" - ); - setSandboxModeText(codexConfig.sandbox_mode ?? ""); - setWebSearchText(codexConfig.web_search ?? "cached"); - setPersonalityText(codexConfig.personality?.trim() || "none"); - setReasoningEffortText(codexConfig.model_reasoning_effort ?? ""); - setPlanModeReasoningEffortText(codexConfig.plan_mode_reasoning_effort ?? ""); - }, [codexConfig]); - - useEffect(() => { - const savedOverride = appSettings?.codex_home_override?.trim() ?? ""; - const savedMode = - appSettings?.codex_home_mode ?? (savedOverride ? "custom" : "user_home_default"); - setConfigLocationMode(savedMode); - setCustomHomeText(savedOverride); - setConfigLocationError(null); - }, [appSettings?.codex_home_mode, appSettings?.codex_home_override]); - function readSavedConfigLocationState() { - const savedOverride = appSettings?.codex_home_override?.trim() ?? ""; - const savedMode = - appSettings?.codex_home_mode ?? (savedOverride ? "custom" : "user_home_default"); + const { savedMode, savedOverride } = readConfigLocationSettings(appSettings); return { savedMode, savedOverride }; } function restoreSavedConfigLocationState() { const { savedMode, savedOverride } = readSavedConfigLocationState(); - setConfigLocationMode(savedMode); - setCustomHomeText(savedOverride); - setConfigLocationError(null); + updateConfigLocationDraft({ + configLocationMode: savedMode, + customHomeText: savedOverride, + configLocationError: null, + }); } const saving = codexConfigSaving; @@ -296,7 +1519,7 @@ export function CliManagerCodexTab({ try { await refreshCodex(); } finally { - setVersionRefreshToken((value) => value + 1); + dispatchUiState({ type: "incrementVersionRefreshToken" }); } } @@ -346,11 +1569,9 @@ export function CliManagerCodexTab({ ); }, [followCodexHomeResolvedDir, userDefaultResolvedHomeDir]); - const followModeLabel = useMemo(() => { - return followModeMatchesDefault - ? "跟随环境变量 $CODEX_HOME(当前路径与固定目录一致)" - : "跟随环境变量 $CODEX_HOME"; - }, [followModeMatchesDefault]); + const followModeLabel = followModeMatchesDefault + ? "跟随环境变量 $CODEX_HOME(当前路径与固定目录一致)" + : "跟随环境变量 $CODEX_HOME"; const configLocationBrowsePath = useMemo(() => { const trimmedCustomHome = customHomeText.trim(); @@ -434,38 +1655,6 @@ export function CliManagerCodexTab({ userDefaultResolvedHomeDir, ]); - useEffect(() => { - const nextPath = codexConfigToml?.config_path ?? null; - const prevPath = lastTomlConfigPathRef.current; - - if (!nextPath) { - lastTomlConfigPathRef.current = null; - return; - } - - if (prevPath && prevPath !== nextPath) { - if (validateTimerRef.current) { - window.clearTimeout(validateTimerRef.current); - validateTimerRef.current = null; - } - - validateSeqRef.current += 1; - setTomlDraft(codexConfigToml?.toml ?? ""); - setTomlDirty(false); - setTomlValidating(false); - setTomlValidation(null); - setTomlEditEnabled(false); - } - - lastTomlConfigPathRef.current = nextPath; - }, [codexConfigToml?.config_path, codexConfigToml?.toml]); - - useEffect(() => { - if (!codexConfigToml) return; - if (tomlDirty) return; - setTomlDraft(codexConfigToml.toml ?? ""); - }, [codexConfigToml, tomlDirty]); - useEffect(() => { if (!tomlAdvancedOpen) return; if (!tomlEditEnabled) return; @@ -496,8 +1685,7 @@ export function CliManagerCodexTab({ const ok = await persistCodexConfigToml(tomlDraft); if (!ok) return; - setTomlEditEnabled(false); - setTomlDirty(false); + updateTomlState({ tomlEditEnabled: false, tomlDirty: false }); } async function persistConfigLocation( @@ -510,10 +1698,10 @@ export function CliManagerCodexTab({ const normalized = normalizeCustomCodexHome(trimmed); if (nextMode === "custom") { const error = validateCustomCodexHome(trimmed); - setConfigLocationError(error); + updateConfigLocationDraft({ configLocationError: error }); if (error) return "validation_failed"; } else { - setConfigLocationError(null); + updateConfigLocationDraft({ configLocationError: null }); } const nextOverride = nextMode === "custom" ? normalized : ""; @@ -522,18 +1710,19 @@ export function CliManagerCodexTab({ return "persist_failed"; } - setConfigLocationMode(nextMode); - setCustomHomeText(nextMode === "custom" ? nextOverride : ""); - setConfigLocationError(null); + updateConfigLocationDraft({ + configLocationMode: nextMode, + customHomeText: nextMode === "custom" ? nextOverride : "", + configLocationError: null, + }); return "saved"; } async function handleConfigLocationModeChange(nextMode: CodexHomeMode) { - setConfigLocationMode(nextMode); + updateConfigLocationDraft({ configLocationMode: nextMode }); if (nextMode !== "custom") { - setCustomHomeText(""); - setConfigLocationError(null); + updateConfigLocationDraft({ customHomeText: "", configLocationError: null }); const result = await persistConfigLocation(nextMode, ""); if (result === "persist_failed") { restoreSavedConfigLocationState(); @@ -542,7 +1731,7 @@ export function CliManagerCodexTab({ } const error = validateCustomCodexHome(customHomeText); - setConfigLocationError(error); + updateConfigLocationDraft({ configLocationError: error }); if (error) { return; } @@ -554,9 +1743,11 @@ export function CliManagerCodexTab({ } async function resetConfigLocation() { - setConfigLocationMode("user_home_default"); - setCustomHomeText(""); - setConfigLocationError(null); + updateConfigLocationDraft({ + configLocationMode: "user_home_default", + customHomeText: "", + configLocationError: null, + }); const result = await persistConfigLocation("user_home_default", ""); if (result === "persist_failed") { restoreSavedConfigLocationState(); @@ -567,17 +1758,16 @@ export function CliManagerCodexTab({ if (!pickCodexHomeDirectory) return; if (configLocationControlsDisabled) return; - setSelectingCodexHomeDir(true); + dispatchUiState({ type: "setSelectingCodexHomeDir", value: true }); try { const picked = await pickCodexHomeDirectory(configLocationBrowsePath || undefined); if (!picked) return; const normalized = normalizeCustomCodexHome(picked); - setConfigLocationMode("custom"); - setCustomHomeText(normalized); + updateConfigLocationDraft({ configLocationMode: "custom", customHomeText: normalized }); const error = validateCustomCodexHome(normalized); - setConfigLocationError(error); + updateConfigLocationDraft({ configLocationError: error }); if (error) { return; } @@ -587,331 +1777,205 @@ export function CliManagerCodexTab({ restoreSavedConfigLocationState(); } } finally { - setSelectingCodexHomeDir(false); + dispatchUiState({ type: "setSelectingCodexHomeDir", value: false }); } } + function setTomlAdvancedOpen(value: boolean) { + dispatchUiState({ type: "setTomlAdvancedOpen", value }); + } + + return { + customHomeInputId, + versionRefreshToken, + modelText, + contextWindowText, + autoCompactLimitText, + sandboxModeText, + webSearchText, + personalityText, + reasoningEffortText, + planModeReasoningEffortText, + configLocationMode, + customHomeText, + configLocationError, + selectingCodexHomeDir, + tomlAdvancedOpen, + tomlDraft, + tomlDirty, + tomlValidating, + tomlValidation, + tomlEditEnabled, + saving, + loading, + tomlBusy, + configLocationControlsDisabled, + proxyModeControlsDisabled, + effectiveSandboxMode, + effectiveFastModeEnabled, + showsGpt54LinkedSettings, + configLocationPreviewPath, + userDefaultResolvedHomeDir, + followCodexHomeResolvedDir, + followModeMatchesDefault, + followModeLabel, + configLocationSummaryText, + activeConfigDirSummaryText, + activeConfigModeBadgeText, + activeConfigDirPrimaryText, + refreshCodexStatus, + updateCodexDraft, + updateConfigLocationDraft, + updateTomlState, + validateToml, + saveTomlDraft, + persistConfigLocation, + restoreSavedConfigLocationState, + handleConfigLocationModeChange, + resetConfigLocation, + handlePickCustomHome, + setTomlAdvancedOpen, + }; +} + +export function CliManagerCodexTab({ + codexAvailable, + codexLoading, + codexConfigLoading, + codexConfigSaving, + codexConfigTomlLoading, + codexConfigTomlSaving, + codexInfo, + codexConfig, + codexConfigToml, + appSettings, + codexHomeSettingsSaving = false, + refreshCodex, + openCodexConfigDir, + persistCodexConfig, + persistCodexConfigToml, + persistCodexHomeSettings, + persistCodexOauthCompatibleProxyMode, + pickCodexHomeDirectory, +}: CliManagerCodexTabProps) { + const { + customHomeInputId, + versionRefreshToken, + modelText, + contextWindowText, + autoCompactLimitText, + sandboxModeText, + webSearchText, + personalityText, + reasoningEffortText, + planModeReasoningEffortText, + configLocationMode, + customHomeText, + configLocationError, + selectingCodexHomeDir, + tomlAdvancedOpen, + tomlDraft, + tomlDirty, + tomlValidating, + tomlValidation, + tomlEditEnabled, + saving, + loading, + tomlBusy, + configLocationControlsDisabled, + proxyModeControlsDisabled, + effectiveSandboxMode, + effectiveFastModeEnabled, + showsGpt54LinkedSettings, + configLocationPreviewPath, + userDefaultResolvedHomeDir, + followCodexHomeResolvedDir, + followModeMatchesDefault, + followModeLabel, + configLocationSummaryText, + activeConfigDirSummaryText, + activeConfigModeBadgeText, + activeConfigDirPrimaryText, + refreshCodexStatus, + updateCodexDraft, + updateConfigLocationDraft, + updateTomlState, + validateToml, + saveTomlDraft, + persistConfigLocation, + restoreSavedConfigLocationState, + handleConfigLocationModeChange, + resetConfigLocation, + handlePickCustomHome, + setTomlAdvancedOpen, + } = useCodexTabController({ + codexAvailable, + codexLoading, + codexConfigLoading, + codexConfigSaving, + codexConfigTomlLoading, + codexConfigTomlSaving, + codexInfo, + codexConfig, + codexConfigToml, + appSettings, + codexHomeSettingsSaving, + refreshCodex, + openCodexConfigDir, + persistCodexConfig, + persistCodexConfigToml, + persistCodexHomeSettings, + persistCodexOauthCompatibleProxyMode, + pickCodexHomeDirectory, + }); + return (
-
-
-
- -
-
-

Codex

-
- {codexAvailable === "available" && codexInfo?.found ? ( - <> - - - 已安装 {codexInfo.version} - - - - ) : codexAvailable === "checking" || loading ? ( - - - 加载中... - - ) : ( - - 未检测到 - - )} -
-
-
- - -
- - {codexConfig && ( -
-
-
- - 当前 .codex 目录 -
-
-
- {codexConfig.config_dir} -
- -
-
- {activeConfigDirSummaryText} -
- {!codexConfig.can_open_config_dir ? ( -
- 受权限限制,无法自动打开该目录;请手动打开该路径。 -
- ) : null} -
- -
-
- - config.toml -
-
- {codexConfig.config_path} -
-
- {codexConfig.exists ? "已存在" : "不存在(将自动创建)"} -
-
- -
-
- - 可执行文件 -
-
- {codexInfo?.executable_path ?? "—"} -
-
- -
-
- - 解析方式 -
-
- {codexInfo?.resolved_via ?? "—"} -
-
- SHELL: {codexInfo?.shell ?? "—"} -
-
-
- )} + + + {codexConfig ? ( + + ) : null} {codexConfig && isWindowsRuntime() ? ( -
-
-
-
-
Windows 本机配置
-
- 仅影响 Windows 本机上的 Codex 用户级{" "} - .codex 目录,不改写 WSL 内各 distro 的 - 目标路径。 -
-
- -
- - {activeConfigModeBadgeText} - - -
-
- -
-
-
-
- 当前会使用 -
-
- {activeConfigDirPrimaryText} -
-
- {configLocationSummaryText} -
-
- -
-
- config.toml -
-
- {codexConfig.config_path} -
-
- {activeConfigDirSummaryText} -
-
-
-
- -
-
- 目录来源 -
-
- - handleConfigLocationModeChange( - value === "follow_codex_home" - ? "follow_codex_home" - : value === "custom" - ? "custom" - : "user_home_default" - ) - } - options={[ - { value: "user_home_default", label: "固定到 Windows 用户目录" }, - { value: "follow_codex_home", label: followModeLabel }, - { value: "custom", label: "手动指定目录" }, - ]} - disabled={configLocationControlsDisabled} - /> -
-
-
- 固定目录: - {userDefaultResolvedHomeDir} -
-
- $CODEX_HOME 当前解析: - {followCodexHomeResolvedDir} - {followModeMatchesDefault ? ( - - 当前路径相同,但后续会随 $CODEX_HOME 变化。 - - ) : null} -
-
-
- - {configLocationMode === "custom" ? ( -
- - -
- { - const next = e.currentTarget.value; - setCustomHomeText(next); - if (configLocationError) { - setConfigLocationError(validateCustomCodexHome(next)); - } - }} - onBlur={() => { - if (configLocationMode !== "custom") return; - void persistConfigLocation("custom", customHomeText).then((result) => { - if (result === "persist_failed") { - restoreSavedConfigLocationState(); - } - }); - }} - onKeyDown={(e) => { - if (e.key === "Enter") e.currentTarget.blur(); - }} - placeholder="例如:D:\\Users\\you\\.codex" - className={cn( - "font-mono text-xs lg:flex-1", - configLocationError && - "border-rose-300 focus-visible:ring-rose-200 dark:border-rose-700" - )} - disabled={configLocationControlsDisabled} - /> - -
- -
-
- -
- {configLocationError - ? configLocationError - : configLocationPreviewPath - ? `保存后将使用 ${configLocationPreviewPath}。支持普通 Windows 路径、UNC 路径,也可以点“选择目录”。` - : "请输入一个 .codex 目录路径,然后按 Enter、移出输入框,或直接使用目录选择器保存。"} -
-
- ) : ( -
- {configLocationMode === "follow_codex_home" - ? `当前为跟随模式,手动目录选择器已收起;现在会使用 ${followCodexHomeResolvedDir}。` - : `当前为默认模式,手动目录选择器已收起;固定使用 ${userDefaultResolvedHomeDir}。`} -
- )} -
-
+ ) : null}
@@ -920,33 +1984,11 @@ export function CliManagerCodexTab({
{appSettings ? ( -
-
-
-
OAuth 兼容代理模式
-
- 开启后,AIO 接管 Codex 代理时只写入{" "} - config.toml 的 AIO - provider,不创建、不备份、 不恢复 auth.json - 。适合继续使用 Codex 自己的 ChatGPT/OAuth 登录状态。 -
-
- 该模式不会写入{" "} - preferred_auth_method = "chatgpt" - ;会在配置中保留 - requires_openai_auth = true。 -
-
- - void persistCodexOauthCompatibleProxyMode?.(checked) - } - disabled={proxyModeControlsDisabled} - /> -
-
+ ) : null}
@@ -957,513 +1999,52 @@ export function CliManagerCodexTab({
暂无配置,请尝试刷新
) : (
-
-

- - 基础配置 -

-
- - setModelText(e.currentTarget.value)} - onBlur={() => - void persistCodexConfig( - buildModelPatch(modelText, contextWindowText, autoCompactLimitText) - ) - } - placeholder="例如:gpt-5-codex" - className="font-mono w-[280px] max-w-full" - disabled={saving} - /> - - - {showsGpt54LinkedSettings ? ( - <> - - setContextWindowText(e.currentTarget.value)} - onBlur={() => - void persistCodexConfig({ - model_context_window: parsePositiveInt(contextWindowText), - }) - } - placeholder={String(GPT_54_CONTEXT_WINDOW)} - className="font-mono w-[220px] max-w-full" - disabled={saving} - /> - - - - setAutoCompactLimitText(e.currentTarget.value)} - onBlur={() => - void persistCodexConfig({ - model_auto_compact_token_limit: parsePositiveInt(autoCompactLimitText), - }) - } - placeholder={String(GPT_54_AUTO_COMPACT_TOKEN_LIMIT)} - className="font-mono w-[220px] max-w-full" - disabled={saving} - /> - - - ) : null} - - - - - - - - - - - { - setReasoningEffortText(value); - void persistCodexConfig({ model_reasoning_effort: value }); - }} - options={[ - { value: "", label: "默认" }, - { value: "minimal", label: "最低 (minimal)" }, - { value: "low", label: "低 (low)" }, - { value: "medium", label: "中 (medium)" }, - { value: "high", label: "高 (high)" }, - { value: "xhigh", label: "极高 (xhigh)" }, - ]} - disabled={saving} - /> - - - - { - setPlanModeReasoningEffortText(value); - void persistCodexConfig({ plan_mode_reasoning_effort: value }); - }} - options={[ - { value: "", label: "默认" }, - { value: "low", label: "低 (low)" }, - { value: "medium", label: "中 (medium)" }, - { value: "high", label: "高 (high)" }, - { value: "xhigh", label: "极高 (xhigh)" }, - ]} - disabled={saving} - /> - - - - { - setWebSearchText(value); - void persistCodexConfig({ web_search: value }); - }} - options={[ - { value: "cached", label: "缓存 (cached)" }, - { value: "live", label: "实时 (live)" }, - { value: "disabled", label: "禁用 (disabled)" }, - ]} - disabled={saving} - /> - - - - { - setPersonalityText(value); - void persistCodexConfig(buildPersonalityPatch(value)); - }} - options={[ - { value: "pragmatic", label: "务实 (pragmatic)" }, - { value: "friendly", label: "友好 (friendly)" }, - { value: "none", label: "默认 / 删除配置 (none)" }, - ]} - disabled={saving} - /> - -
-
- -
-

- - Sandbox(workspace-write) -

-
- - - void persistCodexConfig({ sandbox_workspace_write_network_access: checked }) - } - disabled={saving} - /> - -
- {effectiveSandboxMode !== "workspace-write" ? ( -
- -
- 当前 sandbox_mode 不是 workspace-write - ,此分区设置可能不会生效。 -
-
- ) : null} -
- -
-

- - Features(实验/可选能力) -

-
- - - void persistCodexConfig({ features_shell_snapshot: checked }) - } - disabled={saving} - /> - - - - - void persistCodexConfig({ features_unified_exec: checked }) - } - disabled={saving} - /> - - - - - void persistCodexConfig({ features_shell_tool: checked }) - } - disabled={saving} - /> - - - - - void persistCodexConfig({ features_exec_policy: checked }) - } - disabled={saving} - /> - - - - - void persistCodexConfig({ features_apply_patch_freeform: checked }) - } - disabled={saving} - /> - - - - - void persistCodexConfig({ features_remote_compaction: checked }) - } - disabled={saving} - /> - - - - - void persistCodexConfig(buildFastModePatch(checked)) - } - disabled={saving} - /> - - - - - void persistCodexConfig({ features_responses_websockets_v2: checked }) - } - disabled={saving} - /> - - - - - void persistCodexConfig({ features_multi_agent: checked }) - } - disabled={saving} - /> - -
-
- -
-
setTomlAdvancedOpen((e.currentTarget as HTMLDetailsElement).open)} - > - - - - 高级配置(config.toml) - - - 仅在需要编辑原始 TOML 时使用 - - - - {tomlAdvancedOpen ? ( -
-
-
-
路径
-
- {codexConfig?.config_path ?? codexConfigToml?.config_path ?? "—"} -
-
-
- - - {!tomlEditEnabled ? ( - - ) : ( - <> - - - - )} -
-
- - {codexConfigTomlLoading ? ( -
加载中…
- ) : ( - - 加载编辑器… -
- } - > - { - setTomlDraft(next); - setTomlDirty(true); - } - : undefined - } - readOnly={!tomlEditEnabled || tomlBusy} - language="toml" - minHeight="260px" - placeholder='例如:approval_policy = "on-request"' - /> - - )} - - {tomlValidation?.ok === false && tomlValidation.error ? ( -
- -
-
TOML 校验失败
-
- {tomlValidation.error.message} - {tomlValidation.error.line ? ( - - (line {tomlValidation.error.line} - {tomlValidation.error.column - ? `, column ${tomlValidation.error.column}` - : ""} - ) - - ) : null} -
-
-
- ) : ( -
- 保存前会进行后端 TOML 校验;校验失败不会写入文件。 -
- )} -
- ) : null} -
- + + + + + + + )} diff --git a/src/components/cli-manager/tabs/Cx2ccTab.tsx b/src/components/cli-manager/tabs/Cx2ccTab.tsx index 86d26ced..f34cad60 100644 --- a/src/components/cli-manager/tabs/Cx2ccTab.tsx +++ b/src/components/cli-manager/tabs/Cx2ccTab.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type ReactNode } from "react"; +import { useReducer, type ReactNode } from "react"; import { toast } from "sonner"; import { Settings } from "lucide-react"; import type { AppSettings } from "../../../services/settings/settings"; @@ -25,6 +25,59 @@ type Cx2ccTextSettingKey = | "cx2cc_fallback_model_main" | "cx2cc_service_tier"; +type Cx2ccDraftKey = Cx2ccTextSettingKey | "cx2cc_model_reasoning_effort"; + +type Cx2ccDraftState = { + sourceKey: string; + values: Record; +}; + +type Cx2ccDraftAction = + | { type: "resetFromSettings"; state: Cx2ccDraftState } + | { type: "setValue"; key: Cx2ccDraftKey; value: string }; + +const EMPTY_CX2CC_DRAFT_VALUES: Record = { + cx2cc_fallback_model_opus: "", + cx2cc_fallback_model_sonnet: "", + cx2cc_fallback_model_haiku: "", + cx2cc_fallback_model_main: "", + cx2cc_model_reasoning_effort: "", + cx2cc_service_tier: "", +}; + +function createCx2ccDraftState(appSettings: AppSettings | null): Cx2ccDraftState { + if (!appSettings) { + return { sourceKey: "empty", values: EMPTY_CX2CC_DRAFT_VALUES }; + } + + const values: Record = { + cx2cc_fallback_model_opus: appSettings.cx2cc_fallback_model_opus, + cx2cc_fallback_model_sonnet: appSettings.cx2cc_fallback_model_sonnet, + cx2cc_fallback_model_haiku: appSettings.cx2cc_fallback_model_haiku, + cx2cc_fallback_model_main: appSettings.cx2cc_fallback_model_main, + cx2cc_model_reasoning_effort: appSettings.cx2cc_model_reasoning_effort, + cx2cc_service_tier: appSettings.cx2cc_service_tier, + }; + + return { + sourceKey: Object.values(values).join("\u0000"), + values, + }; +} + +function cx2ccDraftReducer(state: Cx2ccDraftState, action: Cx2ccDraftAction): Cx2ccDraftState { + if (action.type === "resetFromSettings") { + return action.state; + } + return { + ...state, + values: { + ...state.values, + [action.key]: action.value, + }, + }; +} + function SettingItem({ label, subtitle, @@ -57,84 +110,84 @@ export function CliManagerCx2ccTab({ commonSettingsSaving, onPersistCommonSettings, }: CliManagerCx2ccTabProps) { - const [fallbackModelOpusText, setFallbackModelOpusText] = useState(""); - const [fallbackModelSonnetText, setFallbackModelSonnetText] = useState(""); - const [fallbackModelHaikuText, setFallbackModelHaikuText] = useState(""); - const [fallbackModelMainText, setFallbackModelMainText] = useState(""); - const [reasoningEffortText, setReasoningEffortText] = useState(""); - const [serviceTierText, setServiceTierText] = useState(""); - - useEffect(() => { - if (!appSettings) return; - setFallbackModelOpusText(appSettings.cx2cc_fallback_model_opus); - setFallbackModelSonnetText(appSettings.cx2cc_fallback_model_sonnet); - setFallbackModelHaikuText(appSettings.cx2cc_fallback_model_haiku); - setFallbackModelMainText(appSettings.cx2cc_fallback_model_main); - setReasoningEffortText(appSettings.cx2cc_model_reasoning_effort); - setServiceTierText(appSettings.cx2cc_service_tier); - }, [appSettings]); + const nextDraftState = createCx2ccDraftState(appSettings); + const [draftState, dispatchDraft] = useReducer(cx2ccDraftReducer, nextDraftState); + const effectiveDraftState = + draftState.sourceKey === nextDraftState.sourceKey ? draftState : nextDraftState; + if (draftState.sourceKey !== nextDraftState.sourceKey) { + dispatchDraft({ type: "resetFromSettings", state: nextDraftState }); + } + + const fallbackModelOpusText = effectiveDraftState.values.cx2cc_fallback_model_opus; + const fallbackModelSonnetText = effectiveDraftState.values.cx2cc_fallback_model_sonnet; + const fallbackModelHaikuText = effectiveDraftState.values.cx2cc_fallback_model_haiku; + const fallbackModelMainText = effectiveDraftState.values.cx2cc_fallback_model_main; + const reasoningEffortText = effectiveDraftState.values.cx2cc_model_reasoning_effort; + const serviceTierText = effectiveDraftState.values.cx2cc_service_tier; const controlsDisabled = commonSettingsSaving || !appSettings; + function setDraftValue(key: Cx2ccDraftKey, value: string) { + dispatchDraft({ type: "setValue", key, value }); + } + async function persistReasoningEffort(value: string) { if (!appSettings) return; const previous = appSettings.cx2cc_model_reasoning_effort; - setReasoningEffortText(value); + setDraftValue("cx2cc_model_reasoning_effort", value); const updated = await onPersistCommonSettings({ cx2cc_model_reasoning_effort: value }); if (!updated) { - setReasoningEffortText(previous); + setDraftValue("cx2cc_model_reasoning_effort", previous); return; } - setReasoningEffortText(updated.cx2cc_model_reasoning_effort); + setDraftValue("cx2cc_model_reasoning_effort", updated.cx2cc_model_reasoning_effort); } async function persistFallbackModel( key: Exclude, label: string, - value: string, - setText: (value: string) => void + value: string ) { if (!appSettings) return; const previous = appSettings[key]; const trimmed = value.trim(); - setText(trimmed); + setDraftValue(key, trimmed); const validationMessage = validateCx2ccFallbackModel(label, trimmed); if (validationMessage) { toast(validationMessage); - setText(previous); + setDraftValue(key, previous); return; } const updated = await onPersistCommonSettings({ [key]: trimmed } as Partial); - setText(updated ? updated[key] : previous); + setDraftValue(key, updated ? updated[key] : previous); } async function persistOptionalTextSetting( key: Extract, label: string, - value: string, - setText: (value: string) => void + value: string ) { if (!appSettings) return; const previous = appSettings[key]; const trimmed = value.trim(); - setText(trimmed); + setDraftValue(key, trimmed); const validationMessage = validateCx2ccOptionalField(label, trimmed); if (validationMessage) { toast(validationMessage); - setText(previous); + setDraftValue(key, previous); return; } const updated = await onPersistCommonSettings({ [key]: trimmed } as Partial); - setText(updated ? updated[key] : previous); + setDraftValue(key, updated ? updated[key] : previous); } return ( @@ -148,13 +201,12 @@ export function CliManagerCx2ccTab({ setFallbackModelOpusText(e.currentTarget.value)} + onChange={(e) => setDraftValue("cx2cc_fallback_model_opus", e.currentTarget.value)} onBlur={(e) => { void persistFallbackModel( "cx2cc_fallback_model_opus", "Opus 默认模型", - e.currentTarget.value, - setFallbackModelOpusText + e.currentTarget.value ); }} placeholder="gpt-5.4" @@ -169,13 +221,12 @@ export function CliManagerCx2ccTab({ > setFallbackModelSonnetText(e.currentTarget.value)} + onChange={(e) => setDraftValue("cx2cc_fallback_model_sonnet", e.currentTarget.value)} onBlur={(e) => { void persistFallbackModel( "cx2cc_fallback_model_sonnet", "Sonnet 默认模型", - e.currentTarget.value, - setFallbackModelSonnetText + e.currentTarget.value ); }} placeholder="gpt-5.4" @@ -187,13 +238,12 @@ export function CliManagerCx2ccTab({ setFallbackModelHaikuText(e.currentTarget.value)} + onChange={(e) => setDraftValue("cx2cc_fallback_model_haiku", e.currentTarget.value)} onBlur={(e) => { void persistFallbackModel( "cx2cc_fallback_model_haiku", "Haiku 默认模型", - e.currentTarget.value, - setFallbackModelHaikuText + e.currentTarget.value ); }} placeholder="gpt-5.4" @@ -205,13 +255,12 @@ export function CliManagerCx2ccTab({ setFallbackModelMainText(e.currentTarget.value)} + onChange={(e) => setDraftValue("cx2cc_fallback_model_main", e.currentTarget.value)} onBlur={(e) => { void persistFallbackModel( "cx2cc_fallback_model_main", "主模型默认", - e.currentTarget.value, - setFallbackModelMainText + e.currentTarget.value ); }} placeholder="gpt-5.4" @@ -252,13 +301,12 @@ export function CliManagerCx2ccTab({ setServiceTierText(e.currentTarget.value)} + onChange={(e) => setDraftValue("cx2cc_service_tier", e.currentTarget.value)} onBlur={(e) => { void persistOptionalTextSetting( "cx2cc_service_tier", "服务层级", - e.currentTarget.value, - setServiceTierText + e.currentTarget.value ); }} placeholder="例如: fast" diff --git a/src/components/cli-manager/tabs/GeminiTab.tsx b/src/components/cli-manager/tabs/GeminiTab.tsx index 7eeb4f07..be3e718f 100644 --- a/src/components/cli-manager/tabs/GeminiTab.tsx +++ b/src/components/cli-manager/tabs/GeminiTab.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type ReactNode } from "react"; +import { useReducer, useState, type ReactNode } from "react"; import { toast } from "sonner"; import type { GeminiConfigPatch, @@ -74,6 +74,682 @@ function formatNumberInput(value: number | null) { return value == null ? "" : String(value); } +function revertNumberField( + setter: (value: string) => void, + currentValue: number | null | undefined +) { + setter(currentValue == null ? "" : String(currentValue)); +} + +function parseIntegerInput(raw: string) { + const trimmed = raw.trim(); + if (!trimmed) return null; + const value = Number(trimmed); + if (!Number.isFinite(value) || !Number.isInteger(value)) return null; + return value; +} + +function parseFloatInput(raw: string) { + const trimmed = raw.trim(); + if (!trimmed) return null; + const value = Number(trimmed); + if (!Number.isFinite(value)) return null; + return value; +} + +type GeminiDraftKey = + | "modelNameText" + | "defaultApprovalModeText" + | "maxAttemptsText" + | "maxSessionTurnsText" + | "compressionThresholdText" + | "uiThemeText" + | "uiInlineThinkingModeText" + | "sessionRetentionMaxAgeText" + | "authTypeText"; + +type GeminiDraftState = { + sourceKey: string; + values: Record; +}; + +type GeminiDraftAction = + | { type: "resetFromConfig"; state: GeminiDraftState } + | { type: "setValue"; key: GeminiDraftKey; value: string }; + +const EMPTY_GEMINI_DRAFT_VALUES: Record = { + modelNameText: "", + defaultApprovalModeText: "default", + maxAttemptsText: "", + maxSessionTurnsText: "", + compressionThresholdText: "", + uiThemeText: "", + uiInlineThinkingModeText: "off", + sessionRetentionMaxAgeText: "", + authTypeText: "", +}; + +function createGeminiDraftState(geminiConfig: GeminiConfigState | null): GeminiDraftState { + if (!geminiConfig) { + return { sourceKey: "empty", values: EMPTY_GEMINI_DRAFT_VALUES }; + } + + const values: Record = { + modelNameText: geminiConfig.modelName ?? "", + defaultApprovalModeText: stringOrDefault(geminiConfig.defaultApprovalMode, "default"), + maxAttemptsText: formatNumberInput(geminiConfig.maxAttempts), + maxSessionTurnsText: formatNumberInput(geminiConfig.modelMaxSessionTurns), + compressionThresholdText: formatNumberInput(geminiConfig.modelCompressionThreshold), + uiThemeText: geminiConfig.uiTheme ?? "", + uiInlineThinkingModeText: stringOrDefault(geminiConfig.uiInlineThinkingMode, "off"), + sessionRetentionMaxAgeText: geminiConfig.sessionRetentionMaxAge ?? "", + authTypeText: geminiConfig.securityAuthSelectedType ?? "", + }; + + return { + sourceKey: Object.values(values).join("\u0000"), + values, + }; +} + +function geminiDraftReducer(state: GeminiDraftState, action: GeminiDraftAction): GeminiDraftState { + if (action.type === "resetFromConfig") { + return action.state; + } + return { + ...state, + values: { + ...state.values, + [action.key]: action.value, + }, + }; +} + +function GeminiHeader({ + geminiAvailable, + geminiInfo, + loading, + versionRefreshToken, + onRefresh, +}: { + geminiAvailable: CliManagerAvailability; + geminiInfo: SimpleCliInfo | null; + loading: boolean; + versionRefreshToken: number; + onRefresh: () => void; +}) { + return ( +
+
+
+
+
+ +
+
+

Gemini

+
+ {geminiAvailable === "available" && geminiInfo?.found ? ( + <> + + + 已安装 {geminiInfo.version} + + + + ) : geminiAvailable === "checking" || loading ? ( + + + 检测中... + + ) : ( + + 未检测到 + + )} +
+
+
+ + +
+
+
+ ); +} + +function GeminiInfoTile({ + icon, + label, + title, + value, + detail, +}: { + icon: ReactNode; + label: string; + title: string; + value: string; + detail?: ReactNode; +}) { + return ( +
+
+ {icon} + {label} +
+
+ {value} +
+ {detail} +
+ ); +} + +function GeminiInfoGrid({ + configDir, + configPath, + geminiConfig, + geminiInfo, +}: { + configDir: string; + configPath: string; + geminiConfig: GeminiConfigState | null; + geminiInfo: SimpleCliInfo | null; +}) { + return ( +
+ } + label="配置目录" + title={configDir} + value={configDir} + /> + } + label="settings.json" + title={configPath} + value={configPath} + detail={ + geminiConfig ? ( +
+ {geminiConfig.exists ? "已存在" : "不存在(保存时自动创建)"} +
+ ) : null + } + /> + } + label="可执行文件" + title={geminiInfo?.executable_path ?? "—"} + value={geminiInfo?.executable_path ?? "—"} + /> + } + label="解析方式" + title={geminiInfo?.resolved_via ?? "—"} + value={geminiInfo?.resolved_via ?? "—"} + detail={ +
+ SHELL: {geminiInfo?.shell ?? "—"} +
+ } + /> +
+ ); +} + +function GeminiModelBehaviorSection({ + geminiConfig, + modelNameText, + defaultApprovalModeText, + maxAttemptsText, + maxSessionTurnsText, + compressionThresholdText, + saving, + persistGeminiConfig, + setModelNameText, + setDefaultApprovalModeText, + setMaxAttemptsText, + setMaxSessionTurnsText, + setCompressionThresholdText, +}: { + geminiConfig: GeminiConfigState; + modelNameText: string; + defaultApprovalModeText: string; + maxAttemptsText: string; + maxSessionTurnsText: string; + compressionThresholdText: string; + saving: boolean; + persistGeminiConfig: (patch: GeminiConfigPatch) => Promise | void; + setModelNameText: (value: string) => void; + setDefaultApprovalModeText: (value: string) => void; + setMaxAttemptsText: (value: string) => void; + setMaxSessionTurnsText: (value: string) => void; + setCompressionThresholdText: (value: string) => void; +}) { + return ( +
+

+ + 模型与行为 +

+
+ + setModelNameText(e.currentTarget.value)} + onBlur={() => void persistGeminiConfig({ modelName: modelNameText.trim() })} + placeholder="例如:gemini-2.5-pro" + className="font-mono w-[280px] max-w-full" + disabled={saving} + /> + + + + + + + + setMaxAttemptsText(e.currentTarget.value)} + onBlur={() => { + const next = parseIntegerInput(maxAttemptsText); + if (next == null) { + revertNumberField(setMaxAttemptsText, geminiConfig.maxAttempts); + if (maxAttemptsText.trim()) toast.error("maxAttempts 必须为整数"); + return; + } + void persistGeminiConfig({ maxAttempts: next }); + }} + className="font-mono w-[180px] max-w-full" + disabled={saving} + /> + + + + setMaxSessionTurnsText(e.currentTarget.value)} + onBlur={() => { + const next = parseIntegerInput(maxSessionTurnsText); + if (next == null) { + revertNumberField(setMaxSessionTurnsText, geminiConfig.modelMaxSessionTurns); + if (maxSessionTurnsText.trim()) toast.error("maxSessionTurns 必须为整数"); + return; + } + void persistGeminiConfig({ modelMaxSessionTurns: next }); + }} + className="font-mono w-[180px] max-w-full" + disabled={saving} + /> + + + + setCompressionThresholdText(e.currentTarget.value)} + onBlur={() => { + const next = parseFloatInput(compressionThresholdText); + if (next == null) { + revertNumberField( + setCompressionThresholdText, + geminiConfig.modelCompressionThreshold + ); + if (compressionThresholdText.trim()) { + toast.error("compressionThreshold 必须为数字"); + } + return; + } + void persistGeminiConfig({ modelCompressionThreshold: next }); + }} + className="font-mono w-[180px] max-w-full" + disabled={saving} + /> + +
+
+ ); +} + +function GeminiUiSettingsSection({ + geminiConfig, + uiThemeText, + uiInlineThinkingModeText, + saving, + persistGeminiConfig, + setUiThemeText, + setUiInlineThinkingModeText, +}: { + geminiConfig: GeminiConfigState; + uiThemeText: string; + uiInlineThinkingModeText: string; + saving: boolean; + persistGeminiConfig: (patch: GeminiConfigPatch) => Promise | void; + setUiThemeText: (value: string) => void; + setUiInlineThinkingModeText: (value: string) => void; +}) { + return ( +
+

+ + 界面设置 +

+
+ + setUiThemeText(e.currentTarget.value)} + onBlur={() => void persistGeminiConfig({ uiTheme: uiThemeText.trim() })} + className="font-mono w-[220px] max-w-full" + disabled={saving} + /> + + + + void persistGeminiConfig({ uiHideBanner: checked })} + disabled={saving} + /> + + + + void persistGeminiConfig({ uiHideTips: checked })} + disabled={saving} + /> + + + + void persistGeminiConfig({ uiShowLineNumbers: checked })} + disabled={saving} + /> + + + + + + + + void persistGeminiConfig({ vimMode: checked })} + disabled={saving} + /> + +
+
+ ); +} + +function GeminiFeatureTogglesSection({ + geminiConfig, + saving, + persistGeminiConfig, +}: { + geminiConfig: GeminiConfigState; + saving: boolean; + persistGeminiConfig: (patch: GeminiConfigPatch) => Promise | void; +}) { + return ( +
+

+ + 功能开关 +

+
+ + void persistGeminiConfig({ enableAutoUpdate: checked })} + disabled={saving} + /> + + + + + void persistGeminiConfig({ enableNotifications: checked }) + } + disabled={saving} + /> + + + + void persistGeminiConfig({ retryFetchErrors: checked })} + disabled={saving} + /> + + + + + void persistGeminiConfig({ usageStatisticsEnabled: checked }) + } + disabled={saving} + /> + +
+
+ ); +} + +function GeminiSessionAuthSection({ + geminiConfig, + sessionRetentionMaxAgeText, + authTypeText, + saving, + persistGeminiConfig, + setSessionRetentionMaxAgeText, + setAuthTypeText, +}: { + geminiConfig: GeminiConfigState; + sessionRetentionMaxAgeText: string; + authTypeText: string; + saving: boolean; + persistGeminiConfig: (patch: GeminiConfigPatch) => Promise | void; + setSessionRetentionMaxAgeText: (value: string) => void; + setAuthTypeText: (value: string) => void; +}) { + return ( +
+

+ + 会话与认证 +

+
+ + + void persistGeminiConfig({ sessionRetentionEnabled: checked }) + } + disabled={saving} + /> + + + + setSessionRetentionMaxAgeText(e.currentTarget.value)} + onBlur={() => + void persistGeminiConfig({ + sessionRetentionMaxAge: sessionRetentionMaxAgeText.trim(), + }) + } + placeholder="例如:30d" + className="font-mono w-[180px] max-w-full" + disabled={saving} + /> + + + + void persistGeminiConfig({ planModelRouting: checked })} + disabled={saving} + /> + + + + setAuthTypeText(e.currentTarget.value)} + onBlur={() => + void persistGeminiConfig({ + securityAuthSelectedType: authTypeText.trim(), + }) + } + placeholder="例如:gemini-api-key" + className="font-mono w-[220px] max-w-full" + disabled={saving} + /> + +
+
+ ); +} + +function GeminiConfigSections({ + geminiConfig, + draftValues, + saving, + persistGeminiConfig, + setDraftValue, +}: { + geminiConfig: GeminiConfigState; + draftValues: Record; + saving: boolean; + persistGeminiConfig: (patch: GeminiConfigPatch) => Promise | void; + setDraftValue: (key: GeminiDraftKey, value: string) => void; +}) { + return ( + <> + setDraftValue("modelNameText", value)} + setDefaultApprovalModeText={(value) => setDraftValue("defaultApprovalModeText", value)} + setMaxAttemptsText={(value) => setDraftValue("maxAttemptsText", value)} + setMaxSessionTurnsText={(value) => setDraftValue("maxSessionTurnsText", value)} + setCompressionThresholdText={(value) => setDraftValue("compressionThresholdText", value)} + /> + setDraftValue("uiThemeText", value)} + setUiInlineThinkingModeText={(value) => setDraftValue("uiInlineThinkingModeText", value)} + /> + + + setDraftValue("sessionRetentionMaxAgeText", value) + } + setAuthTypeText={(value) => setDraftValue("authTypeText", value)} + /> + + ); +} + export function CliManagerGeminiTab({ geminiAvailable, geminiLoading, @@ -85,29 +761,13 @@ export function CliManagerGeminiTab({ persistGeminiConfig, }: CliManagerGeminiTabProps) { const [versionRefreshToken, setVersionRefreshToken] = useState(0); - const [modelNameText, setModelNameText] = useState(""); - const [defaultApprovalModeText, setDefaultApprovalModeText] = useState("default"); - const [maxAttemptsText, setMaxAttemptsText] = useState(""); - const [maxSessionTurnsText, setMaxSessionTurnsText] = useState(""); - const [compressionThresholdText, setCompressionThresholdText] = useState(""); - const [uiThemeText, setUiThemeText] = useState(""); - const [uiInlineThinkingModeText, setUiInlineThinkingModeText] = useState("off"); - const [sessionRetentionMaxAgeText, setSessionRetentionMaxAgeText] = useState(""); - const [authTypeText, setAuthTypeText] = useState(""); - - useEffect(() => { - if (!geminiConfig) return; - setModelNameText(geminiConfig.modelName ?? ""); - setDefaultApprovalModeText(stringOrDefault(geminiConfig.defaultApprovalMode, "default")); - setMaxAttemptsText(formatNumberInput(geminiConfig.maxAttempts)); - setMaxSessionTurnsText(formatNumberInput(geminiConfig.modelMaxSessionTurns)); - setCompressionThresholdText(formatNumberInput(geminiConfig.modelCompressionThreshold)); - setUiThemeText(geminiConfig.uiTheme ?? ""); - setUiInlineThinkingModeText(stringOrDefault(geminiConfig.uiInlineThinkingMode, "off")); - setSessionRetentionMaxAgeText(geminiConfig.sessionRetentionMaxAge ?? ""); - setAuthTypeText(geminiConfig.securityAuthSelectedType ?? ""); - }, [geminiConfig]); - + const nextDraftState = createGeminiDraftState(geminiConfig); + const [draftState, dispatchDraft] = useReducer(geminiDraftReducer, nextDraftState); + const effectiveDraftState = + draftState.sourceKey === nextDraftState.sourceKey ? draftState : nextDraftState; + if (draftState.sourceKey !== nextDraftState.sourceKey) { + dispatchDraft({ type: "resetFromConfig", state: nextDraftState }); + } const loading = geminiLoading || geminiConfigLoading; const saving = geminiConfigSaving; const configDir = geminiConfig?.configDir ?? "—"; @@ -121,143 +781,27 @@ export function CliManagerGeminiTab({ } } - function revertNumberField( - setter: (value: string) => void, - currentValue: number | null | undefined - ) { - setter(currentValue == null ? "" : String(currentValue)); - } - - function parseIntegerInput(raw: string) { - const trimmed = raw.trim(); - if (!trimmed) return null; - const value = Number(trimmed); - if (!Number.isFinite(value) || !Number.isInteger(value)) return null; - return value; - } - - function parseFloatInput(raw: string) { - const trimmed = raw.trim(); - if (!trimmed) return null; - const value = Number(trimmed); - if (!Number.isFinite(value)) return null; - return value; + function setDraftValue(key: GeminiDraftKey, value: string) { + dispatchDraft({ type: "setValue", key, value }); } return (
-
-
-
-
-
- -
-
-

Gemini

-
- {geminiAvailable === "available" && geminiInfo?.found ? ( - <> - - - 已安装 {geminiInfo.version} - - - - ) : geminiAvailable === "checking" || loading ? ( - - - 检测中... - - ) : ( - - 未检测到 - - )} -
-
-
- - -
- -
-
-
- - 配置目录 -
-
- {configDir} -
-
- -
-
- - settings.json -
-
- {configPath} -
- {geminiConfig ? ( -
- {geminiConfig.exists ? "已存在" : "不存在(保存时自动创建)"} -
- ) : null} -
- -
-
- - 可执行文件 -
-
- {geminiInfo?.executable_path ?? "—"} -
-
- -
-
- - 解析方式 -
-
- {geminiInfo?.resolved_via ?? "—"} -
-
- SHELL: {geminiInfo?.shell ?? "—"} -
-
-
-
+ void refreshGeminiStatus()} + /> +
+
{geminiAvailable === "unavailable" ? ( @@ -267,341 +811,13 @@ export function CliManagerGeminiTab({ ) : (
{geminiConfig ? ( - <> -
-

- - 模型与行为 -

-
- - setModelNameText(e.currentTarget.value)} - onBlur={() => void persistGeminiConfig({ modelName: modelNameText.trim() })} - placeholder="例如:gemini-2.5-pro" - className="font-mono w-[280px] max-w-full" - disabled={saving} - /> - - - - - - - - setMaxAttemptsText(e.currentTarget.value)} - onBlur={() => { - const next = parseIntegerInput(maxAttemptsText); - if (next == null) { - revertNumberField(setMaxAttemptsText, geminiConfig.maxAttempts); - if (maxAttemptsText.trim()) { - toast.error("maxAttempts 必须为整数"); - } - return; - } - void persistGeminiConfig({ maxAttempts: next }); - }} - className="font-mono w-[180px] max-w-full" - disabled={saving} - /> - - - - setMaxSessionTurnsText(e.currentTarget.value)} - onBlur={() => { - const next = parseIntegerInput(maxSessionTurnsText); - if (next == null) { - revertNumberField( - setMaxSessionTurnsText, - geminiConfig.modelMaxSessionTurns - ); - if (maxSessionTurnsText.trim()) { - toast.error("maxSessionTurns 必须为整数"); - } - return; - } - void persistGeminiConfig({ modelMaxSessionTurns: next }); - }} - className="font-mono w-[180px] max-w-full" - disabled={saving} - /> - - - - setCompressionThresholdText(e.currentTarget.value)} - onBlur={() => { - const next = parseFloatInput(compressionThresholdText); - if (next == null) { - revertNumberField( - setCompressionThresholdText, - geminiConfig.modelCompressionThreshold - ); - if (compressionThresholdText.trim()) { - toast.error("compressionThreshold 必须为数字"); - } - return; - } - void persistGeminiConfig({ modelCompressionThreshold: next }); - }} - className="font-mono w-[180px] max-w-full" - disabled={saving} - /> - -
-
- -
-

- - 界面设置 -

-
- - setUiThemeText(e.currentTarget.value)} - onBlur={() => void persistGeminiConfig({ uiTheme: uiThemeText.trim() })} - className="font-mono w-[220px] max-w-full" - disabled={saving} - /> - - - - - void persistGeminiConfig({ uiHideBanner: checked }) - } - disabled={saving} - /> - - - - - void persistGeminiConfig({ uiHideTips: checked }) - } - disabled={saving} - /> - - - - - void persistGeminiConfig({ uiShowLineNumbers: checked }) - } - disabled={saving} - /> - - - - - - - - - void persistGeminiConfig({ vimMode: checked }) - } - disabled={saving} - /> - -
-
- -
-

- - 功能开关 -

-
- - - void persistGeminiConfig({ enableAutoUpdate: checked }) - } - disabled={saving} - /> - - - - - void persistGeminiConfig({ enableNotifications: checked }) - } - disabled={saving} - /> - - - - - void persistGeminiConfig({ retryFetchErrors: checked }) - } - disabled={saving} - /> - - - - - void persistGeminiConfig({ usageStatisticsEnabled: checked }) - } - disabled={saving} - /> - -
-
- -
-

- - 会话与认证 -

-
- - - void persistGeminiConfig({ sessionRetentionEnabled: checked }) - } - disabled={saving} - /> - - - - setSessionRetentionMaxAgeText(e.currentTarget.value)} - onBlur={() => - void persistGeminiConfig({ - sessionRetentionMaxAge: sessionRetentionMaxAgeText.trim(), - }) - } - placeholder="例如:30d" - className="font-mono w-[180px] max-w-full" - disabled={saving} - /> - - - - - void persistGeminiConfig({ planModelRouting: checked }) - } - disabled={saving} - /> - - - - setAuthTypeText(e.currentTarget.value)} - onBlur={() => - void persistGeminiConfig({ - securityAuthSelectedType: authTypeText.trim(), - }) - } - placeholder="例如:gemini-api-key" - className="font-mono w-[220px] max-w-full" - disabled={saving} - /> - -
-
- + ) : (
暂无配置,请尝试刷新 diff --git a/src/components/cli-manager/tabs/GeneralTab.tsx b/src/components/cli-manager/tabs/GeneralTab.tsx index 622803c6..306a506f 100644 --- a/src/components/cli-manager/tabs/GeneralTab.tsx +++ b/src/components/cli-manager/tabs/GeneralTab.tsx @@ -1,5 +1,5 @@ import type { KeyboardEvent as ReactKeyboardEvent } from "react"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { toast } from "sonner"; import { useNavigate } from "react-router-dom"; import { CACHE_ANOMALY_MONITOR_GUIDE_COPY } from "../../../services/gateway/cacheAnomalyMonitorConfig"; @@ -75,6 +75,586 @@ export type CliManagerGeneralTabProps = { blurOnEnter: (e: ReactKeyboardEvent) => void; }; +type CommonSettingsPatch = Partial & { + upstream_proxy_password?: SensitiveStringUpdate; +}; + +type PersistCommonSettings = (patch: CommonSettingsPatch) => Promise; + +type NumberSettingInputProps = { + value: number; + min: number; + max: number; + unit: string; + disabled: boolean; + onValueChange: (value: number) => void; + onKeyDown: (e: ReactKeyboardEvent) => void; + onBlur: (value: number) => void; +}; + +function NumberSettingInput({ + value, + min, + max, + unit, + disabled, + onValueChange, + onKeyDown, + onBlur, +}: NumberSettingInputProps) { + return ( +
+ { + const next = e.currentTarget.valueAsNumber; + if (Number.isFinite(next)) onValueChange(next); + }} + onBlur={(e) => onBlur(e.currentTarget.valueAsNumber)} + onKeyDown={onKeyDown} + style={{ width: "5rem" }} + min={min} + max={max} + disabled={disabled} + /> + {unit} +
+ ); +} + +function GatewayRectifierSettingsSection({ + rectifier, + disabled, + codexSessionIdCompletionEnabled, + codexCompletionDisabled, + onPersistRectifier, + onPersistCodexSessionIdCompletion, +}: { + rectifier: GatewayRectifierSettingsPatch; + disabled: boolean; + codexSessionIdCompletionEnabled: boolean; + codexCompletionDisabled: boolean; + onPersistRectifier: (patch: Partial) => Promise | void; + onPersistCodexSessionIdCompletion: (enable: boolean) => Promise | void; +}) { + return ( +
+

+ + 网关整流器 +

+
+ + + void onPersistRectifier({ verbose_provider_error: checked }) + } + disabled={disabled} + /> + + + + void onPersistRectifier({ intercept_anthropic_warmup_requests: checked }) + } + disabled={disabled} + /> + + + + void onPersistRectifier({ enable_thinking_signature_rectifier: checked }) + } + disabled={disabled} + /> + + + + void onPersistRectifier({ enable_thinking_budget_rectifier: checked }) + } + disabled={disabled} + /> + + + + void onPersistRectifier({ enable_billing_header_rectifier: checked }) + } + disabled={disabled} + /> + + + + void onPersistRectifier({ enable_claude_metadata_user_id_injection: checked }) + } + disabled={disabled} + /> + + + + void onPersistRectifier({ enable_response_fixer: checked }) + } + disabled={disabled} + /> + + {rectifier.enable_response_fixer && ( + <> + + + void onPersistRectifier({ response_fixer_fix_encoding: checked }) + } + disabled={disabled} + /> + + + + void onPersistRectifier({ response_fixer_fix_sse_format: checked }) + } + disabled={disabled} + /> + + + + void onPersistRectifier({ response_fixer_fix_truncated_json: checked }) + } + disabled={disabled} + /> + + + )} + + void onPersistCodexSessionIdCompletion(checked)} + disabled={codexCompletionDisabled} + /> + +
+
+ ); +} + +function NotificationSettingsSection({ + taskCompleteNotifyEnabled, + taskNotifyDisabled, + circuitBreakerNoticeEnabled, + circuitNoticeDisabled, + notificationSoundEnabled, + notificationSoundDisabled, + onPersistTaskCompleteNotify, + onPersistCircuitBreakerNotice, + onPersistNotificationSound, +}: { + taskCompleteNotifyEnabled: boolean; + taskNotifyDisabled: boolean; + circuitBreakerNoticeEnabled: boolean; + circuitNoticeDisabled: boolean; + notificationSoundEnabled: boolean; + notificationSoundDisabled: boolean; + onPersistTaskCompleteNotify: (enable: boolean) => Promise | void; + onPersistCircuitBreakerNotice: (enable: boolean) => Promise | void; + onPersistNotificationSound: (enable: boolean) => Promise | void; +}) { + return ( +
+

+ + 通知 +

+

+ 控制系统通知与音效提醒行为。 + + * 需在系统设置中授予通知权限 + +

+
+ + void onPersistTaskCompleteNotify(checked)} + disabled={taskNotifyDisabled} + /> + + + void onPersistCircuitBreakerNotice(checked)} + disabled={circuitNoticeDisabled} + /> + + + void onPersistNotificationSound(checked)} + disabled={notificationSoundDisabled} + /> + +
+
+ ); +} + +function CacheAnomalyMonitorSection({ + enabled, + disabled, + onPersist, +}: { + enabled: boolean; + disabled: boolean; + onPersist: (enable: boolean) => Promise | void; +}) { + const navigate = useNavigate(); + + return ( +
+

+ + 缓存异常监测(实验) +

+

+ {CACHE_ANOMALY_MONITOR_GUIDE_COPY.overview} +

+
+ + void onPersist(checked)} + disabled={disabled} + /> + +
+
+

{CACHE_ANOMALY_MONITOR_GUIDE_COPY.coldStart}

+

{CACHE_ANOMALY_MONITOR_GUIDE_COPY.nonCachingModel}

+

{CACHE_ANOMALY_MONITOR_GUIDE_COPY.thresholds}

+
+
+ + 提示:告警会以 WARN{" "} + 写入「控制台」页(无需开启调试日志)。 + + +
+
+ ); +} + +function StartupRecoverySection({ + disabled, + settings, + onPersistSettings, +}: { + disabled: boolean; + settings: AppSettings; + onPersistSettings: PersistCommonSettings; +}) { + return ( +
+

+ + 启动与恢复 +

+
+ + + void onPersistSettings({ enable_cli_proxy_startup_recovery: checked }) + } + disabled={disabled} + /> + +
+
+ ); +} + +function TimeoutSettingsSection({ + disabled, + settings, + upstreamFirstByteTimeoutSeconds, + setUpstreamFirstByteTimeoutSeconds, + upstreamStreamIdleTimeoutSeconds, + setUpstreamStreamIdleTimeoutSeconds, + upstreamRequestTimeoutNonStreamingSeconds, + setUpstreamRequestTimeoutNonStreamingSeconds, + blurOnEnter, + onPersistSettings, +}: { + disabled: boolean; + settings: AppSettings | null; + upstreamFirstByteTimeoutSeconds: number; + setUpstreamFirstByteTimeoutSeconds: (value: number) => void; + upstreamStreamIdleTimeoutSeconds: number; + setUpstreamStreamIdleTimeoutSeconds: (value: number) => void; + upstreamRequestTimeoutNonStreamingSeconds: number; + setUpstreamRequestTimeoutNonStreamingSeconds: (value: number) => void; + blurOnEnter: (e: ReactKeyboardEvent) => void; + onPersistSettings: PersistCommonSettings; +}) { + return ( +
+

+ + 超时策略 +

+

+ 控制上游请求的超时行为。0 表示禁用(交由上游/网络自行超时)。 +

+
+ + { + if (!settings) return; + if (!Number.isFinite(next) || next < 0 || next > 3600) { + toast("上游首字节超时必须为 0-3600 秒"); + setUpstreamFirstByteTimeoutSeconds(settings.upstream_first_byte_timeout_seconds); + return; + } + void onPersistSettings({ upstream_first_byte_timeout_seconds: next }); + }} + /> + + + + { + if (!settings) return; + if (!Number.isFinite(next) || next < 0 || next > 3600 || (next > 0 && next < 60)) { + toast("上游流式空闲超时必须为 0(禁用)或 60-3600 秒"); + setUpstreamStreamIdleTimeoutSeconds(settings.upstream_stream_idle_timeout_seconds); + return; + } + void onPersistSettings({ upstream_stream_idle_timeout_seconds: next }); + }} + /> + + + + { + if (!settings) return; + if (!Number.isFinite(next) || next < 0 || next > 86400) { + toast("上游非流式总超时必须为 0-86400 秒"); + setUpstreamRequestTimeoutNonStreamingSeconds( + settings.upstream_request_timeout_non_streaming_seconds + ); + return; + } + void onPersistSettings({ upstream_request_timeout_non_streaming_seconds: next }); + }} + /> + +
+
+ ); +} + +function CircuitBreakerSettingsSection({ + disabled, + settings, + providerCooldownSeconds, + setProviderCooldownSeconds, + providerBaseUrlPingCacheTtlSeconds, + setProviderBaseUrlPingCacheTtlSeconds, + circuitBreakerFailureThreshold, + setCircuitBreakerFailureThreshold, + circuitBreakerOpenDurationMinutes, + setCircuitBreakerOpenDurationMinutes, + blurOnEnter, + onPersistSettings, +}: { + disabled: boolean; + settings: AppSettings | null; + providerCooldownSeconds: number; + setProviderCooldownSeconds: (value: number) => void; + providerBaseUrlPingCacheTtlSeconds: number; + setProviderBaseUrlPingCacheTtlSeconds: (value: number) => void; + circuitBreakerFailureThreshold: number; + setCircuitBreakerFailureThreshold: (value: number) => void; + circuitBreakerOpenDurationMinutes: number; + setCircuitBreakerOpenDurationMinutes: (value: number) => void; + blurOnEnter: (e: ReactKeyboardEvent) => void; + onPersistSettings: PersistCommonSettings; +}) { + return ( +
+

+ + 熔断与重试 +

+

+ 控制 Provider 失败后的冷却、重试与熔断行为。修改后建议重启网关以完全生效。 +

+
+ + { + if (!settings) return; + if (!Number.isFinite(next) || next < 0 || next > 3600) { + toast("短熔断冷却必须为 0-3600 秒"); + setProviderCooldownSeconds(settings.provider_cooldown_seconds); + return; + } + void onPersistSettings({ provider_cooldown_seconds: next }); + }} + /> + + + + { + if (!settings) return; + if (!Number.isFinite(next) || next < 1 || next > 3600) { + toast("Ping 选择缓存 TTL 必须为 1-3600 秒"); + setProviderBaseUrlPingCacheTtlSeconds( + settings.provider_base_url_ping_cache_ttl_seconds + ); + return; + } + void onPersistSettings({ provider_base_url_ping_cache_ttl_seconds: next }); + }} + /> + + + + { + if (!settings) return; + if (!Number.isFinite(next) || next < 1 || next > 50) { + toast("熔断阈值必须为 1-50"); + setCircuitBreakerFailureThreshold(settings.circuit_breaker_failure_threshold); + return; + } + void onPersistSettings({ circuit_breaker_failure_threshold: next }); + }} + /> + + + + { + if (!settings) return; + if (!Number.isFinite(next) || next < 1 || next > 1440) { + toast("熔断时长必须为 1-1440 分钟"); + setCircuitBreakerOpenDurationMinutes( + settings.circuit_breaker_open_duration_minutes + ); + return; + } + void onPersistSettings({ circuit_breaker_open_duration_minutes: next }); + }} + /> + +
+
+ ); +} + export function CliManagerGeneralTab({ rectifierAvailable, settingsReadErrorMessage, @@ -116,7 +696,6 @@ export function CliManagerGeneralTab({ setCircuitBreakerOpenDurationMinutes, blurOnEnter, }: CliManagerGeneralTabProps) { - const navigate = useNavigate(); const settingsUnavailable = rectifierAvailable !== "available"; const rectifierDisabled = rectifierSaving || settingsUnavailable || settingsWriteBlocked; const circuitNoticeDisabled = @@ -150,240 +729,39 @@ export function CliManagerGeneralTab({
数据不可用
) : (
-
-

- - 网关整流器 -

-
- - - void onPersistRectifier({ verbose_provider_error: checked }) - } - disabled={rectifierDisabled} - /> - - - - void onPersistRectifier({ intercept_anthropic_warmup_requests: checked }) - } - disabled={rectifierDisabled} - /> - - - - void onPersistRectifier({ enable_thinking_signature_rectifier: checked }) - } - disabled={rectifierDisabled} - /> - - - - void onPersistRectifier({ enable_thinking_budget_rectifier: checked }) - } - disabled={rectifierDisabled} - /> - - - - void onPersistRectifier({ enable_billing_header_rectifier: checked }) - } - disabled={rectifierDisabled} - /> - - - - void onPersistRectifier({ - enable_claude_metadata_user_id_injection: checked, - }) - } - disabled={rectifierDisabled} - /> - - - - void onPersistRectifier({ enable_response_fixer: checked }) - } - disabled={rectifierDisabled} - /> - - {rectifier.enable_response_fixer && ( - <> - - - void onPersistRectifier({ response_fixer_fix_encoding: checked }) - } - disabled={rectifierDisabled} - /> - - - - void onPersistRectifier({ response_fixer_fix_sse_format: checked }) - } - disabled={rectifierDisabled} - /> - - - - void onPersistRectifier({ response_fixer_fix_truncated_json: checked }) - } - disabled={rectifierDisabled} - /> - - - )} - - void onPersistCodexSessionIdCompletion(checked)} - disabled={codexCompletionDisabled} - /> - -
-
+ -
-

- - 通知 -

-

- 控制系统通知与音效提醒行为。 - - * 需在系统设置中授予通知权限 - -

-
- - void onPersistTaskCompleteNotify(checked)} - disabled={taskNotifyDisabled} - /> - - - void onPersistCircuitBreakerNotice(checked)} - disabled={circuitNoticeDisabled} - /> - - - void onPersistNotificationSound(checked)} - disabled={notificationSoundDisabled} - /> - -
-
+ -
-

- - 缓存异常监测(实验) -

-

- {CACHE_ANOMALY_MONITOR_GUIDE_COPY.overview} -

-
- - void onPersistCacheAnomalyMonitor(checked)} - disabled={cacheMonitorDisabled} - /> - -
-
-

{CACHE_ANOMALY_MONITOR_GUIDE_COPY.coldStart}

-

{CACHE_ANOMALY_MONITOR_GUIDE_COPY.nonCachingModel}

-

{CACHE_ANOMALY_MONITOR_GUIDE_COPY.thresholds}

-
-
- - 提示:告警会以 WARN{" "} - 写入「控制台」页(无需开启调试日志)。 - - -
-
+ {appSettings ? ( -
-

- - 启动与恢复 -

-
- - - void onPersistCommonSettings({ - enable_cli_proxy_startup_recovery: checked, - }) - } - disabled={commonSettingsDisabled} - /> - -
-
+ ) : null} {appSettings ? ( @@ -408,265 +786,35 @@ export function CliManagerGeneralTab({ ) : null} -
-

- - 超时策略 -

-

- 控制上游请求的超时行为。0 表示禁用(交由上游/网络自行超时)。 -

-
- -
- { - const next = e.currentTarget.valueAsNumber; - if (Number.isFinite(next)) setUpstreamFirstByteTimeoutSeconds(next); - }} - onBlur={(e) => { - if (!appSettings) return; - const next = e.currentTarget.valueAsNumber; - if (!Number.isFinite(next) || next < 0 || next > 3600) { - toast("上游首字节超时必须为 0-3600 秒"); - setUpstreamFirstByteTimeoutSeconds( - appSettings.upstream_first_byte_timeout_seconds - ); - return; - } - void onPersistCommonSettings({ upstream_first_byte_timeout_seconds: next }); - }} - onKeyDown={blurOnEnter} - style={{ width: "5rem" }} - min={0} - max={3600} - disabled={commonSettingsDisabled} - /> - -
-
- - -
- { - const next = e.currentTarget.valueAsNumber; - if (Number.isFinite(next)) setUpstreamStreamIdleTimeoutSeconds(next); - }} - onBlur={(e) => { - if (!appSettings) return; - const next = e.currentTarget.valueAsNumber; - if ( - !Number.isFinite(next) || - next < 0 || - next > 3600 || - (next > 0 && next < 60) - ) { - toast("上游流式空闲超时必须为 0(禁用)或 60-3600 秒"); - setUpstreamStreamIdleTimeoutSeconds( - appSettings.upstream_stream_idle_timeout_seconds - ); - return; - } - void onPersistCommonSettings({ - upstream_stream_idle_timeout_seconds: next, - }); - }} - onKeyDown={blurOnEnter} - style={{ width: "5rem" }} - min={0} - max={3600} - disabled={commonSettingsDisabled} - /> - -
-
- - -
- { - const next = e.currentTarget.valueAsNumber; - if (Number.isFinite(next)) - setUpstreamRequestTimeoutNonStreamingSeconds(next); - }} - onBlur={(e) => { - if (!appSettings) return; - const next = e.currentTarget.valueAsNumber; - if (!Number.isFinite(next) || next < 0 || next > 86400) { - toast("上游非流式总超时必须为 0-86400 秒"); - setUpstreamRequestTimeoutNonStreamingSeconds( - appSettings.upstream_request_timeout_non_streaming_seconds - ); - return; - } - void onPersistCommonSettings({ - upstream_request_timeout_non_streaming_seconds: next, - }); - }} - onKeyDown={blurOnEnter} - style={{ width: "5rem" }} - min={0} - max={86400} - disabled={commonSettingsDisabled} - /> - -
-
-
-
+ -
-

- - 熔断与重试 -

-

- 控制 Provider 失败后的冷却、重试与熔断行为。修改后建议重启网关以完全生效。 -

-
- -
- { - const next = e.currentTarget.valueAsNumber; - if (Number.isFinite(next)) setProviderCooldownSeconds(next); - }} - onBlur={(e) => { - if (!appSettings) return; - const next = e.currentTarget.valueAsNumber; - if (!Number.isFinite(next) || next < 0 || next > 3600) { - toast("短熔断冷却必须为 0-3600 秒"); - setProviderCooldownSeconds(appSettings.provider_cooldown_seconds); - return; - } - void onPersistCommonSettings({ provider_cooldown_seconds: next }); - }} - onKeyDown={blurOnEnter} - style={{ width: "5rem" }} - min={0} - max={3600} - disabled={commonSettingsDisabled} - /> - -
-
- - -
- { - const next = e.currentTarget.valueAsNumber; - if (Number.isFinite(next)) setProviderBaseUrlPingCacheTtlSeconds(next); - }} - onBlur={(e) => { - if (!appSettings) return; - const next = e.currentTarget.valueAsNumber; - if (!Number.isFinite(next) || next < 1 || next > 3600) { - toast("Ping 选择缓存 TTL 必须为 1-3600 秒"); - setProviderBaseUrlPingCacheTtlSeconds( - appSettings.provider_base_url_ping_cache_ttl_seconds - ); - return; - } - void onPersistCommonSettings({ - provider_base_url_ping_cache_ttl_seconds: next, - }); - }} - onKeyDown={blurOnEnter} - style={{ width: "5rem" }} - min={1} - max={3600} - disabled={commonSettingsDisabled} - /> - -
-
- - -
- { - const next = e.currentTarget.valueAsNumber; - if (Number.isFinite(next)) setCircuitBreakerFailureThreshold(next); - }} - onBlur={(e) => { - if (!appSettings) return; - const next = e.currentTarget.valueAsNumber; - if (!Number.isFinite(next) || next < 1 || next > 50) { - toast("熔断阈值必须为 1-50"); - setCircuitBreakerFailureThreshold( - appSettings.circuit_breaker_failure_threshold - ); - return; - } - void onPersistCommonSettings({ circuit_breaker_failure_threshold: next }); - }} - onKeyDown={blurOnEnter} - style={{ width: "5rem" }} - min={1} - max={50} - disabled={commonSettingsDisabled} - /> - -
-
- - -
- { - const next = e.currentTarget.valueAsNumber; - if (Number.isFinite(next)) setCircuitBreakerOpenDurationMinutes(next); - }} - onBlur={(e) => { - if (!appSettings) return; - const next = e.currentTarget.valueAsNumber; - if (!Number.isFinite(next) || next < 1 || next > 1440) { - toast("熔断时长必须为 1-1440 分钟"); - setCircuitBreakerOpenDurationMinutes( - appSettings.circuit_breaker_open_duration_minutes - ); - return; - } - void onPersistCommonSettings({ - circuit_breaker_open_duration_minutes: next, - }); - }} - onKeyDown={blurOnEnter} - style={{ width: "5rem" }} - min={1} - max={1440} - disabled={commonSettingsDisabled} - /> - 分钟 -
-
-
-
+
)} @@ -683,33 +831,82 @@ type UpstreamProxySettingsCardProps = { ) => Promise; }; -function UpstreamProxySettingsCard({ +type UpstreamProxyDraft = { + settingsKey: string; + proxyUrl: string; + proxyUsername: string; + proxyPassword: string; + clearSavedPassword: boolean; + hasPendingEdits: boolean; +}; + +type UpstreamProxySettingsController = { + clearSavedPassword: boolean; + detectingExitIp: boolean; + disabled: boolean; + proxyPassword: string; + proxyUrl: string; + proxyUsername: string; + testingConnection: boolean; + handleDetectProxyExitIp: () => void; + handleProxyEnabledChange: (enabled: boolean) => void; + handleTestProxy: () => void; + persistProxyFields: (options?: { successMessage?: string }) => Promise; + updateProxyDraft: (patch: Partial>) => void; + toggleClearSavedPassword: () => void; +}; + +function buildUpstreamProxySettingsKey(settings: AppSettings) { + return [ + settings.upstream_proxy_url ?? "", + settings.upstream_proxy_username ?? "", + settings.upstream_proxy_password_configured ? "password" : "no-password", + ].join("\u0000"); +} + +function buildUpstreamProxyDraft(settings: AppSettings): UpstreamProxyDraft { + return { + settingsKey: buildUpstreamProxySettingsKey(settings), + proxyUrl: settings.upstream_proxy_url ?? "", + proxyUsername: settings.upstream_proxy_username ?? "", + proxyPassword: "", + clearSavedPassword: false, + hasPendingEdits: false, + }; +} + +function useUpstreamProxySettingsController({ available, saving, settings, onPersistSettings, -}: UpstreamProxySettingsCardProps) { - const [proxyUrl, setProxyUrl] = useState(settings.upstream_proxy_url ?? ""); - const [proxyUsername, setProxyUsername] = useState(settings.upstream_proxy_username ?? ""); - const [proxyPassword, setProxyPassword] = useState(""); - const [clearSavedPassword, setClearSavedPassword] = useState(false); +}: UpstreamProxySettingsCardProps): UpstreamProxySettingsController { + const settingsKey = buildUpstreamProxySettingsKey(settings); + const [proxyDraft, setProxyDraft] = useState(() => buildUpstreamProxyDraft(settings)); + let effectiveProxyDraft = proxyDraft; + + if (!proxyDraft.hasPendingEdits && proxyDraft.settingsKey !== settingsKey) { + effectiveProxyDraft = buildUpstreamProxyDraft(settings); + setProxyDraft(effectiveProxyDraft); + } + const [testingConnection, setTestingConnection] = useState(false); const [detectingExitIp, setDetectingExitIp] = useState(false); - const [hasPendingEdits, setHasPendingEdits] = useState(false); const disabled = !available || saving; + const { proxyUrl, proxyUsername, proxyPassword, clearSavedPassword } = effectiveProxyDraft; + + function updateProxyDraft(patch: Partial>) { + setProxyDraft((current) => ({ ...current, ...patch })); + } - useEffect(() => { - if (hasPendingEdits) return; - setProxyUrl(settings.upstream_proxy_url ?? ""); - setProxyUsername(settings.upstream_proxy_username ?? ""); - setProxyPassword(""); - setClearSavedPassword(false); - }, [ - hasPendingEdits, - settings.upstream_proxy_password_configured, - settings.upstream_proxy_url, - settings.upstream_proxy_username, - ]); + function toggleClearSavedPassword() { + setProxyDraft((current) => ({ + ...current, + hasPendingEdits: true, + proxyPassword: "", + clearSavedPassword: !current.clearSavedPassword, + })); + } function resolveProxyPasswordPatch(): SensitiveStringUpdate { if (clearSavedPassword) { @@ -722,11 +919,7 @@ function UpstreamProxySettingsCard({ } function resetProxyDraft() { - setProxyUrl(settings.upstream_proxy_url); - setProxyUsername(settings.upstream_proxy_username); - setProxyPassword(""); - setClearSavedPassword(false); - setHasPendingEdits(false); + setProxyDraft(buildUpstreamProxyDraft(settings)); } function validateProxyDraft(options: { @@ -765,8 +958,7 @@ function UpstreamProxySettingsCard({ upstream_proxy_password: resolveProxyPasswordPatch(), }); if (updated) { - setProxyPassword(""); - setClearSavedPassword(false); + updateProxyDraft({ proxyPassword: "", clearSavedPassword: false }); toast.success(enabled ? "代理已启用" : "代理已禁用"); } } @@ -782,7 +974,7 @@ function UpstreamProxySettingsCard({ sensitiveChanged; if (!fieldsChanged) { - setHasPendingEdits(false); + updateProxyDraft({ hasPendingEdits: false }); return; } if (settings.upstream_proxy_enabled && !trimmedUrl) { @@ -804,13 +996,12 @@ function UpstreamProxySettingsCard({ upstream_proxy_username: trimmedUsername, upstream_proxy_password: resolveProxyPasswordPatch(), }); - setHasPendingEdits(false); + updateProxyDraft({ hasPendingEdits: false }); if (!updated) { resetProxyDraft(); return; } - setProxyPassword(""); - setClearSavedPassword(false); + updateProxyDraft({ proxyPassword: "", clearSavedPassword: false }); if (options?.successMessage) { toast.success(options.successMessage); } @@ -882,6 +1073,46 @@ function UpstreamProxySettingsCard({ } } + return { + clearSavedPassword, + detectingExitIp, + disabled, + proxyPassword, + proxyUrl, + proxyUsername, + testingConnection, + handleDetectProxyExitIp, + handleProxyEnabledChange, + handleTestProxy, + persistProxyFields, + updateProxyDraft, + toggleClearSavedPassword, + }; +} + +function UpstreamProxySettingsForm({ + controller, + settings, +}: { + controller: UpstreamProxySettingsController; + settings: AppSettings; +}) { + const { + clearSavedPassword, + detectingExitIp, + disabled, + proxyPassword, + proxyUrl, + proxyUsername, + testingConnection, + handleDetectProxyExitIp, + handleProxyEnabledChange, + handleTestProxy, + persistProxyFields, + updateProxyDraft, + toggleClearSavedPassword, + } = controller; + return (

@@ -909,8 +1140,7 @@ function UpstreamProxySettingsCard({ type="text" value={proxyUrl} onChange={(e) => { - setHasPendingEdits(true); - setProxyUrl(e.currentTarget.value); + updateProxyDraft({ hasPendingEdits: true, proxyUrl: e.currentTarget.value }); }} onBlur={() => void persistProxyFields({ @@ -944,8 +1174,7 @@ function UpstreamProxySettingsCard({ type="text" value={proxyUsername} onChange={(e) => { - setHasPendingEdits(true); - setProxyUsername(e.currentTarget.value); + updateProxyDraft({ hasPendingEdits: true, proxyUsername: e.currentTarget.value }); }} onBlur={() => void persistProxyFields({ @@ -962,9 +1191,11 @@ function UpstreamProxySettingsCard({ type="password" value={proxyPassword} onChange={(e) => { - setHasPendingEdits(true); - setProxyPassword(e.currentTarget.value); - setClearSavedPassword(false); + updateProxyDraft({ + hasPendingEdits: true, + proxyPassword: e.currentTarget.value, + clearSavedPassword: false, + }); }} onBlur={() => void persistProxyFields({ @@ -986,11 +1217,7 @@ function UpstreamProxySettingsCard({ type="button" className="text-accent hover:text-accent/80" disabled={disabled} - onClick={() => { - setHasPendingEdits(true); - setProxyPassword(""); - setClearSavedPassword((prev) => !prev); - }} + onClick={toggleClearSavedPassword} > {clearSavedPassword ? "取消清空" : "清空已保存密码"} @@ -1001,3 +1228,8 @@ function UpstreamProxySettingsCard({

); } + +function UpstreamProxySettingsCard(props: UpstreamProxySettingsCardProps) { + const controller = useUpstreamProxySettingsController(props); + return ; +} diff --git a/src/components/cli-manager/tabs/__tests__/ClaudeOAuthCard.test.tsx b/src/components/cli-manager/tabs/__tests__/ClaudeOAuthCard.test.tsx index f6caa0ab..4fcf6373 100644 --- a/src/components/cli-manager/tabs/__tests__/ClaudeOAuthCard.test.tsx +++ b/src/components/cli-manager/tabs/__tests__/ClaudeOAuthCard.test.tsx @@ -60,6 +60,7 @@ function makeProvider(overrides: Partial = {}): ProviderSummary api_key_configured: overrides.api_key_configured ?? false, ...overrides, stream_idle_timeout_seconds: overrides.stream_idle_timeout_seconds ?? null, + extension_values: overrides.extension_values ?? [], }; } diff --git a/src/components/cli-manager/tabs/__tests__/ClaudeTab.test.tsx b/src/components/cli-manager/tabs/__tests__/ClaudeTab.test.tsx index a232d841..d7a284a8 100644 --- a/src/components/cli-manager/tabs/__tests__/ClaudeTab.test.tsx +++ b/src/components/cli-manager/tabs/__tests__/ClaudeTab.test.tsx @@ -560,4 +560,137 @@ describe("components/cli-manager/tabs/ClaudeTab", () => { expect(screen.getByText("添加 Hook")).toBeInTheDocument(); expect(toast).toHaveBeenCalledWith("保存 Hooks 失败:请稍后重试"); }); + + it("edits hook commands with per-hook timeout and deletes groups", async () => { + const mutateAsync = vi.fn().mockResolvedValue(undefined); + vi.mocked(useCliManagerClaudeHooksQuery).mockReturnValue({ + data: { + settings_path: "/home/user/.claude/settings.json", + groups: [ + { + event: "PreToolUse", + matcher: "Edit|Write", + hooks: [ + { hook_type: "command", command: "echo first", timeout: 5 }, + { hook_type: "command", command: "echo second", timeout: null }, + ], + }, + { + event: "Notification", + matcher: "", + hooks: [{ hook_type: "command", command: "echo notify", timeout: null }], + }, + ], + }, + error: null, + isError: false, + isLoading: false, + refetch: vi.fn(), + } as any); + vi.mocked(useCliManagerClaudeHooksSetMutation).mockReturnValue({ + isPending: false, + mutateAsync, + } as any); + + render( + + ); + + expect(screen.getByText("Edit|Write")).toBeInTheDocument(); + expect(screen.getByText("(5s)")).toBeInTheDocument(); + + fireEvent.click(screen.getAllByTitle("编辑此命令")[0]); + fireEvent.change(screen.getByPlaceholderText("要执行的 shell 命令"), { + target: { value: " echo edited " }, + }); + fireEvent.change(screen.getByPlaceholderText("例如 30"), { + target: { value: "12s" }, + }); + expect(screen.getByPlaceholderText("例如 30")).toHaveValue("12"); + fireEvent.click(screen.getByRole("button", { name: "保存" })); + + await waitFor(() => { + expect(mutateAsync).toHaveBeenCalledWith({ + groups: [ + { + event: "PreToolUse", + matcher: "Edit|Write", + hooks: [ + { hook_type: "command", command: "echo edited", timeout: 12 }, + { hook_type: "command", command: "echo second", timeout: null }, + ], + }, + { + event: "Notification", + matcher: "", + hooks: [{ hook_type: "command", command: "echo notify", timeout: null }], + }, + ], + }); + }); + expect(toast).toHaveBeenCalledWith("已保存 Hooks 配置"); + + fireEvent.click(screen.getAllByTitle("删除")[0]); + expect(screen.getByText("确认删除 Hook")).toBeInTheDocument(); + expect(screen.getByText(/matcher: Edit\|Write/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "确认删除" })); + + await waitFor(() => { + expect(mutateAsync).toHaveBeenLastCalledWith({ + groups: [ + { + event: "Notification", + matcher: "", + hooks: [{ hook_type: "command", command: "echo notify", timeout: null }], + }, + ], + }); + }); + }); + + it("validates hook timeout before saving", async () => { + const mutateAsync = vi.fn(); + vi.mocked(useCliManagerClaudeHooksSetMutation).mockReturnValue({ + isPending: false, + mutateAsync, + } as any); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: /添加/ })); + fireEvent.change(screen.getByPlaceholderText("要执行的 shell 命令"), { + target: { value: "echo invalid timeout" }, + }); + fireEvent.change(screen.getByPlaceholderText("例如 30"), { + target: { value: String(Number.MAX_SAFE_INTEGER + 1) }, + }); + fireEvent.click(screen.getByRole("button", { name: "保存" })); + + expect(toast).toHaveBeenCalledWith("超时必须为非负安全整数"); + expect(mutateAsync).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/cli-manager/tabs/__tests__/CodexTab.test.tsx b/src/components/cli-manager/tabs/__tests__/CodexTab.test.tsx index 08194d3f..508ec771 100644 --- a/src/components/cli-manager/tabs/__tests__/CodexTab.test.tsx +++ b/src/components/cli-manager/tabs/__tests__/CodexTab.test.tsx @@ -810,4 +810,161 @@ describe("components/cli-manager/tabs/CodexTab", () => { expect(screen.getByRole("button", { name: "编辑" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "取消" })).not.toBeInTheDocument(); }); + + it("validates, cancels, reloads, and saves raw config.toml edits", async () => { + const persistCodexConfigToml = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); + vi.mocked(cliManagerCodexConfigTomlValidate) + .mockResolvedValueOnce({ ok: true, error: null }) + .mockResolvedValueOnce({ + ok: false, + error: { message: "invalid toml", line: 2, column: 3 }, + }) + .mockResolvedValueOnce({ ok: true, error: null }) + .mockResolvedValueOnce({ ok: true, error: null }); + + render( + + ); + + fireEvent.click(screen.getByText("高级配置(config.toml)")); + const reloadButton = await screen.findByRole("button", { name: "重新加载" }); + expect( + screen.getByText((_, element) => element?.textContent === "/home/user/.codex/config.toml") + ).toBeInTheDocument(); + + fireEvent.click(reloadButton); + expect(screen.getByLabelText("mock-code-editor")).toHaveValue('model = "gpt-5"\n'); + + fireEvent.click(screen.getByRole("button", { name: "编辑" })); + await waitFor(() => expect(cliManagerCodexConfigTomlValidate).toHaveBeenCalled()); + + fireEvent.change(screen.getByLabelText("mock-code-editor"), { + target: { value: "bad = [" }, + }); + fireEvent.click(screen.getByRole("button", { name: "保存" })); + + expect(await screen.findByText("TOML 校验失败")).toBeInTheDocument(); + expect(screen.getByText("invalid toml")).toBeInTheDocument(); + expect(screen.getByText("(line 2, column 3)")).toBeInTheDocument(); + expect(persistCodexConfigToml).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText("mock-code-editor"), { + target: { value: 'model = "gpt-5.4"\n' }, + }); + await waitFor( + () => { + expect(cliManagerCodexConfigTomlValidate).toHaveBeenCalledWith('model = "gpt-5.4"\n'); + }, + { timeout: 1200 } + ); + fireEvent.click(screen.getByRole("button", { name: "保存" })); + await waitFor(() => { + expect(persistCodexConfigToml).toHaveBeenCalledWith('model = "gpt-5.4"\n'); + }); + expect(screen.getByRole("button", { name: "取消" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "保存" })); + await waitFor(() => expect(persistCodexConfigToml).toHaveBeenCalledTimes(2)); + expect(await screen.findByRole("button", { name: "编辑" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "编辑" })); + fireEvent.change(screen.getByLabelText("mock-code-editor"), { + target: { value: 'model = "discarded"\n' }, + }); + fireEvent.click(screen.getByRole("button", { name: "取消" })); + expect(screen.getByLabelText("mock-code-editor")).toHaveValue('model = "gpt-5"\n'); + }); + + it("renders loading, missing config, fallback info, and detection error states", async () => { + const refreshCodex = vi.fn().mockResolvedValue(undefined); + + const { rerender } = render( + + ); + + expect(screen.getByText("加载中...")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "刷新" })).toBeDisabled(); + expect(screen.getByText("暂无配置,请尝试刷新")).toBeInTheDocument(); + + rerender( + + ); + + expect(screen.getByText("未检测到")).toBeInTheDocument(); + expect(screen.getByText("不存在(将自动创建)")).toBeInTheDocument(); + expect(screen.getAllByText("—").length).toBeGreaterThan(0); + expect(screen.getByText("检测失败:")).toBeInTheDocument(); + expect(screen.getByText("codex boom")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "刷新" })); + await waitFor(() => expect(refreshCodex).toHaveBeenCalled()); + }); }); diff --git a/src/components/cli-manager/tabs/__tests__/GeminiTab.test.tsx b/src/components/cli-manager/tabs/__tests__/GeminiTab.test.tsx index 6aec0252..4936925d 100644 --- a/src/components/cli-manager/tabs/__tests__/GeminiTab.test.tsx +++ b/src/components/cli-manager/tabs/__tests__/GeminiTab.test.tsx @@ -1,7 +1,12 @@ -import { fireEvent, render, screen, within } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import { toast } from "sonner"; import { CliManagerGeminiTab } from "../GeminiTab"; +vi.mock("sonner", () => ({ + toast: Object.assign(vi.fn(), { error: vi.fn() }), +})); + vi.mock("../../CliVersionBadge", () => ({ CliVersionBadge: ({ cliKey }: { cliKey: string }) =>
version-badge-{cliKey}
, })); @@ -120,6 +125,207 @@ describe("components/cli-manager/tabs/GeminiTab", () => { expect(persistGeminiConfig).toHaveBeenCalledWith({ usageStatisticsEnabled: true }); }); + it("validates number fields and persists session/auth fields", () => { + const persistGeminiConfig = vi.fn(); + + render( + + ); + + const maxAttemptsItem = screen.getByText("最大尝试次数 (general.maxAttempts)").parentElement + ?.parentElement; + const maxAttemptsInput = within(maxAttemptsItem as HTMLElement).getByRole("spinbutton"); + fireEvent.change(maxAttemptsInput, { target: { value: "1.5" } }); + fireEvent.blur(maxAttemptsInput); + expect(toast.error).toHaveBeenCalledWith("maxAttempts 必须为整数"); + expect(maxAttemptsInput).toHaveValue(5); + + const turnsItem = screen.getByText("会话轮次上限 (model.maxSessionTurns)").parentElement + ?.parentElement; + const turnsInput = within(turnsItem as HTMLElement).getByRole("spinbutton"); + fireEvent.change(turnsInput, { target: { value: "18" } }); + fireEvent.blur(turnsInput); + expect(persistGeminiConfig).toHaveBeenCalledWith({ modelMaxSessionTurns: 18 }); + + fireEvent.change(turnsInput, { target: { value: "" } }); + fireEvent.blur(turnsInput); + expect(turnsInput).toHaveValue(12); + + const thresholdItem = screen.getByText("压缩阈值 (model.compressionThreshold)").parentElement + ?.parentElement; + const thresholdInput = within(thresholdItem as HTMLElement).getByRole("spinbutton"); + fireEvent.change(thresholdInput, { target: { value: "" } }); + fireEvent.blur(thresholdInput); + expect(thresholdInput).toHaveValue(0.8); + + fireEvent.change(thresholdInput, { target: { value: "0.65" } }); + fireEvent.blur(thresholdInput); + expect(persistGeminiConfig).toHaveBeenCalledWith({ modelCompressionThreshold: 0.65 }); + + const themeItem = screen.getByText("主题 (ui.theme)").parentElement?.parentElement; + const themeInput = within(themeItem as HTMLElement).getByRole("textbox"); + fireEvent.change(themeInput, { target: { value: " light " } }); + fireEvent.blur(themeInput); + expect(persistGeminiConfig).toHaveBeenCalledWith({ uiTheme: "light" }); + + const thinkingItem = screen.getByText("思考展示模式 (ui.inlineThinkingMode)").parentElement + ?.parentElement; + fireEvent.change(within(thinkingItem as HTMLElement).getByRole("combobox"), { + target: { value: "off" }, + }); + expect(persistGeminiConfig).toHaveBeenCalledWith({ uiInlineThinkingMode: "off" }); + + const sessionAgeItem = screen.getByText("会话保留时长 (general.sessionRetention.maxAge)") + .parentElement?.parentElement; + const sessionAgeInput = within(sessionAgeItem as HTMLElement).getByRole("textbox"); + fireEvent.change(sessionAgeInput, { target: { value: " 45d " } }); + fireEvent.blur(sessionAgeInput); + expect(persistGeminiConfig).toHaveBeenCalledWith({ sessionRetentionMaxAge: "45d" }); + + const authItem = screen.getByText("认证类型 (security.auth.selectedType)").parentElement + ?.parentElement; + const authInput = within(authItem as HTMLElement).getByRole("textbox"); + fireEvent.change(authInput, { target: { value: " gemini-api-key " } }); + fireEvent.blur(authInput); + expect(persistGeminiConfig).toHaveBeenCalledWith({ + securityAuthSelectedType: "gemini-api-key", + }); + }); + + it("persists remaining switches and disables controls while saving", () => { + const persistGeminiConfig = vi.fn(); + + render( + + ); + + const hideTipsSwitch = within( + screen.getByText("隐藏 Tips (ui.hideTips)").parentElement?.parentElement as HTMLElement + ).getByRole("switch"); + expect(hideTipsSwitch).not.toBeChecked(); + expect(hideTipsSwitch).toBeDisabled(); + + for (const label of [ + "显示行号 (ui.showLineNumbers)", + "Vim 模式 (general.vimMode)", + "自动更新 (general.enableAutoUpdate)", + "通知 (general.enableNotifications)", + "重试抓取错误 (general.retryFetchErrors)", + "会话保留 (general.sessionRetention.enabled)", + "计划模式模型路由 (general.plan.modelRouting)", + ]) { + const item = screen.getByText(label).parentElement?.parentElement; + fireEvent.click(within(item as HTMLElement).getByRole("switch")); + } + + expect(persistGeminiConfig).not.toHaveBeenCalled(); + }); + + it("renders checking/no-info/null-config states and increments refresh token after refresh", async () => { + const refreshGeminiInfo = vi.fn().mockResolvedValue(undefined); + + const { rerender } = render( + + ); + + expect(screen.getByText("检测中...")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "刷新状态" })).toBeDisabled(); + expect(screen.getByText("暂无信息,请尝试刷新")).toBeInTheDocument(); + + rerender( + + ); + + expect(screen.getByText("暂无配置,请尝试刷新")).toBeInTheDocument(); + expect(screen.getAllByText("—").length).toBeGreaterThan(0); + + fireEvent.click(screen.getByRole("button", { name: "刷新状态" })); + await waitFor(() => expect(refreshGeminiInfo).toHaveBeenCalledTimes(1)); + }); + + it("resets draft values when a new config source is rendered", () => { + const persistGeminiConfig = vi.fn(); + + const { rerender } = render( + + ); + + const modelItem = screen.getByText("默认模型 (model.name)").parentElement?.parentElement; + const modelInput = within(modelItem as HTMLElement).getByRole("textbox"); + fireEvent.change(modelInput, { target: { value: "dirty-draft" } }); + expect(modelInput).toHaveValue("dirty-draft"); + + rerender( + + ); + + expect(within(modelItem as HTMLElement).getByRole("textbox")).toHaveValue("gemini-new"); + }); + it("renders unavailable and error states", () => { const { rerender } = render(